初始化
This commit is contained in:
1 parent
34474bd5fc
commit
b638b541d0
3008 files changed
+837699
No files matched your search
@@ -0,0 +1,220 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user