feat: sync latest canvas dialog and selection updates
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-06 19:29:35 +08:00
parent c23c395010
commit d7ac4417d1
7 changed files with 130 additions and 40 deletions

View File

@ -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",
}, },

View File

@ -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}

View File

@ -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}
/> />

View File

@ -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>

View File

@ -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)}
/> />
)} )}

View File

@ -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)}
/> />
)} )}

View File

@ -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}
/> />
)} )}