deerflow-code/frontend-web/src/open-canvas/components/artifact/WritingBubbleMenu.tsx
2026-09-07 18:24:55 +08:00

221 lines
6.7 KiB
TypeScript

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 (
<button
type="button"
onMouseDown={(e) => {
e.preventDefault(); // keep editor focused
onClick();
}}
className={cn(
"inline-flex h-7 items-center gap-1 px-2 text-xs font-medium rounded transition-colors",
"hover:bg-black/8 dark:hover:bg-white/10",
active && "bg-black/10 dark:bg-white/15",
className,
)}
>
{children}
</button>
);
}
function BubbleSep() {
return <div className="w-px h-4 bg-black/15 dark:bg-white/25 mx-0.5 shrink-0" />;
}
// ── Main component ───────────────────────────────────────────────────────────
interface WritingBubbleMenuProps {
editor: Editor;
onAiRewrite: () => void;
}
export function WritingBubbleMenu({ editor, onAiRewrite }: WritingBubbleMenuProps) {
const { setWritingSelection } = useCanvasUIContext();
const [position, setPosition] = useState<MenuPosition | null>(null);
const [copied, setCopied] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const [computedStyle, setComputedStyle] = useState<React.CSSProperties>({
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(
<div
ref={menuRef}
style={computedStyle}
className="animate-in fade-in zoom-in-95 duration-100"
>
<div className="flex items-center gap-0 rounded-lg px-1 py-1 shadow-lg ring-1 ring-black/8 bg-popover text-foreground dark:bg-neutral-900 dark:ring-white/10">
{/* AI 改写 — primary */}
<BubbleButton
onClick={handleAiRewrite}
className="text-amber-500 hover:text-amber-600 font-semibold"
>
<Sparkles className="h-3.5 w-3.5" />
AI 改写
</BubbleButton>
<BubbleSep />
{/* Inline format */}
<BubbleButton
onClick={() => editor.chain().focus().toggleBold().run()}
active={editor.isActive("bold")}
>
<Bold className="h-3.5 w-3.5" />
</BubbleButton>
<BubbleButton
onClick={() => editor.chain().focus().toggleItalic().run()}
active={editor.isActive("italic")}
>
<Italic className="h-3.5 w-3.5" />
</BubbleButton>
<BubbleButton
onClick={() => editor.chain().focus().toggleUnderline().run()}
active={editor.isActive("underline")}
>
<Underline className="h-3.5 w-3.5" />
</BubbleButton>
<BubbleSep />
{/* Heading */}
<BubbleButton
onClick={() =>
editor.chain().focus().toggleHeading({ level: 1 }).run()
}
active={editor.isActive("heading", { level: 1 })}
>
<Heading1 className="h-3.5 w-3.5" />
</BubbleButton>
<BubbleSep />
{/* Copy */}
<BubbleButton onClick={handleCopy}>
{copied ? (
<Check className="h-3.5 w-3.5 text-green-400" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</BubbleButton>
</div>
</div>,
document.body,
);
}