mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-07 18:25:18 +08:00
feat(canvas): consolidate multi-image generation into a single image node with enhanced metadata structure
This commit is contained in:
@@ -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,
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user