feat: 标签悬停预览功能,显示图片全貌和标注位置
This commit is contained in:
parent
d169a3e931
commit
09e6b070c7
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user