import { BubbleMenu, BubbleMenuProps } from "@tiptap/react/menus"; import { isNodeSelection, useEditorState } from "@tiptap/react"; import type { Editor } from "@tiptap/react"; import { FC, SetStateAction, useCallback, useEffect, useRef, useState } from "react"; import { IconBold, IconCode, IconItalic, IconStrikethrough, IconUnderline, IconMessage, IconSparkles, } from "@tabler/icons-react"; import clsx from "clsx"; import classes from "./bubble-menu.module.css"; import { ActionIcon, Button, rem, Tooltip } from "@mantine/core"; import { ColorSelector } from "./color-selector"; import { NodeSelector } from "./node-selector"; import { TextAlignmentSelector } from "./text-alignment-selector"; import { draftCommentIdAtom, showCommentPopupAtom, } from "@/features/comment/atoms/comment-atom"; import { useAtom, useAtomValue } from "jotai"; import { v7 as uuid7 } from "uuid"; import { isCellSelection, isTextSelected } from "@docmost/editor-ext"; import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx"; import { useTranslation } from "react-i18next"; import { showAiMenuAtom } from "@/features/editor/atoms/editor-atoms"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom"; export interface BubbleMenuItem { name: string; isActive: () => boolean; command: () => void; icon: typeof IconBold; } type EditorBubbleMenuProps = Omit & { editor: Editor | null; }; export const EditorBubbleMenu: FC = (props) => { const { t } = useTranslation(); const [showAiMenu, setShowAiMenu] = useAtom(showAiMenuAtom); const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom); const workspace = useAtomValue(workspaceAtom); const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true; const [, setDraftCommentId] = useAtom(draftCommentIdAtom); const showCommentPopupRef = useRef(showCommentPopup); const showAiMenuRef = useRef(showAiMenu); const isLinkSelectorOpenRef = useRef(false); useEffect(() => { showCommentPopupRef.current = showCommentPopup; }, [showCommentPopup]); useEffect(() => { showAiMenuRef.current = showAiMenu; }, [showAiMenu]); const editorState = useEditorState({ editor: props.editor, selector: (ctx) => { if (!props.editor) { return null; } return { isBold: ctx.editor.isActive("bold"), isItalic: ctx.editor.isActive("italic"), isUnderline: ctx.editor.isActive("underline"), isStrike: ctx.editor.isActive("strike"), isCode: ctx.editor.isActive("code"), isComment: ctx.editor.isActive("comment"), }; }, }); const items: BubbleMenuItem[] = [ { name: "Bold", isActive: () => editorState?.isBold, command: () => props.editor.chain().focus().toggleBold().run(), icon: IconBold, }, { name: "Italic", isActive: () => editorState?.isItalic, command: () => props.editor.chain().focus().toggleItalic().run(), icon: IconItalic, }, { name: "Underline", isActive: () => editorState?.isUnderline, command: () => props.editor.chain().focus().toggleUnderline().run(), icon: IconUnderline, }, { name: "Strike", isActive: () => editorState?.isStrike, command: () => props.editor.chain().focus().toggleStrike().run(), icon: IconStrikethrough, }, { name: "Code", isActive: () => editorState?.isCode, command: () => props.editor.chain().focus().toggleCode().run(), icon: IconCode, }, ]; const commentItem: BubbleMenuItem = { name: "Comment", isActive: () => editorState?.isComment, command: () => { const commentId = uuid7(); props.editor.chain().focus().setCommentDecoration().run(); setDraftCommentId(commentId); setShowCommentPopup(true); }, icon: IconMessage, }; const bubbleMenuProps: EditorBubbleMenuProps = { ...props, shouldShow: ({ state, editor }) => { if (isLinkSelectorOpenRef.current) { return true; } const { selection } = state; const { empty } = selection; if ( !editor.isEditable || editor.isActive("image") || empty || isNodeSelection(selection) || isCellSelection(selection) || showAiMenuRef.current || showCommentPopupRef?.current ) { return false; } return isTextSelected(editor); }, options: { placement: "top", offset: 8, onHide: () => { setIsNodeSelectorOpen(false); setIsTextAlignmentOpen(false); setIsLinkSelectorOpen(false); setIsColorSelectorOpen(false); }, }, }; const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false); const [isTextAlignmentSelectorOpen, setIsTextAlignmentOpen] = useState(false); const [isLinkSelectorOpen, _setIsLinkSelectorOpen] = useState(false); const setIsLinkSelectorOpen = useCallback((value: SetStateAction) => { _setIsLinkSelectorOpen((prev) => { const next = typeof value === 'function' ? value(prev) : value; isLinkSelectorOpenRef.current = next; return next; }); }, []); const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false); // Hide the bubble menu immediately when AI menu is shown if (showAiMenu) return; return (
{isGenerativeAiEnabled && ( <>
)} { setIsNodeSelectorOpen(!isNodeSelectorOpen); setIsTextAlignmentOpen(false); setIsLinkSelectorOpen(false); setIsColorSelectorOpen(false); }} /> { setIsTextAlignmentOpen(!isTextAlignmentSelectorOpen); setIsNodeSelectorOpen(false); setIsLinkSelectorOpen(false); setIsColorSelectorOpen(false); }} /> {items.map((item, index) => ( ))} { setIsLinkSelectorOpen(value); setIsNodeSelectorOpen(false); setIsTextAlignmentOpen(false); setIsColorSelectorOpen(false); }} /> { setIsColorSelectorOpen(!isColorSelectorOpen); setIsNodeSelectorOpen(false); setIsTextAlignmentOpen(false); setIsLinkSelectorOpen(false); }} />
); };