feat(canvas): 画布工具 - 抓手框选改开关并精简多选整理

将底部抓手与区域选择收成胶囊开关,精简框选悬浮栏,并在右键提供按相对布局加大边距的自适应整理。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ddCat
2026-09-08 23:53:22 +08:00
parent f176043ac4
commit 94dba61a6c
19 changed files with 533 additions and 39 deletions

View File

@@ -11,6 +11,7 @@ type CanvasProjectContextMenuProps = {
canUndo: boolean;
canRedo: boolean;
canPaste: boolean;
selectedCount: number;
screenToCanvas: (clientX: number, clientY: number) => Position;
onClose: () => void;
onAddNode: (type: CanvasNodeTypeId, position: Position) => void;
@@ -38,6 +39,9 @@ type CanvasProjectContextMenuProps = {
onUploadToArkPrivateAsset: (node: CanvasNodeData) => void;
onSetAssetCategory: (nodeId: string, category: CanvasAssetCategory) => void;
onToggleFrame: (node: CanvasNodeData) => void;
onSpreadSelection: () => void;
onCopySelection: () => void;
onDeleteSelection: () => void;
};
export function CanvasProjectContextMenu({ menu, node, screenToCanvas, ...props }: CanvasProjectContextMenuProps) {
@@ -52,6 +56,7 @@ export function CanvasProjectContextMenu({ menu, node, screenToCanvas, ...props
canUndo={props.canUndo}
canRedo={props.canRedo}
canPaste={props.canPaste}
selectedCount={props.selectedCount}
onClose={props.onClose}
onAddNode={(type) => {
if (menu.type === "canvas") props.onAddNode(type, menu.position);
@@ -106,6 +111,9 @@ export function CanvasProjectContextMenu({ menu, node, screenToCanvas, ...props
onToggleFrame={() => {
if (node?.type === CanvasNodeType.Frame) props.onToggleFrame(node);
}}
onSpreadSelection={props.onSpreadSelection}
onCopySelection={props.onCopySelection}
onDeleteSelection={props.onDeleteSelection}
/>
);
}

View File

@@ -947,6 +947,7 @@ function InfiniteCanvasPage() {
alignSelectedNodes,
autoArrangeCanvasNodes,
arrangeSelectedNodes,
spreadSelectedNodes,
copyNodesToClipboard,
copySelectedNodes,
createFolder,
@@ -1769,7 +1770,10 @@ function InfiniteCanvasPage() {
(event: ReactMouseEvent, id: string) => {
event.preventDefault();
event.stopPropagation();
setSelectedNodeIds(new Set([id]));
setSelectedNodeIds((current) => {
if (current.has(id) && current.size > 1) return current;
return new Set([id]);
});
setSelectedConnectionId(null);
closeConnectionCreateMenu();
setToolbarNodeId(null);
@@ -2660,6 +2664,7 @@ function InfiniteCanvasPage() {
canUndo={historyState.canUndo}
canRedo={historyState.canRedo}
canPaste={hasCopiedNodes || Boolean(navigator.clipboard)}
selectedCount={selectedNodeIds.size}
screenToCanvas={screenToCanvas}
onClose={() => setContextMenu(null)}
onAddNode={(type, position) => createNode(type, position)}
@@ -2693,6 +2698,9 @@ function InfiniteCanvasPage() {
onUploadToArkPrivateAsset={confirmUploadNodeImageToArkPrivateAsset}
onSetAssetCategory={(nodeId, assetCategory) => handleConfigNodeChange(nodeId, { assetCategory })}
onToggleFrame={(node) => handleFrameToggle(node.id)}
onSpreadSelection={spreadSelectedNodes}
onCopySelection={copySelectedNodes}
onDeleteSelection={() => deleteNodes(selectedNodeIds)}
/>
<CanvasUploadModal open={uploadModalOpen} onClose={closeUploadModal} onUpload={handleUploadFiles} />

View File

@@ -5,7 +5,7 @@ import { nanoid } from "nanoid";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { FOLDER_COLLAPSED_HEIGHT, FOLDER_COLLAPSED_WIDTH, FRAME_HEADER_HEIGHT, getFrameChildIds, getFrameChildren, isFrameNode } from "@/lib/canvas/canvas-frame";
import { alignCanvasNodes, layoutCanvasAuto, layoutCanvasFlow, layoutCanvasNodes, nextCanvasVersionLabel, type CanvasAlignmentMode } from "@/lib/canvas/canvas-layout";
import { alignCanvasNodes, layoutCanvasAuto, layoutCanvasFlow, layoutCanvasNodes, nextCanvasVersionLabel, spreadCanvasNodes, type CanvasAlignmentMode } from "@/lib/canvas/canvas-layout";
import { applyCanvasConnectionPromptSync } from "@/lib/canvas/canvas-resource-references";
import { createCanvasNode, isHiddenBatchChild, removeCanvasNodes } from "@/lib/canvas/canvas-project-domain";
import { isolateCopiedNodeMetadata, nextCopiedNodeTitle } from "@/lib/canvas/canvas-node-copy";
@@ -206,6 +206,19 @@ export function useCanvasNodeOperations({
message.success(hasSelection ? "已按媒体分类整理选中节点" : "已按媒体分类整理画布");
}, [commitNodes, connectionsRef, message, nodesRef, selectedNodeIdsRef]);
const spreadSelectedNodes = useCallback(() => {
const currentNodes = nodesRef.current;
const selected = currentNodes.filter((node) => selectedNodeIdsRef.current.has(node.id) && !node.metadata?.locked && !isFrameNode(node) && !isHiddenBatchChild(node, currentNodes));
if (selected.length < 2) {
message.info("请至少选择两个可整理节点");
return;
}
const positions = spreadCanvasNodes(selected);
if (!positions.size) return;
commitNodes(currentNodes.map((node) => positions.has(node.id) ? { ...node, position: positions.get(node.id)! } : node));
message.success("已按相对布局加大间距");
}, [commitNodes, message, nodesRef, selectedNodeIdsRef]);
const alignSelectedNodes = useCallback((mode: CanvasAlignmentMode) => {
const selected = nodesRef.current.filter((node) => selectedNodeIdsRef.current.has(node.id) && !node.metadata?.locked && !isFrameNode(node));
if (selected.length < 2 || ((mode === "distributeX" || mode === "distributeY") && selected.length < 3)) return;
@@ -541,6 +554,7 @@ export function useCanvasNodeOperations({
alignSelectedNodes,
autoArrangeCanvasNodes,
arrangeSelectedNodes,
spreadSelectedNodes,
copyNodesToClipboard,
copySelectedNodes,
createFolder,