mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-06 01:14:36 +08:00
feat(canvas): add group node support with drag-and-drop functionality
This commit is contained in:
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
+ [新增] 画布新增组节点,支持节点拖入/拖出分组、拖拽高亮吸附和移动组时带动子节点。
|
||||||
|
|
||||||
## v0.6.0 - 2026-07-09
|
## v0.6.0 - 2026-07-09
|
||||||
|
|
||||||
+ [新增] 新增Codex App插件支持。
|
+ [新增] 新增Codex App插件支持。
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ description: 当前版本已实现但仍需人工验证的变更项
|
|||||||
|
|
||||||
# 待测试
|
# 待测试
|
||||||
|
|
||||||
|
- 画布组节点:工具栏新增“组”节点,普通节点拖入组区域会高亮目标组,松手后自动吸附进组;拖出组区域会解除归属,拖动组节点会一起移动组内子节点。
|
||||||
- GitHub Pages:新增版本 tag 触发的前端静态站点自动发布 workflow,需在仓库 Pages 设置中选择 GitHub Actions 并验证路由刷新回退。
|
- GitHub Pages:新增版本 tag 触发的前端静态站点自动发布 workflow,需在仓库 Pages 设置中选择 GitHub Actions 并验证路由刷新回退。
|
||||||
- 画布图片切图:行数 / 列数会生成可直接拖拽的等分切图线,并支持新增横向 / 纵向线、删除单条线、重置切图线和切片数量预览。
|
- 画布图片切图:行数 / 列数会生成可直接拖拽的等分切图线,并支持新增横向 / 纵向线、删除单条线、重置切图线和切片数量预览。
|
||||||
- Codex App 插件文档:新增插件安装教程,区分 AI 自动安装、手动安装和本仓库开发调试安装;同步优化插件 README 与技能说明。
|
- Codex App 插件文档:新增插件安装教程,区分 AI 自动安装、手动安装和本仓库开发调试安装;同步优化插件 README 与技能说明。
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export function Minimap({ nodes, viewport, viewportSize, onViewportChange }: { n
|
|||||||
>
|
>
|
||||||
{nodes.map((node) => {
|
{nodes.map((node) => {
|
||||||
const pos = toMinimap(node.position.x, node.position.y);
|
const pos = toMinimap(node.position.x, node.position.y);
|
||||||
const color = node.type === CanvasNodeType.Image ? "#10b981" : node.type === CanvasNodeType.Video ? "#f97316" : node.type === CanvasNodeType.Audio ? "#a855f7" : node.type === CanvasNodeType.Config ? "#60a5fa" : theme.node.muted;
|
const color = node.type === CanvasNodeType.Image ? "#10b981" : node.type === CanvasNodeType.Video ? "#f97316" : node.type === CanvasNodeType.Audio ? "#a855f7" : node.type === CanvasNodeType.Config ? "#60a5fa" : node.type === CanvasNodeType.Group ? "#94a3b8" : theme.node.muted;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={node.id}
|
key={node.id}
|
||||||
|
|||||||
@@ -254,7 +254,7 @@ export function CanvasNodeInfoModal({ node, open, onClose }: { node: CanvasNodeD
|
|||||||
{view === "info" ? (
|
{view === "info" ? (
|
||||||
<div className="thin-scrollbar h-full space-y-3 overflow-auto pr-1">
|
<div className="thin-scrollbar h-full space-y-3 overflow-auto pr-1">
|
||||||
<InfoRow label="ID" value={node.id} />
|
<InfoRow label="ID" value={node.id} />
|
||||||
<InfoRow label="类型" value={node.type === CanvasNodeType.Text ? "文本" : node.type === CanvasNodeType.Image ? "图片" : node.type === CanvasNodeType.Video ? "视频" : node.type === CanvasNodeType.Audio ? "音频" : "生成配置"} />
|
<InfoRow label="类型" value={node.type === CanvasNodeType.Text ? "文本" : node.type === CanvasNodeType.Image ? "图片" : node.type === CanvasNodeType.Video ? "视频" : node.type === CanvasNodeType.Audio ? "音频" : node.type === CanvasNodeType.Group ? "组" : "生成配置"} />
|
||||||
<InfoRow label="尺寸" value={`${Math.round(node.width)} x ${Math.round(node.height)}`} />
|
<InfoRow label="尺寸" value={`${Math.round(node.width)} x ${Math.round(node.height)}`} />
|
||||||
<InfoRow label="位置" value={`${Math.round(node.position.x)}, ${Math.round(node.position.y)}`} />
|
<InfoRow label="位置" value={`${Math.round(node.position.x)}, ${Math.round(node.position.y)}`} />
|
||||||
<InfoRow label="状态" value={node.metadata?.status || "idle"} />
|
<InfoRow label="状态" value={node.metadata?.status || "idle"} />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { ChevronRight, Image as ImageIcon, Music2, RefreshCw, Star, Video } from "lucide-react";
|
import { ChevronRight, Group, Image as ImageIcon, Music2, RefreshCw, Star, Video } from "lucide-react";
|
||||||
|
|
||||||
import { canvasThemes } from "@/lib/canvas-theme";
|
import { canvasThemes } from "@/lib/canvas-theme";
|
||||||
import { formatBytes } from "@/lib/image-utils";
|
import { formatBytes } from "@/lib/image-utils";
|
||||||
@@ -28,6 +28,8 @@ type CanvasNodeProps = {
|
|||||||
renderPanel?: (node: CanvasNodeData) => ReactNode;
|
renderPanel?: (node: CanvasNodeData) => ReactNode;
|
||||||
renderNodeContent?: (node: CanvasNodeData) => ReactNode;
|
renderNodeContent?: (node: CanvasNodeData) => ReactNode;
|
||||||
batchCount?: number;
|
batchCount?: number;
|
||||||
|
groupChildCount?: number;
|
||||||
|
isGroupDropTarget?: boolean;
|
||||||
batchExpanded?: boolean;
|
batchExpanded?: boolean;
|
||||||
batchClosing?: boolean;
|
batchClosing?: boolean;
|
||||||
batchOpening?: boolean;
|
batchOpening?: boolean;
|
||||||
@@ -65,6 +67,7 @@ type NodeContentRendererProps = {
|
|||||||
onGenerateImage?: (node: CanvasNodeData) => void;
|
onGenerateImage?: (node: CanvasNodeData) => void;
|
||||||
onToggleBatch?: () => void;
|
onToggleBatch?: () => void;
|
||||||
onSetBatchPrimary?: () => void;
|
onSetBatchPrimary?: () => void;
|
||||||
|
groupChildCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CanvasNode = React.memo(function CanvasNode({
|
export const CanvasNode = React.memo(function CanvasNode({
|
||||||
@@ -83,6 +86,8 @@ export const CanvasNode = React.memo(function CanvasNode({
|
|||||||
renderPanel,
|
renderPanel,
|
||||||
renderNodeContent,
|
renderNodeContent,
|
||||||
batchCount = 0,
|
batchCount = 0,
|
||||||
|
groupChildCount = 0,
|
||||||
|
isGroupDropTarget = false,
|
||||||
batchExpanded = false,
|
batchExpanded = false,
|
||||||
batchClosing = false,
|
batchClosing = false,
|
||||||
batchOpening = false,
|
batchOpening = false,
|
||||||
@@ -107,6 +112,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
|||||||
const hasImageContent = data.type === CanvasNodeType.Image && Boolean(data.metadata?.content);
|
const hasImageContent = data.type === CanvasNodeType.Image && Boolean(data.metadata?.content);
|
||||||
const hasVideoContent = data.type === CanvasNodeType.Video && Boolean(data.metadata?.content);
|
const hasVideoContent = data.type === CanvasNodeType.Video && Boolean(data.metadata?.content);
|
||||||
const hasAudioContent = data.type === CanvasNodeType.Audio && 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 isBatchRoot = data.type === CanvasNodeType.Image && Boolean(data.metadata?.isBatchRoot) && batchCount > 1;
|
||||||
const isBatchChild = data.type === CanvasNodeType.Image && Boolean(data.metadata?.batchRootId);
|
const isBatchChild = data.type === CanvasNodeType.Image && Boolean(data.metadata?.batchRootId);
|
||||||
const isActive = isConnectionTarget || isSelected || isFocusRelated;
|
const isActive = isConnectionTarget || isSelected || isFocusRelated;
|
||||||
@@ -237,7 +243,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-node-id={data.id}
|
data-node-id={data.id}
|
||||||
className={`node-element absolute flex select-none flex-col transition-shadow duration-200 ${isSelected ? "z-50" : "z-10"}`}
|
className={`node-element absolute flex select-none flex-col transition-shadow duration-200 ${isSelected ? "z-50" : isGroup ? "z-[5]" : "z-10"}`}
|
||||||
style={{
|
style={{
|
||||||
transform: `translate(${data.position.x}px, ${data.position.y}px)`,
|
transform: `translate(${data.position.x}px, ${data.position.y}px)`,
|
||||||
width: data.width,
|
width: data.width,
|
||||||
@@ -258,9 +264,10 @@ export const CanvasNode = React.memo(function CanvasNode({
|
|||||||
<div
|
<div
|
||||||
className="relative h-full w-full overflow-visible rounded-3xl border-2"
|
className="relative h-full w-full overflow-visible rounded-3xl border-2"
|
||||||
style={{
|
style={{
|
||||||
background: hasImageContent || hasVideoContent ? "transparent" : theme.node.fill,
|
background: isGroup ? `${theme.toolbar.panel}66` : hasImageContent || hasVideoContent ? "transparent" : theme.node.fill,
|
||||||
borderColor: hasImageContent ? imageBorderColor : isActive ? selectionBlue : isRelated ? theme.node.muted : theme.node.stroke,
|
borderColor: isGroup ? (isGroupDropTarget || isActive ? selectionBlue : theme.node.stroke) : hasImageContent ? imageBorderColor : isActive ? selectionBlue : isRelated ? theme.node.muted : theme.node.stroke,
|
||||||
boxShadow: 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,
|
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,
|
||||||
}}
|
}}
|
||||||
onMouseDown={(event) => onMouseDown(event, data.id)}
|
onMouseDown={(event) => onMouseDown(event, data.id)}
|
||||||
onDoubleClick={(event) => {
|
onDoubleClick={(event) => {
|
||||||
@@ -283,7 +290,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
|||||||
className={`relative flex h-full w-full items-center justify-center rounded-[inherit] ${isBatchRoot ? "overflow-visible" : "overflow-hidden"}`}
|
className={`relative flex h-full w-full items-center justify-center rounded-[inherit] ${isBatchRoot ? "overflow-visible" : "overflow-hidden"}`}
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
background: hasImageContent || hasVideoContent ? "transparent" : theme.node.fill,
|
background: isGroup ? "transparent" : hasImageContent || hasVideoContent ? "transparent" : theme.node.fill,
|
||||||
"--batch-from-x": `${batchMotion?.x || 0}px`,
|
"--batch-from-x": `${batchMotion?.x || 0}px`,
|
||||||
"--batch-from-y": `${batchMotion?.y || 0}px`,
|
"--batch-from-y": `${batchMotion?.y || 0}px`,
|
||||||
"--batch-from-rotate": `${6 + (batchMotion?.index || 0) * 4}deg`,
|
"--batch-from-rotate": `${6 + (batchMotion?.index || 0) * 4}deg`,
|
||||||
@@ -310,13 +317,14 @@ export const CanvasNode = React.memo(function CanvasNode({
|
|||||||
onGenerateImage={onGenerateImage}
|
onGenerateImage={onGenerateImage}
|
||||||
onToggleBatch={() => onToggleBatch?.(data.id)}
|
onToggleBatch={() => onToggleBatch?.(data.id)}
|
||||||
onSetBatchPrimary={() => onSetBatchPrimary?.(data)}
|
onSetBatchPrimary={() => onSetBatchPrimary?.(data)}
|
||||||
|
groupChildCount={groupChildCount}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showImageInfo && hasImageContent ? <ImageInfoBar node={data} /> : null}
|
{showImageInfo && hasImageContent ? <ImageInfoBar node={data} /> : null}
|
||||||
{resourceLabel ? <ResourceLabelBadge reference={resourceLabel} /> : null}
|
{resourceLabel ? <ResourceLabelBadge reference={resourceLabel} /> : null}
|
||||||
|
|
||||||
{!hasImageContent && !hasVideoContent && !hasAudioContent ? <div className="pointer-events-none absolute inset-x-0 bottom-0 h-12" style={{ background: `linear-gradient(to top, ${theme.canvas.background}66, transparent)` }} /> : null}
|
{!isGroup && !hasImageContent && !hasVideoContent && !hasAudioContent ? <div className="pointer-events-none absolute inset-x-0 bottom-0 h-12" style={{ background: `linear-gradient(to top, ${theme.canvas.background}66, transparent)` }} /> : null}
|
||||||
|
|
||||||
<ResizeHandle corner="top-left" onMouseDown={handleResizeMouseDown} />
|
<ResizeHandle corner="top-left" onMouseDown={handleResizeMouseDown} />
|
||||||
<ResizeHandle corner="top-right" onMouseDown={handleResizeMouseDown} />
|
<ResizeHandle corner="top-right" onMouseDown={handleResizeMouseDown} />
|
||||||
@@ -324,10 +332,10 @@ export const CanvasNode = React.memo(function CanvasNode({
|
|||||||
<ResizeHandle corner="bottom-right" onMouseDown={handleResizeMouseDown} />
|
<ResizeHandle corner="bottom-right" onMouseDown={handleResizeMouseDown} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConnectionHandleDot side="left" visible={hovered || isSelected || isConnecting} onMouseDown={(event) => onConnectStart(event, data.id, "target")} />
|
{!isGroup ? <ConnectionHandleDot side="left" visible={hovered || isSelected || isConnecting} onMouseDown={(event) => onConnectStart(event, data.id, "target")} /> : null}
|
||||||
<ConnectionHandleDot side="right" visible={data.type !== CanvasNodeType.Config && (hovered || isSelected || isConnecting)} onMouseDown={(event) => onConnectStart(event, data.id, "source")} />
|
{!isGroup ? <ConnectionHandleDot side="right" visible={data.type !== CanvasNodeType.Config && (hovered || isSelected || isConnecting)} onMouseDown={(event) => onConnectStart(event, data.id, "source")} /> : null}
|
||||||
|
|
||||||
{showPanel && renderPanel ? <div className="absolute left-1/2 top-full z-[70] w-[500px] -translate-x-1/2 pt-4">{renderPanel(data)}</div> : null}
|
{showPanel && !isGroup && renderPanel ? <div className="absolute left-1/2 top-full z-[70] w-[500px] -translate-x-1/2 pt-4">{renderPanel(data)}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -348,8 +356,26 @@ const nodeContentRenderers = {
|
|||||||
[CanvasNodeType.Config]: EmptyImageContent,
|
[CanvasNodeType.Config]: EmptyImageContent,
|
||||||
[CanvasNodeType.Video]: VideoNodeContent,
|
[CanvasNodeType.Video]: VideoNodeContent,
|
||||||
[CanvasNodeType.Audio]: AudioNodeContent,
|
[CanvasNodeType.Audio]: AudioNodeContent,
|
||||||
|
[CanvasNodeType.Group]: GroupNodeContent,
|
||||||
} satisfies Record<CanvasNodeType, (props: NodeContentRendererProps) => ReactNode>;
|
} satisfies Record<CanvasNodeType, (props: NodeContentRendererProps) => ReactNode>;
|
||||||
|
|
||||||
|
function GroupNodeContent({ node, theme, groupChildCount }: NodeContentRendererProps) {
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none flex h-full w-full flex-col p-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold" style={{ color: theme.node.text }}>
|
||||||
|
<span className="grid size-8 place-items-center rounded-xl" style={{ background: theme.toolbar.activeBg, color: theme.node.muted }}>
|
||||||
|
<Group className="size-4" />
|
||||||
|
</span>
|
||||||
|
<span>{node.title || "组"}</span>
|
||||||
|
<span className="ml-auto rounded-full px-2 py-1 text-[11px] font-medium" style={{ background: theme.node.fill, color: theme.node.muted }}>
|
||||||
|
{groupChildCount} 个节点
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex-1 rounded-2xl border border-dashed" style={{ borderColor: theme.node.stroke, background: `${theme.node.fill}55` }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function LoadingContent({ theme }: Pick<NodeContentRendererProps, "theme">) {
|
function LoadingContent({ theme }: Pick<NodeContentRendererProps, "theme">) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3" style={{ color: theme.node.activeStroke }}>
|
<div className="flex h-full w-full flex-col items-center justify-center gap-3" style={{ color: theme.node.activeStroke }}>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactNode, RefObject } from "react";
|
import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactNode, RefObject } from "react";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { Button, Segmented, Switch } from "antd";
|
import { Button, Segmented, Switch } from "antd";
|
||||||
import { CircleDot, Eraser, FolderOpen, Grid2x2, Hand, Image as ImageIcon, Info, Moon, Music2, Palette, Redo2, Settings2, Square, Sun, Trash2, Type, Undo2, Upload, Video } from "lucide-react";
|
import { CircleDot, Eraser, FolderOpen, Grid2x2, Group, Hand, Image as ImageIcon, Info, Moon, Music2, Palette, Redo2, Settings2, Square, Sun, Trash2, Type, Undo2, Upload, Video } from "lucide-react";
|
||||||
|
|
||||||
import { canvasThemes, type CanvasBackgroundMode, type CanvasColorTheme, type CanvasTheme } from "@/lib/canvas-theme";
|
import { canvasThemes, type CanvasBackgroundMode, type CanvasColorTheme, type CanvasTheme } from "@/lib/canvas-theme";
|
||||||
import { useThemeStore } from "@/stores/use-theme-store";
|
import { useThemeStore } from "@/stores/use-theme-store";
|
||||||
@@ -18,6 +18,7 @@ export function CanvasToolbar({
|
|||||||
onAddAudio,
|
onAddAudio,
|
||||||
onAddText,
|
onAddText,
|
||||||
onAddConfig,
|
onAddConfig,
|
||||||
|
onAddGroup,
|
||||||
onUndo,
|
onUndo,
|
||||||
onRedo,
|
onRedo,
|
||||||
onUpload,
|
onUpload,
|
||||||
@@ -38,6 +39,7 @@ export function CanvasToolbar({
|
|||||||
onAddAudio: () => void;
|
onAddAudio: () => void;
|
||||||
onAddText: () => void;
|
onAddText: () => void;
|
||||||
onAddConfig: () => void;
|
onAddConfig: () => void;
|
||||||
|
onAddGroup: () => void;
|
||||||
onUndo: () => void;
|
onUndo: () => void;
|
||||||
onRedo: () => void;
|
onRedo: () => void;
|
||||||
onUpload: () => void;
|
onUpload: () => void;
|
||||||
@@ -90,6 +92,9 @@ export function CanvasToolbar({
|
|||||||
<ToolbarButton id="tool-config" label="生成配置" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddConfig}>
|
<ToolbarButton id="tool-config" label="生成配置" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddConfig}>
|
||||||
<Settings2 className="size-4.5" />
|
<Settings2 className="size-4.5" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
|
<ToolbarButton id="tool-group" label="组" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddGroup}>
|
||||||
|
<Group className="size-4.5" />
|
||||||
|
</ToolbarButton>
|
||||||
<ToolbarButton id="tool-upload" label="上传素材" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onUpload}>
|
<ToolbarButton id="tool-upload" label="上传素材" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onUpload}>
|
||||||
<Upload className="size-4.5" />
|
<Upload className="size-4.5" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
@@ -281,6 +286,7 @@ function toolLabel(id: string) {
|
|||||||
if (id === "tool-video") return "视频";
|
if (id === "tool-video") return "视频";
|
||||||
if (id === "tool-audio") return "音频";
|
if (id === "tool-audio") return "音频";
|
||||||
if (id === "tool-config") return "生成配置";
|
if (id === "tool-config") return "生成配置";
|
||||||
|
if (id === "tool-group") return "组";
|
||||||
if (id === "tool-upload") return "上传素材";
|
if (id === "tool-upload") return "上传素材";
|
||||||
if (id === "tool-assets") return "我的素材";
|
if (id === "tool-assets") return "我的素材";
|
||||||
if (id === "tool-style") return "画布外观";
|
if (id === "tool-style") return "画布外观";
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const NODE_DEFAULT_SIZE = {
|
|||||||
[CanvasNodeType.Config]: { width: 340, height: 240, title: "生成配置" },
|
[CanvasNodeType.Config]: { width: 340, height: 240, title: "生成配置" },
|
||||||
[CanvasNodeType.Video]: { width: 420, height: 236, title: "Video" },
|
[CanvasNodeType.Video]: { width: 420, height: 236, title: "Video" },
|
||||||
[CanvasNodeType.Audio]: { width: 340, height: 120, title: "Audio" },
|
[CanvasNodeType.Audio]: { width: 340, height: 120, title: "Audio" },
|
||||||
|
[CanvasNodeType.Group]: { width: 760, height: 480, title: "组" },
|
||||||
} satisfies Record<CanvasNodeType, { width: number; height: number; title: string }>;
|
} satisfies Record<CanvasNodeType, { width: number; height: number; title: string }>;
|
||||||
|
|
||||||
export const NODE_SPECS = {
|
export const NODE_SPECS = {
|
||||||
@@ -37,6 +38,10 @@ export const NODE_SPECS = {
|
|||||||
...NODE_DEFAULT_SIZE[CanvasNodeType.Audio],
|
...NODE_DEFAULT_SIZE[CanvasNodeType.Audio],
|
||||||
metadata: { content: "", status: "idle" },
|
metadata: { content: "", status: "idle" },
|
||||||
},
|
},
|
||||||
|
[CanvasNodeType.Group]: {
|
||||||
|
...NODE_DEFAULT_SIZE[CanvasNodeType.Group],
|
||||||
|
metadata: { status: "idle" },
|
||||||
|
},
|
||||||
} satisfies Record<CanvasNodeType, CanvasNodeSpec>;
|
} satisfies Record<CanvasNodeType, CanvasNodeSpec>;
|
||||||
|
|
||||||
export function getNodeSpec(type: CanvasNodeType) {
|
export function getNodeSpec(type: CanvasNodeType) {
|
||||||
|
|||||||
@@ -312,6 +312,7 @@ function InfiniteCanvasPage() {
|
|||||||
const [collapsingBatchIds, setCollapsingBatchIds] = useState<Set<string>>(new Set());
|
const [collapsingBatchIds, setCollapsingBatchIds] = useState<Set<string>>(new Set());
|
||||||
const [openingBatchIds, setOpeningBatchIds] = useState<Set<string>>(new Set());
|
const [openingBatchIds, setOpeningBatchIds] = useState<Set<string>>(new Set());
|
||||||
const [isNodeDragging, setIsNodeDragging] = useState(false);
|
const [isNodeDragging, setIsNodeDragging] = useState(false);
|
||||||
|
const [dropTargetGroupId, setDropTargetGroupId] = useState<string | null>(null);
|
||||||
|
|
||||||
const nodesRef = useRef(nodes);
|
const nodesRef = useRef(nodes);
|
||||||
const connectionsRef = useRef(connections);
|
const connectionsRef = useRef(connections);
|
||||||
@@ -598,7 +599,7 @@ function InfiniteCanvasPage() {
|
|||||||
setConnections((prev) => [...prev, { id: nanoid(), ...connection }]);
|
setConnections((prev) => [...prev, { id: nanoid(), ...connection }]);
|
||||||
setSelectedNodeIds(new Set([newNode.id]));
|
setSelectedNodeIds(new Set([newNode.id]));
|
||||||
setSelectedConnectionId(null);
|
setSelectedConnectionId(null);
|
||||||
if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Audio) setDialogNodeId(newNode.id);
|
if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Audio && type !== CanvasNodeType.Group) setDialogNodeId(newNode.id);
|
||||||
setPendingConnectionCreate(null);
|
setPendingConnectionCreate(null);
|
||||||
setConnecting(null);
|
setConnecting(null);
|
||||||
},
|
},
|
||||||
@@ -679,6 +680,14 @@ function InfiniteCanvasPage() {
|
|||||||
});
|
});
|
||||||
return map;
|
return map;
|
||||||
}, [nodes]);
|
}, [nodes]);
|
||||||
|
const groupChildCountById = useMemo(() => {
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
const groupId = node.metadata?.groupId;
|
||||||
|
if (groupId) map.set(groupId, (map.get(groupId) || 0) + 1);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [nodes]);
|
||||||
const batchMotionById = useMemo(() => {
|
const batchMotionById = useMemo(() => {
|
||||||
const map = new Map<string, { x: number; y: number; index: number }>();
|
const map = new Map<string, { x: number; y: number; index: number }>();
|
||||||
nodes.forEach((node) => {
|
nodes.forEach((node) => {
|
||||||
@@ -790,7 +799,7 @@ function InfiniteCanvasPage() {
|
|||||||
setNodes((prev) => [...prev, newNode]);
|
setNodes((prev) => [...prev, newNode]);
|
||||||
setSelectedNodeIds(new Set([newNode.id]));
|
setSelectedNodeIds(new Set([newNode.id]));
|
||||||
setSelectedConnectionId(null);
|
setSelectedConnectionId(null);
|
||||||
if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Audio) setDialogNodeId(newNode.id);
|
if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Audio && type !== CanvasNodeType.Group) setDialogNodeId(newNode.id);
|
||||||
},
|
},
|
||||||
[effectiveConfig.canvasImageCount, effectiveConfig.count, effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, getCanvasCenter],
|
[effectiveConfig.canvasImageCount, effectiveConfig.count, effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, getCanvasCenter],
|
||||||
);
|
);
|
||||||
@@ -805,6 +814,8 @@ function InfiniteCanvasPage() {
|
|||||||
setNodes((prev) => {
|
setNodes((prev) => {
|
||||||
const next = prev.filter((node) => !allIds.has(node.id));
|
const next = prev.filter((node) => !allIds.has(node.id));
|
||||||
return next.map((node) => {
|
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));
|
const childIds = node.metadata?.batchChildIds?.filter((childId) => !allIds.has(childId));
|
||||||
if (!node.metadata?.isBatchRoot || childIds?.length === node.metadata.batchChildIds?.length) return node;
|
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 primaryImageId = childIds?.includes(node.metadata.primaryImageId || "") ? node.metadata.primaryImageId : childIds?.[0];
|
||||||
@@ -888,7 +899,7 @@ function InfiniteCanvasPage() {
|
|||||||
setNodes((prev) => [...prev, next]);
|
setNodes((prev) => [...prev, next]);
|
||||||
setSelectedNodeIds(new Set([id]));
|
setSelectedNodeIds(new Set([id]));
|
||||||
setSelectedConnectionId(null);
|
setSelectedConnectionId(null);
|
||||||
setDialogNodeId(id);
|
if (next.type !== CanvasNodeType.Group) setDialogNodeId(id);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const copySelectedNodes = useCallback(() => {
|
const copySelectedNodes = useCallback(() => {
|
||||||
@@ -943,6 +954,12 @@ function InfiniteCanvasPage() {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const pastedNodes = nextNodes.map((node) => {
|
||||||
|
const groupId = node.metadata?.groupId;
|
||||||
|
if (!groupId) return node;
|
||||||
|
return { ...node, metadata: { ...node.metadata, groupId: idMap.get(groupId) } };
|
||||||
|
});
|
||||||
|
|
||||||
const nextConnections = clipboard.connections.flatMap((connection, index) => {
|
const nextConnections = clipboard.connections.flatMap((connection, index) => {
|
||||||
const fromNodeId = idMap.get(connection.fromNodeId);
|
const fromNodeId = idMap.get(connection.fromNodeId);
|
||||||
const toNodeId = idMap.get(connection.toNodeId);
|
const toNodeId = idMap.get(connection.toNodeId);
|
||||||
@@ -957,12 +974,12 @@ function InfiniteCanvasPage() {
|
|||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
setNodes((prev) => [...prev, ...nextNodes]);
|
setNodes((prev) => [...prev, ...pastedNodes]);
|
||||||
setConnections((prev) => [...prev, ...nextConnections]);
|
setConnections((prev) => [...prev, ...nextConnections]);
|
||||||
setSelectedNodeIds(new Set(nextNodes.map((node) => node.id)));
|
setSelectedNodeIds(new Set(pastedNodes.map((node) => node.id)));
|
||||||
setSelectedConnectionId(null);
|
setSelectedConnectionId(null);
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
setDialogNodeId(nextNodes[0]?.id || null);
|
setDialogNodeId(pastedNodes[0]?.type === CanvasNodeType.Group ? null : pastedNodes[0]?.id || null);
|
||||||
return true;
|
return true;
|
||||||
}, [getCanvasCenter]);
|
}, [getCanvasCenter]);
|
||||||
|
|
||||||
@@ -1091,7 +1108,13 @@ function InfiniteCanvasPage() {
|
|||||||
setSelectedNodeIds(nextSelected);
|
setSelectedNodeIds(nextSelected);
|
||||||
const dragIds = new Set(nextSelected);
|
const dragIds = new Set(nextSelected);
|
||||||
currentNodes.forEach((node) => {
|
currentNodes.forEach((node) => {
|
||||||
if (nextSelected.has(node.id)) node.metadata?.batchChildIds?.forEach((childId) => dragIds.add(childId));
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
dragRef.current = {
|
dragRef.current = {
|
||||||
isDraggingNode: true,
|
isDraggingNode: true,
|
||||||
@@ -1122,14 +1145,23 @@ function InfiniteCanvasPage() {
|
|||||||
historyPausedRef.current = false;
|
historyPausedRef.current = false;
|
||||||
nodeDraggingRef.current = false;
|
nodeDraggingRef.current = false;
|
||||||
setIsNodeDragging(false);
|
setIsNodeDragging(false);
|
||||||
|
setDropTargetGroupId(null);
|
||||||
if (dragRef.current.hasMoved && clientX != null && clientY != null) {
|
if (dragRef.current.hasMoved && clientX != null && clientY != null) {
|
||||||
setNodes((prev) =>
|
const movedIds = new Set(initialPositions.map((item) => item.id));
|
||||||
prev.map((node) => {
|
setNodes((prev) => {
|
||||||
|
const moved = prev.map((node) => {
|
||||||
const initial = initialPositions.find((item) => item.id === node.id);
|
const initial = initialPositions.find((item) => item.id === node.id);
|
||||||
if (!initial) return node;
|
return initial ? { ...node, position: { x: initial.x + dx, y: initial.y + dy } } : node;
|
||||||
return { ...node, position: { x: initial.x + dx, y: initial.y + dy } };
|
});
|
||||||
}),
|
const targetGroup = findGroupDropTarget(movedIds, moved);
|
||||||
);
|
if (targetGroup) return snapNodesIntoGroup(movedIds, moved, targetGroup);
|
||||||
|
return moved.map((node) => {
|
||||||
|
if (!movedIds.has(node.id) || node.type === CanvasNodeType.Group) return node;
|
||||||
|
const groupId = findContainingGroupId(node, moved);
|
||||||
|
if (node.metadata?.groupId === groupId) return node;
|
||||||
|
return { ...node, metadata: { ...node.metadata, groupId } };
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
dragRef.current.isDraggingNode = false;
|
dragRef.current.isDraggingNode = false;
|
||||||
@@ -1139,7 +1171,7 @@ function InfiniteCanvasPage() {
|
|||||||
const clickedNode = nodesRef.current.find((node) => node.id === clickedNodeId);
|
const clickedNode = nodesRef.current.find((node) => node.id === clickedNodeId);
|
||||||
if (clickedNode?.type === CanvasNodeType.Text) {
|
if (clickedNode?.type === CanvasNodeType.Text) {
|
||||||
setDialogNodeId((current) => (current === clickedNodeId ? current : null));
|
setDialogNodeId((current) => (current === clickedNodeId ? current : null));
|
||||||
} else {
|
} else if (clickedNode?.type !== CanvasNodeType.Group) {
|
||||||
setDialogNodeId(clickedNodeId);
|
setDialogNodeId(clickedNodeId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1157,6 +1189,13 @@ function InfiniteCanvasPage() {
|
|||||||
dragRef.current.hasMoved = true;
|
dragRef.current.hasMoved = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const movedIds = new Set(initialPositions.map((item) => item.id));
|
||||||
|
const previewNodes = nodesRef.current.map((node) => {
|
||||||
|
const initial = initialPositions.find((item) => item.id === node.id);
|
||||||
|
return initial ? { ...node, position: { x: initial.x + dx, y: initial.y + dy } } : node;
|
||||||
|
});
|
||||||
|
setDropTargetGroupId(findGroupDropTarget(movedIds, previewNodes)?.id || null);
|
||||||
|
|
||||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||||
rafRef.current = requestAnimationFrame(() => {
|
rafRef.current = requestAnimationFrame(() => {
|
||||||
setNodes((prev) =>
|
setNodes((prev) =>
|
||||||
@@ -2567,6 +2606,8 @@ function InfiniteCanvasPage() {
|
|||||||
editRequestNonce={editingNodeId === node.id ? editRequestNonce : 0}
|
editRequestNonce={editingNodeId === node.id ? editRequestNonce : 0}
|
||||||
showPanel={dialogNodeId === node.id && !selectionBox}
|
showPanel={dialogNodeId === node.id && !selectionBox}
|
||||||
batchCount={batchChildCountById.get(node.id) || 0}
|
batchCount={batchChildCountById.get(node.id) || 0}
|
||||||
|
groupChildCount={groupChildCountById.get(node.id) || 0}
|
||||||
|
isGroupDropTarget={dropTargetGroupId === node.id}
|
||||||
batchExpanded={Boolean(node.metadata?.imageBatchExpanded)}
|
batchExpanded={Boolean(node.metadata?.imageBatchExpanded)}
|
||||||
batchClosing={Boolean(node.metadata?.batchRootId && collapsingBatchIds.has(node.metadata.batchRootId))}
|
batchClosing={Boolean(node.metadata?.batchRootId && collapsingBatchIds.has(node.metadata.batchRootId))}
|
||||||
batchOpening={openingBatchIds.has(node.id)}
|
batchOpening={openingBatchIds.has(node.id)}
|
||||||
@@ -2693,6 +2734,7 @@ function InfiniteCanvasPage() {
|
|||||||
onAddAudio={() => createNode(CanvasNodeType.Audio)}
|
onAddAudio={() => createNode(CanvasNodeType.Audio)}
|
||||||
onAddText={() => createNode(CanvasNodeType.Text)}
|
onAddText={() => createNode(CanvasNodeType.Text)}
|
||||||
onAddConfig={() => createNode(CanvasNodeType.Config)}
|
onAddConfig={() => createNode(CanvasNodeType.Config)}
|
||||||
|
onAddGroup={() => createNode(CanvasNodeType.Group)}
|
||||||
onUndo={undoCanvas}
|
onUndo={undoCanvas}
|
||||||
onRedo={redoCanvas}
|
onRedo={redoCanvas}
|
||||||
onUpload={() => handleUploadRequest()}
|
onUpload={() => handleUploadRequest()}
|
||||||
@@ -3122,6 +3164,63 @@ function applyNodeConfigPatch(node: CanvasNodeData, patch: Partial<CanvasNodeDat
|
|||||||
return size && (node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Video) ? { ...next, ...size, position: { x: node.position.x + node.width / 2 - size.width / 2, y: node.position.y + node.height / 2 - size.height / 2 } } : next;
|
return size && (node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Video) ? { ...next, ...size, position: { x: node.position.x + node.width / 2 - size.width / 2, y: node.position.y + node.height / 2 - size.height / 2 } } : next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findGroupDropTarget(movedIds: Set<string>, nodes: CanvasNodeData[]) {
|
||||||
|
if (nodes.some((node) => movedIds.has(node.id) && node.type === CanvasNodeType.Group)) return null;
|
||||||
|
const movingNodes = nodes.filter((node) => movedIds.has(node.id) && node.type !== CanvasNodeType.Group);
|
||||||
|
if (!movingNodes.length) return null;
|
||||||
|
return (
|
||||||
|
[...nodes]
|
||||||
|
.reverse()
|
||||||
|
.find((group) => {
|
||||||
|
if (group.type !== CanvasNodeType.Group || movedIds.has(group.id)) return false;
|
||||||
|
return movingNodes.some((node) => {
|
||||||
|
const centerX = node.position.x + node.width / 2;
|
||||||
|
const centerY = node.position.y + node.height / 2;
|
||||||
|
return centerX >= group.position.x && centerX <= group.position.x + group.width && centerY >= group.position.y && centerY <= group.position.y + group.height;
|
||||||
|
});
|
||||||
|
}) || null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapNodesIntoGroup(movedIds: Set<string>, nodes: CanvasNodeData[], group: CanvasNodeData) {
|
||||||
|
const movingNodes = nodes.filter((node) => movedIds.has(node.id) && node.type !== CanvasNodeType.Group);
|
||||||
|
if (!movingNodes.length) return nodes;
|
||||||
|
const pad = 24;
|
||||||
|
const bounds = nodeBounds(movingNodes);
|
||||||
|
const left = group.position.x + pad;
|
||||||
|
const top = group.position.y + pad;
|
||||||
|
const right = group.position.x + group.width - pad;
|
||||||
|
const bottom = group.position.y + group.height - pad;
|
||||||
|
const dx = bounds.right - bounds.left > right - left ? left - bounds.left : bounds.left < left ? left - bounds.left : bounds.right > right ? right - bounds.right : 0;
|
||||||
|
const dy = bounds.bottom - bounds.top > bottom - top ? top - bounds.top : bounds.top < top ? top - bounds.top : bounds.bottom > bottom ? bottom - bounds.bottom : 0;
|
||||||
|
return nodes.map((node) => {
|
||||||
|
if (!movedIds.has(node.id) || node.type === CanvasNodeType.Group) return node;
|
||||||
|
return { ...node, position: { x: node.position.x + dx, y: node.position.y + dy }, metadata: { ...node.metadata, groupId: group.id } };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeBounds(nodes: CanvasNodeData[]) {
|
||||||
|
return nodes.reduce(
|
||||||
|
(acc, node) => ({
|
||||||
|
left: Math.min(acc.left, node.position.x),
|
||||||
|
top: Math.min(acc.top, node.position.y),
|
||||||
|
right: Math.max(acc.right, node.position.x + node.width),
|
||||||
|
bottom: Math.max(acc.bottom, node.position.y + node.height),
|
||||||
|
}),
|
||||||
|
{ left: Infinity, top: Infinity, right: -Infinity, bottom: -Infinity },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findContainingGroupId(node: CanvasNodeData, nodes: CanvasNodeData[]) {
|
||||||
|
const centerX = node.position.x + node.width / 2;
|
||||||
|
const centerY = node.position.y + node.height / 2;
|
||||||
|
return (
|
||||||
|
[...nodes]
|
||||||
|
.reverse()
|
||||||
|
.find((group) => group.type === CanvasNodeType.Group && group.id !== node.id && centerX >= group.position.x && centerX <= group.position.x + group.width && centerY >= group.position.y && centerY <= group.position.y + group.height)?.id || undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function getConnectionTargetAnchor(node: CanvasNodeData, current: ConnectionHandle) {
|
function getConnectionTargetAnchor(node: CanvasNodeData, current: ConnectionHandle) {
|
||||||
return {
|
return {
|
||||||
x: current.handleType === "source" ? node.position.x : node.position.x + node.width,
|
x: current.handleType === "source" ? node.position.x : node.position.x + node.width,
|
||||||
@@ -3133,6 +3232,7 @@ function normalizeConnection(firstNodeId: string, secondNodeId: string, nodes: C
|
|||||||
const first = nodes.find((node) => node.id === firstNodeId);
|
const first = nodes.find((node) => node.id === firstNodeId);
|
||||||
const second = nodes.find((node) => node.id === secondNodeId);
|
const second = nodes.find((node) => node.id === secondNodeId);
|
||||||
if (!first || !second || first.id === second.id) return null;
|
if (!first || !second || first.id === second.id) return null;
|
||||||
|
if (first.type === CanvasNodeType.Group || second.type === CanvasNodeType.Group) return null;
|
||||||
if (first.type === CanvasNodeType.Config && second.type === CanvasNodeType.Config) return null;
|
if (first.type === CanvasNodeType.Config && second.type === CanvasNodeType.Config) return null;
|
||||||
if (second.type === CanvasNodeType.Config) return { fromNodeId: first.id, toNodeId: second.id };
|
if (second.type === CanvasNodeType.Config) return { fromNodeId: first.id, toNodeId: second.id };
|
||||||
if (first.type === CanvasNodeType.Config && firstHandleType === "target") return { fromNodeId: second.id, toNodeId: first.id };
|
if (first.type === CanvasNodeType.Config && firstHandleType === "target") return { fromNodeId: second.id, toNodeId: first.id };
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export enum CanvasNodeType {
|
|||||||
Config = "config",
|
Config = "config",
|
||||||
Video = "video",
|
Video = "video",
|
||||||
Audio = "audio",
|
Audio = "audio",
|
||||||
|
Group = "group",
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CanvasNodeStatus = "idle" | "success" | "loading" | "error";
|
export type CanvasNodeStatus = "idle" | "success" | "loading" | "error";
|
||||||
@@ -56,6 +57,7 @@ export type CanvasNodeMetadata = {
|
|||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
bytes?: number;
|
bytes?: number;
|
||||||
durationMs?: number;
|
durationMs?: number;
|
||||||
|
groupId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CanvasNodeData = {
|
export type CanvasNodeData = {
|
||||||
|
|||||||
Reference in New Issue
Block a user