mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-05 17:04:27 +08:00
feat(canvas): add group node support with drag-and-drop functionality
This commit is contained in:
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
+ [新增] 画布节点支持统一维护名称字段,默认显示在节点上方,并可直接双击名称编辑。
|
||||||
+ [新增] 画布新增组节点,支持节点拖入/拖出分组、拖拽高亮吸附和移动组时带动子节点。
|
+ [新增] 画布新增组节点,支持节点拖入/拖出分组、拖拽高亮吸附和移动组时带动子节点。
|
||||||
|
+ [调整] 画布节点顶部工具条改为点击选中节点后显示,避免鼠标经过节点时频繁弹出。
|
||||||
|
|
||||||
## v0.6.0 - 2026-07-09
|
## v0.6.0 - 2026-07-09
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ description: 当前版本已实现但仍需人工验证的变更项
|
|||||||
|
|
||||||
# 待测试
|
# 待测试
|
||||||
|
|
||||||
|
- 画布节点名称:每个节点保留 `title` 名称字段,默认名称显示在节点上方,双击名称可直接编辑,节点信息弹窗和 JSON 可查看该字段。
|
||||||
|
- 画布节点工具条:节点顶部工具条改为点击选中节点后显示,单纯鼠标移入 / 移出节点不应再频繁弹出。
|
||||||
- 画布组节点:工具栏新增“组”节点,普通节点拖入组区域会高亮目标组,松手后自动吸附进组;拖出组区域会解除归属,拖动组节点会一起移动组内子节点。
|
- 画布组节点:工具栏新增“组”节点,普通节点拖入组区域会高亮目标组,松手后自动吸附进组;拖出组区域会解除归属,拖动组节点会一起移动组内子节点。
|
||||||
- GitHub Pages:新增版本 tag 触发的前端静态站点自动发布 workflow,需在仓库 Pages 设置中选择 GitHub Actions 并验证路由刷新回退。
|
- GitHub Pages:新增版本 tag 触发的前端静态站点自动发布 workflow,需在仓库 Pages 设置中选择 GitHub Actions 并验证路由刷新回退。
|
||||||
- 画布图片切图:行数 / 列数会生成可直接拖拽的等分切图线,并支持新增横向 / 纵向线、删除单条线、重置切图线和切片数量预览。
|
- 画布图片切图:行数 / 列数会生成可直接拖拽的等分切图线,并支持新增横向 / 纵向线、删除单条线、重置切图线和切片数量预览。
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ export function CanvasNodeContextMenu({ menu, onClose, onDuplicate, onDelete }:
|
|||||||
style={{ left: menu.x, top: menu.y, background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
|
style={{ left: menu.x, top: menu.y, background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
|
||||||
onPointerDown={(event) => event.stopPropagation()}
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
{menu.type === "node" ? <MenuButton icon={<Plus className="size-4" />} label="Duplicate" onClick={onDuplicate} /> : null}
|
{menu.type === "node" ? <MenuButton icon={<Plus className="size-4" />} label="复制" onClick={onDuplicate} /> : null}
|
||||||
<MenuButton icon={<Trash2 className="size-4" />} label="Delete" onClick={onDelete} danger />
|
<MenuButton icon={<Trash2 className="size-4" />} label="删除" onClick={onDelete} danger />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -218,7 +218,6 @@ export function CanvasNodeInfoModal({ node, open, onClose }: { node: CanvasNodeD
|
|||||||
return JSON.stringify(
|
return JSON.stringify(
|
||||||
node,
|
node,
|
||||||
(key, value) => {
|
(key, value) => {
|
||||||
if (key === "title") return undefined;
|
|
||||||
if (key === "content" && typeof value === "string" && value.startsWith("data:image/")) {
|
if (key === "content" && typeof value === "string" && value.startsWith("data:image/")) {
|
||||||
return "[base64 image]";
|
return "[base64 image]";
|
||||||
}
|
}
|
||||||
@@ -254,6 +253,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.title || "未命名节点"} />
|
||||||
<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={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)}`} />
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ type CanvasNodeProps = {
|
|||||||
onConnectStart: (event: React.MouseEvent, nodeId: string, handleType: "source" | "target") => void;
|
onConnectStart: (event: React.MouseEvent, nodeId: string, handleType: "source" | "target") => void;
|
||||||
onResize: (nodeId: string, width: number, height: number, position?: Position) => void;
|
onResize: (nodeId: string, width: number, height: number, position?: Position) => void;
|
||||||
onContentChange: (nodeId: string, content: string) => void;
|
onContentChange: (nodeId: string, content: string) => void;
|
||||||
|
onTitleChange: (nodeId: string, title: string) => void;
|
||||||
onToggleBatch?: (nodeId: string) => void;
|
onToggleBatch?: (nodeId: string) => void;
|
||||||
onSetBatchPrimary?: (node: CanvasNodeData) => void;
|
onSetBatchPrimary?: (node: CanvasNodeData) => void;
|
||||||
onRetry?: (node: CanvasNodeData) => void;
|
onRetry?: (node: CanvasNodeData) => void;
|
||||||
@@ -99,6 +100,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
|||||||
onConnectStart,
|
onConnectStart,
|
||||||
onResize,
|
onResize,
|
||||||
onContentChange,
|
onContentChange,
|
||||||
|
onTitleChange,
|
||||||
onToggleBatch,
|
onToggleBatch,
|
||||||
onSetBatchPrimary,
|
onSetBatchPrimary,
|
||||||
onRetry,
|
onRetry,
|
||||||
@@ -109,6 +111,8 @@ export const CanvasNode = React.memo(function CanvasNode({
|
|||||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
const [isEditingContent, setIsEditingContent] = useState(false);
|
const [isEditingContent, setIsEditingContent] = useState(false);
|
||||||
|
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||||
|
const [titleDraft, setTitleDraft] = useState(data.title || "");
|
||||||
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);
|
||||||
@@ -118,6 +122,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
|||||||
const isActive = isConnectionTarget || isSelected || isFocusRelated;
|
const isActive = isConnectionTarget || isSelected || isFocusRelated;
|
||||||
const imageBorderColor = isActive ? selectionBlue : isRelated && !isBatchChild ? theme.node.muted : "transparent";
|
const imageBorderColor = isActive ? selectionBlue : isRelated && !isBatchChild ? theme.node.muted : "transparent";
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const titleInputRef = useRef<HTMLInputElement>(null);
|
||||||
const resizeRef = useRef({
|
const resizeRef = useRef({
|
||||||
isResizing: false,
|
isResizing: false,
|
||||||
corner: "bottom-right" as ResizeCorner,
|
corner: "bottom-right" as ResizeCorner,
|
||||||
@@ -131,6 +136,34 @@ export const CanvasNode = React.memo(function CanvasNode({
|
|||||||
ratio: 1,
|
ratio: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTitleDraft(data.title || "");
|
||||||
|
}, [data.title]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEditingTitle) return;
|
||||||
|
titleInputRef.current?.focus();
|
||||||
|
titleInputRef.current?.select();
|
||||||
|
}, [isEditingTitle]);
|
||||||
|
|
||||||
|
const finishTitleEditing = useCallback(() => {
|
||||||
|
const title = titleDraft.trim() || data.title || "未命名节点";
|
||||||
|
setTitleDraft(title);
|
||||||
|
setIsEditingTitle(false);
|
||||||
|
if (title !== data.title) onTitleChange(data.id, title);
|
||||||
|
}, [data.id, data.title, onTitleChange, titleDraft]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEditingTitle) return;
|
||||||
|
const handleOutsidePointerDown = (event: PointerEvent) => {
|
||||||
|
const target = event.target;
|
||||||
|
if (target instanceof Node && titleInputRef.current?.contains(target)) return;
|
||||||
|
finishTitleEditing();
|
||||||
|
};
|
||||||
|
window.addEventListener("pointerdown", handleOutsidePointerDown, true);
|
||||||
|
return () => window.removeEventListener("pointerdown", handleOutsidePointerDown, true);
|
||||||
|
}, [finishTitleEditing, isEditingTitle]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const textarea = textareaRef.current;
|
const textarea = textareaRef.current;
|
||||||
if (!textarea) return;
|
if (!textarea) return;
|
||||||
@@ -261,6 +294,40 @@ export const CanvasNode = React.memo(function CanvasNode({
|
|||||||
}}
|
}}
|
||||||
onContextMenu={(event) => onContextMenu(event, data.id)}
|
onContextMenu={(event) => onContextMenu(event, data.id)}
|
||||||
>
|
>
|
||||||
|
<div className="absolute left-3 top-[-28px] z-[65] max-w-[calc(100%-24px)]" onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
||||||
|
{isEditingTitle ? (
|
||||||
|
<input
|
||||||
|
ref={titleInputRef}
|
||||||
|
value={titleDraft}
|
||||||
|
maxLength={64}
|
||||||
|
className="h-6 max-w-full border-0 border-b border-dashed bg-transparent px-0 text-left text-xs font-medium outline-none"
|
||||||
|
style={{ borderColor: theme.node.muted, color: theme.node.text }}
|
||||||
|
onChange={(event) => setTitleDraft(event.target.value)}
|
||||||
|
onBlur={finishTitleEditing}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") finishTitleEditing();
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
setTitleDraft(data.title || "");
|
||||||
|
setIsEditingTitle(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="block max-w-full truncate border-b border-dashed border-transparent px-0 py-0.5 text-left text-xs font-medium opacity-75 transition hover:border-current hover:opacity-100"
|
||||||
|
style={{ color: theme.node.text }}
|
||||||
|
title="双击修改节点名称"
|
||||||
|
onDoubleClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setIsEditingTitle(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data.title || "未命名节点"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<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={{
|
||||||
@@ -366,7 +433,7 @@ function GroupNodeContent({ node, theme, groupChildCount }: NodeContentRendererP
|
|||||||
<span className="grid size-8 place-items-center rounded-xl" style={{ background: theme.toolbar.activeBg, color: theme.node.muted }}>
|
<span className="grid size-8 place-items-center rounded-xl" style={{ background: theme.toolbar.activeBg, color: theme.node.muted }}>
|
||||||
<Group className="size-4" />
|
<Group className="size-4" />
|
||||||
</span>
|
</span>
|
||||||
<span>{node.title || "组"}</span>
|
<span>组</span>
|
||||||
<span className="ml-auto rounded-full px-2 py-1 text-[11px] font-medium" style={{ background: theme.node.fill, color: theme.node.muted }}>
|
<span className="ml-auto rounded-full px-2 py-1 text-[11px] font-medium" style={{ background: theme.node.fill, color: theme.node.muted }}>
|
||||||
{groupChildCount} 个节点
|
{groupChildCount} 个节点
|
||||||
</span>
|
</span>
|
||||||
@@ -547,7 +614,7 @@ function AudioNodeContent({ node, theme }: NodeContentRendererProps) {
|
|||||||
<div className="flex h-full w-full flex-col justify-center gap-3 px-4" style={{ background: theme.node.fill, color: theme.node.text }}>
|
<div className="flex h-full w-full flex-col justify-center gap-3 px-4" style={{ background: theme.node.fill, color: theme.node.text }}>
|
||||||
<div className="flex min-w-0 items-center gap-2 text-sm opacity-70">
|
<div className="flex min-w-0 items-center gap-2 text-sm opacity-70">
|
||||||
<Music2 className="size-4 shrink-0" />
|
<Music2 className="size-4 shrink-0" />
|
||||||
<span className="truncate">{node.title || "音频"}</span>
|
<span className="truncate">音频</span>
|
||||||
</div>
|
</div>
|
||||||
<audio src={node.metadata.content} controls className="w-full" data-canvas-no-zoom />
|
<audio src={node.metadata.content} controls className="w-full" data-canvas-no-zoom />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ type CanvasNodeSpec = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const NODE_DEFAULT_SIZE = {
|
export const NODE_DEFAULT_SIZE = {
|
||||||
[CanvasNodeType.Image]: { width: 340, height: 240, title: "New Generation" },
|
[CanvasNodeType.Image]: { width: 340, height: 240, title: "图片" },
|
||||||
[CanvasNodeType.Text]: { width: 340, height: 240, title: "Note" },
|
[CanvasNodeType.Text]: { width: 340, height: 240, title: "文本" },
|
||||||
[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: "视频" },
|
||||||
[CanvasNodeType.Audio]: { width: 340, height: 120, title: "Audio" },
|
[CanvasNodeType.Audio]: { width: 340, height: 120, title: "音频" },
|
||||||
[CanvasNodeType.Group]: { width: 760, height: 480, title: "组" },
|
[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 }>;
|
||||||
|
|
||||||
|
|||||||
@@ -235,7 +235,6 @@ function InfiniteCanvasPage() {
|
|||||||
const historyPausedRef = useRef(false);
|
const historyPausedRef = useRef(false);
|
||||||
const didInitialCenterRef = useRef(false);
|
const didInitialCenterRef = useRef(false);
|
||||||
const rafRef = useRef<number | null>(null);
|
const rafRef = useRef<number | null>(null);
|
||||||
const toolbarHideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const nodeDraggingRef = useRef(false);
|
const nodeDraggingRef = useRef(false);
|
||||||
const dragRef = useRef<{
|
const dragRef = useRef<{
|
||||||
isDraggingNode: boolean;
|
isDraggingNode: boolean;
|
||||||
@@ -547,25 +546,12 @@ function InfiniteCanvasPage() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const keepNodeToolbar = useCallback(
|
const keepNodeToolbar = useCallback((nodeId: string) => {
|
||||||
(nodeId: string) => {
|
if (nodeDraggingRef.current || nodeImageSettingsOpen || !selectedNodeIdsRef.current.has(nodeId)) return;
|
||||||
if (nodeDraggingRef.current || nodeImageSettingsOpen) return;
|
|
||||||
if (toolbarHideTimerRef.current) {
|
|
||||||
clearTimeout(toolbarHideTimerRef.current);
|
|
||||||
toolbarHideTimerRef.current = null;
|
|
||||||
}
|
|
||||||
setToolbarNodeId(nodeId);
|
setToolbarNodeId(nodeId);
|
||||||
},
|
}, [nodeImageSettingsOpen]);
|
||||||
[nodeImageSettingsOpen],
|
|
||||||
);
|
|
||||||
|
|
||||||
const hideNodeToolbar = useCallback(() => {
|
const hideNodeToolbar = useCallback(() => {}, []);
|
||||||
if (toolbarHideTimerRef.current) clearTimeout(toolbarHideTimerRef.current);
|
|
||||||
toolbarHideTimerRef.current = setTimeout(() => {
|
|
||||||
setToolbarNodeId(null);
|
|
||||||
toolbarHideTimerRef.current = null;
|
|
||||||
}, 120);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const connectNodes = useCallback(
|
const connectNodes = useCallback(
|
||||||
(current: ConnectionHandle, targetNodeId: string) => {
|
(current: ConnectionHandle, targetNodeId: string) => {
|
||||||
@@ -1106,6 +1092,7 @@ function InfiniteCanvasPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setSelectedNodeIds(nextSelected);
|
setSelectedNodeIds(nextSelected);
|
||||||
|
setToolbarNodeId(nextSelected.size === 1 && nextSelected.has(nodeId) ? nodeId : null);
|
||||||
const dragIds = new Set(nextSelected);
|
const dragIds = new Set(nextSelected);
|
||||||
currentNodes.forEach((node) => {
|
currentNodes.forEach((node) => {
|
||||||
if (!nextSelected.has(node.id)) return;
|
if (!nextSelected.has(node.id)) return;
|
||||||
@@ -1502,6 +1489,10 @@ function InfiniteCanvasPage() {
|
|||||||
setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, content } } : node)));
|
setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, content } } : node)));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleNodeTitleChange = useCallback((nodeId: string, title: string) => {
|
||||||
|
setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, title } : node)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const toggleBatchExpanded = useCallback((nodeId: string) => {
|
const toggleBatchExpanded = useCallback((nodeId: string) => {
|
||||||
const isExpanded = Boolean(nodesRef.current.find((node) => node.id === nodeId)?.metadata?.imageBatchExpanded);
|
const isExpanded = Boolean(nodesRef.current.find((node) => node.id === nodeId)?.metadata?.imageBatchExpanded);
|
||||||
if (isExpanded) {
|
if (isExpanded) {
|
||||||
@@ -2658,15 +2649,14 @@ function InfiniteCanvasPage() {
|
|||||||
onHoverStart={(nodeId) => {
|
onHoverStart={(nodeId) => {
|
||||||
if (nodeDraggingRef.current) return;
|
if (nodeDraggingRef.current) return;
|
||||||
setHoveredNodeId(nodeId);
|
setHoveredNodeId(nodeId);
|
||||||
keepNodeToolbar(nodeId);
|
|
||||||
}}
|
}}
|
||||||
onHoverEnd={(nodeId) => {
|
onHoverEnd={(nodeId) => {
|
||||||
setHoveredNodeId((current) => (current === nodeId ? null : current));
|
setHoveredNodeId((current) => (current === nodeId ? null : current));
|
||||||
hideNodeToolbar();
|
|
||||||
}}
|
}}
|
||||||
onConnectStart={handleConnectStart}
|
onConnectStart={handleConnectStart}
|
||||||
onResize={handleNodeResize}
|
onResize={handleNodeResize}
|
||||||
onContentChange={handleNodeContentChange}
|
onContentChange={handleNodeContentChange}
|
||||||
|
onTitleChange={handleNodeTitleChange}
|
||||||
onToggleBatch={toggleBatchExpanded}
|
onToggleBatch={toggleBatchExpanded}
|
||||||
onSetBatchPrimary={setBatchPrimary}
|
onSetBatchPrimary={setBatchPrimary}
|
||||||
onRetry={(node) => void handleRetryNode(node)}
|
onRetry={(node) => void handleRetryNode(node)}
|
||||||
|
|||||||
Reference in New Issue
Block a user