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