feat: 标签悬停预览功能,显示图片全貌和标注位置

This commit is contained in:
Your Name 2026-02-02 12:22:08 +08:00
parent d169a3e931
commit 09e6b070c7

View File

@ -24,6 +24,21 @@ import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils";
import type { Attachment, ChatMessage } from "@/lib/types";
import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar";
// 标签悬停预览信息
interface TagPreviewInfo {
url: string;
name: string;
x: number; // 弹窗位置
y: number;
// 物品标签额外信息
pickedObject?: {
objectName: string;
objectPosition: string;
clickX: number;
clickY: number;
};
}
interface CanvasChatPanelProps {
chatId: string;
initialMessages?: ChatMessage[];
@ -119,6 +134,10 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// 🆕 标签悬停预览状态
const [tagPreview, setTagPreview] = useState<TagPreviewInfo | null>(null);
const previewTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// 🆕 图片参数状态
const [imageParams, setImageParams] = useState<ImageGenerationParams>({
aspectRatio: "auto",
@ -347,6 +366,76 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
}
}, [pendingPickedObjects, attachments, insertPickedObjectTagInEditor, clearPendingPickedObjects]);
// 🆕 标签悬停预览 - 事件委托处理
useEffect(() => {
const editableDiv = textareaRef.current as HTMLDivElement | null;
if (!editableDiv) return;
const handleMouseOver = (e: MouseEvent) => {
const target = e.target as HTMLElement;
// 检查是否悬停在标签上
const tagSpan = target.closest('[data-attachment-url]') as HTMLElement | null;
if (tagSpan) {
// 清除之前的延迟隐藏
if (previewTimeoutRef.current) {
clearTimeout(previewTimeoutRef.current);
previewTimeoutRef.current = null;
}
const url = tagSpan.getAttribute('data-attachment-url');
const isPickedObject = tagSpan.getAttribute('data-picked-object') === 'true';
const objectName = tagSpan.getAttribute('data-object-name');
if (url) {
const rect = tagSpan.getBoundingClientRect();
// 从 attachments 中获取物品信息
const attachment = attachments.find(a =>
a.url === url &&
(isPickedObject ? a.pickedObject?.objectName === objectName : true)
);
setTagPreview({
url,
name: objectName || tagSpan.textContent || '图片',
x: rect.left + rect.width / 2,
y: rect.top,
pickedObject: attachment?.pickedObject ? {
objectName: attachment.pickedObject.objectName,
objectPosition: attachment.pickedObject.objectPosition,
clickX: attachment.pickedObject.clickX,
clickY: attachment.pickedObject.clickY,
} : undefined,
});
}
}
};
const handleMouseOut = (e: MouseEvent) => {
const target = e.target as HTMLElement;
const relatedTarget = e.relatedTarget as HTMLElement | null;
const tagSpan = target.closest('[data-attachment-url]');
// 如果离开标签且不是进入预览弹窗
if (tagSpan && !relatedTarget?.closest('[data-tag-preview]')) {
// 延迟隐藏,给用户时间移动到预览弹窗
previewTimeoutRef.current = setTimeout(() => {
setTagPreview(null);
}, 150);
}
};
editableDiv.addEventListener('mouseover', handleMouseOver);
editableDiv.addEventListener('mouseout', handleMouseOut);
return () => {
editableDiv.removeEventListener('mouseover', handleMouseOver);
editableDiv.removeEventListener('mouseout', handleMouseOut);
if (previewTimeoutRef.current) {
clearTimeout(previewTimeoutRef.current);
}
};
}, [attachments]);
// 获取模型列表
const { data: modelsData } = useSWR<{
models: Array<{
@ -1333,6 +1422,99 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
</div>
</div>
</div>
{/* 🆕 标签悬停预览弹窗 */}
{tagPreview && (
<div
data-tag-preview="true"
className="fixed z-[9999] bg-white dark:bg-zinc-900 rounded-xl shadow-2xl border border-border/50 overflow-hidden animate-in fade-in zoom-in-95 duration-150"
style={{
left: tagPreview.x,
top: tagPreview.y - 8,
transform: 'translate(-50%, -100%)',
maxWidth: '280px',
}}
onMouseEnter={() => {
// 鼠标进入预览弹窗,取消隐藏
if (previewTimeoutRef.current) {
clearTimeout(previewTimeoutRef.current);
previewTimeoutRef.current = null;
}
}}
onMouseLeave={() => {
// 鼠标离开预览弹窗,隐藏
setTagPreview(null);
}}
>
{/* 图片全貌 */}
<div className="relative">
<img
src={tagPreview.url}
alt={tagPreview.name}
className="w-full h-auto max-h-[200px] object-contain bg-zinc-100 dark:bg-zinc-800"
/>
{/* 如果是物品标签,显示标注位置 */}
{tagPreview.pickedObject && (
<>
{/* 标注点 */}
<div
className="absolute pointer-events-none"
style={{
left: `${tagPreview.pickedObject.clickX * 100}%`,
top: `${tagPreview.pickedObject.clickY * 100}%`,
transform: 'translate(-50%, -50%)',
}}
>
{/* 序号标记 */}
<div className="relative flex items-center justify-center">
{/* 脉冲动画背景 */}
<div className="absolute w-8 h-8 rounded-full bg-blue-500/30 animate-ping" />
{/* 主标记点 */}
<div className="relative w-6 h-6 rounded-full bg-blue-500 border-2 border-white shadow-lg flex items-center justify-center">
<svg className="w-3 h-3 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
<path d="M12 2v20M2 12h20" />
</svg>
</div>
</div>
</div>
{/* 物品名称标签 */}
<div
className="absolute bg-blue-500 text-white text-xs px-2 py-0.5 rounded shadow-lg whitespace-nowrap"
style={{
left: `${tagPreview.pickedObject.clickX * 100}%`,
top: `${tagPreview.pickedObject.clickY * 100}%`,
transform: 'translate(-50%, -150%)',
}}
>
{tagPreview.pickedObject.objectName}
</div>
</>
)}
</div>
{/* 底部信息 */}
<div className="px-3 py-2 bg-zinc-50 dark:bg-zinc-800/50 border-t border-border/30">
<div className="text-xs font-medium text-foreground/80 truncate">
{tagPreview.pickedObject ? tagPreview.pickedObject.objectName : tagPreview.name}
</div>
{tagPreview.pickedObject && (
<div className="text-xs text-muted-foreground">
{tagPreview.pickedObject.objectPosition}
</div>
)}
</div>
{/* 小三角箭头 */}
<div
className="absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-full"
style={{
width: 0,
height: 0,
borderLeft: '8px solid transparent',
borderRight: '8px solid transparent',
borderTop: '8px solid rgb(250 250 250)',
}}
/>
</div>
)}
</div>
);
}