import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { Sparkles, Wand2, Expand, Shrink, Mic2, BookCopy, AlertCircle, RefreshCw, ChevronRight, X, Square, Send, } from "lucide-react"; import { cn } from "@/lib/utils"; import type { RewriteAction, RewriteTone } from "../../api/rewrite"; import type { RewriteStatus } from "../../hooks/useWritingRewrite"; // ── Types ───────────────────────────────────────────────────────────────────── interface MenuPos { x: number; y: number; } // ── Tone options ─────────────────────────────────────────────────────────────── const TONE_OPTIONS: { value: RewriteTone; label: string }[] = [ { value: "professional", label: "更专业" }, { value: "casual", label: "更口语化" }, { value: "readable", label: "更易读" }, { value: "academic", label: "更学术" }, { value: "expressive", label: "更有文采" }, ]; // ── Action rows ──────────────────────────────────────────────────────────────── const ACTION_ROWS: { action: RewriteAction; label: string; icon: React.ElementType; }[] = [ { action: "polish", label: "润色", icon: Wand2 }, { action: "expand", label: "扩写", icon: Expand }, { action: "shorten", label: "缩写", icon: Shrink }, { action: "tone", label: "改语气", icon: Mic2 }, { action: "imitate", label: "仿写", icon: BookCopy }, ]; // ── Main component ───────────────────────────────────────────────────────────── interface WritingAiMenuProps { pos: MenuPos; status: RewriteStatus; error: string | null; onAction: (action: RewriteAction, tone?: RewriteTone, customInstruction?: string, referenceText?: string) => void; onAbort: () => void; onRetry: () => void; onClose: () => void; } export function WritingAiMenu({ pos, status, error, onAction, onAbort, onRetry, onClose, }: WritingAiMenuProps) { const [toneOpen, setToneOpen] = useState(false); const [imitateDialogOpen, setImitateDialogOpen] = useState(false); const [referenceText, setReferenceText] = useState(""); const [instruction, setInstruction] = useState(""); const inputRef = useRef(null); const imitateTextareaRef = useRef(null); // autoFocus is unreliable when the menu opens during a mousedown event sequence; // explicit focus via setTimeout(0) runs after the browser finishes that sequence. useEffect(() => { const id = setTimeout(() => inputRef.current?.focus(), 0); return () => clearTimeout(id); }, []); // Focus the dialog textarea when the imitate dialog opens. useEffect(() => { if (!imitateDialogOpen) return; const id = setTimeout(() => imitateTextareaRef.current?.focus(), 0); return () => clearTimeout(id); }, [imitateDialogOpen]); const isLoading = status === "loading"; const isError = status === "error"; // Measure actual height after render and compute safe position before browser paint const menuWidth = 240; const containerRef = useRef(null); const [computedPos, setComputedPos] = useState<{ left: number; top: number } | null>(null); useLayoutEffect(() => { const el = containerRef.current; if (!el) return; const h = el.getBoundingClientRect().height; const safeX = Math.min(Math.max(pos.x, 8), window.innerWidth - menuWidth - 8); const spaceBelow = window.innerHeight - pos.y - 8; const spaceAbove = pos.y - 8; let safeY: number; if (spaceBelow >= h) { safeY = pos.y + 4; } else if (spaceAbove >= h) { safeY = pos.y - h - 4; } else if (spaceBelow >= spaceAbove) { safeY = Math.max(8, window.innerHeight - h - 8); } else { safeY = 8; } setComputedPos({ left: safeX, top: safeY }); }, [pos.x, pos.y]); const fireAction = (action: RewriteAction, tone?: RewriteTone) => { onAction(action, tone, instruction.trim() || undefined); }; const fireImitate = () => { const ref = referenceText.trim(); if (!ref) return; setImitateDialogOpen(false); onAction("imitate", undefined, instruction.trim() || undefined, ref); }; const handleInputEnter = () => { const trimmed = instruction.trim(); onAction("polish", undefined, trimmed || undefined); }; return createPortal( <> {/* Backdrop */}
{/* Header */}
AI 改写
{/* ── Loading state ──────────────────────────────────────── */} {isLoading && (
{/* Bouncing dots */}
{[0, 160, 320].map((delay) => ( ))} AI 正在生成…
{/* Stop button — breathing animation */}
)} {/* ── Error state ─────────────────────────────────────────── */} {isError && (

{error ?? "改写失败"}

)} {/* ── Idle state: input + action list ─────────────────────── */} {!isLoading && !isError && ( <> {/* Custom instruction input */}
setInstruction(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleInputEnter(); } }} placeholder="告诉 AI 你想要什么…" className={cn( "w-full text-xs rounded-md px-2.5 py-1.5 outline-none transition-colors", "bg-muted/50 border border-input focus:border-primary/60", "placeholder:text-muted-foreground/50", )} />
{/* Divider */}
{/* Preset actions */}
{ACTION_ROWS.map(({ action, label, icon: Icon }) => { const isTone = action === "tone"; const isImitate = action === "imitate"; const hasSub = isTone; const isExpanded = isTone && toneOpen; return (
{/* Tone sub-list */} {isTone && toneOpen && (
{TONE_OPTIONS.map(({ value, label: toneLabel }) => ( ))}
)}
); })}
)}
{/* ── Imitate dialog:弹窗内多行输入范文,回车 / 发送即仿写 ───────────── */} {imitateDialogOpen && (
setImitateDialogOpen(false)} />
e.stopPropagation()} > {/* Header */}
仿写
{/* Body */}