221 lines
6.7 KiB
TypeScript
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,
|
|
);
|
|
}
|