164 lines
5.3 KiB
TypeScript
164 lines
5.3 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import type { Editor } from "@tiptap/react";
|
|
import { Sparkles } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { useCanvasUIContext } from "../../contexts/CanvasUIContext";
|
|
|
|
const REWRITABLE_TYPES = new Set(["paragraph", "heading", "blockquote"]);
|
|
|
|
const BTN_SIZE = 24;
|
|
const BTN_GAP = 6; // gap between button right edge and text left edge
|
|
// Extra zone around the button that still counts as "near" (bridges the gap)
|
|
const HOVER_MARGIN = BTN_GAP + 8;
|
|
// Delay before hiding the button (ms) — long enough to cross the gap at slow speed
|
|
const HIDE_DELAY = 380;
|
|
|
|
interface HoverState {
|
|
top: number;
|
|
left: number;
|
|
height: number;
|
|
nodeFrom: number;
|
|
nodeTo: number;
|
|
text: string;
|
|
}
|
|
|
|
interface WritingParagraphMenuProps {
|
|
editor: Editor;
|
|
onAiRewrite: () => void;
|
|
}
|
|
|
|
export function WritingParagraphMenu({ editor, onAiRewrite }: WritingParagraphMenuProps) {
|
|
const { setWritingSelection } = useCanvasUIContext();
|
|
const [hover, setHover] = useState<HoverState | null>(null);
|
|
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
// Keep a ref in sync so the document-level handler always reads fresh state
|
|
const hoverRef = useRef<HoverState | null>(null);
|
|
hoverRef.current = hover;
|
|
|
|
const clearHideTimer = useCallback(() => {
|
|
if (hideTimerRef.current) {
|
|
clearTimeout(hideTimerRef.current);
|
|
hideTimerRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
const scheduleHide = useCallback(() => {
|
|
clearHideTimer();
|
|
hideTimerRef.current = setTimeout(() => setHover(null), HIDE_DELAY);
|
|
}, [clearHideTimer]);
|
|
|
|
// Document-level mousemove: cancel hide when cursor is in the button's vicinity.
|
|
// This bridges the gap between the editor's text edge and the button.
|
|
useEffect(() => {
|
|
const handleDocMove = (e: MouseEvent) => {
|
|
const h = hoverRef.current;
|
|
if (!h) return;
|
|
|
|
const btnLeft = h.left - BTN_SIZE - BTN_GAP;
|
|
const btnTop = h.top + h.height / 2 - BTN_SIZE / 2;
|
|
|
|
const inZone =
|
|
e.clientX >= btnLeft - HOVER_MARGIN &&
|
|
e.clientX <= h.left + HOVER_MARGIN && // extend slightly into text side
|
|
e.clientY >= btnTop - HOVER_MARGIN &&
|
|
e.clientY <= btnTop + BTN_SIZE + HOVER_MARGIN;
|
|
|
|
if (inZone) clearHideTimer();
|
|
};
|
|
|
|
document.addEventListener("mousemove", handleDocMove);
|
|
return () => document.removeEventListener("mousemove", handleDocMove);
|
|
}, [clearHideTimer]);
|
|
|
|
useEffect(() => {
|
|
const editorDom = editor.view.dom as HTMLElement;
|
|
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
const pos = editor.view.posAtCoords({ left: e.clientX, top: e.clientY });
|
|
if (!pos) { scheduleHide(); return; }
|
|
|
|
const $pos = editor.state.doc.resolve(pos.pos);
|
|
const depth = Math.min(1, $pos.depth);
|
|
if (depth === 0) { scheduleHide(); return; }
|
|
|
|
const nodeStart = $pos.before(depth);
|
|
const node = editor.state.doc.nodeAt(nodeStart);
|
|
if (!node || !REWRITABLE_TYPES.has(node.type.name)) {
|
|
scheduleHide();
|
|
return;
|
|
}
|
|
|
|
const text = node.textContent.trim();
|
|
if (!text) { scheduleHide(); return; }
|
|
|
|
const domNode = editor.view.nodeDOM(nodeStart);
|
|
if (!domNode || !(domNode instanceof HTMLElement)) {
|
|
scheduleHide();
|
|
return;
|
|
}
|
|
|
|
const rect = domNode.getBoundingClientRect();
|
|
clearHideTimer();
|
|
setHover({
|
|
top: rect.top,
|
|
left: rect.left,
|
|
height: rect.height,
|
|
nodeFrom: nodeStart + 1,
|
|
nodeTo: nodeStart + node.nodeSize - 1,
|
|
text,
|
|
});
|
|
};
|
|
|
|
editorDom.addEventListener("mousemove", handleMouseMove);
|
|
editorDom.addEventListener("mouseleave", scheduleHide);
|
|
|
|
return () => {
|
|
editorDom.removeEventListener("mousemove", handleMouseMove);
|
|
editorDom.removeEventListener("mouseleave", scheduleHide);
|
|
clearHideTimer();
|
|
};
|
|
}, [editor, clearHideTimer, scheduleHide]);
|
|
|
|
const handleClick = useCallback(() => {
|
|
if (!hover) return;
|
|
editor.chain().focus().setTextSelection({ from: hover.nodeFrom, to: hover.nodeTo }).run();
|
|
editor.commands.setAiHighlight(hover.nodeFrom, hover.nodeTo);
|
|
setWritingSelection({ from: hover.nodeFrom, to: hover.nodeTo, text: hover.text });
|
|
onAiRewrite();
|
|
}, [hover, editor, setWritingSelection, onAiRewrite]);
|
|
|
|
if (!hover) return null;
|
|
|
|
const btnTop = hover.top + hover.height / 2 - BTN_SIZE / 2;
|
|
const btnLeft = Math.max(4, hover.left - BTN_SIZE - BTN_GAP);
|
|
|
|
return createPortal(
|
|
<button
|
|
type="button"
|
|
style={{
|
|
position: "fixed",
|
|
top: btnTop,
|
|
left: btnLeft,
|
|
width: BTN_SIZE,
|
|
height: BTN_SIZE,
|
|
zIndex: 9998,
|
|
}}
|
|
onMouseEnter={clearHideTimer}
|
|
onMouseLeave={scheduleHide}
|
|
onClick={handleClick}
|
|
className={cn(
|
|
"inline-flex items-center justify-center rounded-md",
|
|
"bg-background border border-border shadow-sm",
|
|
"text-muted-foreground hover:text-amber-500 hover:border-amber-400 hover:bg-amber-50 dark:hover:bg-amber-950/30",
|
|
"transition-all duration-150",
|
|
"animate-in fade-in zoom-in-90 duration-100",
|
|
)}
|
|
title="AI 改写此段落"
|
|
>
|
|
<Sparkles className="h-3.5 w-3.5" />
|
|
</button>,
|
|
document.body,
|
|
);
|
|
}
|