"use client"; /** * EditorBubbleMenu — floating formatting toolbar for text selection. * * Positioned with @floating-ui/dom (computePosition + autoUpdate) and * portaled to document.body via createPortal. This escapes ALL overflow * containers in the ancestor chain (Card overflow:hidden, scrollable * containers, etc.) while autoUpdate monitors every ancestor scroll * container to keep the menu anchored to the selection. * * Key design decisions: * - contextElement on the virtual reference tells Floating UI where to * find scroll ancestors, enabling the hide middleware to detect * nested scroll container clipping. * - visibility:hidden (not display:none) keeps the element measurable * so computePosition can size it correctly on first show. * - onMouseDown preventDefault on the portal root prevents all clicks * inside the menu from stealing focus from the editor. */ import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { computePosition, offset, flip, shift, hide, autoUpdate, } from "@floating-ui/dom"; import { useEditorState } from "@tiptap/react"; import type { Editor } from "@tiptap/core"; import { posToDOMRect } from "@tiptap/core"; import { NodeSelection } from "@tiptap/pm/state"; import { toast } from "sonner"; import { useCreateIssue } from "@multica/core/issues/mutations"; import { useT } from "../i18n"; import { createShortcutChord, type ShortcutChord } from "@multica/core/shortcuts"; import { ShortcutKeycaps } from "../common/shortcut-keycaps"; 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 { Popover, PopoverTrigger, PopoverContent, } from "@multica/ui/components/ui/popover"; import { Input } from "@multica/ui/components/ui/input"; import { Button } from "@multica/ui/components/ui/button"; import { Bold, Italic, Strikethrough, Code, Highlighter, Link2, List, ListOrdered, ListTodo, Quote, ChevronDown, Check, X, Unlink, Type, Heading1, Heading2, Heading3, FilePlus, Loader2, } from "lucide-react"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function shouldShowBubbleMenu(editor: Editor): boolean { if (!editor.isEditable) return false; const { selection } = editor.state; if (selection.empty) return false; const { from, to } = selection; if (!editor.state.doc.textBetween(from, to).trim().length) return false; if (selection instanceof NodeSelection) return false; const $from = editor.state.doc.resolve(from); if ($from.parent.type.name === "codeBlock") return false; return true; } // --------------------------------------------------------------------------- // Mark Toggle Button // --------------------------------------------------------------------------- type InlineMark = "bold" | "italic" | "strike" | "code" | "highlight"; const toggleMarkActions: Record 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(), highlight: (e) => e.chain().focus().toggleHighlight().run(), }; function MarkButton({ editor, mark, icon: Icon, label, shortcut, isActive, }: { editor: Editor; mark: InlineMark; icon: React.ComponentType<{ className?: string }>; label: string; shortcut: ShortcutChord; isActive: boolean; }) { return ( toggleMarkActions[mark](editor)} onMouseDown={(e) => e.preventDefault()} /> } > {label} ); } // --------------------------------------------------------------------------- // URL normalisation // --------------------------------------------------------------------------- /** Protocols that can execute code in the browser — the only ones we block. */ const DANGEROUS_PROTOCOL_RE = /^(javascript|data|vbscript):/i; const HAS_PROTOCOL_RE = /^[a-z][a-z0-9+.-]*:\/?\/?/i; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; /** * Normalise a user-entered URL: add protocol, detect mailto, block XSS. * * Uses a blocklist (not allowlist) for protocols — only `javascript:`, * `data:`, and `vbscript:` are blocked. All other protocols pass through * because they can't execute code in the browser and are legitimate * deep-link targets in a team tool (slack://, vscode://, figma://). * Tiptap's `isAllowedUri` in the `setLink` command provides a second * safety layer. */ function normalizeUrl(input: string): string { const trimmed = input.trim(); if (!trimmed) return ""; if (trimmed.startsWith("/")) return trimmed; if (DANGEROUS_PROTOCOL_RE.test(trimmed)) return ""; if (HAS_PROTOCOL_RE.test(trimmed)) return trimmed; if (EMAIL_RE.test(trimmed)) return `mailto:${trimmed}`; if (trimmed.startsWith("//")) return `https:${trimmed}`; return `https://${trimmed}`; } // --------------------------------------------------------------------------- // Link Edit Bar // --------------------------------------------------------------------------- function LinkEditBar({ editor, onClose, }: { editor: Editor; onClose: () => void; }) { const { t } = useT("editor"); const existingHref = editor.getAttributes("link").href as string | undefined; const [url, setUrl] = useState(existingHref ?? ""); const inputRef = useRef(null); useEffect(() => { const t = setTimeout(() => inputRef.current?.focus(), 0); return () => clearTimeout(t); }, []); const apply = useCallback(() => { const href = normalizeUrl(url); if (!href) { editor.chain().focus().extendMarkRange("link").unsetLink().run(); } else { 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 (
e.preventDefault()}> setUrl(e.target.value)} placeholder="https://..." aria-label={t(($) => $.bubble_menu.url_aria_label)} 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(); } }} /> {existingHref && ( )}
); } // --------------------------------------------------------------------------- // Heading Dropdown // --------------------------------------------------------------------------- function HeadingDropdown({ editor, onOpenChange, activeLevel }: { editor: Editor; onOpenChange: (open: boolean) => void; activeLevel: number | undefined }) { const { t } = useT("editor"); const [open, setOpen] = useState(false); const label = activeLevel ? `H${activeLevel}` : t(($) => $.bubble_menu.heading_dropdown.text); const items = [ { label: t(($) => $.bubble_menu.heading_dropdown.normal_text), icon: Type, active: !activeLevel, action: () => editor.chain().focus().setParagraph().run() }, { label: t(($) => $.bubble_menu.heading_dropdown.heading_1), icon: Heading1, active: activeLevel === 1, action: () => editor.chain().focus().toggleHeading({ level: 1 }).run() }, { label: t(($) => $.bubble_menu.heading_dropdown.heading_2), icon: Heading2, active: activeLevel === 2, action: () => editor.chain().focus().toggleHeading({ level: 2 }).run() }, { label: t(($) => $.bubble_menu.heading_dropdown.heading_3), icon: Heading3, active: activeLevel === 3, action: () => editor.chain().focus().toggleHeading({ level: 3 }).run() }, ]; const handleOpenChange = useCallback((next: boolean) => { setOpen(next); onOpenChange(next); }, [onOpenChange]); return ( e.preventDefault()} > {label} {items.map((item) => ( ))} ); } // --------------------------------------------------------------------------- // List Dropdown // --------------------------------------------------------------------------- function ListDropdown({ editor, onOpenChange, isBullet, isOrdered, isTask }: { editor: Editor; onOpenChange: (open: boolean) => void; isBullet: boolean; isOrdered: boolean; isTask: boolean }) { const { t } = useT("editor"); const [open, setOpen] = useState(false); const handleOpenChange = useCallback((next: boolean) => { setOpen(next); onOpenChange(next); }, [onOpenChange]); return ( e.preventDefault()} /> }> {t(($) => $.bubble_menu.list)} ); } // --------------------------------------------------------------------------- // Create Sub-Issue Button // --------------------------------------------------------------------------- /** * Turns the current selection into a sub-issue of `parentIssueId` and replaces * the selection with a mention link to the new issue. Title is the selected * text (trimmed, collapsed whitespace, capped). Only rendered when a parent * issue is in scope; otherwise there's no meaningful "sub-issue of" target. */ function CreateSubIssueButton({ editor, parentIssueId, }: { editor: Editor; parentIssueId: string; }) { const { t } = useT("editor"); const createIssue = useCreateIssue(); const [pending, setPending] = useState(false); const handleClick = useCallback(async () => { if (pending) return; const { from, to } = editor.state.selection; if (from === to) return; // Title from selection: collapse whitespace, cap length. The full selection // still becomes the link text — only the issue title is capped. const rawTitle = editor.state.doc.textBetween(from, to, " ", " ").trim(); const title = rawTitle.replace(/\s+/g, " ").slice(0, 200); if (!title) return; setPending(true); try { const newIssue = await createIssue.mutateAsync({ title, parent_issue_id: parentIssueId, }); editor .chain() .focus() .insertContentAt( { from, to }, [ { type: "mention", attrs: { id: newIssue.id, label: newIssue.identifier, type: "issue", }, }, { type: "text", text: " " }, ], ) .run(); toast.success(t(($) => $.bubble_menu.sub_issue.created, { identifier: newIssue.identifier })); } catch (err) { toast.error( err instanceof Error && err.message ? err.message : t(($) => $.bubble_menu.sub_issue.create_failed), ); } finally { setPending(false); } }, [editor, parentIssueId, createIssue, pending, t]); return ( e.preventDefault()} /> } > {pending ? ( ) : ( )} {t(($) => $.bubble_menu.sub_issue.tooltip)} ); } // --------------------------------------------------------------------------- // Main Bubble Menu — @floating-ui/dom + portal to body // --------------------------------------------------------------------------- function EditorBubbleMenu({ editor, currentIssueId, }: { editor: Editor; currentIssueId?: string; }) { const { t } = useT("editor"); const [visible, setVisible] = useState(false); const [mode, setMode] = useState<"toolbar" | "link-edit">("toolbar"); const floatingRef = useRef(null); // Precise subscription to formatting state — only re-renders when these // values actually change, not on every transaction. const fmt = useEditorState({ editor, selector: ({ editor: e }) => ({ bold: e.isActive("bold"), italic: e.isActive("italic"), strike: e.isActive("strike"), code: e.isActive("code"), highlight: e.isActive("highlight"), link: e.isActive("link"), blockquote: e.isActive("blockquote"), bulletList: e.isActive("bulletList"), orderedList: e.isActive("orderedList"), taskList: e.isActive("taskList"), heading1: e.isActive("heading", { level: 1 }), heading2: e.isActive("heading", { level: 2 }), heading3: e.isActive("heading", { level: 3 }), }), }); // Virtual reference that tracks the text selection. // contextElement tells autoUpdate/hide where to find scroll ancestors. const virtualRef = useMemo( () => ({ getBoundingClientRect: () => { if (editor.isDestroyed) return new DOMRect(); const { from, to } = editor.state.selection; return posToDOMRect(editor.view, from, to); }, contextElement: editor.view.dom, }), [editor], ); // Show/hide based on selection state useEffect(() => { const onTransaction = () => { if (!editor.isInitialized) return; setVisible(shouldShowBubbleMenu(editor)); }; editor.on("transaction", onTransaction); return () => { editor.off("transaction", onTransaction); }; }, [editor]); // Hide on blur — debounced to allow focus to settle (e.g. clicking menu) useEffect(() => { const onBlur = () => { setTimeout(() => { if (editor.isDestroyed) return; const el = floatingRef.current; if (el && el.contains(document.activeElement)) return; if (editor.view.hasFocus()) return; setVisible(false); }, 0); }; editor.on("blur", onBlur); return () => { editor.off("blur", onBlur); }; }, [editor]); // Position the floating element with autoUpdate when visible useEffect(() => { const el = floatingRef.current; if (!visible || !el || !editor.isInitialized) return; const updatePosition = () => { computePosition(virtualRef, el, { strategy: "fixed", placement: "top", middleware: [offset(8), flip(), shift({ padding: 8 }), hide()], }).then(({ x, y, middlewareData }) => { if (!el.isConnected) return; const hidden = middlewareData.hide?.referenceHidden; el.style.visibility = hidden ? "hidden" : "visible"; el.style.left = `${x}px`; el.style.top = `${y}px`; }); }; // autoUpdate monitors all scroll ancestors (via contextElement), // resize, and animation frames — no manual scroll listener needed. const cleanup = autoUpdate(virtualRef, el, updatePosition); return cleanup; }, [visible, editor, virtualRef]); // Close on outside click useEffect(() => { if (!visible) return; const handle = (e: MouseEvent) => { const target = e.target as HTMLElement; if (editor.view.dom.contains(target)) return; if (floatingRef.current?.contains(target)) return; setVisible(false); }; document.addEventListener("mousedown", handle); return () => document.removeEventListener("mousedown", handle); }, [visible, editor]); // Reset mode on selection change useEffect(() => { const handler = () => setMode("toolbar"); editor.on("selectionUpdate", handler); return () => { editor.off("selectionUpdate", handler); }; }, [editor]); // Refocus editor when Popover closes const handleMenuOpenChange = useCallback( (open: boolean) => { if (!open) editor.commands.focus(); }, [editor], ); return (
e.preventDefault()} > {mode === "link-edit" ? ( { setMode("toolbar"); editor.commands.focus(); }} /> ) : (
$.bubble_menu.bold)} shortcut={createShortcutChord("B", { primary: true })} isActive={fmt.bold} /> $.bubble_menu.italic)} shortcut={createShortcutChord("I", { primary: true })} isActive={fmt.italic} /> $.bubble_menu.strikethrough)} shortcut={createShortcutChord("S", { primary: true, shift: true })} isActive={fmt.strike} /> $.bubble_menu.code)} shortcut={createShortcutChord("E", { primary: true })} isActive={fmt.code} /> $.bubble_menu.highlight)} shortcut={createShortcutChord("H", { primary: true, shift: true })} isActive={fmt.highlight} /> setMode("link-edit")} onMouseDown={(e) => e.preventDefault()} /> }> {t(($) => $.bubble_menu.link)} {/* Dedicated one-click toggle for checkbox task lists — turns the current line(s) into a `- [ ]` task item or back to a paragraph. The same toggle also lives in the List dropdown, but a direct button keeps the common "make this a checklist" action one tap away instead of two. */} editor.chain().focus().toggleTaskList().run()} onMouseDown={(e) => e.preventDefault()} /> }> {t(($) => $.bubble_menu.task_list)} editor.chain().focus().toggleBlockquote().run()} onMouseDown={(e) => e.preventDefault()} /> }> {t(($) => $.bubble_menu.quote)} {currentIssueId && ( <> )}
)}
); } export { EditorBubbleMenu };