mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-24 15:24:06 +08:00
Merge branch 'fork/stupid-h4er/feat/ark-agentplan-seedance-video'
# Conflicts: # docs/content/docs/progress/pending-test.mdx
This commit is contained in:
@@ -5,6 +5,7 @@ description: 当前版本已实现但仍需人工验证的变更项
|
||||
|
||||
# 待测试
|
||||
|
||||
- 画布连线支持右键打开删除菜单;拖拽连线到目标卡片内部、连接点附近或卡片边缘外扩范围内会自动吸附并连接,拖到已有但不可连接的节点附近不会再误弹创建节点菜单。
|
||||
- 外部软件可通过 URL 查询参数 `baseUrl`/`baseurl` 和 `apiKey`/`apikey` 跳转到前端;读取后会从地址栏移除这些参数,后台允许自定义渠道时会自动切到自定义渠道、填入配置并打开配置弹窗,未允许时会打开配置弹窗并提示无法导入。
|
||||
- 生图工作台和画布生图会把参考图按当前顺序显示为 `图片1`、`图片2` 等编号,并在图生图请求的实际提示词中注入编号说明;需要验证 `/image` 参考图排序、画布配置节点输入顺序和画布助手参考图编号一致。
|
||||
- GPT Image 生图请求会在前端把 `9:16`、`16:9` 等比例转换成合法 `WIDTHxHEIGHT` 尺寸,并在非法尺寸时直接显示中文错误,避免上游返回 `invalid_value Invalid size`。
|
||||
|
||||
@@ -64,6 +64,11 @@ type PendingConnectionCreate = {
|
||||
position: Position;
|
||||
};
|
||||
|
||||
type ConnectionDropTarget = {
|
||||
nodeId: string | null;
|
||||
isNearNode: boolean;
|
||||
};
|
||||
|
||||
type CanvasHistoryEntry = Pick<CanvasClipboard, "nodes" | "connections"> & {
|
||||
chatSessions: CanvasAssistantSession[];
|
||||
activeChatId: string | null;
|
||||
@@ -73,6 +78,8 @@ type CanvasHistoryEntry = Pick<CanvasClipboard, "nodes" | "connections"> & {
|
||||
|
||||
const VIDEO_NODE_MAX_WIDTH = 420;
|
||||
const VIDEO_NODE_MAX_HEIGHT = 420;
|
||||
const CONNECTION_HANDLE_HIT_RADIUS = 40;
|
||||
const CONNECTION_NODE_HIT_PADDING = 32;
|
||||
const NODE_STATUS_LOADING = "loading" as const;
|
||||
const NODE_STATUS_SUCCESS = "success" as const;
|
||||
const NODE_STATUS_ERROR = "error" as const;
|
||||
@@ -509,23 +516,35 @@ function InfiniteCanvasPage() {
|
||||
setConnecting(null);
|
||||
}, [setConnecting]);
|
||||
|
||||
const getConnectableNodeAtPoint = useCallback(
|
||||
(clientX: number, clientY: number, current: ConnectionHandle) => {
|
||||
const getConnectionDropTarget = useCallback(
|
||||
(clientX: number, clientY: number, current: ConnectionHandle): ConnectionDropTarget => {
|
||||
const world = screenToCanvas(clientX, clientY);
|
||||
return (
|
||||
[...nodesRef.current]
|
||||
.filter((node) => !isHiddenBatchChild(node, nodesRef.current))
|
||||
.reverse()
|
||||
.find(
|
||||
(node) =>
|
||||
node.id !== current.nodeId &&
|
||||
Boolean(normalizeConnection(current.nodeId, node.id, nodesRef.current, current.handleType)) &&
|
||||
world.x >= node.position.x &&
|
||||
world.x <= node.position.x + node.width &&
|
||||
world.y >= node.position.y &&
|
||||
world.y <= node.position.y + node.height,
|
||||
)?.id || null
|
||||
);
|
||||
const scale = Math.max(viewportRef.current.k, 0.05);
|
||||
const padding = CONNECTION_NODE_HIT_PADDING / scale;
|
||||
const handleRadius = CONNECTION_HANDLE_HIT_RADIUS / scale;
|
||||
let isNearNode = false;
|
||||
let best: { nodeId: string; priority: number } | null = null;
|
||||
|
||||
[...nodesRef.current]
|
||||
.filter((node) => !isHiddenBatchChild(node, nodesRef.current))
|
||||
.reverse()
|
||||
.forEach((node) => {
|
||||
const anchor = getConnectionTargetAnchor(node, current);
|
||||
const dx = world.x - anchor.x;
|
||||
const dy = world.y - anchor.y;
|
||||
const hitsHandle = dx * dx + dy * dy <= handleRadius * handleRadius;
|
||||
const hitsInside = world.x >= node.position.x && world.x <= node.position.x + node.width && world.y >= node.position.y && world.y <= node.position.y + node.height;
|
||||
const hitsExpanded = world.x >= node.position.x - padding && world.x <= node.position.x + node.width + padding && world.y >= node.position.y - padding && world.y <= node.position.y + node.height + padding;
|
||||
|
||||
if (!hitsHandle && !hitsInside && !hitsExpanded) return;
|
||||
isNearNode = true;
|
||||
if (node.id === current.nodeId || !normalizeConnection(current.nodeId, node.id, nodesRef.current, current.handleType)) return;
|
||||
|
||||
const priority = hitsInside ? 0 : hitsHandle ? 1 : 2;
|
||||
if (!best || priority < best.priority) best = { nodeId: node.id, priority };
|
||||
});
|
||||
|
||||
return { nodeId: best?.nodeId || null, isNearNode };
|
||||
},
|
||||
[screenToCanvas],
|
||||
);
|
||||
@@ -657,12 +676,18 @@ function InfiniteCanvasPage() {
|
||||
setAngleNodeId((current) => (current && allIds.has(current) ? null : current));
|
||||
setPreviewNodeId((current) => (current && allIds.has(current) ? null : current));
|
||||
setRunningNodeId((current) => (current && allIds.has(current) ? null : current));
|
||||
setContextMenu((current) => (current && allIds.has(current.nodeId) ? null : current));
|
||||
setContextMenu((current) => (current?.type === "node" && allIds.has(current.nodeId) ? null : current));
|
||||
cleanupCanvasFiles({ projectId, nodes: nodesRef.current.filter((node) => !allIds.has(node.id)), chatSessions });
|
||||
},
|
||||
[chatSessions, cleanupCanvasFiles, projectId],
|
||||
);
|
||||
|
||||
const deleteConnection = useCallback((connectionId: string) => {
|
||||
setConnections((prev) => prev.filter((conn) => conn.id !== connectionId));
|
||||
setSelectedConnectionId((current) => (current === connectionId ? null : current));
|
||||
setContextMenu((current) => (current?.type === "connection" && current.connectionId === connectionId ? null : current));
|
||||
}, []);
|
||||
|
||||
const deselectCanvas = useCallback(() => {
|
||||
cancelPendingConnectionCreate();
|
||||
setSelectedNodeIds(new Set());
|
||||
@@ -986,13 +1011,13 @@ function InfiniteCanvasPage() {
|
||||
}
|
||||
|
||||
if (connectingParamsRef.current && !pendingConnectionCreateRef.current) {
|
||||
const targetNodeId = getConnectableNodeAtPoint(event.clientX, event.clientY, connectingParamsRef.current);
|
||||
connectionTargetNodeIdRef.current = targetNodeId;
|
||||
setConnectionTargetNodeId(targetNodeId);
|
||||
const dropTarget = getConnectionDropTarget(event.clientX, event.clientY, connectingParamsRef.current);
|
||||
connectionTargetNodeIdRef.current = dropTarget.nodeId;
|
||||
setConnectionTargetNodeId(dropTarget.nodeId);
|
||||
setMouseWorld(screenToCanvas(event.clientX, event.clientY));
|
||||
}
|
||||
},
|
||||
[finishNodeDrag, getConnectableNodeAtPoint, screenToCanvas],
|
||||
[finishNodeDrag, getConnectionDropTarget, screenToCanvas],
|
||||
);
|
||||
|
||||
const handleGlobalPointerMove = useCallback(
|
||||
@@ -1040,9 +1065,11 @@ function InfiniteCanvasPage() {
|
||||
|
||||
const currentConnection = connectingParamsRef.current;
|
||||
if (currentConnection) {
|
||||
const targetNodeId = getConnectableNodeAtPoint(event.clientX, event.clientY, currentConnection) || connectionTargetNodeIdRef.current;
|
||||
if (targetNodeId) {
|
||||
connectNodes(currentConnection, targetNodeId);
|
||||
const dropTarget = getConnectionDropTarget(event.clientX, event.clientY, currentConnection);
|
||||
if (dropTarget.nodeId) {
|
||||
connectNodes(currentConnection, dropTarget.nodeId);
|
||||
setConnecting(null);
|
||||
} else if (dropTarget.isNearNode) {
|
||||
setConnecting(null);
|
||||
} else {
|
||||
setMouseWorld(screenToCanvas(event.clientX, event.clientY));
|
||||
@@ -1050,7 +1077,7 @@ function InfiniteCanvasPage() {
|
||||
}
|
||||
}
|
||||
},
|
||||
[connectNodes, finishNodeDrag, getConnectableNodeAtPoint, screenToCanvas, setConnecting],
|
||||
[connectNodes, finishNodeDrag, getConnectionDropTarget, screenToCanvas, setConnecting],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1217,8 +1244,7 @@ function InfiniteCanvasPage() {
|
||||
if (selectedNodeIdsRef.current.size) {
|
||||
deleteNodes(new Set(selectedNodeIdsRef.current));
|
||||
} else if (selectedConnectionId) {
|
||||
setConnections((prev) => prev.filter((conn) => conn.id !== selectedConnectionId));
|
||||
setSelectedConnectionId(null);
|
||||
deleteConnection(selectedConnectionId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1240,7 +1266,7 @@ function InfiniteCanvasPage() {
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [copySelectedNodes, deleteNodes, pasteCopiedNodes, pasteSystemClipboard, redoCanvas, selectedConnectionId, setConnecting, undoCanvas]);
|
||||
}, [copySelectedNodes, deleteConnection, deleteNodes, pasteCopiedNodes, pasteSystemClipboard, redoCanvas, selectedConnectionId, setConnecting, undoCanvas]);
|
||||
|
||||
const handleConnectStart = useCallback(
|
||||
(event: ReactMouseEvent, nodeId: string, handleType: "source" | "target") => {
|
||||
@@ -2105,10 +2131,15 @@ function InfiniteCanvasPage() {
|
||||
setSelectedNodeIds(new Set());
|
||||
setContextMenu(null);
|
||||
}}
|
||||
onContextMenu={(event) => {
|
||||
setSelectedConnectionId(connection.id);
|
||||
setSelectedNodeIds(new Set());
|
||||
setContextMenu({ type: "connection", x: event.clientX, y: event.clientY, connectionId: connection.id });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{connectingParams ? <ActiveConnectionPath node={nodeById.get(connectingParams.nodeId)} handle={connectingParams} mouseWorld={mouseWorld} /> : null}
|
||||
{connectingParams ? <ActiveConnectionPath node={nodeById.get(connectingParams.nodeId)} handle={connectingParams} mouseWorld={mouseWorld} target={connectionTargetNodeId ? nodeById.get(connectionTargetNodeId) : undefined} /> : null}
|
||||
</svg>
|
||||
|
||||
{visibleNodes.map((node) => (
|
||||
@@ -2258,11 +2289,16 @@ function InfiniteCanvasPage() {
|
||||
menu={contextMenu}
|
||||
onClose={() => setContextMenu(null)}
|
||||
onDuplicate={() => {
|
||||
if (contextMenu.type !== "node") return;
|
||||
duplicateNode(contextMenu.nodeId);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
onDelete={() => {
|
||||
deleteNodes(new Set([contextMenu.nodeId]));
|
||||
if (contextMenu.type === "node") {
|
||||
deleteNodes(new Set([contextMenu.nodeId]));
|
||||
} else {
|
||||
deleteConnection(contextMenu.connectionId);
|
||||
}
|
||||
setContextMenu(null);
|
||||
}}
|
||||
/>
|
||||
@@ -2631,6 +2667,13 @@ 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;
|
||||
}
|
||||
|
||||
function getConnectionTargetAnchor(node: CanvasNodeData, current: ConnectionHandle) {
|
||||
return {
|
||||
x: current.handleType === "source" ? node.position.x : node.position.x + node.width,
|
||||
y: node.position.y + node.height / 2,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeConnection(firstNodeId: string, secondNodeId: string, nodes: CanvasNodeData[], firstHandleType: "source" | "target") {
|
||||
const first = nodes.find((node) => node.id === firstNodeId);
|
||||
const second = nodes.find((node) => node.id === secondNodeId);
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
import type { MouseEvent as ReactMouseEvent } from "react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import type { CanvasConnection, CanvasNodeData, ConnectionHandle, Position } from "../types";
|
||||
|
||||
export function ConnectionPath({ connection, from, to, active, onSelect }: { connection: CanvasConnection; from: CanvasNodeData; to: CanvasNodeData; active: boolean; onSelect: () => void }) {
|
||||
export function ConnectionPath({
|
||||
connection,
|
||||
from,
|
||||
to,
|
||||
active,
|
||||
onSelect,
|
||||
onContextMenu,
|
||||
}: {
|
||||
connection: CanvasConnection;
|
||||
from: CanvasNodeData;
|
||||
to: CanvasNodeData;
|
||||
active: boolean;
|
||||
onSelect: () => void;
|
||||
onContextMenu?: (event: ReactMouseEvent<SVGPathElement>) => void;
|
||||
}) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const startX = from.position.x + from.width;
|
||||
const startY = from.position.y + from.height / 2;
|
||||
@@ -25,6 +41,11 @@ export function ConnectionPath({ connection, from, to, active, onSelect }: { con
|
||||
event.stopPropagation();
|
||||
onSelect();
|
||||
}}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onContextMenu?.(event);
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
d={pathD}
|
||||
@@ -38,7 +59,7 @@ export function ConnectionPath({ connection, from, to, active, onSelect }: { con
|
||||
);
|
||||
}
|
||||
|
||||
export function ActiveConnectionPath({ node, handle, mouseWorld }: { node?: CanvasNodeData; handle: ConnectionHandle; mouseWorld: Position }) {
|
||||
export function ActiveConnectionPath({ node, handle, mouseWorld, target }: { node?: CanvasNodeData; handle: ConnectionHandle; mouseWorld: Position; target?: CanvasNodeData }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
if (!node) return null;
|
||||
|
||||
@@ -46,8 +67,12 @@ export function ActiveConnectionPath({ node, handle, mouseWorld }: { node?: Canv
|
||||
const startY = handle.handleType === "source" ? node.position.y + node.height / 2 : mouseWorld.y;
|
||||
const endX = handle.handleType === "source" ? mouseWorld.x : node.position.x;
|
||||
const endY = handle.handleType === "source" ? mouseWorld.y : node.position.y + node.height / 2;
|
||||
const distance = Math.abs(endX - startX);
|
||||
const pathD = `M ${startX} ${startY} C ${startX + distance * 0.5} ${startY}, ${endX - distance * 0.5} ${endY}, ${endX} ${endY}`;
|
||||
const snappedStartX = handle.handleType === "target" && target ? target.position.x + target.width : startX;
|
||||
const snappedStartY = handle.handleType === "target" && target ? target.position.y + target.height / 2 : startY;
|
||||
const snappedEndX = handle.handleType === "source" && target ? target.position.x : endX;
|
||||
const snappedEndY = handle.handleType === "source" && target ? target.position.y + target.height / 2 : endY;
|
||||
const distance = Math.abs(snappedEndX - snappedStartX);
|
||||
const pathD = `M ${snappedStartX} ${snappedStartY} C ${snappedStartX + distance * 0.5} ${snappedStartY}, ${snappedEndX - distance * 0.5} ${snappedEndY}, ${snappedEndX} ${snappedEndY}`;
|
||||
|
||||
return <path d={pathD} stroke={theme.node.activeStroke} strokeWidth="2" fill="none" strokeDasharray="5,5" />;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ 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 }}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<MenuButton icon={<Plus className="size-4" />} label="Duplicate" onClick={onDuplicate} />
|
||||
{menu.type === "node" ? <MenuButton icon={<Plus className="size-4" />} label="Duplicate" onClick={onDuplicate} /> : null}
|
||||
<MenuButton icon={<Trash2 className="size-4" />} label="Delete" onClick={onDelete} danger />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -118,9 +118,16 @@ export type SelectionBox = {
|
||||
initialSelectedNodeIds: string[];
|
||||
};
|
||||
|
||||
export type ContextMenuState = {
|
||||
type: "node";
|
||||
x: number;
|
||||
y: number;
|
||||
nodeId: string;
|
||||
};
|
||||
export type ContextMenuState =
|
||||
| {
|
||||
type: "node";
|
||||
x: number;
|
||||
y: number;
|
||||
nodeId: string;
|
||||
}
|
||||
| {
|
||||
type: "connection";
|
||||
x: number;
|
||||
y: number;
|
||||
connectionId: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user