mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-07 01:54:24 +08:00
feat(canvas): add support for creating independent image nodes from expanded alternatives
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, Trash2, Video } from "lucide-react";
|
||||
import { ChevronRight, Copy, 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,7 @@ type CanvasNodeProps = {
|
||||
onTitleChange: (nodeId: string, title: string) => void;
|
||||
onToggleBatch?: (nodeId: string) => void;
|
||||
onSetBatchPrimary?: (nodeId: string, imageId: string) => void;
|
||||
onDuplicateBatchImage?: (node: CanvasNodeData, imageId: string) => void;
|
||||
onRetryBatchImage?: (node: CanvasNodeData, imageId: string) => void;
|
||||
onDeleteBatchImage?: (nodeId: string, imageId: string) => void;
|
||||
onRetry?: (node: CanvasNodeData) => void;
|
||||
@@ -72,6 +73,7 @@ type NodeContentRendererProps = {
|
||||
onGenerateImage?: (node: CanvasNodeData) => void;
|
||||
onToggleBatch?: () => void;
|
||||
onSetBatchPrimary?: (imageId: string) => void;
|
||||
onDuplicateBatchImage?: (imageId: string) => void;
|
||||
onRetryBatchImage?: (imageId: string) => void;
|
||||
onDeleteBatchImage?: (imageId: string) => void;
|
||||
groupChildCount: number;
|
||||
@@ -107,6 +109,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
onTitleChange,
|
||||
onToggleBatch,
|
||||
onSetBatchPrimary,
|
||||
onDuplicateBatchImage,
|
||||
onRetryBatchImage,
|
||||
onDeleteBatchImage,
|
||||
onRetry,
|
||||
@@ -404,6 +407,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
onGenerateImage={onGenerateImage}
|
||||
onToggleBatch={() => onToggleBatch?.(data.id)}
|
||||
onSetBatchPrimary={(imageId) => onSetBatchPrimary?.(data.id, imageId)}
|
||||
onDuplicateBatchImage={(imageId) => onDuplicateBatchImage?.(data, imageId)}
|
||||
onRetryBatchImage={(imageId) => onRetryBatchImage?.(data, imageId)}
|
||||
onDeleteBatchImage={(imageId) => onDeleteBatchImage?.(data.id, imageId)}
|
||||
groupChildCount={groupChildCount}
|
||||
@@ -578,6 +582,7 @@ function ImageNodeContent(props: NodeContentRendererProps) {
|
||||
batchExpanded={props.batchExpanded}
|
||||
onToggleBatch={props.onToggleBatch}
|
||||
onSetBatchPrimary={props.onSetBatchPrimary}
|
||||
onDuplicateBatchImage={props.onDuplicateBatchImage}
|
||||
onRetryBatchImage={props.onRetryBatchImage}
|
||||
onDeleteBatchImage={props.onDeleteBatchImage}
|
||||
/>
|
||||
@@ -633,6 +638,7 @@ function ImageContent({
|
||||
batchExpanded,
|
||||
onToggleBatch,
|
||||
onSetBatchPrimary,
|
||||
onDuplicateBatchImage,
|
||||
onRetryBatchImage,
|
||||
onDeleteBatchImage,
|
||||
}: {
|
||||
@@ -640,6 +646,7 @@ function ImageContent({
|
||||
batchExpanded: boolean;
|
||||
onToggleBatch?: () => void;
|
||||
onSetBatchPrimary?: (imageId: string) => void;
|
||||
onDuplicateBatchImage?: (imageId: string) => void;
|
||||
onRetryBatchImage?: (imageId: string) => void;
|
||||
onDeleteBatchImage?: (imageId: string) => void;
|
||||
}) {
|
||||
@@ -657,7 +664,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)} onRetry={() => onRetryBatchImage?.(image.id)} onDelete={() => onDeleteBatchImage?.(image.id)} />)
|
||||
.map((image, index) => <ExpandedImageCard key={image.id} node={node} image={image} index={index} onSetPrimary={() => onSetBatchPrimary?.(image.id)} onDuplicate={() => onDuplicateBatchImage?.(image.id)} onRetry={() => onRetryBatchImage?.(image.id)} onDelete={() => onDeleteBatchImage?.(image.id)} />)
|
||||
: null}
|
||||
<div className="h-full w-full overflow-hidden rounded-3xl">
|
||||
{primaryContent ? (
|
||||
@@ -694,7 +701,7 @@ function ImageContent({
|
||||
);
|
||||
}
|
||||
|
||||
function ExpandedImageCard({ node, image, index, onSetPrimary, onRetry, onDelete }: { node: CanvasNodeData; image: CanvasNodeImage; index: number; onSetPrimary: () => void; onRetry: () => void; onDelete: () => void }) {
|
||||
function ExpandedImageCard({ node, image, index, onSetPrimary, onDuplicate, onRetry, onDelete }: { node: CanvasNodeData; image: CanvasNodeImage; index: number; onSetPrimary: () => void; onDuplicate: () => void; onRetry: () => void; onDelete: () => void }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const { t } = useTranslation();
|
||||
const count = node.metadata?.images?.length || 0;
|
||||
@@ -729,18 +736,16 @@ function ExpandedImageCard({ node, image, index, onSetPrimary, onRetry, onDelete
|
||||
>
|
||||
{image.content ? <img src={image.content} alt={node.title} draggable={false} className="pointer-events-none h-full w-full select-none object-contain" /> : <ImageSlotStatus image={image} />}
|
||||
{image.content ? (
|
||||
<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 className="absolute right-3 top-3 flex items-center gap-1.5">
|
||||
<button type="button" className="flex h-9 items-center gap-1.5 rounded-xl border px-2.5 text-xs font-medium shadow-[0_8px_20px_rgba(15,23,42,.18)] backdrop-blur-md transition hover:scale-[1.02]" style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.activeText }} onClick={(event) => (event.stopPropagation(), onDuplicate())}>
|
||||
<Copy className="size-3.5" />
|
||||
{t("canvas.node.createCopy")}
|
||||
</button>
|
||||
<button type="button" className="flex h-9 items-center gap-1.5 rounded-xl border px-2.5 text-xs font-medium shadow-[0_8px_20px_rgba(15,23,42,.18)] backdrop-blur-md transition hover:scale-[1.02]" style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.activeText }} onClick={(event) => (event.stopPropagation(), onSetPrimary())}>
|
||||
<Star className="size-3.5" style={{ color: selectionBlue }} />
|
||||
{t("canvas.node.setPrimary")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{image.status === "error" ? <BatchImageFailureActions placement="right" onRetry={onRetry} onDelete={onDelete} /> : null}
|
||||
</div>
|
||||
|
||||
@@ -256,7 +256,7 @@ export default {
|
||||
},
|
||||
node: {
|
||||
node: "Node",
|
||||
untitled: "Untitled node", renameHint: "Double-click to rename the node", group: "Group", nodeCount: "{{count}} nodes", generating: "Generating", failed: "Generation failed", retry: "Retry", missingPlugin: "Plugin missing", missingPluginDescription: "The plugin for node type “{{type}}” is not installed or enabled", generateImage: "Generate image from text", generate: "Generate", editText: "Double-click to edit text", emptyImage: "Empty image node", emptyVideo: "Empty video node", emptyAudio: "Empty audio node", audio: "Audio", batchExpanded: "Image group expanded", batchCollapsed: "Image group collapsed", setPrimary: "Set as primary",
|
||||
untitled: "Untitled node", renameHint: "Double-click to rename the node", group: "Group", nodeCount: "{{count}} nodes", generating: "Generating", failed: "Generation failed", retry: "Retry", missingPlugin: "Plugin missing", missingPluginDescription: "The plugin for node type “{{type}}” is not installed or enabled", generateImage: "Generate image from text", generate: "Generate", editText: "Double-click to edit text", emptyImage: "Empty image node", emptyVideo: "Empty video node", emptyAudio: "Empty audio node", audio: "Audio", batchExpanded: "Image group expanded", batchCollapsed: "Image group collapsed", createCopy: "Create copy", setPrimary: "Set as primary",
|
||||
},
|
||||
sidePanel: {
|
||||
canvas: "Canvas", assets: "Assets", prompts: "Prompt Library", resize: "Resize left panel", elements: "Canvas elements", select: "Select", searchNodes: "Search nodes", focusNode: "Focus node", preview: "Large preview", noNodes: "No nodes on this canvas", clearAll: "Clear all", selected: "{{count}} selected", exporting: "Exporting selected elements…", exportName: "canvas-elements-{{count}}", exported: "Exported {{count}} elements", exportFailed: "Export failed. Try again.",
|
||||
|
||||
@@ -256,7 +256,7 @@ export default {
|
||||
},
|
||||
node: {
|
||||
node: "节点",
|
||||
untitled: "未命名节点", renameHint: "双击修改节点名称", group: "组", nodeCount: "{{count}} 个节点", generating: "生成中", failed: "生成失败", retry: "重试", missingPlugin: "缺少插件", missingPluginDescription: "节点类型“{{type}}”的插件未安装或未启用", generateImage: "用文本生图", generate: "生图", editText: "双击编辑文字", emptyImage: "空图片节点", emptyVideo: "空视频节点", emptyAudio: "空音频节点", audio: "音频", batchExpanded: "图片组已展开", batchCollapsed: "图片组已收起", setPrimary: "设为主图",
|
||||
untitled: "未命名节点", renameHint: "双击修改节点名称", group: "组", nodeCount: "{{count}} 个节点", generating: "生成中", failed: "生成失败", retry: "重试", missingPlugin: "缺少插件", missingPluginDescription: "节点类型“{{type}}”的插件未安装或未启用", generateImage: "用文本生图", generate: "生图", editText: "双击编辑文字", emptyImage: "空图片节点", emptyVideo: "空视频节点", emptyAudio: "空音频节点", audio: "音频", batchExpanded: "图片组已展开", batchCollapsed: "图片组已收起", createCopy: "创建副本", setPrimary: "设为主图",
|
||||
},
|
||||
sidePanel: {
|
||||
canvas: "画布", assets: "资产", prompts: "提示词库", resize: "调整左侧面板宽度", elements: "画布元素", select: "选择", searchNodes: "搜索节点", focusNode: "定位到节点", preview: "放大预览", noNodes: "画布暂无节点", clearAll: "取消全选", selected: "已选 {{count}}", exporting: "正在导出选中元素…", exportName: "画布元素-{{count}}个", exported: "已导出 {{count}} 个元素", exportFailed: "导出失败,请重试",
|
||||
|
||||
@@ -1485,6 +1485,41 @@ function InfiniteCanvasPage() {
|
||||
);
|
||||
}, []);
|
||||
|
||||
const duplicateBatchImage = useCallback((node: CanvasNodeData, imageId: string) => {
|
||||
const image = node.metadata?.images?.find((item) => item.id === imageId);
|
||||
if (!image?.content) return;
|
||||
const id = nanoid();
|
||||
const edge = Math.max(node.width, node.height);
|
||||
const size = fitNodeSize(image.naturalWidth, image.naturalHeight, edge, edge);
|
||||
const copy: CanvasNodeData = {
|
||||
id,
|
||||
type: CanvasNodeType.Image,
|
||||
title: node.title,
|
||||
position: { x: node.position.x + node.width * 2 + 96, y: node.position.y + node.height / 2 - size.height / 2 },
|
||||
...size,
|
||||
metadata: {
|
||||
content: image.content,
|
||||
storageKey: image.storageKey,
|
||||
naturalWidth: image.naturalWidth,
|
||||
naturalHeight: image.naturalHeight,
|
||||
bytes: image.bytes,
|
||||
mimeType: image.mimeType,
|
||||
status: NODE_STATUS_SUCCESS,
|
||||
prompt: node.metadata?.prompt,
|
||||
generationType: node.metadata?.generationType,
|
||||
model: node.metadata?.model,
|
||||
size: node.metadata?.size,
|
||||
quality: node.metadata?.quality,
|
||||
background: node.metadata?.background,
|
||||
references: node.metadata?.references,
|
||||
},
|
||||
};
|
||||
setNodes((prev) => [...prev, copy]);
|
||||
setSelectedNodeIds(new Set([id]));
|
||||
setSelectedConnectionId(null);
|
||||
setDialogNodeId(id);
|
||||
}, []);
|
||||
|
||||
const openTextEditor = useCallback((node: CanvasNodeData) => {
|
||||
if (node.type !== CanvasNodeType.Text) return;
|
||||
setSelectedNodeIds(new Set([node.id]));
|
||||
@@ -2817,6 +2852,7 @@ function InfiniteCanvasPage() {
|
||||
onTitleChange={handleNodeTitleChange}
|
||||
onToggleBatch={toggleBatchExpanded}
|
||||
onSetBatchPrimary={setBatchPrimary}
|
||||
onDuplicateBatchImage={duplicateBatchImage}
|
||||
onRetryBatchImage={retryBatchImage}
|
||||
onDeleteBatchImage={deleteBatchImage}
|
||||
onRetry={handleNodeRetry}
|
||||
|
||||
Reference in New Issue
Block a user