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", "react-router-dom": "^7.0.1",
"semver": "^7.7.2", "semver": "^7.7.2",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"tippy.js": "^6.3.7",
"tiptap-extension-global-drag-handle": "^0.1.18", "tiptap-extension-global-drag-handle": "^0.1.18",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
@@ -1,16 +1,41 @@
import { ReactRenderer, useEditor } from "@tiptap/react"; import { ReactRenderer, useEditor } from "@tiptap/react";
import EmojiList from "./emoji-list"; import EmojiList from "./emoji-list";
import tippy from "tippy.js";
import { init } from "emoji-mart"; import { init } from "emoji-mart";
import {
autoUpdate,
computePosition,
flip,
offset,
shift,
} from "@floating-ui/dom";
const renderEmojiItems = () => { const renderEmojiItems = () => {
let component: ReactRenderer | null = null; 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 { return {
onBeforeStart: (props: { onBeforeStart: (props: {
editor: ReturnType<typeof useEditor>; editor: ReturnType<typeof useEditor>;
clientRect: DOMRect; clientRect: () => DOMRect;
}) => { }) => {
init({ init({
data: async () => (await import("@emoji-mart/data")).default, data: async () => (await import("@emoji-mart/data")).default,
@@ -25,51 +50,61 @@ const renderEmojiItems = () => {
return; return;
} }
// @ts-ignore getReferenceClientRect = props.clientRect;
popup = tippy("body", { popup = document.createElement("div");
getReferenceClientRect: props.clientRect, popup.style.zIndex = "9999";
appendTo: () => document.body, popup.style.position = "absolute";
content: component.element, popup.style.top = "0";
showOnCreate: true, popup.style.left = "0";
interactive: true, popup.appendChild(component.element);
trigger: "manual", document.body.appendChild(popup);
placement: "bottom",
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: { onStart: (props: {
editor: ReturnType<typeof useEditor>; editor: ReturnType<typeof useEditor>;
clientRect: DOMRect; clientRect: () => DOMRect;
}) => { }) => {
component?.updateProps({...props, isLoading: false}); component?.updateProps({ ...props, isLoading: false });
if (!props.clientRect) { if (props.clientRect) {
return; getReferenceClientRect = props.clientRect;
} }
popup &&
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
}, },
onUpdate: (props: { onUpdate: (props: {
editor: ReturnType<typeof useEditor>; editor: ReturnType<typeof useEditor>;
clientRect: DOMRect; clientRect: () => DOMRect;
}) => { }) => {
component?.updateProps(props); component?.updateProps(props);
if (!props.clientRect) { if (props.clientRect) {
return; getReferenceClientRect = props.clientRect;
} }
popup &&
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
}, },
onKeyDown: (props: { event: KeyboardEvent }) => { onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") { if (props.event.key === "Escape") {
popup?.[0].hide(); destroy();
component?.destroy()
return true; return true;
} }
@@ -78,13 +113,7 @@ const renderEmojiItems = () => {
return component?.ref?.onKeyDown(props); return component?.ref?.onKeyDown(props);
}, },
onExit: () => { onExit: () => {
if (popup && !popup[0]?.state.isDestroyed) { destroy();
popup[0]?.destroy();
}
if (component) {
component?.destroy();
}
}, },
}; };
}; };
@@ -1,5 +1,11 @@
import { ReactRenderer, useEditor } from "@tiptap/react"; 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"; import MentionList from "@/features/editor/components/mention/mention-list.tsx";
function getWhitespaceCount(query: string) { function getWhitespaceCount(query: string) {
@@ -9,16 +15,27 @@ function getWhitespaceCount(query: string) {
const mentionRenderItems = () => { const mentionRenderItems = () => {
let component: ReactRenderer | null = null; 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 { return {
onStart: (props: { onStart: (props: {
editor: ReturnType<typeof useEditor>; editor: ReturnType<typeof useEditor>;
clientRect: DOMRect; clientRect: () => DOMRect;
query: string; query: string;
}) => { }) => {
// query must not start with a whitespace // query must not start with a whitespace
if (props.query.charAt(0) === ' '){ if (props.query.charAt(0) === " ") {
return; return;
} }
@@ -37,37 +54,63 @@ const mentionRenderItems = () => {
return; return;
} }
// @ts-ignore activeClientRect = props.clientRect;
popup = tippy("body", {
getReferenceClientRect: props.clientRect, const { element } = component;
appendTo: () => document.body, document.body.appendChild(element);
content: component.element,
showOnCreate: true, updatePositionCleanup = autoUpdate(
interactive: true, {
trigger: "manual", getBoundingClientRect: () =>
placement: "bottom-start", 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: { onUpdate: (props: {
editor: ReturnType<typeof useEditor>; editor: ReturnType<typeof useEditor>;
clientRect: DOMRect; clientRect: () => DOMRect;
query: string; query: string;
}) => { }) => {
// query must not start with a whitespace // query must not start with a whitespace
if (props.query.charAt(0) === ' '){ if (props.query.charAt(0) === " ") {
component?.destroy(); destroy();
return; return;
} }
// only update component if popup is not destroyed // only update component if popup is not destroyed
if (!popup?.[0].state.isDestroyed) { if (component) {
component?.updateProps(props); component.updateProps(props);
} }
if (!props || !props.clientRect) { if (!props || !props.clientRect) {
return; return;
} }
activeClientRect = props.clientRect;
const whitespaceCount = getWhitespaceCount(props.query); const whitespaceCount = getWhitespaceCount(props.query);
// destroy component if space is greater 3 without a match // destroy component if space is greater 3 without a match
@@ -76,37 +119,23 @@ const mentionRenderItems = () => {
//@ts-ignore //@ts-ignore
props.editor.storage.mentionItems.length === 0 props.editor.storage.mentionItems.length === 0
) { ) {
popup?.[0]?.destroy(); destroy();
component?.destroy();
return; return;
} }
popup &&
!popup?.[0].state.isDestroyed &&
popup?.[0].setProps({
getReferenceClientRect: props.clientRect,
});
}, },
onKeyDown: (props: { event: KeyboardEvent }) => { onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key) if (props.event.key)
if ( if (
props.event.key === "Escape" || props.event.key === "Escape" ||
(props.event.key === "Enter" && !popup?.[0].state.isShown) (props.event.key === "Enter" && !component)
) { ) {
popup?.[0].destroy(); destroy();
component?.destroy();
return false; return false;
} }
return (component?.ref as any)?.onKeyDown(props); return (component?.ref as any)?.onKeyDown(props);
}, },
onExit: () => { onExit: () => {
if (popup && !popup?.[0].state.isDestroyed) { destroy();
popup[0].destroy();
}
if (component) {
component.destroy();
}
}, },
}; };
}; };
@@ -1,10 +1,35 @@
import { ReactRenderer, useEditor } from "@tiptap/react"; import { ReactRenderer, useEditor } from "@tiptap/react";
import CommandList from "@/features/editor/components/slash-menu/command-list"; 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 = () => { const renderItems = () => {
let component: ReactRenderer | null = null; 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 { return {
onStart: (props: { onStart: (props: {
@@ -21,15 +46,29 @@ const renderItems = () => {
} }
// @ts-ignore // @ts-ignore
popup = tippy("body", { getReferenceClientRect = props.clientRect;
getReferenceClientRect: props.clientRect,
appendTo: () => document.body, popup = document.createElement("div");
content: component.element, popup.style.zIndex = "9999";
showOnCreate: true, popup.style.position = "absolute";
interactive: true, popup.style.top = "0";
trigger: "manual", popup.style.left = "0";
placement: "bottom-start",
}); document.body.appendChild(popup);
popup.appendChild(component.element);
cleanup = autoUpdate(
// @ts-ignore
{
getBoundingClientRect: () => {
return getReferenceClientRect
? getReferenceClientRect()
: new DOMRect();
},
},
popup,
updatePosition
);
}, },
onUpdate: (props: { onUpdate: (props: {
editor: ReturnType<typeof useEditor>; editor: ReturnType<typeof useEditor>;
@@ -41,14 +80,15 @@ const renderItems = () => {
return; return;
} }
popup && // @ts-ignore
popup[0].setProps({ getReferenceClientRect = props.clientRect;
getReferenceClientRect: props.clientRect, updatePosition();
});
}, },
onKeyDown: (props: { event: KeyboardEvent }) => { onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") { if (props.event.key === "Escape") {
popup?.[0].hide(); if (popup) {
popup.style.display = "none";
}
return true; return true;
} }
@@ -57,12 +97,19 @@ const renderItems = () => {
return component?.ref?.onKeyDown(props); return component?.ref?.onKeyDown(props);
}, },
onExit: () => { onExit: () => {
if (popup && !popup[0].state.isDestroyed) { if (cleanup) {
popup[0].destroy(); cleanup();
cleanup = null;
}
if (popup) {
popup.remove();
popup = null;
} }
if (component) { if (component) {
component.destroy(); component.destroy();
component = null;
} }
}, },
}; };
@@ -29,7 +29,7 @@ export const SubpagesMenu = React.memo(
return editor.isActive("subpages"); return editor.isActive("subpages");
}, },
[editor], [editor]
); );
const getReferenceClientRect = useCallback(() => { const getReferenceClientRect = useCallback(() => {
@@ -60,16 +60,6 @@ export const SubpagesMenu = React.memo(
editor={editor} editor={editor}
pluginKey={`subpages-menu`} pluginKey={`subpages-menu`}
updateDelay={0} updateDelay={0}
/* tippyOptions={{
getReferenceClientRect,
offset: [0, 8],
zIndex: 99,
popperOptions: {
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
}}*/
shouldShow={shouldShow} shouldShow={shouldShow}
> >
<Tooltip position="top" label={t("Delete")}> <Tooltip position="top" label={t("Delete")}>
@@ -85,7 +75,7 @@ export const SubpagesMenu = React.memo(
</Tooltip> </Tooltip>
</BaseBubbleMenu> </BaseBubbleMenu>
); );
}, }
); );
export default SubpagesMenu; export default SubpagesMenu;
@@ -3,7 +3,7 @@ import {
EditorMenuProps, EditorMenuProps,
ShouldShowProps, ShouldShowProps,
} from "@/features/editor/components/table/types/types.ts"; } 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 { ActionIcon, Tooltip } from "@mantine/core";
import { import {
IconBoxMargin, IconBoxMargin,
@@ -15,6 +15,7 @@ import {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TableBackgroundColor } from "./table-background-color"; import { TableBackgroundColor } from "./table-background-color";
import { TableTextAlignment } from "./table-text-alignment"; import { TableTextAlignment } from "./table-text-alignment";
import { BubbleMenu } from "@tiptap/react/menus";
export const TableCellMenu = React.memo( export const TableCellMenu = React.memo(
({ editor, appendTo }: EditorMenuProps): JSX.Element => { ({ editor, appendTo }: EditorMenuProps): JSX.Element => {
@@ -27,7 +28,7 @@ export const TableCellMenu = React.memo(
return isCellSelection(state.selection); return isCellSelection(state.selection);
}, },
[editor], [editor]
); );
const mergeCells = useCallback(() => { const mergeCells = useCallback(() => {
@@ -51,16 +52,20 @@ export const TableCellMenu = React.memo(
}, [editor]); }, [editor]);
return ( return (
<TiptapTippyBubbleMenu <BubbleMenu
editor={editor} editor={editor}
pluginKey="table-cell-menu" pluginKey="table-cell-menu"
updateDelay={0} updateDelay={0}
tippyOptions={{ appendTo={() => {
appendTo: () => { return appendTo?.current;
return appendTo?.current; }}
ref={(element) => {
element.style.zIndex = "99";
}}
options={{
offset: {
mainAxis: 15,
}, },
offset: [0, 15],
zIndex: 99,
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
@@ -123,9 +128,9 @@ export const TableCellMenu = React.memo(
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</ActionIcon.Group> </ActionIcon.Group>
</TiptapTippyBubbleMenu> </BubbleMenu>
); );
}, }
); );
export default TableCellMenu; export default TableCellMenu;
@@ -17,7 +17,8 @@ import {
IconTableRow, IconTableRow,
IconTrashX, IconTrashX,
} from "@tabler/icons-react"; } 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"; import { useTranslation } from "react-i18next";
export const TableMenu = React.memo( export const TableMenu = React.memo(
@@ -31,20 +32,28 @@ export const TableMenu = React.memo(
return editor.isActive("table") && !isCellSelection(state.selection); return editor.isActive("table") && !isCellSelection(state.selection);
}, },
[editor], [editor]
); );
const getReferenceClientRect = useCallback(() => { const getReferencedVirtualElement = useCallback(() => {
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "table"; const predicate = (node: PMNode) => node.type.name === "table";
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
if (parent) { if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement; 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]); }, [editor]);
const toggleHeaderColumn = useCallback(() => { const toggleHeaderColumn = useCallback(() => {
@@ -84,35 +93,27 @@ export const TableMenu = React.memo(
}, [editor]); }, [editor]);
return ( return (
<TiptapTippyBubbleMenu <BubbleMenu
editor={editor} editor={editor}
pluginKey="table-menu" pluginKey="table-menu"
updateDelay={0} resizeDelay={0}
tippyOptions={{ getReferencedVirtualElement={getReferencedVirtualElement}
getReferenceClientRect: getReferenceClientRect, ref={(element) => {
offset: [0, 15], element.style.zIndex = "99";
zIndex: 99, }}
popperOptions: { options={{
modifiers: [ placement: "top",
{ offset: {
name: "preventOverflow", mainAxis: 15,
enabled: true, },
options: { flip: {
altAxis: true, fallbackPlacements: ["top", "bottom"],
boundary: "clippingParents", padding: { top: 35 + 15, left: 8, right: 8, bottom: -Infinity },
padding: 8, boundary: editor.options.element as HTMLElement,
}, },
}, shift: {
{ padding: 8 + 15,
name: "flip", crossAxis: true,
enabled: true,
options: {
boundary: editor.options.element,
fallbackPlacements: ["top", "bottom"],
padding: { top: 35, left: 8, right: 8, bottom: -Infinity },
},
},
],
}, },
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
@@ -218,9 +219,9 @@ export const TableMenu = React.memo(
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</ActionIcon.Group> </ActionIcon.Group>
</TiptapTippyBubbleMenu> </BubbleMenu>
); );
}, }
); );
export default TableMenu; export default TableMenu;
-1
View File
@@ -23,4 +23,3 @@ export * from "./lib/subpages";
export * from "./lib/highlight"; export * from "./lib/highlight";
export * from "./lib/heading/heading"; export * from "./lib/heading/heading";
export * from "./lib/unique-id"; 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";
-15
View File
@@ -337,9 +337,6 @@ importers:
socket.io-client: socket.io-client:
specifier: ^4.8.1 specifier: ^4.8.1
version: 4.8.1 version: 4.8.1
tippy.js:
specifier: ^6.3.7
version: 6.3.7
tiptap-extension-global-drag-handle: tiptap-extension-global-drag-handle:
specifier: ^0.1.18 specifier: ^0.1.18
version: 0.1.18 version: 0.1.18
@@ -3350,9 +3347,6 @@ packages:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'} engines: {node: '>=14'}
'@popperjs/core@2.11.8':
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
'@radix-ui/primitive@1.1.1': '@radix-ui/primitive@1.1.1':
resolution: {integrity: sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==} resolution: {integrity: sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==}
@@ -9423,9 +9417,6 @@ packages:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
tippy.js@6.3.7:
resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==}
tiptap-extension-global-drag-handle@0.1.18: tiptap-extension-global-drag-handle@0.1.18:
resolution: {integrity: sha512-jwFuy1K8DP3a4bFy76Hpc63w1Sil0B7uZ3mvhQomVvUFCU787Lg2FowNhn7NFzeyok761qY2VG+PZ/FDthWUdg==} resolution: {integrity: sha512-jwFuy1K8DP3a4bFy76Hpc63w1Sil0B7uZ3mvhQomVvUFCU787Lg2FowNhn7NFzeyok761qY2VG+PZ/FDthWUdg==}
@@ -13620,8 +13611,6 @@ snapshots:
'@pkgjs/parseargs@0.11.0': '@pkgjs/parseargs@0.11.0':
optional: true optional: true
'@popperjs/core@2.11.8': {}
'@radix-ui/primitive@1.1.1': {} '@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)': '@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) fdir: 6.5.0(picomatch@4.0.3)
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: {} tiptap-extension-global-drag-handle@0.1.18: {}
tldts-core@6.1.72: {} tldts-core@6.1.72: {}