mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-07 01:54:24 +08:00
feat(canvas): consolidate multi-image generation into a single image node with enhanced metadata structure
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
+ [优化] 画布多图生成结果合并为单个图片节点。
|
||||
|
||||
## v0.14.0 - 2026-08-05
|
||||
|
||||
+ [新增] 文档站支持英文默认与简体中文切换,正文、导航、搜索及 LLM 文档接口按语言独立展示。
|
||||
|
||||
@@ -48,9 +48,9 @@ type CanvasConnection = {
|
||||
};
|
||||
```
|
||||
|
||||
Node positions and sizes use canvas-world coordinates. Edges store only node IDs; their paths are calculated while rendering. Deleting a node removes connected edges, and deleting an image-batch root also removes its child nodes.
|
||||
Node positions and sizes use canvas-world coordinates. Edges store only node IDs; their paths are calculated while rendering. Deleting a node removes its connected edges.
|
||||
|
||||
Common metadata includes content, prompt, status, generation mode, model, size, count, natural dimensions, batch relationships, input order, `storageKey`, MIME type, and byte size. Image and video `content` values are normally session-scoped `blob:` URLs; `storageKey` is the persistent identity.
|
||||
Common metadata includes content, prompt, status, generation mode, model, size, count, natural dimensions, image results, input order, `storageKey`, MIME type, and byte size. A multi-image generation remains one image node: `images` stores every result and `primaryImageId` selects the result mirrored by the node-level `content` and image metadata. Image and video `content` values are normally session-scoped `blob:` URLs; `storageKey` is the persistent identity.
|
||||
|
||||
## Assistant sessions
|
||||
|
||||
|
||||
@@ -77,6 +77,16 @@ type CanvasNodeData = {
|
||||
`metadata` 当前常用字段:
|
||||
|
||||
```ts
|
||||
type CanvasNodeImage = {
|
||||
id: string;
|
||||
content: string;
|
||||
storageKey: string;
|
||||
naturalWidth: number;
|
||||
naturalHeight: number;
|
||||
bytes: number;
|
||||
mimeType: string;
|
||||
};
|
||||
|
||||
type CanvasNodeMetadata = {
|
||||
content?: string;
|
||||
prompt?: string;
|
||||
@@ -90,11 +100,8 @@ type CanvasNodeMetadata = {
|
||||
naturalWidth?: number;
|
||||
naturalHeight?: number;
|
||||
freeResize?: boolean;
|
||||
isBatchRoot?: boolean;
|
||||
batchRootId?: string;
|
||||
batchChildIds?: string[];
|
||||
images?: CanvasNodeImage[];
|
||||
primaryImageId?: string;
|
||||
imageBatchExpanded?: boolean;
|
||||
inputOrder?: string[];
|
||||
storageKey?: string;
|
||||
mimeType?: string;
|
||||
@@ -108,7 +115,7 @@ type CanvasNodeMetadata = {
|
||||
- 视频节点:`content` 是当前可播放的视频 URL,通常是 `blob:` URL;`storageKey` 指向本地视频 Blob;`bytes/mimeType` 保存文件信息。
|
||||
- 文本节点:`content` 保存文本内容;`fontSize` 保存字体大小;`prompt/status/errorDetails` 保存生成状态。
|
||||
- 生成配置节点:`generationMode/model/size/count/inputOrder` 保存生成配置;`generationMode` 可选择文本、图片或视频;上游输入通过 `connections` 计算。
|
||||
- 图片组节点:根节点用 `isBatchRoot/batchChildIds/primaryImageId/imageBatchExpanded` 记录批量生成结果;子图节点用 `batchRootId` 指回根节点。
|
||||
- 多图图片节点:批量生成仍只保存为一个图片节点,`images` 记录全部结果,`primaryImageId` 指定由节点级 `content` 和图片元数据展示的主图;展开状态仅属于当前界面,不写入项目数据。
|
||||
|
||||
## 连线结构
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ The current release needs manual verification in these areas:
|
||||
- English and Simplified Chinese switching across navigation, settings, titles, descriptions, Ant Design components, and persisted preferences.
|
||||
- Global Dropdown, Menu, Select, Cascader, and TreeSelect popup backgrounds, hover states, and selected states in both light and dark themes.
|
||||
- Canvas node resize stability, prompt scrolling, generated-prompt restoration, image editing, drag-and-drop references, and generation configuration.
|
||||
- Multi-image canvas generation: generating N images should create one image node with exactly N stored results and one incoming connection; the collapsed count control should remain clearly legible over any image, hide the floating toolbar when clicked, and expand the N images upward and to the right; every secondary image should keep a clearly legible primary-image action visible in both themes, and clicking the empty canvas should collapse the group.
|
||||
- Canvas Agent startup, MCP status, model and permission controls, approvals, version reporting, diagnostics, streaming responses, history consistency, multi-tab isolation, and local Skill management.
|
||||
- Agent header tabs in English and Chinese at different panel widths; labels should collapse to icons and counts before either edge is clipped.
|
||||
- Agent message layout, Markdown, code blocks, attachments, token statistics, progress timelines, command groups, and scroll-follow behavior.
|
||||
|
||||
@@ -48,6 +48,7 @@ description: 当前版本已实现但仍需人工验证的变更项
|
||||
- 画布节点提示词:图片等节点下方的提示词超过输入区域高度后,鼠标悬停在输入区域内滚动应查看提示词内容,不应缩放画布。
|
||||
- 画布节点提示词回显:单图或多图生成完成后,切换选择生成结果的根节点及任一子节点,下方输入区域都应继续显示生成时的提示词;切换到其他节点再选回来后也不应清空。
|
||||
- 画布生成配置:连接文本节点后连续点击生成(包含失败重试),组装提示词不应重复追加上游文本;在生图、文本、视频、音频之间切换时,实际请求应始终使用当前面板展示的对应类型模型。
|
||||
- 画布多图生成:生成 N 张图片后应只创建一个包含 N 张结果的图片节点,并且来源到结果只保留一条连线;收起状态下右上角应显示醒目的「N 张」数量提示,在任意图片背景上都清晰可辨;点击数量后,浮动工具条应暂时隐藏,主图与其余图片应从当前节点向右上方整齐展开且总数严格为 N;其余图片的「设为主图」应常驻显示,并在亮色、深色主题及不同图片背景上保持清晰;点击画布空白处应自动收起并恢复工具条。
|
||||
- 画布左侧元素列表:点击元素整行应平滑定位并选中对应节点;有内容的图片元素应显示预览按钮,点击后打开大图弹窗且不触发画布定位。
|
||||
- 配置与用户偏好:导出 JSON 后应包含渠道、默认模型、生成偏好、提示词来源和 WebDAV 配置;在修改当前配置后重新导入该文件,应恢复导出时的设置,错误 JSON 文件应提示格式不正确。配置文件包含 API Key 和 WebDAV 凭据,不应公开分享。
|
||||
- 模型渠道协议:渠道编辑可选择「火山方舟」并自动填入方舟接口地址;任意名称的生图模型应按方舟 JSON 格式提交参考图,任意名称的视频模型应按方舟任务格式提交和查询,不再依赖模型名包含 `doubao`、`seedream` 或 `seedance`;1080p 不应再因模型名包含 `fast` 被禁用,参考视频应允许最大 200MB、总像素 409600-8295044,并继续校验官方宽高、比例和时长限制。
|
||||
|
||||
@@ -219,7 +219,7 @@ export function CanvasNodeInfoModal({ node, open, onClose }: { node: CanvasNodeD
|
||||
const { t } = useTranslation();
|
||||
const [view, setView] = useState<"info" | "json">("info");
|
||||
const imageBytes = node?.type === CanvasNodeType.Image && node.metadata?.content ? getDataUrlByteSize(node.metadata.content) : 0;
|
||||
const batchCount = node?.type === CanvasNodeType.Image ? node.metadata?.batchChildIds?.length || 0 : 0;
|
||||
const batchCount = node?.type === CanvasNodeType.Image ? node.metadata?.images?.length || 0 : 0;
|
||||
const json = useMemo(() => {
|
||||
if (!node) return "";
|
||||
return JSON.stringify(
|
||||
|
||||
@@ -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,27 +666,61 @@ 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" : ""}`} />
|
||||
<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}
|
||||
{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?.();
|
||||
}}
|
||||
</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>
|
||||
) : null}
|
||||
</BatchFrame>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -48,7 +48,8 @@ export async function hydrateCanvasImages(nodes: CanvasNodeData[]) {
|
||||
const content = node.metadata?.content;
|
||||
if ((node.type === CanvasNodeType.Video || node.type === CanvasNodeType.Audio) && node.metadata?.storageKey) return { ...node, metadata: { ...node.metadata, content: await resolveMediaUrl(node.metadata.storageKey, content) } };
|
||||
if (node.type !== CanvasNodeType.Image || !content) return node;
|
||||
if (node.metadata?.storageKey) return { ...node, metadata: { ...node.metadata, content: await resolveImageUrl(node.metadata.storageKey, content) } };
|
||||
const images = await Promise.all((node.metadata.images || []).map(async (image) => ({ ...image, content: await resolveImageUrl(image.storageKey, image.content) })));
|
||||
if (node.metadata?.storageKey) return { ...node, metadata: { ...node.metadata, content: await resolveImageUrl(node.metadata.storageKey, content), images } };
|
||||
if (!content.startsWith("data:image/")) return node;
|
||||
return { ...node, metadata: { ...node.metadata, ...imageMetadata(await uploadImage(content)) } };
|
||||
}),
|
||||
|
||||
@@ -74,18 +74,3 @@ export function normalizeConnection(firstNodeId: string, secondNodeId: string, n
|
||||
if (first.type === CanvasNodeType.Config) return { fromNodeId: first.id, toNodeId: second.id };
|
||||
return { fromNodeId: first.id, toNodeId: second.id };
|
||||
}
|
||||
|
||||
export function isHiddenBatchChild(node: CanvasNodeData, nodes: CanvasNodeData[], collapsingBatchIds?: Set<string>) {
|
||||
const rootId = node.metadata?.batchRootId;
|
||||
if (!rootId) return false;
|
||||
const root = nodes.find((item) => item.id === rootId);
|
||||
if (root && collapsingBatchIds?.has(rootId)) return false;
|
||||
return Boolean(root && !root.metadata?.imageBatchExpanded);
|
||||
}
|
||||
|
||||
export function isHiddenBatchConnectionEndpoint(node: CanvasNodeData, nodes: CanvasNodeData[]) {
|
||||
const rootId = node.metadata?.batchRootId;
|
||||
if (!rootId) return false;
|
||||
const root = nodes.find((item) => item.id === rootId);
|
||||
return Boolean(root && !root.metadata?.imageBatchExpanded);
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ import { usePluginHost } from "@/pages/canvas/hooks/use-plugin-host";
|
||||
import { buildNodeMentionReferences, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
import { exportCanvasProjects } from "@/lib/canvas/canvas-export";
|
||||
import { applyNodeConfigPatch, audioMetadata, buildAudioGenerationMetadata, buildImageGenerationMetadata, createCanvasNode, imageMetadata, videoMetadata } from "@/lib/canvas/canvas-node-factory";
|
||||
import { findContainingGroupId, findGroupDropTarget, getConnectionTargetAnchor, isHiddenBatchChild, isHiddenBatchConnectionEndpoint, normalizeConnection, snapNodesIntoGroup } from "@/lib/canvas/canvas-node-geometry";
|
||||
import { findContainingGroupId, findGroupDropTarget, getConnectionTargetAnchor, normalizeConnection, snapNodesIntoGroup } from "@/lib/canvas/canvas-node-geometry";
|
||||
import {
|
||||
audioExtension,
|
||||
buildAngleLabel,
|
||||
@@ -77,6 +77,7 @@ import {
|
||||
type CanvasAssistantSession,
|
||||
type CanvasConnection,
|
||||
type CanvasNodeData,
|
||||
type CanvasNodeImage,
|
||||
type CanvasNodeMetadata,
|
||||
type CanvasNodeTypeId,
|
||||
type ConnectionHandle,
|
||||
@@ -233,8 +234,7 @@ function InfiniteCanvasPage() {
|
||||
const [titleEditing, setTitleEditing] = useState(false);
|
||||
const [titleDraft, setTitleDraft] = useState("");
|
||||
const [historyState, setHistoryState] = useState({ canUndo: false, canRedo: false });
|
||||
const [collapsingBatchIds, setCollapsingBatchIds] = useState<Set<string>>(new Set());
|
||||
const [openingBatchIds, setOpeningBatchIds] = useState<Set<string>>(new Set());
|
||||
const [expandedImageNodeId, setExpandedImageNodeId] = useState<string | null>(null);
|
||||
const [isNodeDragging, setIsNodeDragging] = useState(false);
|
||||
const [isNodeResizing, setIsNodeResizing] = useState(false);
|
||||
const [dropTargetGroupId, setDropTargetGroupId] = useState<string | null>(null);
|
||||
@@ -532,7 +532,6 @@ function InfiniteCanvasPage() {
|
||||
let bestPriority = Number.POSITIVE_INFINITY;
|
||||
|
||||
[...nodesRef.current]
|
||||
.filter((node) => !isHiddenBatchChild(node, nodesRef.current))
|
||||
.reverse()
|
||||
.forEach((node) => {
|
||||
const anchor = getConnectionTargetAnchor(node, current);
|
||||
@@ -568,8 +567,8 @@ function InfiniteCanvasPage() {
|
||||
const viewRight = viewLeft + width / viewport.k + padding * 2;
|
||||
const viewBottom = viewTop + height / viewport.k + padding * 2;
|
||||
|
||||
return nodes.filter((node) => !isHiddenBatchChild(node, nodes, collapsingBatchIds) && node.position.x + node.width > viewLeft && node.position.x < viewRight && node.position.y + node.height > viewTop && node.position.y < viewBottom);
|
||||
}, [collapsingBatchIds, nodes, size.height, size.width, viewport.k, viewport.x, viewport.y]);
|
||||
return nodes.filter((node) => node.position.x + node.width > viewLeft && node.position.x < viewRight && node.position.y + node.height > viewTop && node.position.y < viewBottom);
|
||||
}, [nodes, size.height, size.width, viewport.k, viewport.x, viewport.y]);
|
||||
|
||||
const nodeById = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]);
|
||||
// The toolbar follows a single selected node selected by click, creation, marquee, or keyboard.
|
||||
@@ -586,13 +585,6 @@ function InfiniteCanvasPage() {
|
||||
const previewNode = previewNodeId ? nodeById.get(previewNodeId) || null : null;
|
||||
const hasMultipleSelectedNodes = selectedNodeIds.size > 1;
|
||||
const activeNodeId = hasMultipleSelectedNodes ? null : hoveredNodeId || (selectedNodeIds.size === 1 ? Array.from(selectedNodeIds)[0] : null);
|
||||
const batchChildCountById = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
nodes.forEach((node) => {
|
||||
if (node.metadata?.isBatchRoot) map.set(node.id, node.metadata.batchChildIds?.length || 0);
|
||||
});
|
||||
return map;
|
||||
}, [nodes]);
|
||||
const groupChildCountById = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
nodes.forEach((node) => {
|
||||
@@ -601,19 +593,6 @@ function InfiniteCanvasPage() {
|
||||
});
|
||||
return map;
|
||||
}, [nodes]);
|
||||
const batchMotionById = useMemo(() => {
|
||||
const map = new Map<string, { x: number; y: number; index: number }>();
|
||||
nodes.forEach((node) => {
|
||||
const rootId = node.metadata?.batchRootId;
|
||||
if (!rootId) return;
|
||||
const root = nodeById.get(rootId);
|
||||
const index = root?.metadata?.batchChildIds?.indexOf(node.id) ?? 0;
|
||||
const stackX = root ? root.position.x + 34 + index * 14 : node.position.x;
|
||||
const stackY = root ? root.position.y + 14 + index * 8 : node.position.y;
|
||||
map.set(node.id, { x: stackX - node.position.x, y: stackY - node.position.y, index: Math.max(index, 0) });
|
||||
});
|
||||
return map;
|
||||
}, [nodeById, nodes]);
|
||||
const relatedHighlight = useMemo(() => {
|
||||
const nodeIds = new Set<string>();
|
||||
const connectionIds = new Set<string>();
|
||||
@@ -712,29 +691,12 @@ function InfiniteCanvasPage() {
|
||||
(ids: Set<string>) => {
|
||||
if (!ids.size) return;
|
||||
const allIds = new Set(ids);
|
||||
nodesRef.current.forEach((node) => {
|
||||
if (ids.has(node.id)) node.metadata?.batchChildIds?.forEach((childId) => allIds.add(childId));
|
||||
});
|
||||
setNodes((prev) => {
|
||||
const next = prev.filter((node) => !allIds.has(node.id));
|
||||
return next.map((node) => {
|
||||
const groupId = node.metadata?.groupId;
|
||||
if (groupId && allIds.has(groupId)) return { ...node, metadata: { ...node.metadata, groupId: undefined } };
|
||||
const childIds = node.metadata?.batchChildIds?.filter((childId) => !allIds.has(childId));
|
||||
if (!node.metadata?.isBatchRoot || childIds?.length === node.metadata.batchChildIds?.length) return node;
|
||||
const primaryImageId = childIds?.includes(node.metadata.primaryImageId || "") ? node.metadata.primaryImageId : childIds?.[0];
|
||||
const primaryNode = next.find((item) => item.id === primaryImageId);
|
||||
return {
|
||||
...node,
|
||||
metadata: {
|
||||
...node.metadata,
|
||||
batchChildIds: childIds,
|
||||
primaryImageId,
|
||||
content: primaryNode?.metadata?.content || node.metadata.content,
|
||||
naturalWidth: primaryNode?.metadata?.naturalWidth || node.metadata.naturalWidth,
|
||||
naturalHeight: primaryNode?.metadata?.naturalHeight || node.metadata.naturalHeight,
|
||||
},
|
||||
};
|
||||
return node;
|
||||
});
|
||||
});
|
||||
setConnections((prev) => prev.filter((conn) => !allIds.has(conn.fromNodeId) && !allIds.has(conn.toNodeId)));
|
||||
@@ -764,6 +726,7 @@ function InfiniteCanvasPage() {
|
||||
|
||||
const deselectCanvas = useCallback(() => {
|
||||
cancelPendingConnectionCreate();
|
||||
setExpandedImageNodeId(null);
|
||||
setSelectedNodeIds(new Set());
|
||||
setSelectedConnectionId(null);
|
||||
setContextMenu(null);
|
||||
@@ -1004,6 +967,7 @@ function InfiniteCanvasPage() {
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
setContextMenu(null);
|
||||
setNodeCreatePosition(null);
|
||||
setExpandedImageNodeId(null);
|
||||
if (pendingConnectionCreateRef.current) cancelPendingConnectionCreate();
|
||||
if (event.button !== 0) return;
|
||||
|
||||
@@ -1076,7 +1040,6 @@ function InfiniteCanvasPage() {
|
||||
const dragIds = new Set(nextSelected);
|
||||
currentNodes.forEach((node) => {
|
||||
if (!nextSelected.has(node.id)) return;
|
||||
node.metadata?.batchChildIds?.forEach((childId) => dragIds.add(childId));
|
||||
if (node.type === CanvasNodeType.Group) {
|
||||
currentNodes.forEach((child) => {
|
||||
if (child.metadata?.groupId === node.id) dragIds.add(child.id);
|
||||
@@ -1209,7 +1172,6 @@ function InfiniteCanvasPage() {
|
||||
const nextSelected = new Set<string>(currentSelection.additive ? currentSelection.initialSelectedNodeIds : []);
|
||||
|
||||
nodesRef.current
|
||||
.filter((node) => !isHiddenBatchChild(node, nodesRef.current))
|
||||
.forEach((node) => {
|
||||
const intersects = rectX < node.position.x + node.width && rectX + rectW > node.position.x && rectY < node.position.y + node.height && rectY + rectH > node.position.y;
|
||||
|
||||
@@ -1484,55 +1446,32 @@ function InfiniteCanvasPage() {
|
||||
}, []);
|
||||
|
||||
const toggleBatchExpanded = useCallback((nodeId: string) => {
|
||||
const isExpanded = Boolean(nodesRef.current.find((node) => node.id === nodeId)?.metadata?.imageBatchExpanded);
|
||||
if (isExpanded) {
|
||||
setCollapsingBatchIds((prev) => new Set(prev).add(nodeId));
|
||||
window.setTimeout(() => {
|
||||
setCollapsingBatchIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(nodeId);
|
||||
return next;
|
||||
});
|
||||
}, 320);
|
||||
} else {
|
||||
setOpeningBatchIds((prev) => new Set(prev).add(nodeId));
|
||||
window.setTimeout(() => {
|
||||
setOpeningBatchIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(nodeId);
|
||||
return next;
|
||||
});
|
||||
}, 260);
|
||||
}
|
||||
setExpandedImageNodeId((current) => (current === nodeId ? null : nodeId));
|
||||
}, []);
|
||||
|
||||
const setBatchPrimary = useCallback((nodeId: string, imageId: string) => {
|
||||
setNodes((prev) =>
|
||||
prev.map((node) => {
|
||||
if (node.id !== nodeId) return node;
|
||||
return { ...node, metadata: { ...node.metadata, imageBatchExpanded: !node.metadata?.imageBatchExpanded } };
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const setBatchPrimary = useCallback((child: CanvasNodeData) => {
|
||||
const rootId = child.metadata?.batchRootId;
|
||||
if (!rootId || !child.metadata?.content) return;
|
||||
setNodes((prev) =>
|
||||
prev.map((node) =>
|
||||
node.id === rootId
|
||||
? {
|
||||
const image = node.metadata?.images?.find((item) => item.id === imageId);
|
||||
if (!image) return node;
|
||||
const size = fitNodeSize(image.naturalWidth, image.naturalHeight);
|
||||
return {
|
||||
...node,
|
||||
width: child.width,
|
||||
height: child.height,
|
||||
position: { x: node.position.x + node.width / 2 - size.width / 2, y: node.position.y + node.height / 2 - size.height / 2 },
|
||||
...size,
|
||||
metadata: {
|
||||
...node.metadata,
|
||||
content: child.metadata?.content,
|
||||
primaryImageId: child.id,
|
||||
naturalWidth: child.metadata?.naturalWidth,
|
||||
naturalHeight: child.metadata?.naturalHeight,
|
||||
freeResize: child.metadata?.freeResize,
|
||||
content: image.content,
|
||||
storageKey: image.storageKey,
|
||||
naturalWidth: image.naturalWidth,
|
||||
naturalHeight: image.naturalHeight,
|
||||
bytes: image.bytes,
|
||||
mimeType: image.mimeType,
|
||||
primaryImageId: image.id,
|
||||
},
|
||||
}
|
||||
: node,
|
||||
),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
|
||||
@@ -1928,10 +1867,7 @@ function InfiniteCanvasPage() {
|
||||
...imageMetadata(image),
|
||||
errorDetails: undefined,
|
||||
freeResize: false,
|
||||
isBatchRoot: undefined,
|
||||
batchRootId: undefined,
|
||||
batchChildIds: undefined,
|
||||
batchUsesReferenceImages: undefined,
|
||||
images: undefined,
|
||||
generationType: undefined,
|
||||
model: undefined,
|
||||
size: undefined,
|
||||
@@ -1939,7 +1875,6 @@ function InfiniteCanvasPage() {
|
||||
count: undefined,
|
||||
references: undefined,
|
||||
primaryImageId: undefined,
|
||||
imageBatchExpanded: undefined,
|
||||
},
|
||||
}
|
||||
: node,
|
||||
@@ -2112,18 +2047,15 @@ function InfiniteCanvasPage() {
|
||||
const parentConfig = NODE_DEFAULT_SIZE[isConfigNode ? CanvasNodeType.Config : isImageNode ? CanvasNodeType.Image : CanvasNodeType.Text];
|
||||
const imageConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Image];
|
||||
const parentPosition = sourceNode?.position || { x: 0, y: 0 };
|
||||
const gap = 96;
|
||||
const rowGap = 36;
|
||||
const rootId = isEmptyImageNode ? nodeId : nanoid();
|
||||
const childIds = count > 1 ? Array.from({ length: count }, () => nanoid()) : [];
|
||||
const targetIds = count > 1 ? childIds : [rootId];
|
||||
pendingChildIds = isEmptyImageNode ? childIds : [rootId, ...childIds];
|
||||
const imageIds = Array.from({ length: count }, () => nanoid());
|
||||
pendingChildIds = [rootId];
|
||||
const rootNode: CanvasNodeData = {
|
||||
id: rootId,
|
||||
type: CanvasNodeType.Image,
|
||||
title: effectivePrompt.slice(0, 32) || "Generated Image",
|
||||
position: {
|
||||
x: isEmptyImageNode ? parentPosition.x : parentPosition.x + parentConfig.width + gap,
|
||||
x: isEmptyImageNode ? parentPosition.x : parentPosition.x + parentConfig.width + 96,
|
||||
y: parentPosition.y + parentConfig.height / 2 - imageConfig.height / 2,
|
||||
},
|
||||
width: isEmptyImageNode ? sourceNode?.width || imageConfig.width : imageConfig.width,
|
||||
@@ -2131,26 +2063,10 @@ function InfiniteCanvasPage() {
|
||||
metadata: {
|
||||
prompt: effectivePrompt,
|
||||
status: NODE_STATUS_LOADING,
|
||||
isBatchRoot: count > 1,
|
||||
batchChildIds: count > 1 ? childIds : undefined,
|
||||
batchUsesReferenceImages: referenceImages.length > 0,
|
||||
images: [],
|
||||
...generationMetadata,
|
||||
imageBatchExpanded: count > 1 ? true : undefined,
|
||||
},
|
||||
};
|
||||
const childNodes: CanvasNodeData[] = childIds.map((id, index) => ({
|
||||
id,
|
||||
type: CanvasNodeType.Image,
|
||||
title: effectivePrompt.slice(0, 32) || "Generated Image",
|
||||
position: {
|
||||
x: rootNode.position.x + rootNode.width + 120 + (index % 2) * (imageConfig.width + 36),
|
||||
y: rootNode.position.y + Math.floor(index / 2) * (imageConfig.height + rowGap),
|
||||
},
|
||||
width: imageConfig.width,
|
||||
height: imageConfig.height,
|
||||
metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, batchRootId: count > 1 ? rootId : undefined, ...generationMetadata },
|
||||
}));
|
||||
const batchConnections = [...(isEmptyImageNode ? [] : [{ id: nanoid(), fromNodeId: nodeId, toNodeId: rootId }]), ...childIds.map((childId) => ({ id: nanoid(), fromNodeId: rootId, toNodeId: childId }))];
|
||||
|
||||
setNodes((prev) => [
|
||||
...prev.map((node) =>
|
||||
@@ -2185,51 +2101,49 @@ function InfiniteCanvasPage() {
|
||||
: node,
|
||||
),
|
||||
...(isEmptyImageNode ? [] : [rootNode]),
|
||||
...childNodes,
|
||||
]);
|
||||
setConnections((prev) => [...prev, ...batchConnections]);
|
||||
if (!isEmptyImageNode) setConnections((prev) => [...prev, { id: nanoid(), fromNodeId: nodeId, toNodeId: rootId }]);
|
||||
setSelectedNodeIds(new Set([nodeId]));
|
||||
setSelectedConnectionId(null);
|
||||
setDialogNodeId(nodeId);
|
||||
|
||||
const controller = runController;
|
||||
targetIds.forEach((targetId) => startGenerationRequest(targetId, nodeId, nodeId, controller));
|
||||
if (count > 1) startGenerationRequest(rootId, nodeId, nodeId, controller);
|
||||
const controller = rootId === nodeId ? runController : startGenerationRequest(rootId, nodeId, nodeId, runController);
|
||||
let hasSuccess = false;
|
||||
let hasFailure = false;
|
||||
let firstError = "";
|
||||
await Promise.all(
|
||||
targetIds.map(async (targetId) => {
|
||||
imageIds.map(async (imageId) => {
|
||||
try {
|
||||
const image = referenceImages.length
|
||||
? await requestEdit({ ...generationConfig, count: "1" }, effectivePrompt, referenceImages, undefined, { signal: controller.signal }).then((items) => items[0])
|
||||
: await requestGeneration({ ...generationConfig, count: "1" }, effectivePrompt, { signal: controller.signal }).then((items) => items[0]);
|
||||
const uploaded = await uploadImage(image.dataUrl);
|
||||
const imageSize = fitNodeSize(uploaded.width, uploaded.height, imageConfig.width, imageConfig.height);
|
||||
setNodes((prev) => {
|
||||
const root = prev.find((node) => node.id === rootId);
|
||||
return prev.map((node) => {
|
||||
if (node.id !== targetId && node.id !== rootId) return node;
|
||||
const item: CanvasNodeImage = { id: imageId, content: uploaded.url, storageKey: uploaded.storageKey, naturalWidth: uploaded.width, naturalHeight: uploaded.height, bytes: uploaded.bytes, mimeType: uploaded.mimeType };
|
||||
setNodes((prev) =>
|
||||
prev.map((node) => {
|
||||
if (node.id !== rootId) return node;
|
||||
const images = [...(node.metadata?.images || []), item].sort((a, b) => imageIds.indexOf(a.id) - imageIds.indexOf(b.id));
|
||||
if (node.metadata?.primaryImageId) return { ...node, metadata: { ...node.metadata, images } };
|
||||
const center = { x: node.position.x + node.width / 2, y: node.position.y + node.height / 2 };
|
||||
if (node.id === rootId && (targetId === rootId || !root?.metadata?.primaryImageId))
|
||||
return {
|
||||
...node,
|
||||
position: { x: center.x - imageSize.width / 2, y: center.y - imageSize.height / 2 },
|
||||
width: imageSize.width,
|
||||
height: imageSize.height,
|
||||
metadata: { ...node.metadata, ...imageMetadata(uploaded), primaryImageId: targetId },
|
||||
...imageSize,
|
||||
metadata: {
|
||||
...node.metadata,
|
||||
content: item.content,
|
||||
storageKey: item.storageKey,
|
||||
naturalWidth: item.naturalWidth,
|
||||
naturalHeight: item.naturalHeight,
|
||||
bytes: item.bytes,
|
||||
mimeType: item.mimeType,
|
||||
images,
|
||||
primaryImageId: imageId,
|
||||
},
|
||||
};
|
||||
if (node.id === targetId)
|
||||
return {
|
||||
...node,
|
||||
position: { x: center.x - imageSize.width / 2, y: center.y - imageSize.height / 2 },
|
||||
width: imageSize.width,
|
||||
height: imageSize.height,
|
||||
metadata: { ...node.metadata, ...imageMetadata(uploaded) },
|
||||
};
|
||||
return node;
|
||||
});
|
||||
});
|
||||
}),
|
||||
);
|
||||
hasSuccess = true;
|
||||
if (isConfigNode) setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS, errorDetails: undefined } } : node)));
|
||||
return true;
|
||||
@@ -2238,14 +2152,11 @@ function InfiniteCanvasPage() {
|
||||
const errorDetails = error instanceof Error ? error.message : t("canvas.projectPage.generationFailed");
|
||||
if (!firstError) firstError = errorDetails;
|
||||
hasFailure = true;
|
||||
setNodes((prev) => prev.map((node) => (node.id === targetId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, errorDetails } } : node)));
|
||||
} finally {
|
||||
finishGenerationRequest(targetId, controller);
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
);
|
||||
if (count > 1) finishGenerationRequest(rootId, controller);
|
||||
if (rootId !== nodeId) finishGenerationRequest(rootId, controller);
|
||||
if (controller.signal.aborted) {
|
||||
setNodes((prev) => prev.map((node) => (node.id === nodeId && isConfigNode && node.metadata?.status === NODE_STATUS_LOADING ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_IDLE, errorDetails: undefined } } : node)));
|
||||
return;
|
||||
@@ -2257,10 +2168,8 @@ function InfiniteCanvasPage() {
|
||||
prev.map((node) =>
|
||||
node.id === nodeId && isConfigNode
|
||||
? { ...node, metadata: { ...node.metadata, status: hasSuccess ? NODE_STATUS_SUCCESS : NODE_STATUS_ERROR, errorDetails: hasSuccess ? undefined : t("canvas.projectPage.generationFailed") } }
|
||||
: node.id === nodeId && isEmptyImageNode
|
||||
? { ...node, metadata: { ...node.metadata, status: hasSuccess ? NODE_STATUS_SUCCESS : NODE_STATUS_ERROR, errorDetails: hasSuccess ? undefined : t("canvas.projectPage.generationFailed") } }
|
||||
: node.id === rootId && !hasSuccess && !targetIds.includes(node.id)
|
||||
? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, errorDetails: t("canvas.projectPage.allFailed") } }
|
||||
: node.id === rootId
|
||||
? { ...node, metadata: { ...node.metadata, status: hasSuccess ? NODE_STATUS_SUCCESS : NODE_STATUS_ERROR, errorDetails: hasSuccess ? undefined : t("canvas.projectPage.allFailed") } }
|
||||
: node,
|
||||
),
|
||||
);
|
||||
@@ -2450,8 +2359,7 @@ function InfiniteCanvasPage() {
|
||||
const handleRetryNode = useCallback(
|
||||
async (node: CanvasNodeData) => {
|
||||
const sourceNode = findRetrySourceNode(node.id, nodesRef.current, connectionsRef.current) || node;
|
||||
const batchRoot = node.metadata?.batchRootId ? nodesRef.current.find((item) => item.id === node.metadata?.batchRootId) : null;
|
||||
const savedImageMetadata = node.type === CanvasNodeType.Image ? { ...batchRoot?.metadata, ...node.metadata } : undefined;
|
||||
const savedImageMetadata = node.type === CanvasNodeType.Image ? node.metadata : undefined;
|
||||
const hasSavedImageMetadata = Boolean(savedImageMetadata?.generationType);
|
||||
const generationConfig =
|
||||
hasSavedImageMetadata && savedImageMetadata
|
||||
@@ -2478,7 +2386,7 @@ function InfiniteCanvasPage() {
|
||||
const generationType = savedImageMetadata?.generationType;
|
||||
const useReferenceImages = generationType ? generationType === "edit" : Boolean(context?.referenceImages.length);
|
||||
const retryReferenceImages =
|
||||
hasSavedImageMetadata && savedImageMetadata ? await resolveMetadataReferences(savedImageMetadata) : useReferenceImages ? (context?.referenceImages.length ? context.referenceImages : sourceNodeReferenceImages(batchRoot || sourceNode)) : [];
|
||||
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)));
|
||||
@@ -2546,6 +2454,15 @@ function InfiniteCanvasPage() {
|
||||
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(),
|
||||
content: uploadedImage.url,
|
||||
storageKey: uploadedImage.storageKey,
|
||||
naturalWidth: uploadedImage.width,
|
||||
naturalHeight: uploadedImage.height,
|
||||
bytes: uploadedImage.bytes,
|
||||
mimeType: uploadedImage.mimeType,
|
||||
};
|
||||
const generationMetadata = savedImageMetadata?.generationType
|
||||
? {
|
||||
generationType: savedImageMetadata.generationType,
|
||||
@@ -2565,7 +2482,14 @@ function InfiniteCanvasPage() {
|
||||
type: CanvasNodeType.Image,
|
||||
width: imageSize.width,
|
||||
height: imageSize.height,
|
||||
metadata: { ...item.metadata, ...imageMetadata(uploadedImage), prompt, ...generationMetadata },
|
||||
metadata: {
|
||||
...item.metadata,
|
||||
...imageMetadata(uploadedImage),
|
||||
images: item.metadata?.images?.map((current) => (current.id === retryImage.id ? retryImage : current)),
|
||||
primaryImageId: retryImage.id,
|
||||
prompt,
|
||||
...generationMetadata,
|
||||
},
|
||||
}
|
||||
: item,
|
||||
),
|
||||
@@ -2806,11 +2730,6 @@ function InfiniteCanvasPage() {
|
||||
>
|
||||
<svg className="absolute left-0 top-0 h-[10000px] w-[10000px] overflow-visible" style={{ pointerEvents: "none", transform: "translateZ(0)", zIndex: 0 }}>
|
||||
{connections
|
||||
.filter((connection) => {
|
||||
const from = nodeById.get(connection.fromNodeId);
|
||||
const to = nodeById.get(connection.toNodeId);
|
||||
return Boolean(from && to && !isHiddenBatchConnectionEndpoint(from, nodes) && !isHiddenBatchConnectionEndpoint(to, nodes));
|
||||
})
|
||||
.map((connection) => {
|
||||
const from = nodeById.get(connection.fromNodeId);
|
||||
const to = nodeById.get(connection.toNodeId);
|
||||
@@ -2851,14 +2770,9 @@ function InfiniteCanvasPage() {
|
||||
isConnecting={Boolean(connectingParams)}
|
||||
editRequestNonce={editingNodeId === node.id ? editRequestNonce : 0}
|
||||
showPanel={dialogNodeId === node.id && !selectionBox && !getNodeDefinition(node.type)?.hidePanel}
|
||||
batchCount={batchChildCountById.get(node.id) || 0}
|
||||
groupChildCount={groupChildCountById.get(node.id) || 0}
|
||||
isGroupDropTarget={dropTargetGroupId === node.id}
|
||||
batchExpanded={Boolean(node.metadata?.imageBatchExpanded)}
|
||||
batchClosing={Boolean(node.metadata?.batchRootId && collapsingBatchIds.has(node.metadata.batchRootId))}
|
||||
batchOpening={openingBatchIds.has(node.id)}
|
||||
batchRecovering={collapsingBatchIds.has(node.id)}
|
||||
batchMotion={batchMotionById.get(node.id)}
|
||||
batchExpanded={expandedImageNodeId === node.id}
|
||||
showImageInfo={showImageInfo}
|
||||
mentionReferences={mentionReferencesByNodeId.get(node.id) || EMPTY_REFERENCES}
|
||||
pluginHost={pluginHost}
|
||||
@@ -2911,7 +2825,7 @@ function InfiniteCanvasPage() {
|
||||
</InfiniteCanvas>
|
||||
|
||||
<CanvasNodeHoverToolbar
|
||||
node={isNodeDragging || isNodeResizing || nodeImageSettingsOpen ? null : toolbarNode}
|
||||
node={isNodeDragging || isNodeResizing || nodeImageSettingsOpen || expandedImageNodeId ? null : toolbarNode}
|
||||
viewport={viewport}
|
||||
extraTools={toolbarNode ? buildNodeToolbarItems(toolbarNode) : undefined}
|
||||
onKeep={keepNodeToolbar}
|
||||
|
||||
@@ -453,17 +453,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes canvas-batch-child-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translate(0, 0) rotate(0) scale(1);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translate(var(--batch-from-x), var(--batch-from-y)) rotate(var(--batch-from-rotate)) scale(0.72);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
::view-transition-old(root),
|
||||
|
||||
+11
-5
@@ -25,6 +25,16 @@ export type CanvasNodeStatus = "idle" | "success" | "loading" | "error";
|
||||
export type CanvasGenerationMode = "text" | "image" | "video" | "audio";
|
||||
export type CanvasImageGenerationType = "generation" | "edit";
|
||||
|
||||
export type CanvasNodeImage = {
|
||||
id: string;
|
||||
content: string;
|
||||
storageKey: string;
|
||||
naturalWidth: number;
|
||||
naturalHeight: number;
|
||||
bytes: number;
|
||||
mimeType: string;
|
||||
};
|
||||
|
||||
export type CanvasNodeMetadata = {
|
||||
content?: string;
|
||||
composerContent?: string;
|
||||
@@ -52,12 +62,8 @@ export type CanvasNodeMetadata = {
|
||||
naturalWidth?: number;
|
||||
naturalHeight?: number;
|
||||
freeResize?: boolean;
|
||||
isBatchRoot?: boolean;
|
||||
batchRootId?: string;
|
||||
batchChildIds?: string[];
|
||||
batchUsesReferenceImages?: boolean;
|
||||
images?: CanvasNodeImage[];
|
||||
primaryImageId?: string;
|
||||
imageBatchExpanded?: boolean;
|
||||
storageKey?: string;
|
||||
mimeType?: string;
|
||||
bytes?: number;
|
||||
|
||||
Reference in New Issue
Block a user