deerflow-code/frontend-web/src/open-canvas/components/artifact/WritingAiMenu.tsx
2026-09-07 18:24:55 +08:00

393 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<HTMLInputElement>(null);
const imitateTextareaRef = useRef<HTMLTextAreaElement>(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<HTMLDivElement>(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 */}
<div className="fixed inset-0 z-[9990]" onMouseDown={onClose} />
<div
ref={containerRef}
style={{
position: "fixed",
left: computedPos?.left ?? pos.x,
top: computedPos?.top ?? (pos.y + 4),
width: menuWidth,
zIndex: 9999,
visibility: computedPos ? "visible" : "hidden",
}}
className="rounded-xl border bg-popover shadow-lg animate-in fade-in zoom-in-95 duration-100 overflow-hidden"
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b bg-muted/30">
<span className="flex items-center gap-1.5 text-xs font-semibold text-foreground">
<Sparkles className="h-3.5 w-3.5 text-amber-500" />
AI 改写
</span>
<button
type="button"
onClick={onClose}
className="rounded p-0.5 hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
{/* ── Loading state ──────────────────────────────────────── */}
{isLoading && (
<div className="flex flex-col items-center gap-4 px-4 py-6">
{/* Bouncing dots */}
<div className="flex items-center gap-2">
{[0, 160, 320].map((delay) => (
<span
key={delay}
className="h-1.5 w-1.5 rounded-full bg-primary animate-bouncing"
style={{ animationDelay: `${delay}ms` }}
/>
))}
<span className="ml-1 text-xs text-muted-foreground">AI 正在生成…</span>
</div>
{/* Stop button — breathing animation */}
<button
type="button"
onClick={onAbort}
className={cn(
"animate-breathe-stop",
"flex items-center gap-1.5 px-5 py-2 text-xs font-medium rounded-lg",
"border border-red-300/70 bg-red-50/60 text-red-600",
"dark:border-red-800/50 dark:bg-red-950/20 dark:text-red-400",
"hover:bg-red-100/90 dark:hover:bg-red-950/40 transition-colors",
)}
>
<Square className="h-3 w-3 fill-current" />
停止生成
</button>
</div>
)}
{/* ── Error state ─────────────────────────────────────────── */}
{isError && (
<div className="px-3 py-3 space-y-2">
<p className="flex items-center gap-1.5 text-xs text-destructive">
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
{error ?? "改写失败"}
</p>
<button
type="button"
onClick={onRetry}
className="flex items-center gap-1.5 text-xs text-primary hover:underline"
>
<RefreshCw className="h-3 w-3" />
重试
</button>
</div>
)}
{/* ── Idle state: input + action list ─────────────────────── */}
{!isLoading && !isError && (
<>
{/* Custom instruction input */}
<div className="px-2.5 pt-2.5 pb-2">
<input
ref={inputRef}
type="text"
value={instruction}
onChange={(e) => 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",
)}
/>
</div>
{/* Divider */}
<div className="border-t border-border/50 mx-1" />
{/* Preset actions */}
<div className="p-1 space-y-0.5">
{ACTION_ROWS.map(({ action, label, icon: Icon }) => {
const isTone = action === "tone";
const isImitate = action === "imitate";
const hasSub = isTone;
const isExpanded = isTone && toneOpen;
return (
<div key={action}>
<button
type="button"
onClick={() => {
if (isTone) {
setToneOpen((v) => !v);
} else if (isImitate) {
setToneOpen(false);
setImitateDialogOpen(true);
} else {
fireAction(action);
}
}}
className={cn(
"flex w-full items-center gap-2 px-2.5 py-1.5 text-xs rounded-md transition-colors text-left",
"hover:bg-accent hover:text-accent-foreground",
isExpanded && "bg-accent text-accent-foreground",
)}
>
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="flex-1">{label}</span>
{hasSub && (
<ChevronRight
className={cn(
"h-3.5 w-3.5 text-muted-foreground transition-transform duration-200",
isExpanded && "rotate-90",
)}
/>
)}
</button>
{/* Tone sub-list */}
{isTone && toneOpen && (
<div className="ml-5 border-l pl-2 my-0.5 space-y-0.5">
{TONE_OPTIONS.map(({ value, label: toneLabel }) => (
<button
key={value}
type="button"
onClick={() => fireAction("tone", value)}
className="flex w-full items-center px-2 py-1.5 text-xs rounded-md hover:bg-accent hover:text-accent-foreground transition-colors"
>
{toneLabel}
</button>
))}
</div>
)}
</div>
);
})}
</div>
</>
)}
</div>
{/* ── Imitate dialog:弹窗内多行输入范文,回车 / 发送即仿写 ───────────── */}
{imitateDialogOpen && (
<div className="fixed inset-0 z-[10000] flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/40 animate-in fade-in duration-100"
onMouseDown={() => setImitateDialogOpen(false)}
/>
<div
className="relative w-full max-w-md rounded-xl border bg-popover shadow-2xl animate-in fade-in zoom-in-95 duration-100 overflow-hidden"
onMouseDown={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30">
<span className="flex items-center gap-1.5 text-sm font-semibold text-foreground">
<BookCopy className="h-4 w-4 text-amber-500" />
仿写
</span>
<button
type="button"
onClick={() => setImitateDialogOpen(false)}
className="rounded p-0.5 hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Body */}
<div className="p-4 space-y-3">
<textarea
ref={imitateTextareaRef}
value={referenceText}
onChange={(e) => setReferenceText(e.target.value)}
onKeyDown={(e) => {
// Enter 提交,Shift+Enter 换行
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
fireImitate();
} else if (e.key === "Escape") {
e.preventDefault();
setImitateDialogOpen(false);
}
}}
rows={8}
placeholder="粘贴范文,AI 将模仿其文风、句式与语气改写所选文字…(回车提交,Shift+Enter 换行)"
className={cn(
"w-full resize-none text-sm rounded-md px-3 py-2 outline-none transition-colors leading-relaxed",
"bg-muted/50 border border-input focus:border-primary/60",
"placeholder:text-muted-foreground/50",
)}
/>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t bg-muted/20">
<button
type="button"
onClick={() => setImitateDialogOpen(false)}
className="px-3 py-1.5 text-xs font-medium rounded-md border border-input hover:bg-accent hover:text-accent-foreground transition-colors"
>
取消
</button>
<button
type="button"
onClick={fireImitate}
disabled={!referenceText.trim()}
className={cn(
"flex items-center justify-center gap-1.5 px-4 py-1.5 text-xs font-medium rounded-md transition-colors",
"bg-primary text-primary-foreground hover:bg-primary/90",
"disabled:opacity-40 disabled:cursor-not-allowed",
)}
>
<Send className="h-3.5 w-3.5" />
发送
</button>
</div>
</div>
</div>
)}
</>,
document.body,
);
}