diff --git a/packages/ui/src/RichEditor/BubbleMenu.tsx b/packages/ui/src/RichEditor/BubbleMenu.tsx index 606bc8267..07f2152c4 100644 --- a/packages/ui/src/RichEditor/BubbleMenu.tsx +++ b/packages/ui/src/RichEditor/BubbleMenu.tsx @@ -1,71 +1,154 @@ -import { CodeIcon, LinkIcon, TextBIcon, TextItalicIcon, TextStrikethroughIcon, TextUnderlineIcon } from "@phosphor-icons/react"; -import type { useEditor } from "@tiptap/react"; +import { CodeIcon, LinkIcon, TextBIcon, TextItalicIcon, TextStrikethroughIcon, TextUnderlineIcon, TrashIcon } from "@phosphor-icons/react"; +import { Editor, useEditorState } from "@tiptap/react"; import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; +import { type KeyboardEvent, useEffect, useRef, useState } from "react"; import { tv } from "tailwind-variants"; import { MenuButton } from "./MenuButton"; const bubbleMenuVariants = tv({ - base: ["flex items-center gap-1 rounded-lg border border-border-mid bg-level-0 p-1 shadow-md"], + slots: { + root: "flex flex-col items-stretch rounded-lg border border-border-mid bg-level-0 shadow-md", + toolbar: "flex items-center gap-1 p-1", + linkInput: "flex items-center gap-1 border-t border-border-mid px-2 py-1.5", + }, }); +const { root, toolbar, linkInput } = bubbleMenuVariants(); + type BubbleMenuProps = { - editor: ReturnType; + editor: Editor; }; export function BubbleMenu(props: BubbleMenuProps) { const { editor } = props; + const [showLinkInput, setShowLinkInput] = useState(false); + const [linkUrl, setLinkUrl] = useState(""); + const linkInputRef = useRef(null); + + const state = useEditorState({ + editor, + selector: ({ editor }) => ({ + isBold: editor.isActive("bold"), + isItalic: editor.isActive("italic"), + isUnderline: editor.isActive("underline"), + isStrike: editor.isActive("strike"), + isCode: editor.isActive("code"), + isLink: editor.isActive("link"), + linkHref: editor.getAttributes("link").href as string | undefined, + }), + }); + + useEffect(() => { + if (showLinkInput) { + linkInputRef.current?.focus(); + } + }, [showLinkInput]); + + function handleLinkButtonClick() { + if (showLinkInput) { + setShowLinkInput(false); + setLinkUrl(""); + return; + } + setLinkUrl(state.linkHref ?? ""); + setShowLinkInput(true); + } + + function submitLink() { + if (linkUrl.trim()) { + editor.chain().focus().setLink({ href: linkUrl.trim() }).run(); + } + setShowLinkInput(false); + setLinkUrl(""); + } + + function removeLink() { + editor.chain().focus().unsetLink().run(); + setShowLinkInput(false); + setLinkUrl(""); + } + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Enter") { + e.preventDefault(); + submitLink(); + } + if (e.key === "Escape") { + e.preventDefault(); + setShowLinkInput(false); + setLinkUrl(""); + editor.commands.focus(); + } + } return ( - editor.chain().focus().toggleBold().run()} - > - - - editor.chain().focus().toggleItalic().run()} - > - - - editor.chain().focus().toggleUnderline().run()} - > - - - editor.chain().focus().toggleStrike().run()} - > - - - editor.chain().focus().toggleCode().run()} - > - - - { - if (editor.isActive("link")) { - editor.chain().focus().unsetLink().run(); - return; - } - const url = window.prompt("URL"); - if (url) { - editor.chain().focus().setLink({ href: url }).run(); - } - }} - > - - +
+ editor.chain().focus().toggleBold().run()} + > + + + editor.chain().focus().toggleItalic().run()} + > + + + editor.chain().focus().toggleUnderline().run()} + > + + + editor.chain().focus().toggleStrike().run()} + > + + + editor.chain().focus().toggleCode().run()} + > + + + + + +
+ {showLinkInput && ( +
+ setLinkUrl(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={submitLink} + className="min-w-0 flex-1 bg-transparent text-txt-primary outline-none placeholder:text-txt-quaternary" + /> + {state.isLink && ( + + )} +
+ )}
); }