fix bubble menu

This commit is contained in:
Philipinho
2025-09-27 14:50:27 +01:00
parent cc0146d0cd
commit d31d1f7bbd
14 changed files with 587 additions and 159 deletions
@@ -147,7 +147,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
};
return (
<BubbleMenu {...bubbleMenuProps}>
<BubbleMenu {...bubbleMenuProps} style={{ zIndex: 200, position: "relative"}}>
<div className={classes.bubbleMenu}>
<NodeSelector
editor={props.editor}
@@ -38,7 +38,6 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
},
});
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
@@ -50,17 +49,28 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
[editor],
);
const getReferenceClientRect = useCallback(() => {
const getReferencedVirtualElement = useCallback(() => {
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "callout";
const parent = findParentNode(predicate)(selection);
if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
return dom.getBoundingClientRect();
const domRect = dom.getBoundingClientRect();
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}
return posToDOMRect(editor.view, selection.from, selection.to);
console.log('callout')
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}, [editor]);
const setCalloutType = useCallback(
@@ -109,21 +119,13 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
editor={editor}
pluginKey={`callout-menu}`}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
// getReferenceClientRect,
placement: "right-end",
// offset: 233,
placement: "bottom",
// offset: 233, // // offset: [0, 10],
// zIndex: 99,
flip: false,
}}
//tippyOptions={{
// getReferenceClientRect,
// offset: [0, 10],
// placement: "bottom",
// zIndex: 99,
// popperOptions: {
// modifiers: [{ name: "flip", enabled: false }],
// },
shouldShow={shouldShow}
>
<ActionIcon.Group className="actionIconGroup">
@@ -1,5 +1,5 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect } from "@tiptap/react";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback } from "react";
import { Node as PMNode } from "prosemirror-model";
import {
@@ -20,17 +20,40 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
[editor],
);
const getReferenceClientRect = useCallback(() => {
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const drawioAttr = ctx.editor.getAttributes("drawio");
return {
isDrawio: ctx.editor.isActive("drawio"),
width: drawioAttr?.width ? parseInt(drawioAttr.width) : null,
};
},
});
const getReferencedVirtualElement = useCallback(() => {
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "drawio";
const parent = findParentNode(predicate)(selection);
if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
return dom.getBoundingClientRect();
const domRect = dom.getBoundingClientRect();
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}
return posToDOMRect(editor.view, selection.from, selection.to);
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}, [editor]);
const onWidthChange = useCallback(
@@ -43,24 +66,14 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
return (
<BaseBubbleMenu
editor={editor}
pluginKey={`drawio-menu}`}
pluginKey={`drawio-menu`}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
//getReferenceClientRect,
placement: "bottom",
placement: "top",
offset: 8,
// zIndex: 99,
flip: false,
}}
// tippyOptions={{
// getReferenceClientRect,
// offset: [0, 8],
// zIndex: 99,
// popperOptions: {
// modifiers: [{ name: 'flip', enabled: false }],
// },
// plugins: [sticky],
// sticky: 'popper',
shouldShow={shouldShow}
>
<div
@@ -70,11 +83,8 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
alignItems: "center",
}}
>
{editor.getAttributes("drawio")?.width && (
<NodeWidthResize
onChange={onWidthChange}
value={parseInt(editor.getAttributes("drawio").width)}
/>
{editorState?.width && (
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
)}
</div>
</BaseBubbleMenu>
@@ -1,5 +1,5 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect } from "@tiptap/react";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback } from "react";
import { Node as PMNode } from "prosemirror-model";
import {
@@ -22,17 +22,40 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
[editor],
);
const getReferenceClientRect = useCallback(() => {
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const excalidrawAttr = ctx.editor.getAttributes("excalidraw");
return {
isExcalidraw: ctx.editor.isActive("excalidraw"),
width: excalidrawAttr?.width ? parseInt(excalidrawAttr.width) : null,
};
},
});
const getReferencedVirtualElement = useCallback(() => {
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "excalidraw";
const parent = findParentNode(predicate)(selection);
if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
return dom.getBoundingClientRect();
const domRect = dom.getBoundingClientRect();
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}
return posToDOMRect(editor.view, selection.from, selection.to);
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}, [editor]);
const onWidthChange = useCallback(
@@ -45,27 +68,14 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
return (
<BaseBubbleMenu
editor={editor}
pluginKey={`excalidraw-menu}`}
pluginKey={`excalidraw-menu`}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
//getReferenceClientRect,
placement: "bottom",
placement: "top",
offset: 8,
// zIndex: 99,
// plugins: [sticky],
// sticky: 'popper',
flip: false,
}}
// tippyOptions={{
// getReferenceClientRect,
// offset: [0, 8],
// zIndex: 99,
// popperOptions: {
// modifiers: [{ name: 'flip', enabled: false }],
// },
// plugins: [sticky],
// sticky: 'popper',
// }}
shouldShow={shouldShow}
>
<div
@@ -75,11 +85,8 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
alignItems: "center",
}}
>
{editor.getAttributes("excalidraw")?.width && (
<NodeWidthResize
onChange={onWidthChange}
value={parseInt(editor.getAttributes("excalidraw").width)}
/>
{editorState?.width && (
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
)}
</div>
</BaseBubbleMenu>
@@ -20,7 +20,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
const editorState = useEditorState({
editor,
selector: ctx => {
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
@@ -32,7 +32,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
isAlignLeft: ctx.editor.isActive("image", { align: "left" }),
isAlignCenter: ctx.editor.isActive("image", { align: "center" }),
isAlignRight: ctx.editor.isActive("image", { align: "right" }),
imageWidth: imageAttrs?.width ? parseInt(imageAttrs.width) : null,
width: imageAttrs?.width ? parseInt(imageAttrs.width) : null,
};
},
});
@@ -48,17 +48,25 @@ export function ImageMenu({ editor }: EditorMenuProps) {
[editor],
);
const getReferenceClientRect = useCallback(() => {
const getReferencedVirtualElement = useCallback(() => {
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "image";
const parent = findParentNode(predicate)(selection);
if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
return dom.getBoundingClientRect();
const domRect = dom.getBoundingClientRect();
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}
return posToDOMRect(editor.view, selection.from, selection.to);
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}, [editor]);
const alignImageLeft = useCallback(() => {
@@ -99,25 +107,14 @@ export function ImageMenu({ editor }: EditorMenuProps) {
return (
<BaseBubbleMenu
editor={editor}
pluginKey={`image-menu}`}
pluginKey={`image-menu`}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
// getReferenceClientRect,
placement: "bottom",
placement: "top",
offset: 8,
//zIndex: 99,
flip: false,
}}
// tippyOptions={{
// getReferenceClientRect,
// offset: [0, 8],
// zIndex: 99,
// popperOptions: {
// modifiers: [{ name: "flip", enabled: false }],
// },
// plugins: [sticky],
// sticky: "popper",
// }}
shouldShow={shouldShow}
>
<ActionIcon.Group className="actionIconGroup">
@@ -126,9 +123,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
onClick={alignImageLeft}
size="lg"
aria-label={t("Align left")}
variant={
editorState?.isAlignLeft ? "light" : "default"
}
variant={editorState?.isAlignLeft ? "light" : "default"}
>
<IconLayoutAlignLeft size={18} />
</ActionIcon>
@@ -139,9 +134,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
onClick={alignImageCenter}
size="lg"
aria-label={t("Align center")}
variant={
editorState?.isAlignCenter ? "light" : "default"
}
variant={editorState?.isAlignCenter ? "light" : "default"}
>
<IconLayoutAlignCenter size={18} />
</ActionIcon>
@@ -152,20 +145,15 @@ export function ImageMenu({ editor }: EditorMenuProps) {
onClick={alignImageRight}
size="lg"
aria-label={t("Align right")}
variant={
editorState?.isAlignRight ? "light" : "default"
}
variant={editorState?.isAlignRight ? "light" : "default"}
>
<IconLayoutAlignRight size={18} />
</ActionIcon>
</Tooltip>
</ActionIcon.Group>
{editorState?.imageWidth && (
<NodeWidthResize
onChange={onWidthChange}
value={editorState.imageWidth}
/>
{editorState?.width && (
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
)}
</BaseBubbleMenu>
);
@@ -1,11 +1,9 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import React, { useCallback } from "react";
import {
EditorMenuProps,
ShouldShowProps,
} from "@/features/editor/components/table/types/types.ts";
import { isCellSelection } from "@docmost/editor-ext";
import { isCellSelection, TiptapTippyBubbleMenu } from '@docmost/editor-ext';
import { ActionIcon, Tooltip } from "@mantine/core";
import {
IconBoxMargin,
@@ -53,19 +51,17 @@ export const TableCellMenu = React.memo(
}, [editor]);
return (
<BaseBubbleMenu
<TiptapTippyBubbleMenu
editor={editor}
pluginKey="table-cell-menu"
updateDelay={0}
options={{
//appendTo: () => {
// return appendTo?.current;
// },
placement: "bottom",
offset: 15,
//zIndex: 99,
tippyOptions={{
appendTo: () => {
return appendTo?.current;
},
offset: [0, 15],
zIndex: 99,
}}
shouldShow={shouldShow}
>
<ActionIcon.Group>
@@ -127,7 +123,7 @@ export const TableCellMenu = React.memo(
</ActionIcon>
</Tooltip>
</ActionIcon.Group>
</BaseBubbleMenu>
</TiptapTippyBubbleMenu>
);
},
);
@@ -1,8 +1,6 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { posToDOMRect, findParentNode } from "@tiptap/react";
import { Node as PMNode } from "@tiptap/pm/model";
import React, { useCallback } from "react";
import {
EditorMenuProps,
ShouldShowProps,
@@ -19,7 +17,7 @@ import {
IconTableRow,
IconTrashX,
} from "@tabler/icons-react";
import { isCellSelection } from "@docmost/editor-ext";
import { isCellSelection, TiptapTippyBubbleMenu } from "@docmost/editor-ext";
import { useTranslation } from "react-i18next";
export const TableMenu = React.memo(
@@ -86,42 +84,37 @@ export const TableMenu = React.memo(
}, [editor]);
return (
<BaseBubbleMenu
<TiptapTippyBubbleMenu
editor={editor}
pluginKey="table-menu"
updateDelay={0}
options={{
placement: "bottom",
offset: 15,
//zIndex: 99,
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 },
},
},
],
},
}}
//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 },
// },
// },
// ],
// },
// }}
shouldShow={shouldShow}
>
<ActionIcon.Group>
@@ -225,7 +218,7 @@ export const TableMenu = React.memo(
</ActionIcon>
</Tooltip>
</ActionIcon.Group>
</BaseBubbleMenu>
</TiptapTippyBubbleMenu>
);
},
);
@@ -17,26 +17,26 @@ import { useTranslation } from "react-i18next";
export function VideoMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const editorState = useEditorState({
editor,
selector: ctx => {
if (!ctx.editor) {
return null;
}
const videoAttrs = ctx.editor.getAttributes("video");
return {
isVideo: ctx.editor.isActive("video"),
isAlignLeft: ctx.editor.isActive("video", { align: "left" }),
isAlignCenter: ctx.editor.isActive("video", { align: "center" }),
isAlignRight: ctx.editor.isActive("video", { align: "right" }),
videoWidth: videoAttrs?.width ? parseInt(videoAttrs.width) : null,
width: videoAttrs?.width ? parseInt(videoAttrs.width) : null,
};
},
});
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
@@ -48,17 +48,25 @@ export function VideoMenu({ editor }: EditorMenuProps) {
[editor],
);
const getReferenceClientRect = useCallback(() => {
const getReferencedVirtualElement = useCallback(() => {
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "video";
const parent = findParentNode(predicate)(selection);
if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
return dom.getBoundingClientRect();
const domRect = dom.getBoundingClientRect();
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}
return posToDOMRect(editor.view, selection.from, selection.to);
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}, [editor]);
const alignVideoLeft = useCallback(() => {
@@ -99,13 +107,12 @@ export function VideoMenu({ editor }: EditorMenuProps) {
return (
<BaseBubbleMenu
editor={editor}
pluginKey={`video-menu}`}
pluginKey={`video-menu`}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
//getReferenceClientRect,
placement: "bottom",
placement: "top",
offset: 8,
//zIndex: 99,
flip: false,
}}
shouldShow={shouldShow}
@@ -151,10 +158,10 @@ export function VideoMenu({ editor }: EditorMenuProps) {
</Tooltip>
</ActionIcon.Group>
{editorState?.videoWidth && (
{editorState?.width && (
<NodeWidthResize
onChange={onWidthChange}
value={editorState.videoWidth}
value={editorState.width}
/>
)}
</BaseBubbleMenu>
@@ -197,6 +197,7 @@ export const mainExtensions = [
}),
CustomCodeBlock.configure({
view: CodeBlockView,
//@ts-ignore
lowlight,
HTMLAttributes: {
spellcheck: false,
+1
View File
@@ -20,3 +20,4 @@ export * from "./lib/markdown";
export * from "./lib/search-and-replace";
export * from "./lib/embed-provider";
export * from "./lib/subpages";
export * from "./lib/tippy-bubble-menu";
@@ -0,0 +1,349 @@
// 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 }),
});
};
@@ -0,0 +1,72 @@
// 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>
);
};
@@ -0,0 +1 @@
export { BubbleMenu as TiptapTippyBubbleMenu } from "./bubble-menu-react";
+1
View File
@@ -8,6 +8,7 @@
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2022",
"jsx": "react-jsx",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",