mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
feat: Migrate tippy.js menus to Floating UI
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -23,4 +23,3 @@ export * from "./lib/subpages";
|
||||
export * from "./lib/highlight";
|
||||
export * from "./lib/heading/heading";
|
||||
export * from "./lib/unique-id";
|
||||
export * from "./lib/tippy-bubble-menu";
|
||||
|
||||
@@ -1,349 +0,0 @@
|
||||
// Source: https://github.com/ueberdosis/tiptap/blob/v2/packages/extension-bubble-menu/src/bubble-menu-plugin.ts - MIT
|
||||
import {
|
||||
Editor,
|
||||
isNodeSelection,
|
||||
isTextSelection,
|
||||
posToDOMRect,
|
||||
} from "@tiptap/core";
|
||||
import { EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { EditorView } from "@tiptap/pm/view";
|
||||
import tippy, { Instance, Props } from "tippy.js";
|
||||
|
||||
export interface BubbleMenuPluginProps {
|
||||
/**
|
||||
* The plugin key.
|
||||
* @type {PluginKey | string}
|
||||
* @default 'bubbleMenu'
|
||||
*/
|
||||
pluginKey: PluginKey | string;
|
||||
|
||||
/**
|
||||
* The editor instance.
|
||||
*/
|
||||
editor: Editor;
|
||||
|
||||
/**
|
||||
* The DOM element that contains your menu.
|
||||
* @type {HTMLElement}
|
||||
* @default null
|
||||
*/
|
||||
element: HTMLElement;
|
||||
|
||||
/**
|
||||
* The options for the tippy.js instance.
|
||||
* @see https://atomiks.github.io/tippyjs/v6/all-props/
|
||||
*/
|
||||
tippyOptions?: Partial<Props>;
|
||||
|
||||
/**
|
||||
* The delay in milliseconds before the menu should be updated.
|
||||
* This can be useful to prevent performance issues.
|
||||
* @type {number}
|
||||
* @default 250
|
||||
*/
|
||||
updateDelay?: number;
|
||||
|
||||
/**
|
||||
* A function that determines whether the menu should be shown or not.
|
||||
* If this function returns `false`, the menu will be hidden, otherwise it will be shown.
|
||||
*/
|
||||
shouldShow?:
|
||||
| ((props: {
|
||||
editor: Editor;
|
||||
element: HTMLElement;
|
||||
view: EditorView;
|
||||
state: EditorState;
|
||||
oldState?: EditorState;
|
||||
from: number;
|
||||
to: number;
|
||||
}) => boolean)
|
||||
| null;
|
||||
}
|
||||
|
||||
export type BubbleMenuViewProps = BubbleMenuPluginProps & {
|
||||
view: EditorView;
|
||||
};
|
||||
|
||||
export class BubbleMenuView {
|
||||
public editor: Editor;
|
||||
|
||||
public element: HTMLElement;
|
||||
|
||||
public view: EditorView;
|
||||
|
||||
public preventHide = false;
|
||||
|
||||
public tippy: Instance | undefined;
|
||||
|
||||
public tippyOptions?: Partial<Props>;
|
||||
|
||||
public updateDelay: number;
|
||||
|
||||
private updateDebounceTimer: number | undefined;
|
||||
|
||||
public shouldShow: Exclude<BubbleMenuPluginProps["shouldShow"], null> = ({
|
||||
view,
|
||||
state,
|
||||
from,
|
||||
to,
|
||||
}) => {
|
||||
const { doc, selection } = state;
|
||||
const { empty } = selection;
|
||||
|
||||
// Sometime check for `empty` is not enough.
|
||||
// Doubleclick an empty paragraph returns a node size of 2.
|
||||
// So we check also for an empty text size.
|
||||
const isEmptyTextBlock =
|
||||
!doc.textBetween(from, to).length && isTextSelection(state.selection);
|
||||
|
||||
// When clicking on a element inside the bubble menu the editor "blur" event
|
||||
// is called and the bubble menu item is focussed. In this case we should
|
||||
// consider the menu as part of the editor and keep showing the menu
|
||||
const isChildOfMenu = this.element.contains(document.activeElement);
|
||||
|
||||
const hasEditorFocus = view.hasFocus() || isChildOfMenu;
|
||||
|
||||
if (
|
||||
!hasEditorFocus ||
|
||||
empty ||
|
||||
isEmptyTextBlock ||
|
||||
!this.editor.isEditable
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
constructor({
|
||||
editor,
|
||||
element,
|
||||
view,
|
||||
tippyOptions = {},
|
||||
updateDelay = 250,
|
||||
shouldShow,
|
||||
}: BubbleMenuViewProps) {
|
||||
this.editor = editor;
|
||||
this.element = element;
|
||||
this.view = view;
|
||||
this.updateDelay = updateDelay;
|
||||
|
||||
if (shouldShow) {
|
||||
this.shouldShow = shouldShow;
|
||||
}
|
||||
|
||||
this.element.addEventListener("mousedown", this.mousedownHandler, {
|
||||
capture: true,
|
||||
});
|
||||
this.view.dom.addEventListener("dragstart", this.dragstartHandler);
|
||||
this.editor.on("focus", this.focusHandler);
|
||||
this.editor.on("blur", this.blurHandler);
|
||||
this.tippyOptions = tippyOptions;
|
||||
// Detaches menu content from its current parent
|
||||
this.element.remove();
|
||||
this.element.style.visibility = "visible";
|
||||
}
|
||||
|
||||
mousedownHandler = () => {
|
||||
this.preventHide = true;
|
||||
};
|
||||
|
||||
dragstartHandler = () => {
|
||||
this.hide();
|
||||
};
|
||||
|
||||
focusHandler = () => {
|
||||
// we use `setTimeout` to make sure `selection` is already updated
|
||||
setTimeout(() => this.update(this.editor.view));
|
||||
};
|
||||
|
||||
blurHandler = ({ event }: { event: FocusEvent }) => {
|
||||
if (this.preventHide) {
|
||||
this.preventHide = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
event?.relatedTarget &&
|
||||
this.element.parentNode?.contains(event.relatedTarget as Node)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event?.relatedTarget === this.editor.view.dom) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.hide();
|
||||
};
|
||||
|
||||
tippyBlurHandler = (event: FocusEvent) => {
|
||||
this.blurHandler({ event });
|
||||
};
|
||||
|
||||
createTooltip() {
|
||||
const { element: editorElement } = this.editor.options;
|
||||
//@ts-ignore
|
||||
const editorIsAttached = !!editorElement.parentElement;
|
||||
|
||||
this.element.tabIndex = 0;
|
||||
|
||||
if (this.tippy || !editorIsAttached) {
|
||||
return;
|
||||
}
|
||||
|
||||
//@ts-ignore
|
||||
this.tippy = tippy(editorElement, {
|
||||
duration: 0,
|
||||
getReferenceClientRect: null,
|
||||
content: this.element,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "top",
|
||||
hideOnClick: "toggle",
|
||||
...this.tippyOptions,
|
||||
});
|
||||
|
||||
// maybe we have to hide tippy on its own blur event as well
|
||||
if (this.tippy.popper.firstChild) {
|
||||
(this.tippy.popper.firstChild as HTMLElement).addEventListener(
|
||||
"blur",
|
||||
this.tippyBlurHandler,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
update(view: EditorView, oldState?: EditorState) {
|
||||
const { state } = view;
|
||||
const hasValidSelection = state.selection.from !== state.selection.to;
|
||||
|
||||
if (this.updateDelay > 0 && hasValidSelection) {
|
||||
this.handleDebouncedUpdate(view, oldState);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectionChanged = !oldState?.selection.eq(view.state.selection);
|
||||
const docChanged = !oldState?.doc.eq(view.state.doc);
|
||||
|
||||
this.updateHandler(view, selectionChanged, docChanged, oldState);
|
||||
}
|
||||
|
||||
handleDebouncedUpdate = (view: EditorView, oldState?: EditorState) => {
|
||||
const selectionChanged = !oldState?.selection.eq(view.state.selection);
|
||||
const docChanged = !oldState?.doc.eq(view.state.doc);
|
||||
|
||||
if (!selectionChanged && !docChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.updateDebounceTimer) {
|
||||
clearTimeout(this.updateDebounceTimer);
|
||||
}
|
||||
|
||||
this.updateDebounceTimer = window.setTimeout(() => {
|
||||
this.updateHandler(view, selectionChanged, docChanged, oldState);
|
||||
}, this.updateDelay);
|
||||
};
|
||||
|
||||
updateHandler = (
|
||||
view: EditorView,
|
||||
selectionChanged: boolean,
|
||||
docChanged: boolean,
|
||||
oldState?: EditorState,
|
||||
) => {
|
||||
const { state, composing } = view;
|
||||
const { selection } = state;
|
||||
|
||||
const isSame = !selectionChanged && !docChanged;
|
||||
|
||||
if (composing || isSame) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.createTooltip();
|
||||
|
||||
// support for CellSelections
|
||||
const { ranges } = selection;
|
||||
const from = Math.min(...ranges.map((range) => range.$from.pos));
|
||||
const to = Math.max(...ranges.map((range) => range.$to.pos));
|
||||
|
||||
const shouldShow = this.shouldShow?.({
|
||||
editor: this.editor,
|
||||
element: this.element,
|
||||
view,
|
||||
state,
|
||||
oldState,
|
||||
from,
|
||||
to,
|
||||
});
|
||||
|
||||
if (!shouldShow) {
|
||||
this.hide();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.tippy?.setProps({
|
||||
getReferenceClientRect:
|
||||
this.tippyOptions?.getReferenceClientRect ||
|
||||
(() => {
|
||||
if (isNodeSelection(state.selection)) {
|
||||
let node = view.nodeDOM(from) as HTMLElement;
|
||||
|
||||
if (node) {
|
||||
const nodeViewWrapper = node.dataset.nodeViewWrapper
|
||||
? node
|
||||
: node.querySelector("[data-node-view-wrapper]");
|
||||
|
||||
if (nodeViewWrapper) {
|
||||
node = nodeViewWrapper.firstChild as HTMLElement;
|
||||
}
|
||||
|
||||
if (node) {
|
||||
return node.getBoundingClientRect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return posToDOMRect(view, from, to);
|
||||
}),
|
||||
});
|
||||
|
||||
this.show();
|
||||
};
|
||||
|
||||
show() {
|
||||
this.tippy?.show();
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.tippy?.hide();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.tippy?.popper.firstChild) {
|
||||
(this.tippy.popper.firstChild as HTMLElement).removeEventListener(
|
||||
"blur",
|
||||
this.tippyBlurHandler,
|
||||
);
|
||||
}
|
||||
this.tippy?.destroy();
|
||||
this.element.removeEventListener("mousedown", this.mousedownHandler, {
|
||||
capture: true,
|
||||
});
|
||||
this.view.dom.removeEventListener("dragstart", this.dragstartHandler);
|
||||
this.editor.off("focus", this.focusHandler);
|
||||
this.editor.off("blur", this.blurHandler);
|
||||
}
|
||||
}
|
||||
|
||||
export const BubbleMenuPlugin = (options: BubbleMenuPluginProps) => {
|
||||
return new Plugin({
|
||||
key:
|
||||
typeof options.pluginKey === "string"
|
||||
? new PluginKey(options.pluginKey)
|
||||
: options.pluginKey,
|
||||
view: (view) => new BubbleMenuView({ view, ...options }),
|
||||
});
|
||||
};
|
||||
@@ -1,72 +0,0 @@
|
||||
// Source: https://github.com/ueberdosis/tiptap/blob/v2/packages/react/src/BubbleMenu.tsx - MIT
|
||||
import { BubbleMenuPlugin, BubbleMenuPluginProps } from "./bubble-menu-plugin";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useCurrentEditor } from "@tiptap/react";
|
||||
|
||||
type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>;
|
||||
|
||||
export type BubbleMenuProps = Omit<
|
||||
Optional<BubbleMenuPluginProps, "pluginKey">,
|
||||
"element" | "editor"
|
||||
> & {
|
||||
editor: BubbleMenuPluginProps["editor"] | null;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
updateDelay?: number;
|
||||
};
|
||||
|
||||
export const BubbleMenu = (props: BubbleMenuProps) => {
|
||||
const [element, setElement] = useState<HTMLDivElement | null>(null);
|
||||
const { editor: currentEditor } = useCurrentEditor();
|
||||
|
||||
useEffect(() => {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.editor?.isDestroyed || currentEditor?.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
pluginKey = "bubbleMenu",
|
||||
editor,
|
||||
tippyOptions = {},
|
||||
updateDelay,
|
||||
shouldShow = null,
|
||||
} = props;
|
||||
|
||||
const menuEditor = editor || currentEditor;
|
||||
|
||||
if (!menuEditor) {
|
||||
console.warn(
|
||||
"BubbleMenu component is not rendered inside of an editor component or does not have editor prop.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const plugin = BubbleMenuPlugin({
|
||||
updateDelay,
|
||||
editor: menuEditor,
|
||||
element,
|
||||
pluginKey,
|
||||
shouldShow,
|
||||
tippyOptions,
|
||||
});
|
||||
|
||||
menuEditor.registerPlugin(plugin);
|
||||
return () => {
|
||||
menuEditor.unregisterPlugin(pluginKey);
|
||||
};
|
||||
}, [props.editor, currentEditor, element]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setElement}
|
||||
className={props.className}
|
||||
style={{ visibility: "hidden" }}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export { BubbleMenu as TiptapTippyBubbleMenu } from "./bubble-menu-react";
|
||||
Generated
-15
@@ -337,9 +337,6 @@ importers:
|
||||
socket.io-client:
|
||||
specifier: ^4.8.1
|
||||
version: 4.8.1
|
||||
tippy.js:
|
||||
specifier: ^6.3.7
|
||||
version: 6.3.7
|
||||
tiptap-extension-global-drag-handle:
|
||||
specifier: ^0.1.18
|
||||
version: 0.1.18
|
||||
@@ -3350,9 +3347,6 @@ packages:
|
||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
'@popperjs/core@2.11.8':
|
||||
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
|
||||
|
||||
'@radix-ui/primitive@1.1.1':
|
||||
resolution: {integrity: sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==}
|
||||
|
||||
@@ -9423,9 +9417,6 @@ packages:
|
||||
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
tippy.js@6.3.7:
|
||||
resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==}
|
||||
|
||||
tiptap-extension-global-drag-handle@0.1.18:
|
||||
resolution: {integrity: sha512-jwFuy1K8DP3a4bFy76Hpc63w1Sil0B7uZ3mvhQomVvUFCU787Lg2FowNhn7NFzeyok761qY2VG+PZ/FDthWUdg==}
|
||||
|
||||
@@ -13620,8 +13611,6 @@ snapshots:
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
optional: true
|
||||
|
||||
'@popperjs/core@2.11.8': {}
|
||||
|
||||
'@radix-ui/primitive@1.1.1': {}
|
||||
|
||||
'@radix-ui/react-arrow@1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
@@ -20710,10 +20699,6 @@ snapshots:
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
picomatch: 4.0.3
|
||||
|
||||
tippy.js@6.3.7:
|
||||
dependencies:
|
||||
'@popperjs/core': 2.11.8
|
||||
|
||||
tiptap-extension-global-drag-handle@0.1.18: {}
|
||||
|
||||
tldts-core@6.1.72: {}
|
||||
|
||||
Reference in New Issue
Block a user