feat: sync latest canvas dialog and selection updates
This commit is contained in:
parent
c23c395010
commit
d7ac4417d1
@ -837,7 +837,7 @@ export function CanvasWorkspace({ occlusionRight = 0 }: CanvasWorkspaceProps) {
|
|||||||
height: IMAGE_HANDLE_SIZE,
|
height: IMAGE_HANDLE_SIZE,
|
||||||
right: -IMAGE_HANDLE_OFFSET,
|
right: -IMAGE_HANDLE_OFFSET,
|
||||||
top: -IMAGE_HANDLE_OFFSET,
|
top: -IMAGE_HANDLE_OFFSET,
|
||||||
borderRadius: 999,
|
borderRadius: 0,
|
||||||
border: `${SELECTION_STROKE_WIDTH}px solid ${SELECTION_COLOR}`,
|
border: `${SELECTION_STROKE_WIDTH}px solid ${SELECTION_COLOR}`,
|
||||||
backgroundColor: "#ffffff",
|
backgroundColor: "#ffffff",
|
||||||
},
|
},
|
||||||
@ -846,7 +846,7 @@ export function CanvasWorkspace({ occlusionRight = 0 }: CanvasWorkspaceProps) {
|
|||||||
height: IMAGE_HANDLE_SIZE,
|
height: IMAGE_HANDLE_SIZE,
|
||||||
right: -IMAGE_HANDLE_OFFSET,
|
right: -IMAGE_HANDLE_OFFSET,
|
||||||
bottom: -IMAGE_HANDLE_OFFSET,
|
bottom: -IMAGE_HANDLE_OFFSET,
|
||||||
borderRadius: 999,
|
borderRadius: 0,
|
||||||
border: `${SELECTION_STROKE_WIDTH}px solid ${SELECTION_COLOR}`,
|
border: `${SELECTION_STROKE_WIDTH}px solid ${SELECTION_COLOR}`,
|
||||||
backgroundColor: "#ffffff",
|
backgroundColor: "#ffffff",
|
||||||
},
|
},
|
||||||
@ -855,7 +855,7 @@ export function CanvasWorkspace({ occlusionRight = 0 }: CanvasWorkspaceProps) {
|
|||||||
height: IMAGE_HANDLE_SIZE,
|
height: IMAGE_HANDLE_SIZE,
|
||||||
left: -IMAGE_HANDLE_OFFSET,
|
left: -IMAGE_HANDLE_OFFSET,
|
||||||
bottom: -IMAGE_HANDLE_OFFSET,
|
bottom: -IMAGE_HANDLE_OFFSET,
|
||||||
borderRadius: 999,
|
borderRadius: 0,
|
||||||
border: `${SELECTION_STROKE_WIDTH}px solid ${SELECTION_COLOR}`,
|
border: `${SELECTION_STROKE_WIDTH}px solid ${SELECTION_COLOR}`,
|
||||||
backgroundColor: "#ffffff",
|
backgroundColor: "#ffffff",
|
||||||
},
|
},
|
||||||
@ -864,7 +864,7 @@ export function CanvasWorkspace({ occlusionRight = 0 }: CanvasWorkspaceProps) {
|
|||||||
height: IMAGE_HANDLE_SIZE,
|
height: IMAGE_HANDLE_SIZE,
|
||||||
left: -IMAGE_HANDLE_OFFSET,
|
left: -IMAGE_HANDLE_OFFSET,
|
||||||
top: -IMAGE_HANDLE_OFFSET,
|
top: -IMAGE_HANDLE_OFFSET,
|
||||||
borderRadius: 999,
|
borderRadius: 0,
|
||||||
border: `${SELECTION_STROKE_WIDTH}px solid ${SELECTION_COLOR}`,
|
border: `${SELECTION_STROKE_WIDTH}px solid ${SELECTION_COLOR}`,
|
||||||
backgroundColor: "#ffffff",
|
backgroundColor: "#ffffff",
|
||||||
},
|
},
|
||||||
|
|||||||
@ -2157,7 +2157,10 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
|
|
||||||
{/* 提示词重绘对话框 */}
|
{/* 提示词重绘对话框 */}
|
||||||
<Dialog open={promptDialogOpen} onOpenChange={setPromptDialogOpen}>
|
<Dialog open={promptDialogOpen} onOpenChange={setPromptDialogOpen}>
|
||||||
<DialogContent className="sm:max-w-[560px]">
|
<DialogContent
|
||||||
|
overlayClassName="bg-black"
|
||||||
|
className="sm:max-w-[560px] border-slate-200 bg-white opacity-100 shadow-2xl backdrop-blur-none dark:border-slate-700 dark:bg-slate-950"
|
||||||
|
>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<RefreshCw className="h-5 w-5" />
|
<RefreshCw className="h-5 w-5" />
|
||||||
@ -2176,7 +2179,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
onChange={(e) => setRegeneratePrompt(e.target.value)}
|
onChange={(e) => setRegeneratePrompt(e.target.value)}
|
||||||
placeholder="例如:保持主体不变,把背景改成黄昏海边,氛围光影更柔和,细节更丰富"
|
placeholder="例如:保持主体不变,把背景改成黄昏海边,氛围光影更柔和,细节更丰富"
|
||||||
rows={6}
|
rows={6}
|
||||||
className="resize-y"
|
className="resize-y border-slate-300 bg-white opacity-100 dark:border-slate-700 dark:bg-slate-950"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -2191,7 +2194,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* 工具栏使用 Portal 渲染到 body,避免被裁剪 */}
|
{/* 工具栏使用 Portal 渲染到 body,避免被裁剪 */}
|
||||||
{isSelected && !isMultiSelected && data.src && !isEditingTitle && !sceneFusionDialogOpen && typeof document !== 'undefined' && createPortal(
|
{isSelected && !isMultiSelected && data.src && !isEditingTitle && !sceneFusionDialogOpen && !promptDialogOpen && !notePanelOpen && typeof document !== 'undefined' && createPortal(
|
||||||
<TooltipProvider delayDuration={200}>
|
<TooltipProvider delayDuration={200}>
|
||||||
<div
|
<div
|
||||||
ref={toolbarRef}
|
ref={toolbarRef}
|
||||||
|
|||||||
@ -1915,6 +1915,7 @@ export function LovartHome({
|
|||||||
<ImageAnnotator
|
<ImageAnnotator
|
||||||
imageUrl={editorImageUrl}
|
imageUrl={editorImageUrl}
|
||||||
onConfirm={handleImageEditorConfirm}
|
onConfirm={handleImageEditorConfirm}
|
||||||
|
submitMode="publish-new-canvas"
|
||||||
onCancel={handleImageEditorCancel}
|
onCancel={handleImageEditorCancel}
|
||||||
onHomeClick={handleImageEditorCancel}
|
onHomeClick={handleImageEditorCancel}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import React, { useRef, useState, useEffect, useCallback } from "react";
|
import React, { useRef, useState, useEffect, useCallback } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import { X, Check, Undo, Trash2, Scissors, Sparkles, Download, Expand, Move, Maximize2, ZoomIn, ZoomOut, Loader2, Move3D, Layers, Upload as UploadIcon, Image as ImageIcon, Droplet, Redo, MousePointer2 } from "lucide-react";
|
import { X, Check, Undo, Trash2, Scissors, Sparkles, Download, Expand, Move, Maximize2, ZoomIn, ZoomOut, Loader2, Move3D, Layers, Upload as UploadIcon, Image as ImageIcon, Droplet, Redo, MousePointer2 } from "lucide-react";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
@ -27,6 +28,7 @@ import {
|
|||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { requestLongTaskResult } from "@/lib/client/long-task";
|
import { requestLongTaskResult } from "@/lib/client/long-task";
|
||||||
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
||||||
|
import { getCanvasLaunchStorageKey, type CanvasLaunchAttachment } from "@/lib/canvas-launch";
|
||||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||||
|
|
||||||
type Tool = "pen" | "arrow" | "circle" | "text";
|
type Tool = "pen" | "arrow" | "circle" | "text";
|
||||||
@ -52,6 +54,7 @@ interface ImageAnnotatorProps {
|
|||||||
onHomeClick?: () => void;
|
onHomeClick?: () => void;
|
||||||
initialResizeMode?: boolean; // 是否直接进入尺寸调整模式
|
initialResizeMode?: boolean; // 是否直接进入尺寸调整模式
|
||||||
confirmButtonText?: string;
|
confirmButtonText?: string;
|
||||||
|
submitMode?: "confirm" | "publish-new-canvas";
|
||||||
open?: boolean; // 兼容 Dialog 模式
|
open?: boolean; // 兼容 Dialog 模式
|
||||||
onOpenChange?: (open: boolean) => void;
|
onOpenChange?: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
@ -62,10 +65,12 @@ export function ImageAnnotator({
|
|||||||
onCancel,
|
onCancel,
|
||||||
onHomeClick,
|
onHomeClick,
|
||||||
initialResizeMode = false,
|
initialResizeMode = false,
|
||||||
confirmButtonText = "发送到新对话",
|
confirmButtonText,
|
||||||
|
submitMode = "confirm",
|
||||||
open = true,
|
open = true,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: ImageAnnotatorProps) {
|
}: ImageAnnotatorProps) {
|
||||||
|
const router = useRouter();
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [ctx, setCtx] = useState<CanvasRenderingContext2D | null>(null);
|
const [ctx, setCtx] = useState<CanvasRenderingContext2D | null>(null);
|
||||||
@ -95,6 +100,9 @@ export function ImageAnnotator({
|
|||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const objectUrlRef = useRef<string | null>(null);
|
const objectUrlRef = useRef<string | null>(null);
|
||||||
|
const resolvedConfirmButtonText =
|
||||||
|
confirmButtonText ||
|
||||||
|
(submitMode === "publish-new-canvas" ? "发布到新的画布" : "发送到新对话");
|
||||||
const toCanvasLoadUrl = useCallback((url: string) => {
|
const toCanvasLoadUrl = useCallback((url: string) => {
|
||||||
return buildImageProxyUrl(url);
|
return buildImageProxyUrl(url);
|
||||||
}, []);
|
}, []);
|
||||||
@ -1366,61 +1374,136 @@ export function ImageAnnotator({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 确认
|
const inferImageContentType = useCallback((url: string) => {
|
||||||
const handleConfirm = async () => {
|
const normalized = String(url || "").split("?")[0]?.toLowerCase() || "";
|
||||||
// 优先使用处理后的图片(如果有)
|
if (normalized.endsWith(".jpg") || normalized.endsWith(".jpeg")) return "image/jpeg";
|
||||||
|
if (normalized.endsWith(".webp")) return "image/webp";
|
||||||
|
if (normalized.endsWith(".gif")) return "image/gif";
|
||||||
|
return "image/png";
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resolveConfirmedImageUrl = useCallback(async (): Promise<string | null> => {
|
||||||
if (processedImageUrl) {
|
if (processedImageUrl) {
|
||||||
// 如果有处理后的图片URL,直接使用它
|
return processedImageUrl;
|
||||||
onConfirm(processedImageUrl);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (processedImageBase64) {
|
if (processedImageBase64) {
|
||||||
// 如果有处理后的图片Base64,先上传到服务器获取URL
|
|
||||||
const mimeType = processType === "rmbg" || processType === "resize" ? "image/png" : "image/jpeg";
|
const mimeType = processType === "rmbg" || processType === "resize" ? "image/png" : "image/jpeg";
|
||||||
const dataUrl = `data:${mimeType};base64,${processedImageBase64}`;
|
const dataUrl = `data:${mimeType};base64,${processedImageBase64}`;
|
||||||
const ext = processType === "rmbg" || processType === "resize" ? "png" : "jpg";
|
const ext = processType === "rmbg" || processType === "resize" ? "png" : "jpg";
|
||||||
const filename = `processed-${Date.now()}.${ext}`;
|
const filename = `processed-${Date.now()}.${ext}`;
|
||||||
|
toast.info("正在上传图片...");
|
||||||
try {
|
return await uploadImageToServer(dataUrl, filename);
|
||||||
toast.info("正在上传图片...");
|
|
||||||
const uploadedUrl = await uploadImageToServer(dataUrl, filename);
|
|
||||||
onConfirm(uploadedUrl);
|
|
||||||
} catch {
|
|
||||||
toast.error("上传失败,请重试");
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 否则,导出标注后的图片
|
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas || !image) return;
|
if (!canvas || !image) return null;
|
||||||
|
|
||||||
// 创建一个新的 canvas 用于导出原始尺寸
|
|
||||||
const exportCanvas = document.createElement("canvas");
|
const exportCanvas = document.createElement("canvas");
|
||||||
exportCanvas.width = image.width;
|
exportCanvas.width = image.width;
|
||||||
exportCanvas.height = image.height;
|
exportCanvas.height = image.height;
|
||||||
const exportCtx = exportCanvas.getContext("2d");
|
const exportCtx = exportCanvas.getContext("2d");
|
||||||
if (!exportCtx) return;
|
if (!exportCtx) return null;
|
||||||
|
|
||||||
// 绘制原始图片
|
|
||||||
exportCtx.drawImage(image, 0, 0);
|
exportCtx.drawImage(image, 0, 0);
|
||||||
|
|
||||||
// 绘制所有动作(使用原始尺寸)
|
|
||||||
for (const action of actions) {
|
for (const action of actions) {
|
||||||
drawAction(exportCtx, action, 1);
|
drawAction(exportCtx, action, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 导出为 data URL,然后上传到服务器
|
|
||||||
const dataUrl = exportCanvas.toDataURL("image/png");
|
const dataUrl = exportCanvas.toDataURL("image/png");
|
||||||
const filename = `annotated-${Date.now()}.png`;
|
const filename = `annotated-${Date.now()}.png`;
|
||||||
|
toast.info("正在上传图片...");
|
||||||
|
return await uploadImageToServer(dataUrl, filename);
|
||||||
|
}, [actions, image, processType, processedImageBase64, processedImageUrl]);
|
||||||
|
|
||||||
|
const publishToNewCanvas = useCallback(async (resultUrl: string) => {
|
||||||
|
const normalizedOriginalUrl = String(originalImageUrl || "").trim();
|
||||||
|
const normalizedResultUrl = String(resultUrl || "").trim();
|
||||||
|
const resultLabelMap: Record<NonNullable<typeof processType> | "annotate", string> = {
|
||||||
|
annotate: "编辑结果",
|
||||||
|
rmbg: "抠图结果",
|
||||||
|
enhance: "变清晰结果",
|
||||||
|
extend: "拓展结果",
|
||||||
|
resize: "尺寸调整结果",
|
||||||
|
watermark: "去水印结果",
|
||||||
|
};
|
||||||
|
const resultLabel =
|
||||||
|
resultLabelMap[processType || "annotate"] || "编辑结果";
|
||||||
|
|
||||||
|
const attachments: CanvasLaunchAttachment[] = [];
|
||||||
|
if (normalizedOriginalUrl) {
|
||||||
|
attachments.push({
|
||||||
|
url: normalizedOriginalUrl,
|
||||||
|
name: "原图",
|
||||||
|
contentType: inferImageContentType(normalizedOriginalUrl),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (normalizedResultUrl) {
|
||||||
|
const hasSameAsOriginal =
|
||||||
|
normalizedOriginalUrl && normalizedOriginalUrl === normalizedResultUrl;
|
||||||
|
if (!hasSameAsOriginal || attachments.length === 0) {
|
||||||
|
attachments.push({
|
||||||
|
url: normalizedResultUrl,
|
||||||
|
name: resultLabel,
|
||||||
|
contentType: inferImageContentType(normalizedResultUrl),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachments.length === 0) {
|
||||||
|
throw new Error("没有可发布到画布的图片");
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleLabel = processType ? resultLabel.replace("结果", "") : "图片编辑";
|
||||||
|
const createResponse = await fetch("/api/canvas", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: `${titleLabel}-${new Date().toLocaleDateString("zh-CN")}`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createResult = await createResponse.json();
|
||||||
|
if (!createResponse.ok || !createResult?.data?.id) {
|
||||||
|
throw new Error(createResult?.error || "创建新画布失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvasId = String(createResult.data.id);
|
||||||
|
sessionStorage.setItem(
|
||||||
|
getCanvasLaunchStorageKey(canvasId),
|
||||||
|
JSON.stringify({
|
||||||
|
prompt: "",
|
||||||
|
attachments,
|
||||||
|
autoSend: false,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
toast.success("已发布到新的画布");
|
||||||
|
router.push(`/canvas/${canvasId}`);
|
||||||
|
}, [inferImageContentType, originalImageUrl, processType, router]);
|
||||||
|
|
||||||
|
// 确认
|
||||||
|
const handleConfirm = async () => {
|
||||||
try {
|
try {
|
||||||
toast.info("正在上传图片...");
|
const finalUrl = await resolveConfirmedImageUrl();
|
||||||
const uploadedUrl = await uploadImageToServer(dataUrl, filename);
|
if (!finalUrl) {
|
||||||
onConfirm(uploadedUrl);
|
toast.error("没有可发布的图片");
|
||||||
} catch {
|
return;
|
||||||
toast.error("上传失败,请重试");
|
}
|
||||||
|
|
||||||
|
if (submitMode === "publish-new-canvas") {
|
||||||
|
await publishToNewCanvas(finalUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await onConfirm(finalUrl);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "处理失败,请重试";
|
||||||
|
toast.error(message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1712,7 +1795,7 @@ export function ImageAnnotator({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Check className="mr-2 h-4 w-4" />
|
<Check className="mr-2 h-4 w-4" />
|
||||||
{confirmButtonText}
|
{resolvedConfirmButtonText}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
@ -1877,7 +1960,7 @@ export function ImageAnnotator({
|
|||||||
className="rounded-[18px] px-6 bg-primary text-primary-foreground hover:bg-primary/90"
|
className="rounded-[18px] px-6 bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
>
|
>
|
||||||
<Check className="mr-2 h-4 w-4" />
|
<Check className="mr-2 h-4 w-4" />
|
||||||
{confirmButtonText}
|
{resolvedConfirmButtonText}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -589,6 +589,7 @@ export function ImagePreview({
|
|||||||
setShowAnnotator(false);
|
setShowAnnotator(false);
|
||||||
toast.success("编辑图片已添加到聊天");
|
toast.success("编辑图片已添加到聊天");
|
||||||
}}
|
}}
|
||||||
|
submitMode="publish-new-canvas"
|
||||||
onCancel={() => setShowAnnotator(false)}
|
onCancel={() => setShowAnnotator(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -372,6 +372,7 @@ export const PreviewAttachment = ({
|
|||||||
setShowAnnotator(false);
|
setShowAnnotator(false);
|
||||||
toast.success("编辑图片已添加到聊天");
|
toast.success("编辑图片已添加到聊天");
|
||||||
}}
|
}}
|
||||||
|
submitMode="publish-new-canvas"
|
||||||
onCancel={() => setShowAnnotator(false)}
|
onCancel={() => setShowAnnotator(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -495,6 +495,7 @@ export function TopNavbar({
|
|||||||
<ImageAnnotator
|
<ImageAnnotator
|
||||||
imageUrl={editorImageUrl}
|
imageUrl={editorImageUrl}
|
||||||
onConfirm={handleImageEditorConfirm}
|
onConfirm={handleImageEditorConfirm}
|
||||||
|
submitMode="publish-new-canvas"
|
||||||
onCancel={handleImageEditorCancel}
|
onCancel={handleImageEditorCancel}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user