diff --git a/packages/editor/src/core/components/menus/bubble-menu/alignment-selector.tsx b/packages/editor/src/core/components/menus/bubble-menu/alignment-selector.tsx index b168525a79..aeea0b3177 100644 --- a/packages/editor/src/core/components/menus/bubble-menu/alignment-selector.tsx +++ b/packages/editor/src/core/components/menus/bubble-menu/alignment-selector.tsx @@ -57,7 +57,7 @@ export const TextAlignmentSelector: React.FC = (props) => { ]; return ( -
+
{textAlignmentOptions.map((item) => ( ))}
diff --git a/packages/editor/src/core/components/menus/bubble-menu/bubble-menu-plugin.ts b/packages/editor/src/core/components/menus/bubble-menu/bubble-menu-plugin.ts new file mode 100644 index 0000000000..cab0af3f4f --- /dev/null +++ b/packages/editor/src/core/components/menus/bubble-menu/bubble-menu-plugin.ts @@ -0,0 +1,535 @@ +import { + type Middleware, + arrow, + autoPlacement, + computePosition, + flip, + hide, + inline, + offset, + shift, + size, +} from "@floating-ui/dom"; +import { type Editor, isTextSelection, posToDOMRect } from "@tiptap/core"; +import { Plugin, PluginKey, type EditorState, type PluginView } from "@tiptap/pm/state"; +import { CellSelection } from "@tiptap/pm/tables"; +import type { EditorView } from "@tiptap/pm/view"; + +function combineDOMRects(rect1: DOMRect, rect2: DOMRect): DOMRect { + const top = Math.min(rect1.top, rect2.top); + const bottom = Math.max(rect1.bottom, rect2.bottom); + const left = Math.min(rect1.left, rect2.left); + const right = Math.max(rect1.right, rect2.right); + const width = right - left; + const height = bottom - top; + const x = left; + const y = top; + return new DOMRect(x, y, width, height); +} + +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 delay in milliseconds before the menu should be updated. + * This can be useful to prevent performance issues. + * @type {number} + * @default 250 + */ + updateDelay?: number; + + /** + * The delay in milliseconds before the menu position should be updated on window resize. + * This can be useful to prevent performance issues. + * @type {number} + * @default 60 + */ + resizeDelay?: 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; + + /** + * FloatingUI options. + */ + options?: { + strategy?: "absolute" | "fixed"; + placement?: + | "top" + | "right" + | "bottom" + | "left" + | "top-start" + | "top-end" + | "right-start" + | "right-end" + | "bottom-start" + | "bottom-end" + | "left-start" + | "left-end"; + offset?: Parameters[0] | boolean; + flip?: Parameters[0] | boolean; + shift?: Parameters[0] | boolean; + arrow?: Parameters[0] | false; + size?: Parameters[0] | boolean; + autoPlacement?: Parameters[0] | boolean; + hide?: Parameters[0] | boolean; + inline?: Parameters[0] | boolean; + + onShow?: () => void; + onHide?: () => void; + onUpdate?: () => void; + onDestroy?: () => void; + }; +} + +export type BubbleMenuViewProps = BubbleMenuPluginProps & { + view: EditorView; +}; + +export class BubbleMenuView implements PluginView { + public editor: Editor; + + public element: HTMLElement; + + public view: EditorView; + + public preventHide = false; + + public updateDelay: number; + + public resizeDelay: number; + + private updateDebounceTimer: number | undefined; + + private resizeDebounceTimer: number | undefined; + + private isVisible = false; + + private isSelecting = false; + + private selectionStarted = false; + + private floatingUIOptions: NonNullable = { + strategy: "absolute", + placement: "top", + offset: 8, + flip: {}, + shift: {}, + arrow: false, + size: false, + autoPlacement: false, + hide: false, + inline: false, + onShow: undefined, + onHide: undefined, + onUpdate: undefined, + onDestroy: 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; + }; + + get middlewares() { + const middlewares: Middleware[] = []; + + if (this.floatingUIOptions.flip) { + middlewares.push( + flip(typeof this.floatingUIOptions.flip !== "boolean" ? this.floatingUIOptions.flip : undefined) + ); + } + + if (this.floatingUIOptions.shift) { + middlewares.push( + shift(typeof this.floatingUIOptions.shift !== "boolean" ? this.floatingUIOptions.shift : undefined) + ); + } + + if (this.floatingUIOptions.offset) { + middlewares.push( + offset(typeof this.floatingUIOptions.offset !== "boolean" ? this.floatingUIOptions.offset : undefined) + ); + } + + if (this.floatingUIOptions.arrow) { + middlewares.push(arrow(this.floatingUIOptions.arrow)); + } + + if (this.floatingUIOptions.size) { + middlewares.push( + size(typeof this.floatingUIOptions.size !== "boolean" ? this.floatingUIOptions.size : undefined) + ); + } + + if (this.floatingUIOptions.autoPlacement) { + middlewares.push( + autoPlacement( + typeof this.floatingUIOptions.autoPlacement !== "boolean" ? this.floatingUIOptions.autoPlacement : undefined + ) + ); + } + + if (this.floatingUIOptions.hide) { + middlewares.push( + hide(typeof this.floatingUIOptions.hide !== "boolean" ? this.floatingUIOptions.hide : undefined) + ); + } + + if (this.floatingUIOptions.inline) { + middlewares.push( + inline(typeof this.floatingUIOptions.inline !== "boolean" ? this.floatingUIOptions.inline : undefined) + ); + } + + return middlewares; + } + + constructor({ + editor, + element, + view, + updateDelay = 250, + resizeDelay = 60, + shouldShow, + options, + }: BubbleMenuViewProps) { + this.editor = editor; + this.element = element; + this.view = view; + this.updateDelay = updateDelay; + this.resizeDelay = resizeDelay; + + this.floatingUIOptions = { + ...this.floatingUIOptions, + ...options, + }; + + if (shouldShow) { + this.shouldShow = shouldShow; + } + + // Event listeners + 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); + window.addEventListener("resize", this.resizeHandler); + + // Add mousedown/mouseup listeners for selection tracking + this.view.dom.addEventListener("mousedown", this.editorMousedownHandler); + this.view.dom.addEventListener("mouseup", this.editorMouseupHandler); + + this.update(view, view.state); + + // Don't show initially even if there's a selection + // Wait for user interaction + } + + mousedownHandler = () => { + this.preventHide = true; + }; + + dragstartHandler = () => { + this.hide(); + }; + + editorMousedownHandler = () => { + this.isSelecting = true; + this.selectionStarted = true; + // Hide menu when starting a new selection + this.hide(); + }; + + editorMouseupHandler = () => { + if (!this.isSelecting) { + return; + } + + this.isSelecting = false; + + // Use setTimeout to ensure selection is finalized + setTimeout(() => { + const shouldShow = this.getShouldShow(); + if (shouldShow && this.selectionStarted) { + this.updatePosition(); + this.show(); + } + this.selectionStarted = false; + }, 0); + }; + + /** + * Handles the window resize event to update the position of the bubble menu. + * It uses a debounce mechanism to prevent excessive updates. + * The delay is defined by the `resizeDelay` property. + */ + resizeHandler = () => { + if (this.resizeDebounceTimer) { + clearTimeout(this.resizeDebounceTimer); + } + + this.resizeDebounceTimer = window.setTimeout(() => { + if (this.isVisible) { + this.updatePosition(); + } + }, this.resizeDelay); + }; + + 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(); + }; + + updatePosition() { + const { selection } = this.editor.state; + let virtualElement = { + getBoundingClientRect: () => posToDOMRect(this.view, selection.from, selection.to), + }; + + // this is a special case for cell selections + if (selection instanceof CellSelection) { + const { $anchorCell, $headCell } = selection; + + const from = $anchorCell ? $anchorCell.pos : $headCell!.pos; + const to = $headCell ? $headCell.pos : $anchorCell!.pos; + + const fromDOM = this.view.nodeDOM(from); + const toDOM = this.view.nodeDOM(to); + + if (!fromDOM || !toDOM) { + return; + } + + const clientRect = + fromDOM === toDOM + ? (fromDOM as HTMLElement).getBoundingClientRect() + : combineDOMRects( + (fromDOM as HTMLElement).getBoundingClientRect(), + (toDOM as HTMLElement).getBoundingClientRect() + ); + + virtualElement = { + getBoundingClientRect: () => clientRect, + }; + } + + computePosition(virtualElement, this.element, { + placement: this.floatingUIOptions.placement, + strategy: this.floatingUIOptions.strategy, + middleware: this.middlewares, + }).then(({ x, y, strategy }) => { + this.element.style.width = "max-content"; + this.element.style.position = strategy; + this.element.style.left = `${x}px`; + this.element.style.top = `${y}px`; + + if (this.isVisible && this.floatingUIOptions.onUpdate) { + this.floatingUIOptions.onUpdate(); + } + }); + } + + update(view: EditorView, oldState?: EditorState) { + const { state } = view; + const hasValidSelection = state.selection.from !== state.selection.to; + + // Don't update while user is actively selecting + if (this.isSelecting) { + return; + } + + 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); + }; + + getShouldShow(oldState?: EditorState) { + const { state } = this.view; + const { selection } = state; + + // 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: this.view, + state, + oldState, + from, + to, + }); + + return shouldShow; + } + + updateHandler = (view: EditorView, selectionChanged: boolean, docChanged: boolean, oldState?: EditorState) => { + const { composing } = view; + + const isSame = !selectionChanged && !docChanged; + + if (composing || isSame || this.isSelecting) { + return; + } + + const shouldShow = this.getShouldShow(oldState); + + if (!shouldShow) { + this.hide(); + return; + } + + // Only update position for already visible menu + // New selections are handled by mouseup + if (this.isVisible) { + this.updatePosition(); + } + }; + + show() { + if (this.isVisible) { + return; + } + + this.element.style.visibility = "visible"; + this.element.style.opacity = "1"; + // attach to editor's parent element + this.view.dom.parentElement?.appendChild(this.element); + + if (this.floatingUIOptions.onShow) { + this.floatingUIOptions.onShow(); + } + + this.isVisible = true; + } + + hide() { + if (!this.isVisible) { + return; + } + + this.element.style.visibility = "hidden"; + this.element.style.opacity = "0"; + // remove from the parent element + this.element.remove(); + + if (this.floatingUIOptions.onHide) { + this.floatingUIOptions.onHide(); + } + + this.isVisible = false; + } + + destroy() { + this.hide(); + this.element.removeEventListener("mousedown", this.mousedownHandler, { capture: true }); + this.view.dom.removeEventListener("dragstart", this.dragstartHandler); + this.view.dom.removeEventListener("mousedown", this.editorMousedownHandler); + this.view.dom.removeEventListener("mouseup", this.editorMouseupHandler); + window.removeEventListener("resize", this.resizeHandler); + this.editor.off("focus", this.focusHandler); + this.editor.off("blur", this.blurHandler); + + if (this.floatingUIOptions.onDestroy) { + this.floatingUIOptions.onDestroy(); + } + } +} + +export const BubbleMenuPlugin = (options: BubbleMenuPluginProps) => + new Plugin({ + key: typeof options.pluginKey === "string" ? new PluginKey(options.pluginKey) : options.pluginKey, + view: (view) => new BubbleMenuView({ view, ...options }), + }); diff --git a/packages/editor/src/core/components/menus/bubble-menu/bubble-menu-renderer.tsx b/packages/editor/src/core/components/menus/bubble-menu/bubble-menu-renderer.tsx new file mode 100644 index 0000000000..4bcf15744c --- /dev/null +++ b/packages/editor/src/core/components/menus/bubble-menu/bubble-menu-renderer.tsx @@ -0,0 +1,67 @@ +import React, { useEffect, useRef } from "react"; +import { createPortal } from "react-dom"; +import { type BubbleMenuPluginProps, BubbleMenuPlugin } from "./bubble-menu-plugin"; + +type Optional = Pick, K> & Omit; + +export type BubbleMenuProps = Optional, "element">, "editor"> & + React.HTMLAttributes; + +export const BubbleMenu = React.forwardRef( + ( + { pluginKey = "bubbleMenu", editor, updateDelay, resizeDelay, shouldShow = null, options, children, ...restProps }, + ref + ) => { + const menuEl = useRef(document.createElement("div")); + + if (typeof ref === "function") { + ref(menuEl.current); + } else if (ref) { + ref.current = menuEl.current; + } + + useEffect(() => { + const bubbleMenuElement = menuEl.current; + + bubbleMenuElement.style.visibility = "hidden"; + bubbleMenuElement.style.position = "absolute"; + + if (editor?.isDestroyed) { + return; + } + + const attachToEditor = editor; + + if (!attachToEditor) { + console.warn( + "BubbleMenu component is not rendered inside of an editor component or does not have editor prop." + ); + return; + } + + const plugin = BubbleMenuPlugin({ + updateDelay, + resizeDelay, + editor: attachToEditor, + element: bubbleMenuElement, + pluginKey, + shouldShow, + options, + }); + + attachToEditor.registerPlugin(plugin); + + return () => { + attachToEditor.unregisterPlugin(pluginKey); + window.requestAnimationFrame(() => { + if (bubbleMenuElement.parentNode) { + bubbleMenuElement.parentNode.removeChild(bubbleMenuElement); + } + }); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [editor]); + + return createPortal(
{children}
, menuEl.current); + } +); diff --git a/packages/editor/src/core/components/menus/bubble-menu/bubble-menu.ts b/packages/editor/src/core/components/menus/bubble-menu/bubble-menu.ts new file mode 100644 index 0000000000..c15e7c56ca --- /dev/null +++ b/packages/editor/src/core/components/menus/bubble-menu/bubble-menu.ts @@ -0,0 +1,45 @@ +import { Extension } from "@tiptap/core"; + +import { BubbleMenuPlugin, type BubbleMenuPluginProps } from "./bubble-menu-plugin"; + +export type BubbleMenuOptions = Omit & { + /** + * The DOM element that contains your menu. + * @type {HTMLElement} + * @default null + */ + element: HTMLElement | null; +}; + +/** + * This extension allows you to create a bubble menu. + * @see https://tiptap.dev/api/extensions/bubble-menu + */ +export const BubbleMenu = Extension.create({ + name: "bubbleMenu", + + addOptions() { + return { + element: null, + pluginKey: "bubbleMenu", + updateDelay: undefined, + shouldShow: null, + }; + }, + + addProseMirrorPlugins() { + if (!this.options.element) { + return []; + } + + return [ + BubbleMenuPlugin({ + pluginKey: this.options.pluginKey, + editor: this.editor, + element: this.options.element, + updateDelay: this.options.updateDelay, + shouldShow: this.options.shouldShow, + }), + ]; + }, +}); diff --git a/packages/editor/src/core/components/menus/bubble-menu/color-selector.tsx b/packages/editor/src/core/components/menus/bubble-menu/color-selector.tsx index ced7ea1793..0d9efb525c 100644 --- a/packages/editor/src/core/components/menus/bubble-menu/color-selector.tsx +++ b/packages/editor/src/core/components/menus/bubble-menu/color-selector.tsx @@ -1,3 +1,4 @@ +import { Popover } from "@headlessui/react"; import { Editor } from "@tiptap/react"; import { ALargeSmall, Ban } from "lucide-react"; import { Dispatch, FC, SetStateAction } from "react"; @@ -7,6 +8,7 @@ import { cn } from "@plane/utils"; import { COLORS_LIST } from "@/constants/common"; // helpers import { BackgroundColorItem, TextColorItem } from "../menu-items"; + import { EditorStateType } from "./root"; type Props = { @@ -17,93 +19,103 @@ type Props = { }; export const BubbleMenuColorSelector: FC = (props) => { - const { editor, isOpen, setIsOpen, editorState } = props; + const { editor, editorState, isOpen, setIsOpen } = props; const activeTextColor = editorState.color; const activeBackgroundColor = editorState.backgroundColor; return ( -
- - {isOpen && ( -
-
-

Text colors

-
- {COLORS_LIST.map((color) => ( - -
+ className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity" + style={{ + backgroundColor: color.textColor, + }} + onClick={() => TextColorItem(editor).command({ color: color.key })} + /> + ))} +
-
-

Background colors

-
- {COLORS_LIST.map((color) => ( -
+
+

Background colors

+
+ {COLORS_LIST.map((color) => ( -
+ className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity" + style={{ + backgroundColor: color.backgroundColor, + }} + onClick={() => BackgroundColorItem(editor).command({ color: color.key })} + /> + ))} +
-
- )} -
+
+ + ); }; diff --git a/packages/editor/src/core/components/menus/bubble-menu/link-selector.tsx b/packages/editor/src/core/components/menus/bubble-menu/link-selector.tsx index 6f582f89c6..2001e3c128 100644 --- a/packages/editor/src/core/components/menus/bubble-menu/link-selector.tsx +++ b/packages/editor/src/core/components/menus/bubble-menu/link-selector.tsx @@ -1,6 +1,7 @@ +import { Popover } from "@headlessui/react"; import { Editor } from "@tiptap/core"; import { Check, Link, Trash2 } from "lucide-react"; -import { Dispatch, FC, SetStateAction, useCallback, useRef, useState } from "react"; +import { FC, useCallback, useRef, useState, Dispatch, SetStateAction } from "react"; // plane imports import { cn } from "@plane/utils"; // constants @@ -38,80 +39,77 @@ export const BubbleMenuLinkSelector: FC = (props) => { }, [editor, inputRef, setIsOpen]); return ( -
- - {isOpen && ( -
-
- e.stopPropagation()} - className="flex-1 border-r border-custom-border-300 bg-custom-background-100 py-2 px-1.5 text-xs outline-none placeholder:text-custom-text-400 rounded" - defaultValue={editor.getAttributes("link").href || ""} - onKeyDown={(e) => { - setError(false); - if (e.key === "Enter") { - e.preventDefault(); - handleLinkSubmit(); - } + + Link + + + + +
+ e.stopPropagation()} + className="flex-1 border-r border-custom-border-300 bg-custom-background-100 py-2 px-1.5 text-xs outline-none placeholder:text-custom-text-400 rounded" + defaultValue={editor.getAttributes("link").href || ""} + onKeyDown={(e) => { + setError(false); + if (e.key === "Enter") { + e.preventDefault(); + handleLinkSubmit(); + } + }} + onFocus={() => setError(false)} + autoFocus + /> + {editor.getAttributes("link").href ? ( + - ) : ( - - )} -
- {error && ( -

- Please enter a valid URL -

+ > + + + ) : ( + )}
- )} -
+ {error &&

Please enter a valid URL

} + + ); }; diff --git a/packages/editor/src/core/components/menus/bubble-menu/node-selector.tsx b/packages/editor/src/core/components/menus/bubble-menu/node-selector.tsx index 564f7d97ca..2ee7948ba0 100644 --- a/packages/editor/src/core/components/menus/bubble-menu/node-selector.tsx +++ b/packages/editor/src/core/components/menus/bubble-menu/node-selector.tsx @@ -1,8 +1,8 @@ import { Editor } from "@tiptap/react"; import { Check, ChevronDown } from "lucide-react"; -import { Dispatch, FC, SetStateAction } from "react"; -// plane utils -import { cn } from "@plane/utils"; +import { FC, Dispatch, SetStateAction } from "react"; +// plane imports +import { CustomMenu } from "@plane/ui"; // components import { BulletListItem, @@ -29,7 +29,7 @@ type Props = { }; export const BubbleMenuNodeSelector: FC = (props) => { - const { editor, isOpen, setIsOpen } = props; + const { editor, setIsOpen } = props; const items: EditorMenuItem[] = [ TextItem(editor), @@ -51,45 +51,36 @@ export const BubbleMenuNodeSelector: FC = (props) => { }; return ( -
- - {isOpen && ( -
- {items.map((item) => ( - - ))} -
- )} + {items.map((item) => ( + { + item.command(); + setIsOpen(false); + e.stopPropagation(); + }} + > + + + {item.name} + + {activeItem?.name === item.name && } + + ))} +
); }; diff --git a/packages/editor/src/core/components/menus/bubble-menu/root.tsx b/packages/editor/src/core/components/menus/bubble-menu/root.tsx index a3fa3e2d7d..6d6ceea8cd 100644 --- a/packages/editor/src/core/components/menus/bubble-menu/root.tsx +++ b/packages/editor/src/core/components/menus/bubble-menu/root.tsx @@ -1,32 +1,29 @@ -import { BubbleMenu, BubbleMenuProps, Editor, isNodeSelection, useEditorState } from "@tiptap/react"; -import { FC, useEffect, useState, useRef } from "react"; -// plane utils +import { isNodeSelection, type Editor } from "@tiptap/core"; +import { useEditorState } from "@tiptap/react"; +import { useState } from "react"; import { cn } from "@plane/utils"; -// components +import { COLORS_LIST } from "@/constants/common"; +import { CORE_EXTENSIONS } from "@/constants/extension"; +import { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection"; +import { TEditorCommands } from "@/types"; import { + TextColorItem, BackgroundColorItem, BoldItem, - BubbleMenuColorSelector, - BubbleMenuLinkSelector, - BubbleMenuNodeSelector, CodeItem, EditorMenuItem, ItalicItem, StrikeThroughItem, TextAlignItem, - TextColorItem, UnderLineItem, -} from "@/components/menus"; -// constants -import { COLORS_LIST } from "@/constants/common"; -import { CORE_EXTENSIONS } from "@/constants/extension"; -// extensions -import { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection"; -// local components +} from "../menu-items"; import { TextAlignmentSelector } from "./alignment-selector"; -import { TEditorCommands } from "@/types"; +import { BubbleMenu } from "./bubble-menu-renderer"; +import { BubbleMenuColorSelector } from "./color-selector"; +import { BubbleMenuLinkSelector } from "./link-selector"; +import { BubbleMenuNodeSelector } from "./node-selector"; -type EditorBubbleMenuProps = Omit; +type EditorBubbleMenuProps = { editor: Editor }; export interface EditorStateType { code: boolean; @@ -39,35 +36,53 @@ export interface EditorStateType { center: boolean; color: { key: string; label: string; textColor: string; backgroundColor: string } | undefined; backgroundColor: - | { - key: string; - label: string; - textColor: string; - backgroundColor: string; - } - | undefined; + | { + key: string; + label: string; + textColor: string; + backgroundColor: string; + } + | undefined; } -export const EditorBubbleMenu: FC = (props: { editor: Editor }) => { - const menuRef = useRef(null); +export const EditorBubbleMenu = (bubbleMenuProps: EditorBubbleMenuProps) => { + const { editor } = bubbleMenuProps; const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false); const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false); const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false); - const [isSelecting, setIsSelecting] = useState(false); + + const bubbleMenuPropsInternal = { + shouldShow: ({ state, editor }) => { + const { selection } = state; + const { empty } = selection; + + if ( + empty || + !editor.isEditable || + editor.isActive(CORE_EXTENSIONS.IMAGE) || + editor.isActive(CORE_EXTENSIONS.CUSTOM_IMAGE) || + isNodeSelection(selection) || + isCellSelection(selection) + ) { + return false; + } + return true; + }, + }; const formattingItems = { - code: CodeItem(props.editor), - bold: BoldItem(props.editor), - italic: ItalicItem(props.editor), - underline: UnderLineItem(props.editor), - strikethrough: StrikeThroughItem(props.editor), - "text-align": TextAlignItem(props.editor), + code: CodeItem(editor), + bold: BoldItem(editor), + italic: ItalicItem(editor), + underline: UnderLineItem(editor), + strikethrough: StrikeThroughItem(editor), + "text-align": TextAlignItem(editor), } satisfies { [K in TEditorCommands]?: EditorMenuItem; }; const editorState: EditorStateType = useEditorState({ - editor: props.editor, + editor: editor, selector: ({ editor }: { editor: Editor }) => ({ code: formattingItems.code.isActive(), bold: formattingItems.bold.isActive(), @@ -86,137 +101,78 @@ export const EditorBubbleMenu: FC = (props: { editor: Edi ? [formattingItems.code] : [formattingItems.bold, formattingItems.italic, formattingItems.underline, formattingItems.strikethrough]; - const bubbleMenuProps: EditorBubbleMenuProps = { - ...props, - shouldShow: ({ state, editor }) => { - const { selection } = state; - const { empty } = selection; - - if ( - empty || - !editor.isEditable || - editor.isActive(CORE_EXTENSIONS.IMAGE) || - editor.isActive(CORE_EXTENSIONS.CUSTOM_IMAGE) || - isNodeSelection(selection) || - isCellSelection(selection) || - isSelecting - ) { - return false; - } - return true; - }, - tippyOptions: { - moveTransition: "transform 0.15s ease-out", - duration: [300, 0], - zIndex: 9, - onShow: () => { - props.editor.storage.link.isBubbleMenuOpen = true; - }, - onHidden: () => { - props.editor.storage.link.isBubbleMenuOpen = false; - setIsNodeSelectorOpen(false); - setIsLinkSelectorOpen(false); - setIsColorSelectorOpen(false); - }, - }, - }; - - useEffect(() => { - function handleMouseDown(e: MouseEvent) { - if (menuRef.current?.contains(e.target as Node)) return; - - function handleMouseMove() { - if (!props.editor.state.selection.empty) { - setIsSelecting(true); - document.removeEventListener("mousemove", handleMouseMove); - } - } - - function handleMouseUp() { - setIsSelecting(false); - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - } - - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - } - - document.addEventListener("mousedown", handleMouseDown); - - return () => { - document.removeEventListener("mousedown", handleMouseDown); - }; - }, [props.editor]); - return ( - - {!isSelecting && ( -
-
- { - setIsNodeSelectorOpen((prev) => !prev); - setIsLinkSelectorOpen(false); - setIsColorSelectorOpen(false); + +
+ { + setIsNodeSelectorOpen((prev) => !prev); + setIsLinkSelectorOpen(false); + setIsColorSelectorOpen(false); + }} + /> + {!editorState.code && ( + { + setIsLinkSelectorOpen((prev) => !prev); + setIsNodeSelectorOpen(false); + setIsColorSelectorOpen(false); + }} + /> + )} + {!editorState.code && ( + { + setIsColorSelectorOpen((prev) => !prev); + setIsNodeSelectorOpen(false); + setIsLinkSelectorOpen(false); + }} + /> + )} +
+ {basicFormattingOptions.map((item) => ( +
- {!editorState.code && ( -
- { - setIsLinkSelectorOpen((prev) => !prev); - setIsNodeSelectorOpen(false); - setIsColorSelectorOpen(false); - }} + className={cn( + "size-7 grid place-items-center rounded text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 transition-all duration-200 ease-in-out", + { + "bg-custom-background-80 text-custom-text-100": editorState[item.key], + } + )} + > + -
- )} - {!editorState.code && ( -
- { - setIsColorSelectorOpen((prev) => !prev); - setIsNodeSelectorOpen(false); - setIsLinkSelectorOpen(false); - }} - /> -
- )} -
- {basicFormattingOptions.map((item) => ( - - ))} -
- + + ))}
- )} + +
); };