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(null); const hideTimerRef = useRef | null>(null); // Keep a ref in sync so the document-level handler always reads fresh state const hoverRef = useRef(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( , document.body, ); }