feat: Migrate tippy.js menus to Floating UI

This commit is contained in:
Arek Nawo
2026-01-09 00:33:28 +01:00
parent 601ed88931
commit 974bcea690
12 changed files with 250 additions and 588 deletions
-1
View File
@@ -55,7 +55,6 @@
"react-router-dom": "^7.0.1",
"semver": "^7.7.2",
"socket.io-client": "^4.8.1",
"tippy.js": "^6.3.7",
"tiptap-extension-global-drag-handle": "^0.1.18",
"zod": "^3.25.76"
},
@@ -1,16 +1,41 @@
import { ReactRenderer, useEditor } from "@tiptap/react";
import EmojiList from "./emoji-list";
import tippy from "tippy.js";
import { init } from "emoji-mart";
import {
autoUpdate,
computePosition,
flip,
offset,
shift,
} from "@floating-ui/dom";
const renderEmojiItems = () => {
let component: ReactRenderer | null = null;
let popup: any | null = null;
let popup: HTMLDivElement | null = null;
let cleanup: (() => void) | null = null;
let getReferenceClientRect: (() => DOMRect) | null = null;
const destroy = () => {
if (cleanup) {
cleanup();
cleanup = null;
}
if (popup) {
popup.remove();
popup = null;
}
if (component) {
component.destroy();
component = null;
}
};
return {
onBeforeStart: (props: {
editor: ReturnType<typeof useEditor>;
clientRect: DOMRect;
clientRect: () => DOMRect;
}) => {
init({
data: async () => (await import("@emoji-mart/data")).default,
@@ -25,51 +50,61 @@ const renderEmojiItems = () => {
return;
}
// @ts-ignore
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom",
getReferenceClientRect = props.clientRect;
popup = document.createElement("div");
popup.style.zIndex = "9999";
popup.style.position = "absolute";
popup.style.top = "0";
popup.style.left = "0";
popup.appendChild(component.element);
document.body.appendChild(popup);
const virtualElement = {
getBoundingClientRect: () => {
return getReferenceClientRect
? getReferenceClientRect()
: new DOMRect(0, 0, 0, 0);
},
};
cleanup = autoUpdate(virtualElement, popup, () => {
if (!popup) return;
computePosition(virtualElement, popup, {
placement: "bottom-start",
middleware: [offset(10), flip(), shift()],
}).then(({ x, y }) => {
if (!popup) return;
Object.assign(popup.style, {
transform: `translate(${x}px, ${y}px)`,
});
});
});
},
onStart: (props: {
editor: ReturnType<typeof useEditor>;
clientRect: DOMRect;
clientRect: () => DOMRect;
}) => {
component?.updateProps({...props, isLoading: false});
component?.updateProps({ ...props, isLoading: false });
if (!props.clientRect) {
return;
if (props.clientRect) {
getReferenceClientRect = props.clientRect;
}
popup &&
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onUpdate: (props: {
editor: ReturnType<typeof useEditor>;
clientRect: DOMRect;
clientRect: () => DOMRect;
}) => {
component?.updateProps(props);
if (!props.clientRect) {
return;
if (props.clientRect) {
getReferenceClientRect = props.clientRect;
}
popup &&
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
popup?.[0].hide();
component?.destroy()
destroy();
return true;
}
@@ -78,13 +113,7 @@ const renderEmojiItems = () => {
return component?.ref?.onKeyDown(props);
},
onExit: () => {
if (popup && !popup[0]?.state.isDestroyed) {
popup[0]?.destroy();
}
if (component) {
component?.destroy();
}
destroy();
},
};
};
@@ -1,5 +1,11 @@
import { ReactRenderer, useEditor } from "@tiptap/react";
import tippy from "tippy.js";
import {
autoUpdate,
computePosition,
flip,
offset,
shift,
} from "@floating-ui/dom";
import MentionList from "@/features/editor/components/mention/mention-list.tsx";
function getWhitespaceCount(query: string) {
@@ -9,16 +15,27 @@ function getWhitespaceCount(query: string) {
const mentionRenderItems = () => {
let component: ReactRenderer | null = null;
let popup: any | null = null;
let activeClientRect: (() => DOMRect) | null = null;
let updatePositionCleanup: (() => void) | null = null;
const destroy = () => {
updatePositionCleanup?.();
updatePositionCleanup = null;
component?.destroy();
if (component?.element?.parentNode) {
component.element.parentNode.removeChild(component.element);
}
component = null;
};
return {
onStart: (props: {
editor: ReturnType<typeof useEditor>;
clientRect: DOMRect;
clientRect: () => DOMRect;
query: string;
}) => {
// query must not start with a whitespace
if (props.query.charAt(0) === ' '){
if (props.query.charAt(0) === " ") {
return;
}
@@ -37,37 +54,63 @@ const mentionRenderItems = () => {
return;
}
// @ts-ignore
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
activeClientRect = props.clientRect;
const { element } = component;
document.body.appendChild(element);
updatePositionCleanup = autoUpdate(
{
getBoundingClientRect: () =>
activeClientRect ? activeClientRect() : new DOMRect(),
},
element,
() => {
if (!component?.element) return;
computePosition(
{
getBoundingClientRect: () => {
return activeClientRect ? activeClientRect() : new DOMRect();
},
},
element,
{
placement: "bottom-start",
middleware: [offset(0), flip(), shift()],
}
).then(({ x, y }) => {
Object.assign(element.style, {
left: `${x}px`,
top: `${y}px`,
position: "absolute",
zIndex: "9999",
});
});
}
);
},
onUpdate: (props: {
editor: ReturnType<typeof useEditor>;
clientRect: DOMRect;
clientRect: () => DOMRect;
query: string;
}) => {
// query must not start with a whitespace
if (props.query.charAt(0) === ' '){
component?.destroy();
if (props.query.charAt(0) === " ") {
destroy();
return;
}
// only update component if popup is not destroyed
if (!popup?.[0].state.isDestroyed) {
component?.updateProps(props);
if (component) {
component.updateProps(props);
}
if (!props || !props.clientRect) {
return;
}
activeClientRect = props.clientRect;
const whitespaceCount = getWhitespaceCount(props.query);
// destroy component if space is greater 3 without a match
@@ -76,37 +119,23 @@ const mentionRenderItems = () => {
//@ts-ignore
props.editor.storage.mentionItems.length === 0
) {
popup?.[0]?.destroy();
component?.destroy();
destroy();
return;
}
popup &&
!popup?.[0].state.isDestroyed &&
popup?.[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key)
if (
props.event.key === "Escape" ||
(props.event.key === "Enter" && !popup?.[0].state.isShown)
(props.event.key === "Enter" && !component)
) {
popup?.[0].destroy();
component?.destroy();
destroy();
return false;
}
return (component?.ref as any)?.onKeyDown(props);
},
onExit: () => {
if (popup && !popup?.[0].state.isDestroyed) {
popup[0].destroy();
}
if (component) {
component.destroy();
}
destroy();
},
};
};
@@ -1,10 +1,35 @@
import { ReactRenderer, useEditor } from "@tiptap/react";
import CommandList from "@/features/editor/components/slash-menu/command-list";
import tippy from "tippy.js";
import {
autoUpdate,
computePosition,
flip,
offset,
shift,
} from "@floating-ui/dom";
const renderItems = () => {
let component: ReactRenderer | null = null;
let popup: any | null = null;
let popup: HTMLElement | null = null;
let cleanup: (() => void) | null = null;
let getReferenceClientRect: (() => DOMRect) | null = null;
const updatePosition = () => {
if (!popup || !getReferenceClientRect) return;
// @ts-ignore
const rect = getReferenceClientRect();
computePosition({ getBoundingClientRect: () => rect }, popup, {
placement: "bottom-start",
middleware: [offset(0), flip(), shift()],
}).then(({ x, y }) => {
if (popup) {
popup.style.left = `${x}px`;
popup.style.top = `${y}px`;
}
});
};
return {
onStart: (props: {
@@ -21,15 +46,29 @@ const renderItems = () => {
}
// @ts-ignore
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
getReferenceClientRect = props.clientRect;
popup = document.createElement("div");
popup.style.zIndex = "9999";
popup.style.position = "absolute";
popup.style.top = "0";
popup.style.left = "0";
document.body.appendChild(popup);
popup.appendChild(component.element);
cleanup = autoUpdate(
// @ts-ignore
{
getBoundingClientRect: () => {
return getReferenceClientRect
? getReferenceClientRect()
: new DOMRect();
},
},
popup,
updatePosition
);
},
onUpdate: (props: {
editor: ReturnType<typeof useEditor>;
@@ -41,14 +80,15 @@ const renderItems = () => {
return;
}
popup &&
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
// @ts-ignore
getReferenceClientRect = props.clientRect;
updatePosition();
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
popup?.[0].hide();
if (popup) {
popup.style.display = "none";
}
return true;
}
@@ -57,12 +97,19 @@ const renderItems = () => {
return component?.ref?.onKeyDown(props);
},
onExit: () => {
if (popup && !popup[0].state.isDestroyed) {
popup[0].destroy();
if (cleanup) {
cleanup();
cleanup = null;
}
if (popup) {
popup.remove();
popup = null;
}
if (component) {
component.destroy();
component = null;
}
},
};
@@ -29,7 +29,7 @@ export const SubpagesMenu = React.memo(
return editor.isActive("subpages");
},
[editor],
[editor]
);
const getReferenceClientRect = useCallback(() => {
@@ -60,16 +60,6 @@ export const SubpagesMenu = React.memo(
editor={editor}
pluginKey={`subpages-menu`}
updateDelay={0}
/* tippyOptions={{
getReferenceClientRect,
offset: [0, 8],
zIndex: 99,
popperOptions: {
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
}}*/
shouldShow={shouldShow}
>
<Tooltip position="top" label={t("Delete")}>
@@ -85,7 +75,7 @@ export const SubpagesMenu = React.memo(
</Tooltip>
</BaseBubbleMenu>
);
},
}
);
export default SubpagesMenu;
@@ -3,7 +3,7 @@ import {
EditorMenuProps,
ShouldShowProps,
} from "@/features/editor/components/table/types/types.ts";
import { isCellSelection, TiptapTippyBubbleMenu } from '@docmost/editor-ext';
import { isCellSelection } from "@docmost/editor-ext";
import { ActionIcon, Tooltip } from "@mantine/core";
import {
IconBoxMargin,
@@ -15,6 +15,7 @@ import {
import { useTranslation } from "react-i18next";
import { TableBackgroundColor } from "./table-background-color";
import { TableTextAlignment } from "./table-text-alignment";
import { BubbleMenu } from "@tiptap/react/menus";
export const TableCellMenu = React.memo(
({ editor, appendTo }: EditorMenuProps): JSX.Element => {
@@ -27,7 +28,7 @@ export const TableCellMenu = React.memo(
return isCellSelection(state.selection);
},
[editor],
[editor]
);
const mergeCells = useCallback(() => {
@@ -51,16 +52,20 @@ export const TableCellMenu = React.memo(
}, [editor]);
return (
<TiptapTippyBubbleMenu
<BubbleMenu
editor={editor}
pluginKey="table-cell-menu"
updateDelay={0}
tippyOptions={{
appendTo: () => {
return appendTo?.current;
appendTo={() => {
return appendTo?.current;
}}
ref={(element) => {
element.style.zIndex = "99";
}}
options={{
offset: {
mainAxis: 15,
},
offset: [0, 15],
zIndex: 99,
}}
shouldShow={shouldShow}
>
@@ -123,9 +128,9 @@ export const TableCellMenu = React.memo(
</ActionIcon>
</Tooltip>
</ActionIcon.Group>
</TiptapTippyBubbleMenu>
</BubbleMenu>
);
},
}
);
export default TableCellMenu;
@@ -17,7 +17,8 @@ import {
IconTableRow,
IconTrashX,
} from "@tabler/icons-react";
import { isCellSelection, TiptapTippyBubbleMenu } from "@docmost/editor-ext";
import { BubbleMenu } from "@tiptap/react/menus";
import { isCellSelection } from "@docmost/editor-ext";
import { useTranslation } from "react-i18next";
export const TableMenu = React.memo(
@@ -31,20 +32,28 @@ export const TableMenu = React.memo(
return editor.isActive("table") && !isCellSelection(state.selection);
},
[editor],
[editor]
);
const getReferenceClientRect = useCallback(() => {
const getReferencedVirtualElement = useCallback(() => {
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "table";
const parent = findParentNode(predicate)(selection);
if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
return dom.getBoundingClientRect();
const rect = dom.getBoundingClientRect();
return {
getBoundingClientRect: () => rect,
getClientRects: () => [rect],
};
}
return posToDOMRect(editor.view, selection.from, selection.to);
const rect = posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => rect,
getClientRects: () => [rect],
};
}, [editor]);
const toggleHeaderColumn = useCallback(() => {
@@ -84,35 +93,27 @@ export const TableMenu = React.memo(
}, [editor]);
return (
<TiptapTippyBubbleMenu
<BubbleMenu
editor={editor}
pluginKey="table-menu"
updateDelay={0}
tippyOptions={{
getReferenceClientRect: getReferenceClientRect,
offset: [0, 15],
zIndex: 99,
popperOptions: {
modifiers: [
{
name: "preventOverflow",
enabled: true,
options: {
altAxis: true,
boundary: "clippingParents",
padding: 8,
},
},
{
name: "flip",
enabled: true,
options: {
boundary: editor.options.element,
fallbackPlacements: ["top", "bottom"],
padding: { top: 35, left: 8, right: 8, bottom: -Infinity },
},
},
],
resizeDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
ref={(element) => {
element.style.zIndex = "99";
}}
options={{
placement: "top",
offset: {
mainAxis: 15,
},
flip: {
fallbackPlacements: ["top", "bottom"],
padding: { top: 35 + 15, left: 8, right: 8, bottom: -Infinity },
boundary: editor.options.element as HTMLElement,
},
shift: {
padding: 8 + 15,
crossAxis: true,
},
}}
shouldShow={shouldShow}
@@ -218,9 +219,9 @@ export const TableMenu = React.memo(
</ActionIcon>
</Tooltip>
</ActionIcon.Group>
</TiptapTippyBubbleMenu>
</BubbleMenu>
);
},
}
);
export default TableMenu;