454 lines
16 KiB
TypeScript
454 lines
16 KiB
TypeScript
"use client";
|
||
|
||
import React, { useState } from "react";
|
||
import { X, Download, Edit, Target, Paintbrush, Sparkles, Scissors } from "lucide-react";
|
||
import { toast } from "sonner";
|
||
import { ImageAnnotator } from "./image-annotator";
|
||
|
||
export function ImagePreview({
|
||
src,
|
||
alt,
|
||
onImageEdit,
|
||
onAnnotate,
|
||
onDrawingComplete,
|
||
onImageEnhance,
|
||
onImageRmbg,
|
||
imageIndex
|
||
}: {
|
||
src: string;
|
||
alt?: string;
|
||
onImageEdit?: (imageUrl: string) => void;
|
||
onAnnotate?: (annotation: {
|
||
imageIndex: number;
|
||
description: string;
|
||
formattedText: string;
|
||
}) => void;
|
||
onDrawingComplete?: (annotatedImageUrl: string) => void;
|
||
onImageEnhance?: (imageUrl: string) => void;
|
||
onImageRmbg?: (imageUrl: string) => void;
|
||
imageIndex?: number;
|
||
}) {
|
||
const [isOpen, setIsOpen] = useState(false);
|
||
const [isAnnotationMode, setIsAnnotationMode] = useState(false);
|
||
const [isDetecting, setIsDetecting] = useState(false);
|
||
const [showAnnotator, setShowAnnotator] = useState(false);
|
||
|
||
// 解析 alt 文本,提取原图 URL 和宽度
|
||
// 格式优先级:
|
||
// 1. thumb|||原图URL|||imageSize (最新格式,推荐,不会与 LaTeX/Markdown 冲突)
|
||
// 2. thumb$$原图URL$$imageSize (旧格式,会被 LaTeX 解析器干扰)
|
||
// 3. thumb|原图URL|imageSize (最旧格式)
|
||
const parseImageUrls = () => {
|
||
// 调试日志:查看接收到的 alt 和 src
|
||
console.log("[ImagePreview] Parsing alt:", alt, "src:", src?.substring(0, 80));
|
||
|
||
let originalUrl = src;
|
||
let maxWidth = 500;
|
||
let is4K = false;
|
||
let parsed = false;
|
||
|
||
// 最新格式:使用 ||| 分隔 (不会与 LaTeX 数学公式或 Markdown 表格冲突)
|
||
if (alt && alt.includes("thumb|||")) {
|
||
const content = alt.substring(alt.indexOf("thumb|||") + 8);
|
||
const parts = content.split("|||");
|
||
originalUrl = parts[0];
|
||
const extraInfo = parts[1];
|
||
|
||
if (extraInfo === "4K") {
|
||
is4K = true;
|
||
} else if (extraInfo === "2K") {
|
||
// 2K 不是 4K,但也是高质量
|
||
is4K = false;
|
||
} else if (extraInfo && !isNaN(parseInt(extraInfo, 10))) {
|
||
maxWidth = parseInt(extraInfo, 10);
|
||
}
|
||
parsed = true;
|
||
console.log("[ImagePreview] Parsed with ||| format:", { originalUrl: originalUrl?.substring(0, 80), is4K });
|
||
}
|
||
// 旧格式:使用 $$ 分隔 (可能被 LaTeX 干扰,保持向后兼容)
|
||
else if (alt && alt.includes("thumb$$")) {
|
||
const content = alt.substring(alt.indexOf("thumb$$") + 7);
|
||
const parts = content.split("$$");
|
||
originalUrl = parts[0];
|
||
const extraInfo = parts[1];
|
||
|
||
if (extraInfo === "4K") {
|
||
is4K = true;
|
||
} else if (extraInfo && !isNaN(parseInt(extraInfo, 10))) {
|
||
maxWidth = parseInt(extraInfo, 10);
|
||
}
|
||
parsed = true;
|
||
console.log("[ImagePreview] Parsed with $$ format:", { originalUrl: originalUrl?.substring(0, 80), is4K });
|
||
}
|
||
// 最旧格式:使用 | 分隔
|
||
else if (alt && alt.includes("thumb|")) {
|
||
const content = alt.substring(alt.indexOf("thumb|") + 6);
|
||
const parts = content.split("|");
|
||
originalUrl = parts[0];
|
||
const extraInfo = parts[1];
|
||
|
||
if (extraInfo === "4K") {
|
||
is4K = true;
|
||
} else if (extraInfo && !isNaN(parseInt(extraInfo, 10))) {
|
||
maxWidth = parseInt(extraInfo, 10);
|
||
}
|
||
parsed = true;
|
||
console.log("[ImagePreview] Parsed with | format:", { originalUrl: originalUrl?.substring(0, 80), is4K });
|
||
}
|
||
|
||
// 🛡️ 兜底策略:如果解析失败,或者解析出的 originalUrl 与 src 相同(意味着没提取到),
|
||
// 尝试利用文件命名规律推导原图 URL
|
||
if (!parsed || originalUrl === src) {
|
||
if (src.includes("gemini-thumb-")) {
|
||
originalUrl = src.replace("gemini-thumb-", "gemini-orig-");
|
||
console.log("[ImagePreview] Inferred original URL from gemini-thumb filename");
|
||
} else if (src.includes("openai-thumb-")) {
|
||
originalUrl = src.replace("openai-thumb-", "openai-orig-");
|
||
console.log("[ImagePreview] Inferred original URL from openai-thumb filename");
|
||
} else {
|
||
console.log("[ImagePreview] ⚠️ Could not parse original URL, using thumbnail as fallback");
|
||
}
|
||
}
|
||
|
||
return {
|
||
thumbnail: src,
|
||
original: originalUrl,
|
||
maxWidth,
|
||
is4K,
|
||
};
|
||
};
|
||
|
||
const { thumbnail, original, maxWidth, is4K } = parseImageUrls();
|
||
|
||
// 下载图片函数(下载原图)
|
||
const handleDownload = async () => {
|
||
try {
|
||
const response = await fetch(original);
|
||
const blob = await response.blob();
|
||
const url = window.URL.createObjectURL(blob);
|
||
const link = document.createElement("a");
|
||
link.href = url;
|
||
link.download = `gemini-image-${Date.now()}.jpg`;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
window.URL.revokeObjectURL(url);
|
||
} catch (error) {
|
||
console.error("下载失败:", error);
|
||
}
|
||
};
|
||
|
||
// 处理图片点击(标注模式)
|
||
const handleImageClick = async (e: React.MouseEvent<HTMLImageElement>) => {
|
||
if (!isAnnotationMode || isDetecting || !onAnnotate) return;
|
||
|
||
const img = e.currentTarget;
|
||
const rect = img.getBoundingClientRect();
|
||
const clickX = Math.round(e.clientX - rect.left);
|
||
const clickY = Math.round(e.clientY - rect.top);
|
||
|
||
setIsDetecting(true);
|
||
toast.info("正在识别物体...");
|
||
|
||
try {
|
||
const response = await fetch("/api/detect-object", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
imageUrl: original,
|
||
clickX,
|
||
clickY,
|
||
imageIndex: imageIndex || 1,
|
||
imageWidth: img.naturalWidth,
|
||
imageHeight: img.naturalHeight,
|
||
}),
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success && result.data) {
|
||
toast.success(`识别成功:${result.data.description}`);
|
||
onAnnotate({
|
||
imageIndex: result.data.imageIndex,
|
||
description: result.data.description,
|
||
formattedText: result.data.formattedText,
|
||
});
|
||
setIsOpen(false);
|
||
setIsAnnotationMode(false);
|
||
} else {
|
||
toast.error(result.error || "识别失败,请重试");
|
||
}
|
||
} catch (error) {
|
||
console.error("物体检测失败:", error);
|
||
toast.error("识别失败,请检查网络连接");
|
||
} finally {
|
||
setIsDetecting(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<>
|
||
{/* 缩略图(显示压缩后的缩略图)*/}
|
||
<div className="group relative inline-block max-w-full">
|
||
<img
|
||
src={thumbnail}
|
||
alt="Generated Image"
|
||
className="cursor-pointer rounded-lg transition-opacity hover:opacity-90"
|
||
style={{
|
||
maxWidth: `${maxWidth}px`,
|
||
height: "auto",
|
||
width: "100%",
|
||
objectFit: "contain"
|
||
}}
|
||
onClick={() => setIsOpen(true)}
|
||
/>
|
||
|
||
{/* 左上角:变清晰和抠图按钮 - 始终显示 */}
|
||
{(onImageEnhance || onImageRmbg) && (
|
||
<div className="absolute top-2 left-2 flex gap-1.5 z-10">
|
||
{onImageEnhance && (
|
||
<button
|
||
className="rounded-full bg-blue-600/90 hover:bg-blue-700 p-2 text-white transition-colors shadow-lg"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onImageEnhance(original);
|
||
}}
|
||
type="button"
|
||
aria-label="变清晰"
|
||
title="变清晰"
|
||
>
|
||
<Sparkles size={16} />
|
||
</button>
|
||
)}
|
||
{onImageRmbg && (
|
||
<button
|
||
className="rounded-full bg-green-600/90 hover:bg-green-700 p-2 text-white transition-colors shadow-lg"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onImageRmbg(original);
|
||
}}
|
||
type="button"
|
||
aria-label="抠图"
|
||
title="抠图"
|
||
>
|
||
<Scissors size={16} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 悬停时显示的操作按钮 */}
|
||
<div className="absolute bottom-2 right-2 flex gap-1.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||
{/* 画图按钮 */}
|
||
{onDrawingComplete && (
|
||
<button
|
||
className="rounded-full bg-black/70 p-2 text-white transition-colors hover:bg-black/90"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setShowAnnotator(true);
|
||
}}
|
||
type="button"
|
||
aria-label="画图标注"
|
||
title="画图标注"
|
||
>
|
||
<Paintbrush size={16} />
|
||
</button>
|
||
)}
|
||
|
||
{/* 编辑按钮 */}
|
||
{onImageEdit && (
|
||
<button
|
||
className="rounded-full bg-black/70 p-2 text-white transition-colors hover:bg-black/90"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onImageEdit(original);
|
||
}}
|
||
type="button"
|
||
aria-label="编辑图片"
|
||
>
|
||
<Edit size={16} />
|
||
</button>
|
||
)}
|
||
|
||
{/* 下载按钮 */}
|
||
<button
|
||
className="rounded-full bg-black/70 p-2 text-white transition-colors hover:bg-black/90 flex items-center gap-1.5"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleDownload();
|
||
}}
|
||
type="button"
|
||
aria-label="下载图片"
|
||
title={original !== thumbnail ? (is4K ? "下载4K原图" : "下载原图") : "下载图片"}
|
||
>
|
||
<Download size={16} />
|
||
{original !== thumbnail && <span className="text-xs font-medium pr-1">{is4K ? "4K" : "原图"}</span>}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 弹窗 */}
|
||
{isOpen && (
|
||
<div
|
||
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/80 p-4"
|
||
onClick={() => setIsOpen(false)}
|
||
>
|
||
{/* 按钮组 */}
|
||
<div className="absolute right-4 top-4 flex gap-2">
|
||
{/* 下载按钮 (新增) */}
|
||
<button
|
||
className="rounded-full bg-white/10 p-2 text-white transition-colors hover:bg-white/20 flex items-center gap-1.5"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleDownload();
|
||
}}
|
||
type="button"
|
||
aria-label="下载图片"
|
||
title={original !== thumbnail ? (is4K ? "下载4K原图" : "下载原图") : "下载图片"}
|
||
>
|
||
<Download size={24} />
|
||
{original !== thumbnail && <span className="text-sm font-medium pr-1">{is4K ? "下载4K原图" : "下载原图"}</span>}
|
||
</button>
|
||
|
||
{/* 定位标注按钮 */}
|
||
{onAnnotate && (
|
||
<button
|
||
className={`rounded-full p-2 text-white transition-colors ${
|
||
isAnnotationMode
|
||
? "bg-blue-500 hover:bg-blue-600"
|
||
: "bg-white/10 hover:bg-white/20"
|
||
}`}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setIsAnnotationMode(!isAnnotationMode);
|
||
if (!isAnnotationMode) {
|
||
toast.info("标注模式:点击图片中的物体进行识别");
|
||
}
|
||
}}
|
||
disabled={isDetecting}
|
||
type="button"
|
||
aria-label="定位标注"
|
||
title="定位标注"
|
||
>
|
||
<Target size={24} />
|
||
</button>
|
||
)}
|
||
|
||
{/* 画图标注按钮 */}
|
||
{onDrawingComplete && (
|
||
<button
|
||
className="rounded-full bg-white/10 p-2 text-white transition-colors hover:bg-white/20"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setShowAnnotator(true);
|
||
setIsOpen(false);
|
||
}}
|
||
type="button"
|
||
aria-label="画图标注"
|
||
title="画图标注"
|
||
>
|
||
<Paintbrush size={24} />
|
||
</button>
|
||
)}
|
||
|
||
{/* 编辑按钮 */}
|
||
{onImageEdit && (
|
||
<button
|
||
className="rounded-full bg-white/10 p-2 text-white transition-colors hover:bg-white/20"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onImageEdit(original);
|
||
setIsOpen(false);
|
||
}}
|
||
type="button"
|
||
aria-label="编辑图片"
|
||
title="编辑图片"
|
||
>
|
||
<Edit size={24} />
|
||
</button>
|
||
)}
|
||
|
||
{/* 下载按钮 */}
|
||
<button
|
||
className="rounded-full bg-white/10 p-2 text-white transition-colors hover:bg-white/20"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleDownload();
|
||
}}
|
||
type="button"
|
||
aria-label="下载图片"
|
||
title="下载图片"
|
||
>
|
||
<Download size={24} />
|
||
</button>
|
||
|
||
{/* 关闭按钮 */}
|
||
<button
|
||
className="rounded-full bg-white/10 p-2 text-white transition-colors hover:bg-white/20"
|
||
onClick={() => {
|
||
setIsOpen(false);
|
||
setIsAnnotationMode(false);
|
||
}}
|
||
type="button"
|
||
aria-label="关闭"
|
||
title="关闭"
|
||
>
|
||
<X size={24} />
|
||
</button>
|
||
</div>
|
||
|
||
{/* 标注模式提示 */}
|
||
{isAnnotationMode && (
|
||
<div className="absolute left-1/2 top-4 -translate-x-1/2 transform">
|
||
<div className="rounded-full bg-blue-500 px-4 py-2 text-sm text-white shadow-lg">
|
||
点击图片中的物体进行标注
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 大图(显示原图)*/}
|
||
<div
|
||
className={`relative max-h-[90vh] max-w-[90vw] ${
|
||
isDetecting ? "opacity-50" : ""
|
||
}`}
|
||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||
>
|
||
<img
|
||
src={original}
|
||
alt="Generated Image - Original"
|
||
className={`max-h-[90vh] max-w-full rounded-lg object-contain ${
|
||
isAnnotationMode ? "cursor-crosshair" : ""
|
||
}`}
|
||
style={{ maxWidth: "90vw" }}
|
||
onClick={handleImageClick}
|
||
/>
|
||
|
||
{/* 加载状态 */}
|
||
{isDetecting && (
|
||
<div className="absolute inset-0 flex items-center justify-center">
|
||
<div className="flex items-center gap-3 rounded-lg bg-black/70 px-6 py-3 text-white">
|
||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||
<span>正在识别...</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 图片标注器 */}
|
||
{showAnnotator && onDrawingComplete && (
|
||
<ImageAnnotator
|
||
imageUrl={original}
|
||
onConfirm={(annotatedImageUrl) => {
|
||
onDrawingComplete(annotatedImageUrl);
|
||
setShowAnnotator(false);
|
||
toast.success("标注图片已添加到聊天");
|
||
}}
|
||
onCancel={() => setShowAnnotator(false)}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
}
|