feat: 支持同一张图片标注多个物品,每个物品独立标签
This commit is contained in:
parent
2722a231bc
commit
ee9ac5521f
@ -2,12 +2,20 @@
|
||||
|
||||
import React, { useState, useRef, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X, Loader2, Crosshair } from "lucide-react";
|
||||
import { X, Loader2, Crosshair, Check } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useCanvasStore, type PickedObject } from "@/store/canvas";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// 已标注的物品(本地状态,用于显示标记)
|
||||
interface PickedMarker {
|
||||
clickX: number;
|
||||
clickY: number;
|
||||
objectName: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在图片上绘制标记点,返回带标记的图片 base64
|
||||
* AI 通过看到这个红色标记来识别用户点击的位置
|
||||
@ -113,14 +121,17 @@ export function ObjectPickerOverlay() {
|
||||
const addPendingPickedObject = useCanvasStore((state) => state.addPendingPickedObject);
|
||||
const getNode = useCanvasStore((state) => state.getNode);
|
||||
|
||||
const [clickPosition, setClickPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
// 当前正在识别的点击位置
|
||||
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;
|
||||
|
||||
@ -132,7 +143,7 @@ export function ObjectPickerOverlay() {
|
||||
const clickY = (e.clientY - rect.top) / rect.height;
|
||||
|
||||
// 显示点击位置
|
||||
setClickPosition({ x: clickX, y: clickY });
|
||||
setCurrentClick({ x: clickX, y: clickY });
|
||||
setPickerLoading(true);
|
||||
|
||||
try {
|
||||
@ -169,30 +180,49 @@ export function ObjectPickerOverlay() {
|
||||
boundingBox: result.object.boundingBox,
|
||||
};
|
||||
|
||||
// 直接添加到对话框,不询问确认
|
||||
// 添加到对话框
|
||||
addPendingPickedObject(picked);
|
||||
toast.success(`已添加: ${result.object.name}`);
|
||||
|
||||
// 关闭拾取模式
|
||||
setPickerMode(false);
|
||||
setClickPosition(null);
|
||||
// 添加到已标注列表(用于显示标记)
|
||||
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 || '未能识别物品');
|
||||
setClickPosition(null);
|
||||
setCurrentClick(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('物品拾取失败:', error);
|
||||
toast.error('识别失败,请重试');
|
||||
setClickPosition(null);
|
||||
setCurrentClick(null);
|
||||
} finally {
|
||||
setPickerLoading(false);
|
||||
}
|
||||
}, [imageUrl, isPickerLoading, pickerTargetNodeId, setPickerLoading, addPendingPickedObject, setPickerMode]);
|
||||
}, [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);
|
||||
setClickPosition(null);
|
||||
setCurrentClick(null);
|
||||
setPickedMarkers([]);
|
||||
}, [setPickerMode]);
|
||||
|
||||
if (!isPickerMode || !imageUrl) return null;
|
||||
@ -202,8 +232,8 @@ export function ObjectPickerOverlay() {
|
||||
ref={overlayRef}
|
||||
className="fixed inset-0 z-[9998] bg-black/60 flex items-center justify-center"
|
||||
onClick={(e) => {
|
||||
// 点击背景关闭
|
||||
if (e.target === overlayRef.current) {
|
||||
// 点击背景不关闭,需要点击完成按钮
|
||||
if (e.target === overlayRef.current && pickedMarkers.length === 0) {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
@ -212,16 +242,31 @@ export function ObjectPickerOverlay() {
|
||||
<div className="absolute top-4 left-1/2 -translate-x-1/2 flex items-center gap-3 bg-white rounded-full px-4 py-2 shadow-lg">
|
||||
<Crosshair className="h-5 w-5 text-orange-500" />
|
||||
<span className="text-sm font-medium">
|
||||
{isPickerLoading ? '正在识别...' : '点击图片中的物品进行拾取'}
|
||||
{isPickerLoading
|
||||
? '正在识别...'
|
||||
: pickedMarkers.length > 0
|
||||
? `已标注 ${pickedMarkers.length} 个物品,继续点击或完成`
|
||||
: '点击图片中的物品进行标注'}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-full"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
{pickedMarkers.length > 0 ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 rounded-full bg-green-600 hover:bg-green-700 text-white px-3"
|
||||
onClick={handleFinish}
|
||||
>
|
||||
<Check className="h-4 w-4 mr-1" />
|
||||
完成
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-full"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 图片容器 */}
|
||||
@ -239,13 +284,37 @@ export function ObjectPickerOverlay() {
|
||||
draggable={false}
|
||||
/>
|
||||
|
||||
{/* 点击位置标记(脉冲动画)- 仅在前端显示,不影响发送给 AI 的图片 */}
|
||||
{clickPosition && (
|
||||
{/* 已标注的物品标记 */}
|
||||
{pickedMarkers.map((marker) => (
|
||||
<div
|
||||
key={`${marker.clickX}-${marker.clickY}`}
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
left: `${marker.clickX * 100}%`,
|
||||
top: `${marker.clickY * 100}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
>
|
||||
{/* 序号标记 */}
|
||||
<div className="relative flex items-center justify-center">
|
||||
<div className="w-6 h-6 rounded-full bg-blue-500 border-2 border-white shadow-lg flex items-center justify-center">
|
||||
<span className="text-xs font-bold text-white">{marker.index}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* 物品名称 */}
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-1 bg-blue-500 text-white text-xs px-2 py-0.5 rounded whitespace-nowrap shadow-lg">
|
||||
{marker.objectName}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 当前正在识别的点击位置标记(脉冲动画) */}
|
||||
{currentClick && (
|
||||
<div
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
left: `${clickPosition.x * 100}%`,
|
||||
top: `${clickPosition.y * 100}%`,
|
||||
left: `${currentClick.x * 100}%`,
|
||||
top: `${currentClick.y * 100}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
>
|
||||
@ -270,6 +339,24 @@ export function ObjectPickerOverlay() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部已标注物品列表 */}
|
||||
{pickedMarkers.length > 0 && (
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-2 bg-white rounded-lg px-3 py-2 shadow-lg max-w-[80vw] overflow-x-auto">
|
||||
<span className="text-xs text-gray-500 flex-shrink-0">已标注:</span>
|
||||
{pickedMarkers.map((marker) => (
|
||||
<span
|
||||
key={`list-${marker.clickX}-${marker.clickY}`}
|
||||
className="inline-flex items-center gap-1 bg-blue-50 text-blue-700 text-xs px-2 py-1 rounded-full flex-shrink-0"
|
||||
>
|
||||
<span className="w-4 h-4 rounded-full bg-blue-500 text-white text-xs flex items-center justify-center">
|
||||
{marker.index}
|
||||
</span>
|
||||
{marker.objectName}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user