404 lines
13 KiB
TypeScript
404 lines
13 KiB
TypeScript
"use client";
|
||
|
||
import { Check, Crosshair, Loader2, X } from "lucide-react";
|
||
import type React from "react";
|
||
import { useCallback, useRef, useState } from "react";
|
||
import { createPortal } from "react-dom";
|
||
import { toast } from "sonner";
|
||
import { Button } from "@/components/ui/button";
|
||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||
import { cn } from "@/lib/utils";
|
||
import { type PickedObject, useCanvasStore } from "@/store/canvas";
|
||
|
||
// 已标注的物品(本地状态,用于显示标记)
|
||
interface PickedMarker {
|
||
clickX: number;
|
||
clickY: number;
|
||
objectName: string;
|
||
index: number;
|
||
}
|
||
|
||
/**
|
||
* 在图片上绘制标记点,返回带标记的图片 base64
|
||
* AI 通过看到这个红色标记来识别用户点击的位置
|
||
*/
|
||
async function drawMarkerOnImage(
|
||
imageUrl: string,
|
||
clickX: number,
|
||
clickY: number
|
||
): Promise<string> {
|
||
return new Promise((resolve, reject) => {
|
||
const img = new Image();
|
||
img.crossOrigin = "anonymous";
|
||
const loadUrl = buildImageProxyUrl(imageUrl, { delivery: "proxy" });
|
||
|
||
img.onload = () => {
|
||
// 创建 canvas
|
||
const canvas = document.createElement("canvas");
|
||
const ctx = canvas.getContext("2d");
|
||
if (!ctx) {
|
||
reject(new Error("无法创建 canvas"));
|
||
return;
|
||
}
|
||
|
||
// 压缩图片尺寸(最大 800px),加快 AI 分析速度
|
||
const maxSize = 800;
|
||
let width = img.width;
|
||
let height = img.height;
|
||
|
||
if (width > maxSize || height > maxSize) {
|
||
if (width > height) {
|
||
height = (height / width) * maxSize;
|
||
width = maxSize;
|
||
} else {
|
||
width = (width / height) * maxSize;
|
||
height = maxSize;
|
||
}
|
||
}
|
||
|
||
canvas.width = width;
|
||
canvas.height = height;
|
||
|
||
// 绘制图片
|
||
ctx.drawImage(img, 0, 0, width, height);
|
||
|
||
// 计算标记位置
|
||
const markerX = clickX * width;
|
||
const markerY = clickY * height;
|
||
const markerRadius = Math.max(12, Math.min(width, height) * 0.025); // 标记大小
|
||
|
||
// 绘制红色标记圆圈(带白色边框,更醒目)
|
||
ctx.beginPath();
|
||
ctx.arc(markerX, markerY, markerRadius + 3, 0, Math.PI * 2);
|
||
ctx.fillStyle = "white";
|
||
ctx.fill();
|
||
|
||
ctx.beginPath();
|
||
ctx.arc(markerX, markerY, markerRadius, 0, Math.PI * 2);
|
||
ctx.fillStyle = "#FF0000";
|
||
ctx.fill();
|
||
|
||
// 绘制十字线(帮助 AI 定位)
|
||
ctx.strokeStyle = "#FF0000";
|
||
ctx.lineWidth = 2;
|
||
|
||
// 水平线
|
||
ctx.beginPath();
|
||
ctx.moveTo(markerX - markerRadius * 2, markerY);
|
||
ctx.lineTo(markerX - markerRadius - 2, markerY);
|
||
ctx.stroke();
|
||
ctx.beginPath();
|
||
ctx.moveTo(markerX + markerRadius + 2, markerY);
|
||
ctx.lineTo(markerX + markerRadius * 2, markerY);
|
||
ctx.stroke();
|
||
|
||
// 垂直线
|
||
ctx.beginPath();
|
||
ctx.moveTo(markerX, markerY - markerRadius * 2);
|
||
ctx.lineTo(markerX, markerY - markerRadius - 2);
|
||
ctx.stroke();
|
||
ctx.beginPath();
|
||
ctx.moveTo(markerX, markerY + markerRadius + 2);
|
||
ctx.lineTo(markerX, markerY + markerRadius * 2);
|
||
ctx.stroke();
|
||
|
||
// 转为 base64
|
||
const markedImageBase64 = canvas.toDataURL("image/jpeg", 0.85);
|
||
resolve(markedImageBase64);
|
||
};
|
||
|
||
img.onerror = () => {
|
||
reject(new Error("图片加载失败"));
|
||
};
|
||
|
||
img.src = loadUrl;
|
||
});
|
||
}
|
||
|
||
export function ObjectPickerOverlay() {
|
||
const isPickerMode = useCanvasStore((state) => state.isPickerMode);
|
||
const pickerTargetNodeId = useCanvasStore(
|
||
(state) => state.pickerTargetNodeId
|
||
);
|
||
const isPickerLoading = useCanvasStore((state) => state.isPickerLoading);
|
||
const setPickerMode = useCanvasStore((state) => state.setPickerMode);
|
||
const setPickerLoading = useCanvasStore((state) => state.setPickerLoading);
|
||
const addPendingPickedObject = useCanvasStore(
|
||
(state) => state.addPendingPickedObject
|
||
);
|
||
const getNode = useCanvasStore((state) => state.getNode);
|
||
|
||
// 当前正在识别的点击位置
|
||
const [currentClick, setCurrentClick] = useState<{
|
||
x: number;
|
||
y: number;
|
||
} | null>(null);
|
||
// 已成功标注的物品列表(本次会话中)
|
||
const [pickedMarkers, setPickedMarkers] = useState<PickedMarker[]>([]);
|
||
const overlayRef = useRef<HTMLDivElement>(null);
|
||
|
||
// 获取目标节点
|
||
const targetNode = pickerTargetNodeId ? getNode(pickerTargetNodeId) : null;
|
||
const imageUrl =
|
||
targetNode?.type === "image"
|
||
? ((targetNode.data as any)?.src as string | undefined)
|
||
: undefined;
|
||
|
||
// 处理图片点击 - 识别成功后添加到对话框,但不关闭拾取模式
|
||
const handleImageClick = useCallback(
|
||
async (e: React.MouseEvent<HTMLImageElement>) => {
|
||
if (isPickerLoading || !imageUrl || !pickerTargetNodeId) return;
|
||
|
||
const img = e.currentTarget;
|
||
const rect = img.getBoundingClientRect();
|
||
|
||
// 计算相对坐标 (0-1)
|
||
const clickX = (e.clientX - rect.left) / rect.width;
|
||
const clickY = (e.clientY - rect.top) / rect.height;
|
||
|
||
// 显示点击位置
|
||
setCurrentClick({ x: clickX, y: clickY });
|
||
setPickerLoading(true);
|
||
|
||
try {
|
||
// 在图片上绘制红色标记点,让 AI 能看到用户点击的位置
|
||
const markedImageBase64 = await drawMarkerOnImage(
|
||
imageUrl,
|
||
clickX,
|
||
clickY
|
||
);
|
||
|
||
// 发送带标记的图片给 AI
|
||
const response = await fetch("/api/pick-object", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
imageData: markedImageBase64, // 带红色标记的图片
|
||
clickX,
|
||
clickY,
|
||
}),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error("识别失败");
|
||
}
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success && result.object) {
|
||
// 构建拾取结果(保存原始图片 URL,不是带标记的)
|
||
const picked: PickedObject = {
|
||
imageUrl, // 原始图片
|
||
imageNodeId: pickerTargetNodeId,
|
||
clickX,
|
||
clickY,
|
||
objectName: result.object.name,
|
||
objectPosition: result.object.position,
|
||
confidence: result.object.confidence,
|
||
boundingBox: result.object.boundingBox,
|
||
};
|
||
|
||
// 添加到对话框
|
||
addPendingPickedObject(picked);
|
||
|
||
// 添加到已标注列表(用于显示标记)
|
||
setPickedMarkers((prev) => [
|
||
...prev,
|
||
{
|
||
clickX,
|
||
clickY,
|
||
objectName: result.object.name,
|
||
index: prev.length + 1,
|
||
},
|
||
]);
|
||
|
||
toast.success(`已添加: ${result.object.name},可继续标注其他物品`);
|
||
|
||
// 清除当前点击位置,但不关闭拾取模式
|
||
setCurrentClick(null);
|
||
} else {
|
||
toast.error(result.error || "未能识别物品");
|
||
setCurrentClick(null);
|
||
}
|
||
} catch (error) {
|
||
console.error("物品拾取失败:", error);
|
||
toast.error("识别失败,请重试");
|
||
setCurrentClick(null);
|
||
} finally {
|
||
setPickerLoading(false);
|
||
}
|
||
},
|
||
[
|
||
imageUrl,
|
||
isPickerLoading,
|
||
pickerTargetNodeId,
|
||
setPickerLoading,
|
||
addPendingPickedObject,
|
||
]
|
||
);
|
||
|
||
// 完成标注,关闭拾取模式
|
||
const handleFinish = useCallback(() => {
|
||
setPickerMode(false);
|
||
setCurrentClick(null);
|
||
setPickedMarkers([]);
|
||
if (pickedMarkers.length > 0) {
|
||
toast.success(`已完成 ${pickedMarkers.length} 个物品标注`);
|
||
}
|
||
}, [setPickerMode, pickedMarkers.length]);
|
||
|
||
// 关闭拾取模式(不保存)
|
||
const handleClose = useCallback(() => {
|
||
setPickerMode(false);
|
||
setCurrentClick(null);
|
||
setPickedMarkers([]);
|
||
}, [setPickerMode]);
|
||
|
||
// 🆕 改为直接在 ImageNode 中标注,不再使用全屏覆盖层
|
||
// 保留此组件以便将来可能需要切换回覆盖层模式
|
||
return null;
|
||
|
||
// 原覆盖层代码(已禁用)
|
||
if (!isPickerMode || !imageUrl) return null;
|
||
|
||
return createPortal(
|
||
<div
|
||
className="fixed inset-0 z-[9998] flex items-center justify-center bg-black/60"
|
||
onClick={(e) => {
|
||
// 点击背景不关闭,需要点击完成按钮
|
||
if (e.target === overlayRef.current && pickedMarkers.length === 0) {
|
||
handleClose();
|
||
}
|
||
}}
|
||
ref={overlayRef}
|
||
>
|
||
{/* 顶部提示栏 */}
|
||
<div className="-translate-x-1/2 absolute top-4 left-1/2 flex items-center gap-3 rounded-full bg-white px-4 py-2 shadow-lg">
|
||
<Crosshair className="h-5 w-5 text-orange-500" />
|
||
<span className="font-medium text-sm">
|
||
{isPickerLoading
|
||
? "正在识别..."
|
||
: pickedMarkers.length > 0
|
||
? `已标注 ${pickedMarkers.length} 个物品`
|
||
: "点击图片中的物品进行标注"}
|
||
</span>
|
||
<Button
|
||
className="h-7 w-7 rounded-full"
|
||
onClick={handleClose}
|
||
size="icon"
|
||
variant="ghost"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
|
||
{/* 图片容器 */}
|
||
<div className="relative max-h-[70vh] max-w-[80vw]">
|
||
{/* 图片 - 原始图片,无任何标记 */}
|
||
<img
|
||
alt="拾取目标"
|
||
className={cn(
|
||
"max-h-[70vh] max-w-full rounded-lg border-4 object-contain transition-all",
|
||
isPickerLoading
|
||
? "border-orange-400 opacity-80"
|
||
: "border-orange-500",
|
||
!isPickerLoading && "cursor-crosshair"
|
||
)}
|
||
draggable={false}
|
||
onClick={isPickerLoading ? undefined : handleImageClick}
|
||
src={imageUrl}
|
||
/>
|
||
|
||
{/* 已标注的物品标记 */}
|
||
{pickedMarkers.map((marker) => (
|
||
<div
|
||
className="pointer-events-none absolute"
|
||
key={`${marker.clickX}-${marker.clickY}`}
|
||
style={{
|
||
left: `${marker.clickX * 100}%`,
|
||
top: `${marker.clickY * 100}%`,
|
||
transform: "translate(-50%, -50%)",
|
||
}}
|
||
>
|
||
{/* 序号标记 */}
|
||
<div className="relative flex items-center justify-center">
|
||
<div className="flex h-6 w-6 items-center justify-center rounded-full border-2 border-white bg-blue-500 shadow-lg">
|
||
<span className="font-bold text-white text-xs">
|
||
{marker.index}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
{/* 物品名称 */}
|
||
<div className="-translate-x-1/2 absolute top-full left-1/2 mt-1 whitespace-nowrap rounded bg-blue-500 px-2 py-0.5 text-white text-xs shadow-lg">
|
||
{marker.objectName}
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
{/* 当前正在识别的点击位置标记(脉冲动画) */}
|
||
{currentClick && (
|
||
<div
|
||
className="pointer-events-none absolute"
|
||
style={{
|
||
left: `${(currentClick?.x ?? 0) * 100}%`,
|
||
top: `${(currentClick?.y ?? 0) * 100}%`,
|
||
transform: "translate(-50%, -50%)",
|
||
}}
|
||
>
|
||
{/* 脉冲圆点 */}
|
||
<div
|
||
className={cn(
|
||
"relative h-8 w-8 rounded-full border-2 border-orange-500 bg-orange-500/30",
|
||
isPickerLoading && "animate-pulse"
|
||
)}
|
||
>
|
||
<div className="absolute inset-0 animate-ping rounded-full bg-orange-500 opacity-30" />
|
||
<div className="absolute inset-2 rounded-full bg-orange-500" />
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 加载状态 */}
|
||
{isPickerLoading && (
|
||
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-black/20">
|
||
<div className="flex items-center gap-2 rounded-lg bg-white px-4 py-3 shadow-lg">
|
||
<Loader2 className="h-5 w-5 animate-spin text-orange-500" />
|
||
<span className="text-sm">正在识别...</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 底部工具栏:已标注物品列表 + 完成按钮 */}
|
||
{pickedMarkers.length > 0 && (
|
||
<div className="-translate-x-1/2 absolute bottom-4 left-1/2 flex max-w-[80vw] items-center gap-3 rounded-full bg-white px-3 py-2 shadow-lg">
|
||
<div className="flex items-center gap-2 overflow-x-auto">
|
||
<span className="flex-shrink-0 text-gray-500 text-xs">已标注:</span>
|
||
{pickedMarkers.map((marker) => (
|
||
<span
|
||
className="inline-flex flex-shrink-0 items-center gap-1 rounded-full bg-blue-50 px-2 py-1 text-blue-700 text-xs"
|
||
key={`list-${marker.clickX}-${marker.clickY}`}
|
||
>
|
||
<span className="flex h-4 w-4 items-center justify-center rounded-full bg-blue-500 text-white text-xs">
|
||
{marker.index}
|
||
</span>
|
||
{marker.objectName}
|
||
</span>
|
||
))}
|
||
</div>
|
||
{/* 完成按钮 - 黑色风格 */}
|
||
<Button
|
||
className="h-8 flex-shrink-0 rounded-full bg-black px-4 text-white hover:bg-black/90"
|
||
onClick={handleFinish}
|
||
size="sm"
|
||
>
|
||
<Check className="mr-1 h-4 w-4" />
|
||
完成
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>,
|
||
document.body
|
||
);
|
||
}
|