mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-07 01:54:24 +08:00
feat(canvas): implement retry and delete actions for failed multi-image slots and maintain node scale during primary image selection
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { ChevronRight, Group, Image as ImageIcon, Music2, Puzzle, RefreshCw, Star, Video } from "lucide-react";
|
||||
import { ChevronRight, Group, Image as ImageIcon, Music2, Puzzle, RefreshCw, Star, Trash2, Video } from "lucide-react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { formatBytes } from "@/lib/image-utils";
|
||||
@@ -47,6 +47,8 @@ type CanvasNodeProps = {
|
||||
onTitleChange: (nodeId: string, title: string) => void;
|
||||
onToggleBatch?: (nodeId: string) => void;
|
||||
onSetBatchPrimary?: (nodeId: string, imageId: string) => void;
|
||||
onRetryBatchImage?: (node: CanvasNodeData, imageId: string) => void;
|
||||
onDeleteBatchImage?: (nodeId: string, imageId: string) => void;
|
||||
onRetry?: (node: CanvasNodeData) => void;
|
||||
onGenerateImage?: (node: CanvasNodeData) => void;
|
||||
onViewImage?: (node: CanvasNodeData) => void;
|
||||
@@ -70,6 +72,8 @@ type NodeContentRendererProps = {
|
||||
onGenerateImage?: (node: CanvasNodeData) => void;
|
||||
onToggleBatch?: () => void;
|
||||
onSetBatchPrimary?: (imageId: string) => void;
|
||||
onRetryBatchImage?: (imageId: string) => void;
|
||||
onDeleteBatchImage?: (imageId: string) => void;
|
||||
groupChildCount: number;
|
||||
};
|
||||
|
||||
@@ -103,6 +107,8 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
onTitleChange,
|
||||
onToggleBatch,
|
||||
onSetBatchPrimary,
|
||||
onRetryBatchImage,
|
||||
onDeleteBatchImage,
|
||||
onRetry,
|
||||
onGenerateImage,
|
||||
onViewImage,
|
||||
@@ -399,6 +405,8 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
onGenerateImage={onGenerateImage}
|
||||
onToggleBatch={() => onToggleBatch?.(data.id)}
|
||||
onSetBatchPrimary={(imageId) => onSetBatchPrimary?.(data.id, imageId)}
|
||||
onRetryBatchImage={(imageId) => onRetryBatchImage?.(data, imageId)}
|
||||
onDeleteBatchImage={(imageId) => onDeleteBatchImage?.(data.id, imageId)}
|
||||
groupChildCount={groupChildCount}
|
||||
/>
|
||||
</div>
|
||||
@@ -571,6 +579,8 @@ function ImageNodeContent(props: NodeContentRendererProps) {
|
||||
batchExpanded={props.batchExpanded}
|
||||
onToggleBatch={props.onToggleBatch}
|
||||
onSetBatchPrimary={props.onSetBatchPrimary}
|
||||
onRetryBatchImage={props.onRetryBatchImage}
|
||||
onDeleteBatchImage={props.onDeleteBatchImage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -624,11 +634,15 @@ function ImageContent({
|
||||
batchExpanded,
|
||||
onToggleBatch,
|
||||
onSetBatchPrimary,
|
||||
onRetryBatchImage,
|
||||
onDeleteBatchImage,
|
||||
}: {
|
||||
node: CanvasNodeData;
|
||||
batchExpanded: boolean;
|
||||
onToggleBatch?: () => void;
|
||||
onSetBatchPrimary?: (imageId: string) => void;
|
||||
onRetryBatchImage?: (imageId: string) => void;
|
||||
onDeleteBatchImage?: (imageId: string) => void;
|
||||
}) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const { t } = useTranslation();
|
||||
@@ -644,7 +658,7 @@ function ImageContent({
|
||||
{batchExpanded
|
||||
? images
|
||||
.filter((image) => image.id !== primaryImageId)
|
||||
.map((image, index) => <ExpandedImageCard key={image.id} node={node} image={image} index={index} onSetPrimary={() => onSetBatchPrimary?.(image.id)} />)
|
||||
.map((image, index) => <ExpandedImageCard key={image.id} node={node} image={image} index={index} onSetPrimary={() => onSetBatchPrimary?.(image.id)} onRetry={() => onRetryBatchImage?.(image.id)} onDelete={() => onDeleteBatchImage?.(image.id)} />)
|
||||
: null}
|
||||
<div className="h-full w-full overflow-hidden rounded-3xl">
|
||||
{primaryContent ? (
|
||||
@@ -659,6 +673,7 @@ function ImageContent({
|
||||
<ImageSlotStatus image={primaryImage} />
|
||||
)}
|
||||
</div>
|
||||
{primaryImage?.status === "error" ? <BatchImageFailureActions placement="left" onRetry={() => onRetryBatchImage?.(primaryImage.id)} onDelete={() => onDeleteBatchImage?.(primaryImage.id)} /> : null}
|
||||
{isBatchRoot ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -680,7 +695,7 @@ function ImageContent({
|
||||
);
|
||||
}
|
||||
|
||||
function ExpandedImageCard({ node, image, index, onSetPrimary }: { node: CanvasNodeData; image: CanvasNodeImage; index: number; onSetPrimary: () => void }) {
|
||||
function ExpandedImageCard({ node, image, index, onSetPrimary, onRetry, onDelete }: { node: CanvasNodeData; image: CanvasNodeImage; index: number; onSetPrimary: () => void; onRetry: () => void; onDelete: () => void }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const { t } = useTranslation();
|
||||
const count = node.metadata?.images?.length || 0;
|
||||
@@ -728,6 +743,23 @@ function ExpandedImageCard({ node, image, index, onSetPrimary }: { node: CanvasN
|
||||
{t("canvas.node.setPrimary")}
|
||||
</button>
|
||||
) : null}
|
||||
{image.status === "error" ? <BatchImageFailureActions placement="right" onRetry={onRetry} onDelete={onDelete} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BatchImageFailureActions({ placement, onRetry, onDelete }: { placement: "left" | "right"; onRetry: () => void; onDelete: () => void }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={`absolute top-3 z-30 flex items-center gap-1.5 ${placement === "left" ? "left-3" : "right-3"}`}>
|
||||
<button type="button" className="flex h-8 items-center gap-1.5 rounded-lg border px-2.5 text-xs font-medium shadow-sm transition hover:scale-[1.02]" style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }} onClick={(event) => (event.stopPropagation(), onRetry())}>
|
||||
<RefreshCw className="size-3.5" />
|
||||
{t("canvas.node.retry")}
|
||||
</button>
|
||||
<button type="button" className="grid size-8 place-items-center rounded-lg border shadow-sm transition hover:scale-[1.02]" style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }} onClick={(event) => (event.stopPropagation(), onDelete())} aria-label={t("common.delete")} title={t("common.delete")}>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1461,7 +1461,8 @@ function InfiniteCanvasPage() {
|
||||
if (node.id !== nodeId) return node;
|
||||
const image = node.metadata?.images?.find((item) => item.id === imageId);
|
||||
if (!image?.content) return node;
|
||||
const size = fitNodeSize(image.naturalWidth, image.naturalHeight);
|
||||
const edge = Math.max(node.width, node.height);
|
||||
const size = node.metadata?.freeResize ? { width: node.width, height: node.height } : fitNodeSize(image.naturalWidth, image.naturalHeight, edge, edge);
|
||||
return {
|
||||
...node,
|
||||
position: { x: node.position.x + node.width / 2 - size.width / 2, y: node.position.y + node.height / 2 - size.height / 2 },
|
||||
@@ -2364,7 +2365,7 @@ function InfiniteCanvasPage() {
|
||||
}, [handleGenerateNode]);
|
||||
|
||||
const handleRetryNode = useCallback(
|
||||
async (node: CanvasNodeData) => {
|
||||
async (node: CanvasNodeData, imageId?: string) => {
|
||||
const sourceNode = findRetrySourceNode(node.id, nodesRef.current, connectionsRef.current) || node;
|
||||
const savedImageMetadata = node.type === CanvasNodeType.Image ? node.metadata : undefined;
|
||||
const hasSavedImageMetadata = Boolean(savedImageMetadata?.generationType);
|
||||
@@ -2396,13 +2397,13 @@ function InfiniteCanvasPage() {
|
||||
hasSavedImageMetadata && savedImageMetadata ? await resolveMetadataReferences(savedImageMetadata) : useReferenceImages ? (context?.referenceImages.length ? context.referenceImages : sourceNodeReferenceImages(sourceNode)) : [];
|
||||
if (useReferenceImages && !retryReferenceImages) {
|
||||
message.error(t("canvas.projectPage.referenceMissing"));
|
||||
setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails: t("canvas.projectPage.referenceMissing") } } : item)));
|
||||
setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: item.metadata?.content ? NODE_STATUS_SUCCESS : NODE_STATUS_ERROR, errorDetails: item.metadata?.content ? undefined : t("canvas.projectPage.referenceMissing"), images: item.metadata?.images?.map((image) => (image.id === imageId ? { ...image, status: NODE_STATUS_ERROR, errorDetails: t("canvas.projectPage.referenceMissing") } : image)) } } : item)));
|
||||
return;
|
||||
}
|
||||
const retryImages = retryReferenceImages || [];
|
||||
|
||||
setRunningNodeId(node.id);
|
||||
setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_LOADING, errorDetails: undefined } } : item)));
|
||||
setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_LOADING, errorDetails: undefined, images: item.metadata?.images?.map((image) => (image.id === imageId ? { ...image, status: NODE_STATUS_LOADING, errorDetails: undefined } : image)) } } : item)));
|
||||
const controller = startGenerationRequest(node.id, sourceNode.id, node.id);
|
||||
|
||||
try {
|
||||
@@ -2460,9 +2461,8 @@ function InfiniteCanvasPage() {
|
||||
: await requestGeneration(generationConfig, prompt, { signal: controller.signal }).then((items) => items[0]);
|
||||
const uploadedImage = await uploadImage(image.dataUrl);
|
||||
const imageConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Image];
|
||||
const imageSize = fitNodeSize(uploadedImage.width, uploadedImage.height, imageConfig.width, imageConfig.height);
|
||||
const retryImage: CanvasNodeImage = {
|
||||
id: node.metadata?.primaryImageId || nanoid(),
|
||||
id: imageId || node.metadata?.primaryImageId || nanoid(),
|
||||
status: NODE_STATUS_SUCCESS,
|
||||
content: uploadedImage.url,
|
||||
storageKey: uploadedImage.storageKey,
|
||||
@@ -2483,30 +2483,31 @@ function InfiniteCanvasPage() {
|
||||
}
|
||||
: buildImageGenerationMetadata(useReferenceImages ? "edit" : "generation", generationConfig, 1, retryImages);
|
||||
setNodes((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === node.id
|
||||
? {
|
||||
...item,
|
||||
type: CanvasNodeType.Image,
|
||||
width: imageSize.width,
|
||||
height: imageSize.height,
|
||||
metadata: {
|
||||
...item.metadata,
|
||||
...imageMetadata(uploadedImage),
|
||||
images: item.metadata?.images?.map((current) => (current.id === retryImage.id ? retryImage : current)),
|
||||
primaryImageId: retryImage.id,
|
||||
prompt,
|
||||
...generationMetadata,
|
||||
},
|
||||
}
|
||||
: item,
|
||||
),
|
||||
prev.map((item) => {
|
||||
if (item.id !== node.id) return item;
|
||||
const makePrimary = !imageId || !item.metadata?.content;
|
||||
const edge = imageId ? Math.max(item.width, item.height) : 0;
|
||||
const imageSize = imageId && item.metadata?.freeResize ? { width: item.width, height: item.height } : imageId ? fitNodeSize(uploadedImage.width, uploadedImage.height, edge, edge) : fitNodeSize(uploadedImage.width, uploadedImage.height, imageConfig.width, imageConfig.height);
|
||||
return {
|
||||
...item,
|
||||
type: CanvasNodeType.Image,
|
||||
...(makePrimary ? { width: imageSize.width, height: imageSize.height, ...(imageId ? { position: { x: item.position.x + item.width / 2 - imageSize.width / 2, y: item.position.y + item.height / 2 - imageSize.height / 2 } } : {}) } : {}),
|
||||
metadata: {
|
||||
...item.metadata,
|
||||
...(makePrimary ? imageMetadata(uploadedImage) : { status: NODE_STATUS_SUCCESS }),
|
||||
images: item.metadata?.images?.map((current) => (current.id === retryImage.id ? retryImage : current)),
|
||||
primaryImageId: makePrimary ? retryImage.id : item.metadata?.primaryImageId,
|
||||
prompt,
|
||||
...generationMetadata,
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
if (isGenerationCanceled(error)) return;
|
||||
const errorDetails = error instanceof Error ? error.message : t("canvas.projectPage.generationFailed");
|
||||
message.error(errorDetails);
|
||||
setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails } } : item)));
|
||||
setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: item.metadata?.content ? NODE_STATUS_SUCCESS : NODE_STATUS_ERROR, errorDetails: item.metadata?.content ? undefined : errorDetails, images: item.metadata?.images?.map((image) => (image.id === imageId ? { ...image, status: NODE_STATUS_ERROR, errorDetails } : image)) } } : item)));
|
||||
} finally {
|
||||
finishGenerationRequest(node.id, controller);
|
||||
setRunningNodeId(null);
|
||||
@@ -2515,6 +2516,20 @@ function InfiniteCanvasPage() {
|
||||
[effectiveConfig, finishGenerationRequest, isAiConfigReady, message, openConfigDialog, startGenerationRequest, t],
|
||||
);
|
||||
|
||||
const deleteBatchImage = useCallback((nodeId: string, imageId: string) => {
|
||||
const node = nodesRef.current.find((item) => item.id === nodeId);
|
||||
if ((node?.metadata?.images?.length || 0) <= 2) setExpandedImageNodeId(null);
|
||||
setNodes((prev) =>
|
||||
prev.map((item) => {
|
||||
if (item.id !== nodeId) return item;
|
||||
const images = item.metadata?.images?.filter((image) => image.id !== imageId) || [];
|
||||
return { ...item, metadata: { ...item.metadata, images, count: images.length, primaryImageId: item.metadata?.primaryImageId === imageId ? images[0]?.id : item.metadata?.primaryImageId } };
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const retryBatchImage = useCallback((node: CanvasNodeData, imageId: string) => void handleRetryNode(node, imageId), [handleRetryNode]);
|
||||
|
||||
const generateImageFromTextNode = useCallback(
|
||||
(node: CanvasNodeData) => {
|
||||
const prompt = (node.metadata?.content || node.metadata?.prompt || "").trim();
|
||||
@@ -2799,6 +2814,8 @@ function InfiniteCanvasPage() {
|
||||
onTitleChange={handleNodeTitleChange}
|
||||
onToggleBatch={toggleBatchExpanded}
|
||||
onSetBatchPrimary={setBatchPrimary}
|
||||
onRetryBatchImage={retryBatchImage}
|
||||
onDeleteBatchImage={deleteBatchImage}
|
||||
onRetry={handleNodeRetry}
|
||||
onGenerateImage={generateImageFromTextNode}
|
||||
onViewImage={handleNodeViewImage}
|
||||
|
||||
Reference in New Issue
Block a user