Files
multica/packages/views/editor/bubble-menu.tsx
Naiyuan Qing 06b1b99638 fix(editor): use w-auto for bubble menu dropdown widths
Prevents text wrapping in heading/list dropdown items.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 19:23:05 +08:00

550 lines
15 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { BubbleMenu } from "@tiptap/react/menus";
import type { Editor } from "@tiptap/core";
import { NodeSelection } from "@tiptap/pm/state";
import type { EditorState } from "@tiptap/pm/state";
import type { EditorView } from "@tiptap/pm/view";
import { Toggle } from "@multica/ui/components/ui/toggle";
import { Separator } from "@multica/ui/components/ui/separator";
import {
Tooltip,
TooltipTrigger,
TooltipContent,
TooltipProvider,
} from "@multica/ui/components/ui/tooltip";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@multica/ui/components/ui/dropdown-menu";
import { Input } from "@multica/ui/components/ui/input";
import { Button } from "@multica/ui/components/ui/button";
import {
Bold,
Italic,
Strikethrough,
Code,
Link2,
List,
ListOrdered,
Quote,
ChevronDown,
Check,
X,
Unlink,
Type,
Heading1,
Heading2,
Heading3,
} from "lucide-react";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Force re-render when editor state changes so isActive() returns fresh values */
function useEditorTransactionUpdate(editor: Editor) {
const [, setState] = useState(0);
useEffect(() => {
const handler = () => setState((n) => n + 1);
editor.on("transaction", handler);
return () => {
editor.off("transaction", handler);
};
}, [editor]);
}
function shouldShowBubbleMenu({
editor,
state,
from,
to,
}: {
editor: Editor;
view: EditorView;
state: EditorState;
oldState?: EditorState;
from: number;
to: number;
}) {
if (!editor.isEditable) return false;
if (state.selection.empty) return false;
if (!state.doc.textBetween(from, to).length) return false;
if (state.selection instanceof NodeSelection) return false;
const $from = state.doc.resolve(from);
if ($from.parent.type.name === "codeBlock") return false;
return true;
}
/** Detect macOS for keyboard shortcut labels */
const isMac =
typeof navigator !== "undefined" && /Mac/.test(navigator.platform);
const mod = isMac ? "\u2318" : "Ctrl";
/** Hoisted to avoid new reference on every render (triggers plugin updateOptions) */
const BUBBLE_MENU_OPTIONS = {
strategy: "fixed" as const,
placement: "top" as const,
offset: 8,
flip: true,
shift: { padding: 8 },
};
// ---------------------------------------------------------------------------
// Mark Toggle Button
// ---------------------------------------------------------------------------
type InlineMark = "bold" | "italic" | "strike" | "code";
const toggleMarkActions: Record<InlineMark, (editor: Editor) => void> = {
bold: (e) => e.chain().focus().toggleBold().run(),
italic: (e) => e.chain().focus().toggleItalic().run(),
strike: (e) => e.chain().focus().toggleStrike().run(),
code: (e) => e.chain().focus().toggleCode().run(),
};
function MarkButton({
editor,
mark,
icon: Icon,
label,
shortcut,
}: {
editor: Editor;
mark: InlineMark;
icon: React.ComponentType<{ className?: string }>;
label: string;
shortcut: string;
}) {
return (
<Tooltip>
<TooltipTrigger
render={
<Toggle
size="sm"
pressed={editor.isActive(mark)}
onPressedChange={() => toggleMarkActions[mark](editor)}
onMouseDown={(e) => e.preventDefault()}
/>
}
>
<Icon className="size-3.5" />
</TooltipTrigger>
<TooltipContent side="top" sideOffset={8}>
{label}
<span className="ml-1.5 text-muted-foreground">{shortcut}</span>
</TooltipContent>
</Tooltip>
);
}
// ---------------------------------------------------------------------------
// Link Edit Bar
// ---------------------------------------------------------------------------
function LinkEditBar({
editor,
onClose,
}: {
editor: Editor;
onClose: () => void;
}) {
const existingHref = editor.getAttributes("link").href as string | undefined;
const [url, setUrl] = useState(existingHref ?? "");
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
// autoFocus workaround — setTimeout to ensure the input is mounted
const t = setTimeout(() => inputRef.current?.focus(), 0);
return () => clearTimeout(t);
}, []);
const apply = useCallback(() => {
let href = url.trim();
if (!href) {
editor.chain().focus().extendMarkRange("link").unsetLink().run();
} else {
if (!/^https?:\/\//.test(href) && !href.startsWith("/")) {
href = `https://${href}`;
}
editor
.chain()
.focus()
.extendMarkRange("link")
.setLink({ href })
.run();
}
onClose();
}, [editor, url, onClose]);
const remove = useCallback(() => {
editor.chain().focus().extendMarkRange("link").unsetLink().run();
onClose();
}, [editor, onClose]);
return (
<div
className="bubble-menu-link-edit"
onMouseDown={(e) => e.preventDefault()}
>
<Input
ref={inputRef}
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://..."
aria-label="URL"
className="h-7 flex-1 text-xs"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
apply();
}
if (e.key === "Escape") {
e.preventDefault();
onClose();
editor.commands.focus();
}
}}
/>
<Button
size="icon-xs"
variant="ghost"
onClick={apply}
onMouseDown={(e) => e.preventDefault()}
>
<Check className="size-3.5" />
</Button>
{existingHref && (
<Button
size="icon-xs"
variant="ghost"
onClick={remove}
onMouseDown={(e) => e.preventDefault()}
>
<Unlink className="size-3.5" />
</Button>
)}
<Button
size="icon-xs"
variant="ghost"
onClick={() => {
onClose();
editor.commands.focus();
}}
onMouseDown={(e) => e.preventDefault()}
>
<X className="size-3.5" />
</Button>
</div>
);
}
// ---------------------------------------------------------------------------
// Heading Dropdown
// ---------------------------------------------------------------------------
function HeadingDropdown({
editor,
onOpenChange,
}: {
editor: Editor;
onOpenChange: (open: boolean) => void;
}) {
const activeLevel = [1, 2, 3].find((l) =>
editor.isActive("heading", { level: l }),
);
const label = activeLevel ? `H${activeLevel}` : "Text";
const items = [
{
label: "Normal Text",
icon: Type,
active: !activeLevel,
action: () => editor.chain().focus().setParagraph().run(),
},
{
label: "Heading 1",
icon: Heading1,
active: activeLevel === 1,
action: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
},
{
label: "Heading 2",
icon: Heading2,
active: activeLevel === 2,
action: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
},
{
label: "Heading 3",
icon: Heading3,
active: activeLevel === 3,
action: () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
},
];
return (
<DropdownMenu onOpenChange={onOpenChange}>
<DropdownMenuTrigger
className="inline-flex h-7 items-center gap-0.5 rounded-md px-1.5 text-xs font-medium hover:bg-muted"
onMouseDown={(e) => e.preventDefault()}
>
{label}
<ChevronDown className="size-3" />
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
sideOffset={8}
align="start"
className="w-auto"
>
{items.map((item) => (
<DropdownMenuItem
key={item.label}
onClick={item.action}
className="gap-2 text-xs"
>
<item.icon className="size-3.5" />
{item.label}
{item.active && <Check className="ml-auto size-3.5" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
// ---------------------------------------------------------------------------
// List Dropdown
// ---------------------------------------------------------------------------
function ListDropdown({
editor,
onOpenChange,
}: {
editor: Editor;
onOpenChange: (open: boolean) => void;
}) {
const isBullet = editor.isActive("bulletList");
const isOrdered = editor.isActive("orderedList");
return (
<DropdownMenu onOpenChange={onOpenChange}>
<Tooltip>
<TooltipTrigger
render={
<DropdownMenuTrigger
className="inline-flex h-7 items-center gap-0.5 rounded-md px-1.5 text-xs font-medium hover:bg-muted aria-pressed:bg-muted"
aria-pressed={isBullet || isOrdered}
onMouseDown={(e) => e.preventDefault()}
/>
}
>
<List className="size-3.5" />
<ChevronDown className="size-3" />
</TooltipTrigger>
<TooltipContent side="top" sideOffset={8}>
List
</TooltipContent>
</Tooltip>
<DropdownMenuContent
side="bottom"
sideOffset={8}
align="start"
className="w-auto"
>
<DropdownMenuItem
onClick={() => editor.chain().focus().toggleBulletList().run()}
className="gap-2 text-xs"
>
<List className="size-3.5" />
Bullet List
{isBullet && <Check className="ml-auto size-3.5" />}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className="gap-2 text-xs"
>
<ListOrdered className="size-3.5" />
Ordered List
{isOrdered && <Check className="ml-auto size-3.5" />}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
// ---------------------------------------------------------------------------
// Main Bubble Menu
// ---------------------------------------------------------------------------
function EditorBubbleMenu({ editor }: { editor: Editor }) {
const [mode, setMode] = useState<"toolbar" | "link-edit">("toolbar");
const [focused, setFocused] = useState(editor.view.hasFocus());
const modeRef = useRef(mode);
modeRef.current = mode;
// Track whether a child dropdown is open — blur during dropdown interaction should not hide
const menuOpenRef = useRef(false);
const handleMenuOpenChange = useCallback((open: boolean) => {
menuOpenRef.current = open;
}, []);
useEditorTransactionUpdate(editor);
// Hide bubble menu when editor loses focus (but not when a child dropdown is open)
useEffect(() => {
const onFocus = () => setFocused(true);
const onBlur = () => {
setTimeout(() => {
if (!editor.isDestroyed && !editor.view.hasFocus() && !menuOpenRef.current) {
setFocused(false);
}
}, 0);
};
editor.on("focus", onFocus);
editor.on("blur", onBlur);
return () => {
editor.off("focus", onFocus);
editor.off("blur", onBlur);
};
}, [editor]);
// Reset to toolbar mode when selection changes — but not during link editing.
// Also restore focused state (scroll sets it to false, new selection should bring it back).
useEffect(() => {
const handler = () => {
if (modeRef.current !== "link-edit") setMode("toolbar");
if (editor.view.hasFocus()) setFocused(true);
};
editor.on("selectionUpdate", handler);
return () => {
editor.off("selectionUpdate", handler);
};
}, [editor]);
// Hide when an ancestor of the editor scrolls (capture phase catches non-bubbling scroll events).
// Scoped to ancestors only — dropdown/sidebar scrolls won't trigger this.
useEffect(() => {
const handler = (e: Event) => {
const target = e.target;
if (
target instanceof HTMLElement &&
target.contains(editor.view.dom)
) {
setFocused(false);
}
};
document.addEventListener("scroll", handler, true);
return () => document.removeEventListener("scroll", handler, true);
}, [editor]);
const openLinkEdit = useCallback(() => {
setMode("link-edit");
}, []);
const closeLinkEdit = useCallback(() => {
setMode("toolbar");
}, []);
if (!focused) return null;
return (
<BubbleMenu
editor={editor}
shouldShow={shouldShowBubbleMenu}
updateDelay={0}
style={{ zIndex: 50 }}
options={BUBBLE_MENU_OPTIONS}
>
{mode === "link-edit" ? (
<LinkEditBar editor={editor} onClose={closeLinkEdit} />
) : (
<TooltipProvider delay={300}>
<div className="bubble-menu">
{/* Group 1: Inline Marks */}
<MarkButton
editor={editor}
mark="bold"
icon={Bold}
label="Bold"
shortcut={`${mod}+B`}
/>
<MarkButton
editor={editor}
mark="italic"
icon={Italic}
label="Italic"
shortcut={`${mod}+I`}
/>
<MarkButton
editor={editor}
mark="strike"
icon={Strikethrough}
label="Strikethrough"
shortcut={`${mod}+Shift+S`}
/>
<MarkButton
editor={editor}
mark="code"
icon={Code}
label="Code"
shortcut={`${mod}+E`}
/>
<Separator orientation="vertical" className="mx-0.5 h-5" />
{/* Group 2: Link */}
<Tooltip>
<TooltipTrigger
render={
<Toggle
size="sm"
pressed={editor.isActive("link")}
onPressedChange={openLinkEdit}
onMouseDown={(e) => e.preventDefault()}
/>
}
>
<Link2 className="size-3.5" />
</TooltipTrigger>
<TooltipContent side="top" sideOffset={8}>
Link
</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="mx-0.5 h-5" />
{/* Group 3: Block Transforms */}
<HeadingDropdown editor={editor} onOpenChange={handleMenuOpenChange} />
<ListDropdown editor={editor} onOpenChange={handleMenuOpenChange} />
<Tooltip>
<TooltipTrigger
render={
<Toggle
size="sm"
pressed={editor.isActive("blockquote")}
onPressedChange={() =>
editor.chain().focus().toggleBlockquote().run()
}
onMouseDown={(e) => e.preventDefault()}
/>
}
>
<Quote className="size-3.5" />
</TooltipTrigger>
<TooltipContent side="top" sideOffset={8}>
Quote
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
)}
</BubbleMenu>
);
}
export { EditorBubbleMenu };