From d31d1f7bbdf08a1661e714c69f4d31e9b04f464a Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sat, 27 Sep 2025 14:50:27 +0100 Subject: [PATCH] fix bubble menu --- .../components/bubble-menu/bubble-menu.tsx | 2 +- .../components/callout/callout-menu.tsx | 32 +- .../editor/components/drawio/drawio-menu.tsx | 54 +-- .../components/excalidraw/excalidraw-menu.tsx | 57 +-- .../editor/components/image/image-menu.tsx | 54 ++- .../components/table/table-cell-menu.tsx | 22 +- .../editor/components/table/table-menu.tsx | 65 ++-- .../editor/components/video/video-menu.tsx | 35 +- .../features/editor/extensions/extensions.ts | 1 + packages/editor-ext/src/index.ts | 1 + .../tippy-bubble-menu/bubble-menu-plugin.ts | 349 ++++++++++++++++++ .../tippy-bubble-menu/bubble-menu-react.tsx | 72 ++++ .../src/lib/tippy-bubble-menu/index.ts | 1 + packages/editor-ext/tsconfig.json | 1 + 14 files changed, 587 insertions(+), 159 deletions(-) create mode 100644 packages/editor-ext/src/lib/tippy-bubble-menu/bubble-menu-plugin.ts create mode 100644 packages/editor-ext/src/lib/tippy-bubble-menu/bubble-menu-react.tsx create mode 100644 packages/editor-ext/src/lib/tippy-bubble-menu/index.ts diff --git a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx index 0f6153dc..412f2bd7 100644 --- a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx +++ b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx @@ -147,7 +147,7 @@ export const EditorBubbleMenu: FC = (props) => { }; return ( - +
{ 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} > diff --git a/apps/client/src/features/editor/components/drawio/drawio-menu.tsx b/apps/client/src/features/editor/components/drawio/drawio-menu.tsx index 14b06ed5..e5fbb13e 100644 --- a/apps/client/src/features/editor/components/drawio/drawio-menu.tsx +++ b/apps/client/src/features/editor/components/drawio/drawio-menu.tsx @@ -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 (
- {editor.getAttributes("drawio")?.width && ( - + {editorState?.width && ( + )}
diff --git a/apps/client/src/features/editor/components/excalidraw/excalidraw-menu.tsx b/apps/client/src/features/editor/components/excalidraw/excalidraw-menu.tsx index 1717f223..b651f38f 100644 --- a/apps/client/src/features/editor/components/excalidraw/excalidraw-menu.tsx +++ b/apps/client/src/features/editor/components/excalidraw/excalidraw-menu.tsx @@ -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 (
- {editor.getAttributes("excalidraw")?.width && ( - + {editorState?.width && ( + )}
diff --git a/apps/client/src/features/editor/components/image/image-menu.tsx b/apps/client/src/features/editor/components/image/image-menu.tsx index 6795037c..2c206ae3 100644 --- a/apps/client/src/features/editor/components/image/image-menu.tsx +++ b/apps/client/src/features/editor/components/image/image-menu.tsx @@ -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 ( @@ -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"} > @@ -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"} > @@ -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"} > - {editorState?.imageWidth && ( - + {editorState?.width && ( + )} ); diff --git a/apps/client/src/features/editor/components/table/table-cell-menu.tsx b/apps/client/src/features/editor/components/table/table-cell-menu.tsx index c50ad2b3..4f56c764 100644 --- a/apps/client/src/features/editor/components/table/table-cell-menu.tsx +++ b/apps/client/src/features/editor/components/table/table-cell-menu.tsx @@ -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 ( - { - // return appendTo?.current; - // }, - placement: "bottom", - offset: 15, - //zIndex: 99, + tippyOptions={{ + appendTo: () => { + return appendTo?.current; + }, + offset: [0, 15], + zIndex: 99, }} - shouldShow={shouldShow} > @@ -127,7 +123,7 @@ export const TableCellMenu = React.memo( - + ); }, ); diff --git a/apps/client/src/features/editor/components/table/table-menu.tsx b/apps/client/src/features/editor/components/table/table-menu.tsx index 643f67d1..2cfe63c9 100644 --- a/apps/client/src/features/editor/components/table/table-menu.tsx +++ b/apps/client/src/features/editor/components/table/table-menu.tsx @@ -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 ( - @@ -225,7 +218,7 @@ export const TableMenu = React.memo( - + ); }, ); diff --git a/apps/client/src/features/editor/components/video/video-menu.tsx b/apps/client/src/features/editor/components/video/video-menu.tsx index c58de114..5bad1ed9 100644 --- a/apps/client/src/features/editor/components/video/video-menu.tsx +++ b/apps/client/src/features/editor/components/video/video-menu.tsx @@ -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 (
- {editorState?.videoWidth && ( + {editorState?.width && ( )} diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index 49e0df0b..ecba77e9 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -197,6 +197,7 @@ export const mainExtensions = [ }), CustomCodeBlock.configure({ view: CodeBlockView, + //@ts-ignore lowlight, HTMLAttributes: { spellcheck: false, diff --git a/packages/editor-ext/src/index.ts b/packages/editor-ext/src/index.ts index a0efaa1b..2ace4511 100644 --- a/packages/editor-ext/src/index.ts +++ b/packages/editor-ext/src/index.ts @@ -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"; diff --git a/packages/editor-ext/src/lib/tippy-bubble-menu/bubble-menu-plugin.ts b/packages/editor-ext/src/lib/tippy-bubble-menu/bubble-menu-plugin.ts new file mode 100644 index 00000000..f9d6d092 --- /dev/null +++ b/packages/editor-ext/src/lib/tippy-bubble-menu/bubble-menu-plugin.ts @@ -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; + + /** + * 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; + + public updateDelay: number; + + private updateDebounceTimer: number | undefined; + + public shouldShow: Exclude = ({ + 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 }), + }); +}; diff --git a/packages/editor-ext/src/lib/tippy-bubble-menu/bubble-menu-react.tsx b/packages/editor-ext/src/lib/tippy-bubble-menu/bubble-menu-react.tsx new file mode 100644 index 00000000..d2f7b008 --- /dev/null +++ b/packages/editor-ext/src/lib/tippy-bubble-menu/bubble-menu-react.tsx @@ -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 = Pick, K> & Omit; + +export type BubbleMenuProps = Omit< + Optional, + "element" | "editor" +> & { + editor: BubbleMenuPluginProps["editor"] | null; + className?: string; + children: React.ReactNode; + updateDelay?: number; +}; + +export const BubbleMenu = (props: BubbleMenuProps) => { + const [element, setElement] = useState(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 ( +
+ {props.children} +
+ ); +}; diff --git a/packages/editor-ext/src/lib/tippy-bubble-menu/index.ts b/packages/editor-ext/src/lib/tippy-bubble-menu/index.ts new file mode 100644 index 00000000..b385802d --- /dev/null +++ b/packages/editor-ext/src/lib/tippy-bubble-menu/index.ts @@ -0,0 +1 @@ +export { BubbleMenu as TiptapTippyBubbleMenu } from "./bubble-menu-react"; diff --git a/packages/editor-ext/tsconfig.json b/packages/editor-ext/tsconfig.json index efbfcd61..974fea06 100644 --- a/packages/editor-ext/tsconfig.json +++ b/packages/editor-ext/tsconfig.json @@ -8,6 +8,7 @@ "experimentalDecorators": true, "allowSyntheticDefaultImports": true, "target": "ES2022", + "jsx": "react-jsx", "sourceMap": true, "outDir": "./dist", "baseUrl": "./",