import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import type { Editor } from "@tiptap/react"; import { Sparkles, Bold, Italic, Underline, Heading1, Copy, Check, } from "lucide-react"; import { cn } from "@/lib/utils"; import { useCanvasUIContext } from "../../contexts/CanvasUIContext"; // ── Types ──────────────────────────────────────────────────────────────────── interface MenuPosition { x: number; y: number; // top of selection bottom: number; // bottom of selection (used to flip menu below when near top) } // ── Sub-components ─────────────────────────────────────────────────────────── interface BubbleButtonProps { onClick: () => void; active?: boolean; className?: string; children: React.ReactNode; } function BubbleButton({ onClick, active, className, children }: BubbleButtonProps) { return ( ); } function BubbleSep() { return
; } // ── Main component ─────────────────────────────────────────────────────────── interface WritingBubbleMenuProps { editor: Editor; onAiRewrite: () => void; } export function WritingBubbleMenu({ editor, onAiRewrite }: WritingBubbleMenuProps) { const { setWritingSelection } = useCanvasUIContext(); const [position, setPosition] = useState(null); const [copied, setCopied] = useState(false); const menuRef = useRef(null); const [computedStyle, setComputedStyle] = useState({ position: "fixed", left: 0, top: 0, zIndex: 9999, visibility: "hidden", }); useLayoutEffect(() => { if (!position || !menuRef.current) return; const { width: mw, height: mh } = menuRef.current.getBoundingClientRect(); // X: center on selection, clamp to viewport const rawLeft = position.x - mw / 2; const safeLeft = Math.max(8, Math.min(rawLeft, window.innerWidth - mw - 8)); // Y: prefer above; flip below if too close to top let safeTop: number; if (position.y - mh - 8 >= 8) { safeTop = position.y - mh - 8; } else { safeTop = Math.min(position.bottom + 8, window.innerHeight - mh - 8); } setComputedStyle({ position: "fixed", left: safeLeft, top: safeTop, zIndex: 9999, visibility: "visible" }); }, [position]); // Track selection → compute menu position useEffect(() => { const updatePosition = () => { const { from, to, empty } = editor.state.selection; if (empty || editor.isActive("codeBlock")) { setPosition(null); return; } // Use the native DOM selection for pixel coordinates const domSel = window.getSelection(); if (!domSel || domSel.rangeCount === 0) { setPosition(null); return; } const range = domSel.getRangeAt(0); const rect = range.getBoundingClientRect(); if (!rect.width && !rect.height) { setPosition(null); return; } // Store selection for AI rewrite const text = editor.state.doc.textBetween(from, to, "\n"); if (text.trim()) { setWritingSelection({ from, to, text }); } setPosition({ x: rect.left + rect.width / 2, y: rect.top, bottom: rect.bottom, }); }; editor.on("selectionUpdate", updatePosition); editor.on("blur", () => setPosition(null)); return () => { editor.off("selectionUpdate", updatePosition); editor.off("blur", () => setPosition(null)); }; }, [editor, setWritingSelection]); const handleAiRewrite = useCallback(() => { const { from, to, empty } = editor.state.selection; if (!empty) { editor.commands.setAiHighlight(from, to); } onAiRewrite(); }, [onAiRewrite, editor]); const handleCopy = useCallback(() => { const { from, to } = editor.state.selection; const text = editor.state.doc.textBetween(from, to, "\n"); navigator.clipboard.writeText(text).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1500); }); }, [editor]); if (!position) return null; return createPortal(
{/* AI 改写 — primary */} AI 改写 {/* Inline format */} editor.chain().focus().toggleBold().run()} active={editor.isActive("bold")} > editor.chain().focus().toggleItalic().run()} active={editor.isActive("italic")} > editor.chain().focus().toggleUnderline().run()} active={editor.isActive("underline")} > {/* Heading */} editor.chain().focus().toggleHeading({ level: 1 }).run() } active={editor.isActive("heading", { level: 1 })} > {/* Copy */} {copied ? ( ) : ( )}
, document.body, ); }