lovart3/components/image-annotator.tsx
Your Name 7c253e04a0 feat: 添加图像拓展功能
- 新增 /api/extend 路由,集成阿里云Dashscope图像拓展API
- 在系统设置'本地AI设置'中添加图像拓展API配置(API密钥、X/Y轴放大倍数)
- 在图片标注组件中添加'图像拓展'按钮(橙色,Expand图标)
- 在聊天对话图片上添加'图像拓展'按钮,支持动态占位卡片
- 更新ImageProcessingLoading组件支持extend类型(橙色主题)
- 处理后的图片上传到S3,保存到数据库(modelId: extend-1.0)
- 同步到我的相册和出图记录
- 添加完整的API调用文档
2025-12-29 10:54:32 +08:00

788 lines
28 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.

"use client";
import React, { useRef, useState, useEffect, useCallback } from "react";
import { X, Pencil, Circle, ArrowRight, Type, Check, Undo, Trash2, Scissors, Sparkles, Download, Expand } from "lucide-react";
import { Button } from "./ui/button";
import { toast } from "sonner";
type Tool = "pen" | "arrow" | "circle" | "text";
type DrawingAction = {
type: Tool;
color: string;
lineWidth: number;
points?: { x: number; y: number }[];
startX?: number;
startY?: number;
endX?: number;
endY?: number;
radius?: number;
text?: string;
fontSize?: number;
};
interface ImageAnnotatorProps {
imageUrl: string;
onConfirm: (annotatedImageUrl: string) => void;
onCancel: () => void;
}
const COLORS = [
"#FF0000", // 红色
"#FF6B00", // 橙色
"#FFCC00", // 黄色
"#00CC00", // 绿色
"#0099FF", // 蓝色
"#9933FF", // 紫色
"#FF0099", // 粉色
"#FFFFFF", // 白色
"#000000", // 黑色
];
const LINE_WIDTHS = [2, 4, 6, 8];
export function ImageAnnotator({ imageUrl, onConfirm, onCancel }: ImageAnnotatorProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [ctx, setCtx] = useState<CanvasRenderingContext2D | null>(null);
const [image, setImage] = useState<HTMLImageElement | null>(null);
const [isDrawing, setIsDrawing] = useState(false);
const [tool, setTool] = useState<Tool>("pen");
const [color, setColor] = useState("#FF0000");
const [lineWidth, setLineWidth] = useState(4);
const [actions, setActions] = useState<DrawingAction[]>([]);
const [currentAction, setCurrentAction] = useState<DrawingAction | null>(null);
const [textInput, setTextInput] = useState("");
const [textPosition, setTextPosition] = useState<{ x: number; y: number } | null>(null);
const [scale, setScale] = useState(1);
const [offset, setOffset] = useState({ x: 0, y: 0 });
const [isLoading, setIsLoading] = useState(true);
const [isRemovingBg, setIsRemovingBg] = useState(false);
const [isEnhancing, setIsEnhancing] = useState(false);
const [isExtending, setIsExtending] = useState(false);
const [originalImageUrl, setOriginalImageUrl] = useState<string>(imageUrl);
const [processedImageUrl, setProcessedImageUrl] = useState<string | null>(null);
const [processedImageBase64, setProcessedImageBase64] = useState<string | null>(null);
const [processType, setProcessType] = useState<"rmbg" | "enhance" | "extend" | null>(null);
// 加载图片
useEffect(() => {
setIsLoading(true);
setOriginalImageUrl(imageUrl);
setProcessedImageUrl(null);
setProcessedImageBase64(null);
setProcessType(null);
const img = new window.Image();
img.crossOrigin = "anonymous";
img.onload = () => {
setImage(img);
setIsLoading(false);
};
img.onerror = () => {
setIsLoading(false);
};
img.src = imageUrl;
}, [imageUrl]);
// 初始化 canvas - 适配弹窗大小
useEffect(() => {
if (!canvasRef.current || !image || !containerRef.current) return;
const canvas = canvasRef.current;
const container = containerRef.current;
const context = canvas.getContext("2d");
if (!context) return;
// 弹窗内画布区域大小(减去工具栏和边距,考虑并排显示)
const containerWidth = processedImageBase64 ? 550 : 1100; // 如果有处理后图片,每个区域550px;否则1100px
const containerHeight = 350; // 550 - 工具栏高度
const scaleX = containerWidth / image.width;
const scaleY = containerHeight / image.height;
const newScale = Math.min(scaleX, scaleY, 1); // 不放大,只缩小
setScale(newScale);
canvas.width = image.width * newScale;
canvas.height = image.height * newScale;
// 计算居中偏移
const offsetX = (containerWidth - canvas.width) / 2;
const offsetY = (containerHeight - canvas.height) / 2;
setOffset({ x: Math.max(0, offsetX), y: Math.max(0, offsetY) });
setCtx(context);
redrawCanvas(context, image, newScale, []);
}, [image]);
// 重绘画布
const redrawCanvas = useCallback(
(context: CanvasRenderingContext2D, img: HTMLImageElement, currentScale: number, drawActions: DrawingAction[]) => {
const canvas = context.canvas;
context.clearRect(0, 0, canvas.width, canvas.height);
// 绘制图片
context.drawImage(img, 0, 0, canvas.width, canvas.height);
// 绘制所有动作
for (const action of drawActions) {
drawAction(context, action, currentScale);
}
},
[]
);
// 当 actions 变化时重绘
useEffect(() => {
if (ctx && image) {
redrawCanvas(ctx, image, scale, actions);
}
}, [actions, ctx, image, scale, redrawCanvas]);
// 绘制单个动作
const drawAction = (context: CanvasRenderingContext2D, action: DrawingAction, currentScale: number) => {
context.strokeStyle = action.color;
context.fillStyle = action.color;
context.lineWidth = action.lineWidth;
context.lineCap = "round";
context.lineJoin = "round";
switch (action.type) {
case "pen":
if (action.points && action.points.length > 0) {
context.beginPath();
context.moveTo(action.points[0].x * currentScale, action.points[0].y * currentScale);
for (let i = 1; i < action.points.length; i++) {
context.lineTo(action.points[i].x * currentScale, action.points[i].y * currentScale);
}
context.stroke();
}
break;
case "arrow":
if (action.startX !== undefined && action.startY !== undefined && action.endX !== undefined && action.endY !== undefined) {
const sx = action.startX * currentScale;
const sy = action.startY * currentScale;
const ex = action.endX * currentScale;
const ey = action.endY * currentScale;
// 绘制线条
context.beginPath();
context.moveTo(sx, sy);
context.lineTo(ex, ey);
context.stroke();
// 绘制箭头
const angle = Math.atan2(ey - sy, ex - sx);
const arrowLength = 15 + action.lineWidth * 2;
context.beginPath();
context.moveTo(ex, ey);
context.lineTo(ex - arrowLength * Math.cos(angle - Math.PI / 6), ey - arrowLength * Math.sin(angle - Math.PI / 6));
context.lineTo(ex - arrowLength * Math.cos(angle + Math.PI / 6), ey - arrowLength * Math.sin(angle + Math.PI / 6));
context.closePath();
context.fill();
}
break;
case "circle":
if (action.startX !== undefined && action.startY !== undefined && action.radius !== undefined) {
context.beginPath();
context.arc(action.startX * currentScale, action.startY * currentScale, action.radius * currentScale, 0, 2 * Math.PI);
context.stroke();
}
break;
case "text":
if (action.startX !== undefined && action.startY !== undefined && action.text) {
const fontSize = (action.fontSize || 20) * currentScale;
context.font = `bold ${fontSize}px sans-serif`;
context.fillText(action.text, action.startX * currentScale, action.startY * currentScale);
}
break;
}
};
// 获取鼠标/触摸位置(转换为原始图片坐标)
const getPosition = (e: React.MouseEvent | React.TouchEvent) => {
const canvas = canvasRef.current;
if (!canvas) return { x: 0, y: 0 };
const rect = canvas.getBoundingClientRect();
let clientX: number;
let clientY: number;
if ("touches" in e) {
clientX = e.touches[0].clientX;
clientY = e.touches[0].clientY;
} else {
clientX = e.clientX;
clientY = e.clientY;
}
// 转换为原始图片坐标
return {
x: (clientX - rect.left) / scale,
y: (clientY - rect.top) / scale,
};
};
// 开始绘制
const handleStart = (e: React.MouseEvent | React.TouchEvent) => {
if (tool === "text") {
const pos = getPosition(e);
setTextPosition(pos);
return;
}
setIsDrawing(true);
const pos = getPosition(e);
const newAction: DrawingAction = {
type: tool,
color,
lineWidth,
};
if (tool === "pen") {
newAction.points = [pos];
} else {
newAction.startX = pos.x;
newAction.startY = pos.y;
newAction.endX = pos.x;
newAction.endY = pos.y;
if (tool === "circle") {
newAction.radius = 0;
}
}
setCurrentAction(newAction);
};
// 绘制中
const handleMove = (e: React.MouseEvent | React.TouchEvent) => {
if (!isDrawing || !currentAction || !ctx || !image) return;
const pos = getPosition(e);
const updatedAction = { ...currentAction };
if (tool === "pen" && updatedAction.points) {
updatedAction.points = [...updatedAction.points, pos];
} else if (tool === "arrow") {
updatedAction.endX = pos.x;
updatedAction.endY = pos.y;
} else if (tool === "circle" && updatedAction.startX !== undefined && updatedAction.startY !== undefined) {
const dx = pos.x - updatedAction.startX;
const dy = pos.y - updatedAction.startY;
updatedAction.radius = Math.sqrt(dx * dx + dy * dy);
}
setCurrentAction(updatedAction);
// 实时重绘
redrawCanvas(ctx, image, scale, [...actions, updatedAction]);
};
// 结束绘制
const handleEnd = () => {
if (!isDrawing || !currentAction) return;
setIsDrawing(false);
setActions((prev) => [...prev, currentAction]);
setCurrentAction(null);
};
// 添加文字
const handleAddText = () => {
if (!textInput.trim() || !textPosition) return;
const textAction: DrawingAction = {
type: "text",
color,
lineWidth,
startX: textPosition.x,
startY: textPosition.y,
text: textInput,
fontSize: 20 + lineWidth * 2,
};
setActions((prev) => [...prev, textAction]);
setTextInput("");
setTextPosition(null);
};
// 撤销
const handleUndo = () => {
setActions((prev) => prev.slice(0, -1));
};
// 清空
const handleClear = () => {
setActions([]);
};
// 抠图功能
const handleRemoveBackground = async () => {
if (!image || !canvasRef.current) return;
setIsRemovingBg(true);
try {
// 获取当前画布的图片(包含标注)
const canvas = canvasRef.current;
const currentImageData = canvas.toDataURL("image/png");
// 提取Base64数据(去掉data:image/png;base64,前缀)
const base64Data = currentImageData.split(",")[1];
// 调用抠图API
const response = await fetch("/api/rmbg", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
image_base64: base64Data,
prompt: "画图标注-抠图处理", // 可选:添加提示词
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "抠图失败");
}
const result = await response.json();
if (result.success && result.image_base64) {
// 保存处理后的图片URL和Base64
if (result.original_url) {
setProcessedImageUrl(result.original_url);
} else {
setProcessedImageUrl(null);
}
setProcessedImageBase64(result.image_base64);
setProcessType("rmbg");
toast.success(`抠图完成!图片已保存到相册。处理时间: ${result.processing_time?.toFixed(2) || "未知"}秒`);
} else {
throw new Error(result.error || "抠图处理失败");
}
} catch (error) {
console.error("[抠图] 错误:", error);
toast.error(error instanceof Error ? error.message : "抠图失败,请稍后重试");
} finally {
setIsRemovingBg(false);
}
};
// 变清晰处理
const handleEnhance = async () => {
if (!image || !canvasRef.current) return;
setIsEnhancing(true);
try {
// 获取当前画布的图片(包含标注)
const canvas = canvasRef.current;
const currentImageData = canvas.toDataURL("image/png");
// 提取Base64数据(去掉data:image/png;base64,前缀)
const base64Data = currentImageData.split(",")[1];
// 调用变清晰API
const response = await fetch("/api/enhance", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
image_base64: base64Data,
prompt: "画图标注-变清晰处理", // 可选:添加提示词
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "变清晰处理失败");
}
const result = await response.json();
if (result.success && result.image_base64) {
// 保存处理后的图片URL和Base64
if (result.original_url) {
setProcessedImageUrl(result.original_url);
} else {
setProcessedImageUrl(null);
}
setProcessedImageBase64(result.image_base64);
setProcessType("enhance");
toast.success(`变清晰完成!图片已保存到相册。处理时间: ${result.processing_time?.toFixed(2) || "未知"}秒`);
} else {
throw new Error(result.error || "变清晰处理失败");
}
} catch (error) {
console.error("[变清晰] 错误:", error);
toast.error(error instanceof Error ? error.message : "变清晰失败,请稍后重试");
} finally {
setIsEnhancing(false);
}
};
// 图像拓展处理
const handleExtend = async () => {
if (!image || !canvasRef.current) return;
setIsExtending(true);
try {
// 获取当前画布的图片(包含标注)
const canvas = canvasRef.current;
const currentImageData = canvas.toDataURL("image/png");
// 提取Base64数据(去掉data:image/png;base64,前缀)
const base64Data = currentImageData.split(",")[1];
// 调用图像拓展API
const response = await fetch("/api/extend", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
image_base64: base64Data,
prompt: "画图标注-图像拓展处理", // 可选:添加提示词
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "图像拓展处理失败");
}
const result = await response.json();
if (result.success && result.base64) {
// 保存处理后的图片URL和Base64
if (result.original_url) {
setProcessedImageUrl(result.original_url);
} else {
setProcessedImageUrl(null);
}
setProcessedImageBase64(result.base64);
setProcessType("extend");
toast.success(`图像拓展完成!图片已保存到相册。处理时间: ${result.processing_time?.toFixed(2) || "未知"}秒`);
} else {
throw new Error(result.error || "图像拓展处理失败");
}
} catch (error) {
console.error("[图像拓展] 错误:", error);
toast.error(error instanceof Error ? error.message : "图像拓展失败,请稍后重试");
} finally {
setIsExtending(false);
}
};
// 下载处理后的图片(必须使用原图URL)
const handleDownloadProcessed = async () => {
if (!processedImageUrl) {
toast.error("原图地址不可用,无法下载");
return;
}
try {
// 确定文件类型和MIME类型
const isPng = processType === "rmbg";
const fileExtension = isPng ? "png" : "jpg";
const fileName = processType === "rmbg"
? `抠图-${Date.now()}.${fileExtension}`
: processType === "enhance"
? `变清晰-${Date.now()}.${fileExtension}`
: `图像拓展-${Date.now()}.${fileExtension}`;
// 从原图URL下载(原图已上传到S3)
const response = await fetch(processedImageUrl);
if (!response.ok) {
throw new Error("下载原图失败");
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
toast.success("原图下载成功");
} catch (error) {
console.error("[下载] 错误:", error);
toast.error("下载失败,请稍后重试");
}
};
// 确认
const handleConfirm = () => {
const canvas = canvasRef.current;
if (!canvas || !image) return;
// 创建一个新的 canvas 用于导出原始尺寸
const exportCanvas = document.createElement("canvas");
exportCanvas.width = image.width;
exportCanvas.height = image.height;
const exportCtx = exportCanvas.getContext("2d");
if (!exportCtx) return;
// 绘制原始图片
exportCtx.drawImage(image, 0, 0);
// 绘制所有动作(使用原始尺寸)
for (const action of actions) {
drawAction(exportCtx, action, 1);
}
// 导出为 data URL
const dataUrl = exportCanvas.toDataURL("image/png");
onConfirm(dataUrl);
};
return (
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={onCancel}>
{/* 弹窗容器 */}
<div
className="flex flex-col overflow-hidden rounded-xl border border-border bg-background shadow-2xl"
style={{ width: "1200px", height: "550px" }}
onClick={(e) => e.stopPropagation()}
>
{/* 顶部标题栏 */}
<div className="flex items-center justify-between border-b px-4 py-3">
<span className="text-base font-medium">图片标注</span>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={handleRemoveBackground}
disabled={isRemovingBg || isEnhancing || isExtending || !image}
title="移除背景(抠图)"
>
<Scissors className={`mr-1 h-4 w-4 ${isRemovingBg ? "animate-spin" : ""}`} />
{isRemovingBg ? "处理中..." : "抠图"}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleEnhance}
disabled={isEnhancing || isRemovingBg || isExtending || !image}
title="图片变清晰"
>
<Sparkles className={`mr-1 h-4 w-4 ${isEnhancing ? "animate-spin" : ""}`} />
{isEnhancing ? "处理中..." : "变清晰"}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleExtend}
disabled={isExtending || isRemovingBg || isEnhancing || !image}
title="图像拓展"
>
<Expand className={`mr-1 h-4 w-4 ${isExtending ? "animate-spin" : ""}`} />
{isExtending ? "处理中..." : "图像拓展"}
</Button>
<Button variant="ghost" size="sm" onClick={handleUndo} disabled={actions.length === 0}>
<Undo className="mr-1 h-4 w-4" />
撤销
</Button>
<Button variant="ghost" size="sm" onClick={handleClear} disabled={actions.length === 0}>
<Trash2 className="mr-1 h-4 w-4" />
清空
</Button>
<Button variant="ghost" size="sm" onClick={onCancel}>
<X className="mr-1 h-4 w-4" />
取消
</Button>
<Button size="sm" onClick={handleConfirm} className="bg-primary text-primary-foreground hover:bg-primary/90">
<Check className="mr-1 h-4 w-4" />
确定
</Button>
</div>
</div>
{/* 工具选择栏 */}
<div className="flex flex-wrap items-center justify-center gap-3 border-b px-4 py-2">
{/* 工具按钮 */}
<div className="flex items-center gap-1 rounded-lg bg-muted p-1">
<button
type="button"
className={`rounded-md p-1.5 transition-colors ${tool === "pen" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted-foreground/10 hover:text-foreground"}`}
onClick={() => setTool("pen")}
title="画笔"
>
<Pencil className="h-4 w-4" />
</button>
<button
type="button"
className={`rounded-md p-1.5 transition-colors ${tool === "arrow" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted-foreground/10 hover:text-foreground"}`}
onClick={() => setTool("arrow")}
title="箭头"
>
<ArrowRight className="h-4 w-4" />
</button>
<button
type="button"
className={`rounded-md p-1.5 transition-colors ${tool === "circle" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted-foreground/10 hover:text-foreground"}`}
onClick={() => setTool("circle")}
title="圆圈"
>
<Circle className="h-4 w-4" />
</button>
<button
type="button"
className={`rounded-md p-1.5 transition-colors ${tool === "text" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted-foreground/10 hover:text-foreground"}`}
onClick={() => setTool("text")}
title="文字"
>
<Type className="h-4 w-4" />
</button>
</div>
{/* 颜色选择 */}
<div className="flex items-center gap-1 rounded-lg bg-muted p-1">
{COLORS.map((c) => (
<button
key={c}
type="button"
className={`h-6 w-6 rounded-full border-2 transition-transform ${color === c ? "scale-110 border-primary" : "border-transparent hover:scale-105"}`}
style={{ backgroundColor: c }}
onClick={() => setColor(c)}
title={c}
/>
))}
</div>
{/* 线宽选择 */}
<div className="flex items-center gap-1 rounded-lg bg-muted p-1">
{LINE_WIDTHS.map((w) => (
<button
key={w}
type="button"
className={`flex h-7 w-7 items-center justify-center rounded-md transition-colors ${lineWidth === w ? "bg-primary/20" : "hover:bg-muted-foreground/10"}`}
onClick={() => setLineWidth(w)}
title={`线宽 ${w}px`}
>
<div className="rounded-full bg-foreground" style={{ width: w + 2, height: w + 2 }} />
</button>
))}
</div>
</div>
{/* 画布区域 - 并排显示原图和处理后的图片 */}
<div ref={containerRef} className="flex flex-1 items-center justify-center gap-4 overflow-hidden bg-muted/30 p-4">
{isLoading ? (
/* 加载动画 */
<div className="flex flex-col items-center justify-center gap-3">
<div className="flex items-center gap-1">
<span
className="h-2 w-2 rounded-full bg-primary animate-bounce"
style={{ animationDelay: "0ms" }}
/>
<span
className="h-2 w-2 rounded-full bg-primary animate-bounce"
style={{ animationDelay: "150ms" }}
/>
<span
className="h-2 w-2 rounded-full bg-primary animate-bounce"
style={{ animationDelay: "300ms" }}
/>
</div>
<span className="text-sm text-muted-foreground">正在加载图片...</span>
</div>
) : (
<>
{/* 原图画布 */}
<div className="flex flex-1 flex-col items-center justify-center">
<div className="mb-2 text-xs text-muted-foreground">原图</div>
<canvas
ref={canvasRef}
className="cursor-crosshair rounded-lg shadow-lg max-w-full"
onMouseDown={handleStart}
onMouseMove={handleMove}
onMouseUp={handleEnd}
onMouseLeave={handleEnd}
onTouchStart={handleStart}
onTouchMove={handleMove}
onTouchEnd={handleEnd}
/>
</div>
{/* 处理后的图片展示区域 */}
{processedImageBase64 && (
<div className="flex flex-1 flex-col items-center justify-center">
<div className="mb-2 flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{processType === "rmbg" ? "抠图结果" : processType === "enhance" ? "清晰结果" : "拓展结果"}
</span>
<Button
variant="ghost"
size="sm"
onClick={handleDownloadProcessed}
className="h-7 px-2"
title="下载处理后的图片"
>
<Download className="h-3.5 w-3.5" />
</Button>
</div>
<div className="relative rounded-lg border-2 border-primary/50 bg-background p-2 shadow-lg">
{processedImageUrl ? (
<img
src={processedImageUrl}
alt={processType === "rmbg" ? "抠图结果" : processType === "enhance" ? "清晰结果" : "拓展结果"}
className="max-h-[350px] max-w-full rounded-lg"
style={{ maxWidth: canvasRef.current?.width || "400px" }}
/>
) : processedImageBase64 ? (
<img
src={`data:image/${processType === "rmbg" ? "png" : "jpeg"};base64,${processedImageBase64}`}
alt={processType === "rmbg" ? "抠图结果" : processType === "enhance" ? "清晰结果" : "拓展结果"}
className="max-h-[350px] max-w-full rounded-lg"
style={{ maxWidth: canvasRef.current?.width || "400px" }}
/>
) : null}
</div>
</div>
)}
</>
)}
</div>
</div>
{/* 文字输入弹窗 */}
{textPosition && (
<div className="fixed inset-0 z-[310] flex items-center justify-center bg-black/50" onClick={() => setTextPosition(null)}>
<div className="rounded-lg border bg-background p-4 shadow-xl" onClick={(e) => e.stopPropagation()}>
<div className="mb-3 text-sm font-medium">输入标注文字</div>
<input
type="text"
value={textInput}
onChange={(e) => setTextInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleAddText();
}}
className="mb-3 w-64 rounded-md border bg-muted px-3 py-2 text-foreground outline-none focus:border-primary"
placeholder="输入文字..."
autoFocus
/>
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => setTextPosition(null)}>
取消
</Button>
<Button size="sm" onClick={handleAddText} disabled={!textInput.trim()}>
添加
</Button>
</div>
</div>
</div>
)}
</div>
);
}