feat(canvas): consolidate multi-image generation into a single image node with enhanced metadata structure

This commit is contained in:
HouYunFei
2026-08-06 11:16:28 +08:00
parent 705fdd9b2f
commit de49430976
12 changed files with 196 additions and 302 deletions
+78 -90
View File
@@ -8,7 +8,7 @@ import { getNodeDefinition } from "@/lib/canvas/node-registry";
import { buildNodeContext } from "@/lib/canvas/plugin-node-context";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea";
import { CanvasNodeType, type CanvasNodeData, type Position } from "@/types/canvas";
import { CanvasNodeType, type CanvasNodeData, type CanvasNodeImage, type Position } from "@/types/canvas";
import type { CanvasNodeContext, CanvasPluginHost } from "@/types/canvas-plugin";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { useTranslation } from "react-i18next";
@@ -32,14 +32,9 @@ type CanvasNodeProps = {
registryVersion?: number;
renderPanel?: (node: CanvasNodeData) => ReactNode;
renderNodeContent?: (node: CanvasNodeData) => ReactNode;
batchCount?: number;
groupChildCount?: number;
isGroupDropTarget?: boolean;
batchExpanded?: boolean;
batchClosing?: boolean;
batchOpening?: boolean;
batchRecovering?: boolean;
batchMotion?: { x: number; y: number; index: number };
onMouseDown: (event: React.MouseEvent, nodeId: string) => void;
onSelectCapture?: (event: React.MouseEvent, nodeId: string) => void;
onHoverStart: (nodeId: string) => void;
@@ -51,7 +46,7 @@ type CanvasNodeProps = {
onContentChange: (nodeId: string, content: string) => void;
onTitleChange: (nodeId: string, title: string) => void;
onToggleBatch?: (nodeId: string) => void;
onSetBatchPrimary?: (node: CanvasNodeData) => void;
onSetBatchPrimary?: (nodeId: string, imageId: string) => void;
onRetry?: (node: CanvasNodeData) => void;
onGenerateImage?: (node: CanvasNodeData) => void;
onViewImage?: (node: CanvasNodeData) => void;
@@ -66,8 +61,6 @@ type NodeContentRendererProps = {
isBatchRoot: boolean;
batchCount: number;
batchExpanded: boolean;
batchOpening: boolean;
batchRecovering: boolean;
renderNodeContent?: (node: CanvasNodeData) => ReactNode;
pluginContext?: CanvasNodeContext | null;
onContentChange: (nodeId: string, content: string) => void;
@@ -76,7 +69,7 @@ type NodeContentRendererProps = {
onRetry?: (node: CanvasNodeData) => void;
onGenerateImage?: (node: CanvasNodeData) => void;
onToggleBatch?: () => void;
onSetBatchPrimary?: () => void;
onSetBatchPrimary?: (imageId: string) => void;
groupChildCount: number;
};
@@ -95,14 +88,9 @@ export const CanvasNode = React.memo(function CanvasNode({
pluginHost,
renderPanel,
renderNodeContent,
batchCount = 0,
groupChildCount = 0,
isGroupDropTarget = false,
batchExpanded = false,
batchClosing = false,
batchOpening = false,
batchRecovering = false,
batchMotion,
onMouseDown,
onSelectCapture,
onHoverStart,
@@ -132,17 +120,17 @@ export const CanvasNode = React.memo(function CanvasNode({
const hasVideoContent = data.type === CanvasNodeType.Video && Boolean(data.metadata?.content);
const hasAudioContent = data.type === CanvasNodeType.Audio && Boolean(data.metadata?.content);
const isGroup = data.type === CanvasNodeType.Group;
const isBatchRoot = data.type === CanvasNodeType.Image && Boolean(data.metadata?.isBatchRoot) && batchCount > 1;
const batchCount = data.type === CanvasNodeType.Image ? data.metadata?.images?.length || 0 : 0;
const isBatchRoot = batchCount > 1;
// Nodes with the interaction/move toggle ignore content pointer events in move mode and allow interaction in interactive mode.
// forceInteractive states such as editing stay interactive, as do empty nodes so their upload and generation actions remain usable.
const supportsInteractionToggle = Boolean(definition?.interactionToggle);
const forceInteractive = supportsInteractionToggle ? Boolean(definition?.forceInteractive?.(data)) : false;
const contentInteractive = !supportsInteractionToggle || forceInteractive || !data.metadata?.content ? true : Boolean(data.metadata?.interactive);
const isBatchChild = data.type === CanvasNodeType.Image && Boolean(data.metadata?.batchRootId);
// Transparent nodes such as SVGs blend into the canvas while retaining outlines for selected or related states.
const transparentBg = Boolean(definition?.transparentBackground);
const isActive = isConnectionTarget || isSelected || isFocusRelated;
const imageBorderColor = isActive ? selectionBlue : isRelated && !isBatchChild ? theme.node.muted : "transparent";
const imageBorderColor = isActive ? selectionBlue : isRelated ? theme.node.muted : "transparent";
const textareaRef = useRef<HTMLTextAreaElement>(null);
const titleInputRef = useRef<HTMLInputElement>(null);
const resizeRef = useRef({
@@ -362,7 +350,7 @@ export const CanvasNode = React.memo(function CanvasNode({
background: isGroup ? `${theme.toolbar.panel}66` : hasImageContent || hasVideoContent || transparentBg ? "transparent" : theme.node.fill,
borderColor: isGroup ? (isGroupDropTarget || isActive ? selectionBlue : theme.node.stroke) : hasImageContent ? imageBorderColor : isActive ? selectionBlue : isRelated ? theme.node.muted : transparentBg ? "transparent" : theme.node.stroke,
borderStyle: isGroup ? "dashed" : "solid",
boxShadow: isGroupDropTarget ? `0 0 0 2px ${selectionBlue}66, inset 0 0 0 999px ${selectionBlue}10` : isActive ? `0 0 0 1px ${selectionBlue}55` : isRelated && !isBatchChild ? `0 0 0 1px ${theme.node.muted}55, 0 18px 48px rgba(0,0,0,.14)` : undefined,
boxShadow: isGroupDropTarget ? `0 0 0 2px ${selectionBlue}66, inset 0 0 0 999px ${selectionBlue}10` : isActive ? `0 0 0 1px ${selectionBlue}55` : isRelated ? `0 0 0 1px ${theme.node.muted}55, 0 18px 48px rgba(0,0,0,.14)` : undefined,
}}
onMouseDown={(event) => onMouseDown(event, data.id)}
onDoubleClick={(event) => {
@@ -391,11 +379,6 @@ export const CanvasNode = React.memo(function CanvasNode({
{
background: isGroup ? "transparent" : hasImageContent || hasVideoContent || transparentBg ? "transparent" : theme.node.fill,
pointerEvents: contentInteractive ? undefined : "none",
"--batch-from-x": `${batchMotion?.x || 0}px`,
"--batch-from-y": `${batchMotion?.y || 0}px`,
"--batch-from-rotate": `${6 + (batchMotion?.index || 0) * 4}deg`,
animation: data.metadata?.batchRootId ? (batchClosing ? "canvas-batch-child-out 260ms cubic-bezier(.4,0,.2,1) both" : "canvas-batch-child-in 340ms cubic-bezier(.2,.85,.18,1) both") : undefined,
animationDelay: data.metadata?.batchRootId ? `${batchClosing ? 0 : 45 + (batchMotion?.index || 0) * 24}ms` : undefined,
} as React.CSSProperties
}
>
@@ -407,8 +390,6 @@ export const CanvasNode = React.memo(function CanvasNode({
isBatchRoot={isBatchRoot}
batchCount={batchCount}
batchExpanded={batchExpanded}
batchOpening={batchOpening}
batchRecovering={batchRecovering}
renderNodeContent={renderNodeContent}
pluginContext={pluginContext}
mentionReferences={mentionReferences}
@@ -417,7 +398,7 @@ export const CanvasNode = React.memo(function CanvasNode({
onRetry={onRetry}
onGenerateImage={onGenerateImage}
onToggleBatch={() => onToggleBatch?.(data.id)}
onSetBatchPrimary={() => onSetBatchPrimary?.(data)}
onSetBatchPrimary={(imageId) => onSetBatchPrimary?.(data.id, imageId)}
groupChildCount={groupChildCount}
/>
</div>
@@ -582,40 +563,21 @@ function TextContent({ node, theme, isEditingContent, textareaRef, mentionRefere
}
function ImageNodeContent(props: NodeContentRendererProps) {
if (!props.node.metadata?.content && props.isBatchRoot) {
const content =
props.node.metadata?.status === "loading" ? (
<LoadingContent theme={props.theme} />
) : props.node.metadata?.status === "error" ? (
<ErrorContent node={props.node} theme={props.theme} onRetry={props.onRetry} />
) : (
<EmptyImageContent {...props} isBatchRoot={false} />
);
return (
<BatchFrame batchCount={props.batchCount} batchExpanded={props.batchExpanded} batchOpening={props.batchOpening} batchRecovering={props.batchRecovering} onToggleBatch={props.onToggleBatch}>
{content}
</BatchFrame>
);
}
if (!props.node.metadata?.content) return <EmptyImageContent {...props} />;
return (
<ImageContent
node={props.node}
isBatchRoot={props.isBatchRoot}
batchCount={props.batchCount}
batchExpanded={props.batchExpanded}
batchOpening={props.batchOpening}
batchRecovering={props.batchRecovering}
onToggleBatch={props.onToggleBatch}
onSetBatchPrimary={props.onSetBatchPrimary}
/>
);
}
function EmptyImageContent({ theme, isBatchRoot, batchCount, batchExpanded, batchOpening, batchRecovering, onToggleBatch }: NodeContentRendererProps) {
function EmptyImageContent({ theme }: NodeContentRendererProps) {
const { t } = useTranslation();
const content = (
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3" style={{ color: theme.node.placeholder }}>
<div className="flex size-14 items-center justify-center rounded-2xl" style={{ background: theme.toolbar.activeBg }}>
<ImageIcon className="size-6 opacity-30" />
@@ -623,13 +585,6 @@ function EmptyImageContent({ theme, isBatchRoot, batchCount, batchExpanded, batc
<span className="text-[10px] tracking-[0.18em] opacity-50">{t("canvas.node.emptyImage")}</span>
</div>
);
if (isBatchRoot)
return (
<BatchFrame batchCount={batchCount} batchExpanded={batchExpanded} batchOpening={batchOpening} batchRecovering={batchRecovering} onToggleBatch={onToggleBatch}>
{content}
</BatchFrame>
);
return content;
}
function VideoNodeContent({ node, theme }: NodeContentRendererProps) {
@@ -666,29 +621,29 @@ function AudioNodeContent({ node, theme }: NodeContentRendererProps) {
function ImageContent({
node,
isBatchRoot,
batchCount,
batchExpanded,
batchOpening,
batchRecovering,
onToggleBatch,
onSetBatchPrimary,
}: {
node: CanvasNodeData;
isBatchRoot: boolean;
batchCount: number;
batchExpanded: boolean;
batchOpening: boolean;
batchRecovering: boolean;
onToggleBatch?: () => void;
onSetBatchPrimary?: () => void;
onSetBatchPrimary?: (imageId: string) => void;
}) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const { t } = useTranslation();
const isBatchChild = Boolean(node.metadata?.batchRootId);
const images = node.metadata?.images || [];
const batchCount = images.length;
const isBatchRoot = batchCount > 1;
const primaryImageId = node.metadata?.primaryImageId || images[0]?.id;
return (
<BatchFrame batchCount={isBatchRoot ? batchCount : 0} batchExpanded={batchExpanded} batchOpening={batchOpening} batchRecovering={batchRecovering} onToggleBatch={onToggleBatch}>
<BatchFrame batchCount={batchCount} batchExpanded={batchExpanded} onToggleBatch={onToggleBatch}>
{batchExpanded
? images
.filter((image) => image.id !== primaryImageId)
.map((image, index) => <ExpandedImageCard key={image.id} node={node} image={image} index={index} onSetPrimary={() => onSetBatchPrimary?.(image.id)} />)
: null}
<div className="h-full w-full overflow-hidden rounded-3xl">
<img
src={node.metadata!.content!}
@@ -701,8 +656,8 @@ function ImageContent({
{isBatchRoot ? (
<button
type="button"
className="absolute right-2.5 top-2.5 z-30 flex h-8 items-center justify-center gap-1 rounded-full border px-2.5 text-xs font-semibold shadow-[0_6px_18px_rgba(15,23,42,.10)] backdrop-blur-md transition hover:scale-[1.02]"
style={{ background: `${theme.toolbar.panel}d9`, borderColor: `${theme.toolbar.border}cc`, color: theme.node.text }}
className="absolute right-2.5 top-2.5 z-30 flex h-8 items-center justify-center gap-1.5 rounded-full border px-3 text-xs font-semibold shadow-[0_6px_18px_rgba(28,25,23,.16)] backdrop-blur-md transition hover:scale-[1.02]"
style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.activeText }}
aria-label={batchExpanded ? t("canvas.node.batchExpanded") : t("canvas.node.batchCollapsed")}
onClick={(event) => {
event.stopPropagation();
@@ -711,30 +666,64 @@ function ImageContent({
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<span className="leading-none text-[#2f80ff]">{batchCount}</span>
<ChevronRight className={`size-3.5 opacity-55 transition-transform ${batchExpanded ? "rotate-90" : ""}`} />
</button>
) : null}
{isBatchChild ? (
<button
type="button"
className="absolute right-3 top-3 z-30 flex h-9 items-center gap-1.5 rounded-xl border px-2.5 text-xs font-medium opacity-0 shadow-[0_8px_20px_rgba(68,64,60,.13)] backdrop-blur-md transition group-hover/batch:opacity-100 hover:scale-[1.02]"
style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
onClick={(event) => {
event.stopPropagation();
onSetBatchPrimary?.();
}}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<Star className="size-3.5 text-[#2f80ff]" />
{t("canvas.node.setPrimary")}
<span className="leading-none">{t("canvas.controls.images", { count: batchCount })}</span>
<ChevronRight className={`size-3.5 opacity-80 transition-transform ${batchExpanded ? "rotate-90" : ""}`} />
</button>
) : null}
</BatchFrame>
);
}
function ExpandedImageCard({ node, image, index, onSetPrimary }: { node: CanvasNodeData; image: CanvasNodeImage; index: number; onSetPrimary: () => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const { t } = useTranslation();
const count = node.metadata?.images?.length || 0;
const rows = Math.ceil(count / 2);
const rootSlot = (rows - 1) * 2;
const slot = index >= rootSlot ? index + 1 : index;
const column = slot % 2;
const row = Math.floor(slot / 2);
const x = column * (node.width + 18);
const y = (row - rows + 1) * (node.height + 18);
return (
<div
className="absolute z-20 overflow-hidden rounded-3xl border shadow-[0_18px_50px_rgba(28,25,23,.18)]"
style={
{
left: x,
top: y,
width: node.width,
height: node.height,
background: theme.node.panel,
borderColor: theme.node.stroke,
"--batch-from-x": `${-x}px`,
"--batch-from-y": `${-y}px`,
"--batch-from-rotate": `${4 + index * 2}deg`,
animation: `canvas-batch-child-in 320ms ${index * 35}ms cubic-bezier(.2,.85,.18,1) both`,
} as React.CSSProperties
}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
>
<img src={image.content} alt={node.title} draggable={false} className="pointer-events-none h-full w-full select-none object-contain" />
<button
type="button"
className="absolute right-3 top-3 flex h-9 items-center gap-1.5 rounded-xl border border-white/20 bg-black/70 px-2.5 text-xs font-medium text-white shadow-[0_8px_20px_rgba(15,23,42,.24)] backdrop-blur-md transition hover:scale-[1.02] hover:bg-black/80"
style={{ color: "#fff" }}
onClick={(event) => {
event.stopPropagation();
onSetPrimary();
}}
>
<Star className="size-3.5 text-[#2f80ff]" />
{t("canvas.node.setPrimary")}
</button>
</div>
);
}
function ImageInfoBar({ node }: { node: CanvasNodeData }) {
const width = Math.round(node.metadata?.naturalWidth || node.width);
const height = Math.round(node.metadata?.naturalHeight || node.height);
@@ -749,7 +738,7 @@ function ImageInfoBar({ node }: { node: CanvasNodeData }) {
);
}
function BatchFrame({ batchCount, batchExpanded, batchOpening, batchRecovering, onToggleBatch, children }: { batchCount: number; batchExpanded: boolean; batchOpening: boolean; batchRecovering: boolean; onToggleBatch?: () => void; children: ReactNode }) {
function BatchFrame({ batchCount, batchExpanded, onToggleBatch, children }: { batchCount: number; batchExpanded: boolean; onToggleBatch?: () => void; children: ReactNode }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const isBatchRoot = batchCount > 1;
return (
@@ -774,9 +763,8 @@ function BatchFrame({ batchCount, batchExpanded, batchOpening, batchRecovering,
inset: 0,
background: `linear-gradient(135deg, ${theme.node.panel}, ${theme.node.fill})`,
borderColor: theme.node.stroke,
opacity: batchExpanded && !batchOpening ? 0.34 : 1,
transform:
batchOpening || batchRecovering ? `translate(${54 + index * 22}px, ${20 + index * 12}px) rotate(${8 + index * 5}deg) scale(.98)` : `translate(${34 + index * 18}px, ${14 + index * 10}px) rotate(${6 + index * 4}deg)`,
opacity: batchExpanded ? 0 : 1,
transform: `translate(${30 + index * 14}px, ${12 + index * 8}px) rotate(${5 + index * 3}deg)`,
zIndex: -index - 1,
}}
/>