Compare commits

...

2 Commits

Author SHA1 Message Date
huangmin 8ab41ba7f3 refactor(WorkflowCanvas): 优化框选节点的逻辑和状态管理 1 month ago
huangmin 86c9ade93e fix(GenerationComposer): 修复分组节点内选中编辑器的定位问题 1 month ago
  1. 136
      src/components/WorkflowCanvas.tsx
  2. 30
      src/components/__tests__/GenerationComposer.test.tsx
  3. 34
      src/components/composer/GenerationComposer.tsx

136
src/components/WorkflowCanvas.tsx

@ -428,11 +428,22 @@ export function WorkflowCanvas() {
const [isSplitting, setIsSplitting] = useState(false);
const [isBuildingWorkflow, setIsBuildingWorkflow] = useState(false);
const [isMacOSClient, setIsMacOSClient] = useState(false);
const boxSelectionActiveRef = useRef(false);
const boxSelectionEndTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
setIsMacOSClient(/Mac|iPod|iPhone|iPad/.test(navigator.platform));
}, []);
useEffect(() => {
return () => {
if (boxSelectionEndTimerRef.current) {
clearTimeout(boxSelectionEndTimerRef.current);
}
document.documentElement.classList.remove("box-selection-active");
};
}, []);
useEffect(() => {
let cancelled = false;
fetch("/api/env-status")
@ -2283,10 +2294,79 @@ export function WorkflowCanvas() {
// Uses statistical outlier detection to identify and deselect nodes that are clearly
// outside the actual selection area.
const handleSelectionChange = useCallback(({ nodes: selectedNodes }: OnSelectionChangeParams) => {
if (selectedNodes.length <= 1) return;
if (!boxSelectionActiveRef.current) return;
if (selectedNodes.length === 0) return;
const getNodeGroupId = (node: Node): string | undefined =>
(node as Node & { groupId?: string }).groupId;
const selectedGroupNodeIds = new Set<string>();
const selectedGroupIds = new Set<string>();
selectedNodes.forEach((node) => {
if (node.type !== "group") return;
selectedGroupNodeIds.add(node.id);
const groupId = (node.data as { groupId?: string }).groupId;
if (groupId) selectedGroupIds.add(groupId);
});
selectedNodes.forEach((node) => {
if (node.type === "group") return;
const groupId = getNodeGroupId(node);
if (groupId) selectedGroupIds.add(groupId);
if (node.parentId) selectedGroupNodeIds.add(node.parentId);
});
const groupNodesToSelect = nodes.filter((node) => {
if (node.type !== "group") return false;
const groupId = (node.data as { groupId?: string }).groupId;
return selectedGroupNodeIds.has(node.id) || Boolean(groupId && selectedGroupIds.has(groupId));
});
groupNodesToSelect.forEach((node) => {
selectedGroupNodeIds.add(node.id);
const groupId = (node.data as { groupId?: string }).groupId;
if (groupId) selectedGroupIds.add(groupId);
});
const groupChildNodes = selectedNodes.filter((node) => {
if (node.type === "group") return false;
const groupId = getNodeGroupId(node);
return Boolean(
(groupId && selectedGroupIds.has(groupId)) ||
(node.parentId && selectedGroupNodeIds.has(node.parentId))
);
});
const groupChildNodeIds = new Set(groupChildNodes.map((node) => node.id));
const groupNodeIdsToSelect = new Set(groupNodesToSelect.map((node) => node.id));
const outlierCandidateNodes = [
...selectedNodes.filter((node) => !groupChildNodeIds.has(node.id)),
...groupNodesToSelect.filter((node) => !selectedNodes.some((selectedNode) => selectedNode.id === node.id)),
];
if (outlierCandidateNodes.length <= 1) {
const selectionChanges = [
...groupChildNodes.map((node) => ({
type: 'select' as const,
id: node.id,
selected: false,
})),
...groupNodesToSelect
.filter((node) => !node.selected)
.map((node) => ({
type: 'select' as const,
id: node.id,
selected: true,
})),
];
if (selectionChanges.length > 0) {
onNodesChange(
selectionChanges
);
}
return;
}
// Get positions of all selected nodes
const positions = selectedNodes.map(n => ({
const positions = outlierCandidateNodes.map(n => ({
id: n.id,
x: n.position.x,
y: n.position.y,
@ -2314,16 +2394,52 @@ export function WorkflowCanvas() {
p.x < minX || p.x > maxX || p.y < minY || p.y > maxY
);
if (outliers.length > 0) {
onNodesChange(
outliers.map(o => ({
const nodesToDeselect = new Map<string, string>();
outliers.forEach((node) => nodesToDeselect.set(node.id, node.id));
groupChildNodes.forEach((node) => nodesToDeselect.set(node.id, node.id));
const groupOutlierIds = new Set(outliers.map((node) => node.id));
const selectionChanges = [
...[...nodesToDeselect.keys()].map((id) => ({
type: 'select' as const,
id,
selected: false,
})),
...groupNodesToSelect
.filter((node) => !node.selected && groupNodeIdsToSelect.has(node.id) && !groupOutlierIds.has(node.id))
.map((node) => ({
type: 'select' as const,
id: o.id,
selected: false,
}))
id: node.id,
selected: true,
})),
];
if (selectionChanges.length > 0) {
onNodesChange(
selectionChanges
);
}
}, [onNodesChange]);
}, [nodes, onNodesChange]);
const handleSelectionStart = useCallback(() => {
if (boxSelectionEndTimerRef.current) {
clearTimeout(boxSelectionEndTimerRef.current);
boxSelectionEndTimerRef.current = null;
}
boxSelectionActiveRef.current = true;
document.documentElement.classList.add("box-selection-active");
}, []);
const handleSelectionEnd = useCallback(() => {
if (boxSelectionEndTimerRef.current) {
clearTimeout(boxSelectionEndTimerRef.current);
}
boxSelectionEndTimerRef.current = setTimeout(() => {
boxSelectionActiveRef.current = false;
boxSelectionEndTimerRef.current = null;
document.documentElement.classList.remove("box-selection-active");
}, 0);
}, []);
const handleDragOver = useCallback((event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
@ -2592,6 +2708,8 @@ export function WorkflowCanvas() {
onMoveEnd={() => { isPanningRef.current = false; document.documentElement.classList.remove("canvas-interacting"); }}
onNodeDragStart={() => { isDraggingNodeRef.current = true; document.documentElement.classList.add("canvas-interacting"); }}
onNodeDragStop={(event, node) => { isDraggingNodeRef.current = false; document.documentElement.classList.remove("canvas-interacting"); handleNodeDragStop(event, node); }}
onSelectionStart={handleSelectionStart}
onSelectionEnd={handleSelectionEnd}
onSelectionChange={handleSelectionChange}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}

30
src/components/__tests__/GenerationComposer.test.tsx

@ -427,6 +427,36 @@ describe("GenerationComposer", () => {
expect(screen.getByPlaceholderText(/Describe what you want to generate/)).toHaveClass("h-[13.5rem]");
});
it("positions the selected node editor below grouped nodes using absolute canvas coordinates", () => {
useWorkflowStore.setState({
nodes: [
{
id: "group-node-group-1",
type: "group",
position: { x: 400, y: 200 },
data: { groupId: "group-1" },
style: { width: 500, height: 360 },
} as WorkflowNode,
{
...imageNode("img-1", true),
parentId: "group-node-group-1",
extent: "parent",
expandParent: true,
groupId: "group-1",
position: { x: 100, y: 40 },
measured: { width: 260, height: 180 },
} as WorkflowNode,
],
});
render(<GenerationComposer />);
expect(screen.getByTestId("node-inline-composer")).toHaveStyle({
left: "108px",
top: "436px",
});
});
it("keeps the selected node editor below the node when below would leave the viewport", () => {
const originalInnerHeight = window.innerHeight;
const originalInnerWidth = window.innerWidth;

34
src/components/composer/GenerationComposer.tsx

@ -892,6 +892,30 @@ function getAdapter(nodeType: NodeType | null): ComposerAdapter<WorkflowNodeData
return null;
}
function getAbsoluteNodePosition(
node: WorkflowNode,
nodes: WorkflowNode[]
): { x: number; y: number } {
const nodeById = new Map(nodes.map((item) => [item.id, item]));
const visited = new Set<string>();
const resolve = (current: WorkflowNode): { x: number; y: number } => {
if (!current.parentId || visited.has(current.id)) return current.position;
visited.add(current.id);
const parent = nodeById.get(current.parentId);
if (!parent) return current.position;
const parentPosition = resolve(parent);
return {
x: parentPosition.x + current.position.x,
y: parentPosition.y + current.position.y,
};
};
return resolve(node);
}
function deriveComposerContext(nodes: WorkflowNode[], edges: WorkflowEdge[]): ComposerContext {
const selectedNodes = nodes.filter((node) => node.selected);
if (selectedNodes.length === 0) {
@ -1702,6 +1726,10 @@ export function GenerationComposer() {
() => context.node ? getComposerNodeDimensions(context.node) : null,
[context.node]
);
const selectedNodePosition = useMemo(
() => context.node ? getAbsoluteNodePosition(context.node, nodes) : null,
[context.node, nodes]
);
const safeZoom = zoom > 0 ? zoom : 1;
const viewportWidth = typeof window === "undefined" ? Number.POSITIVE_INFINITY : window.innerWidth;
const visibleLeft = -viewportX / safeZoom;
@ -1709,15 +1737,15 @@ export function GenerationComposer() {
const composerWidth = isComposerCollapsed ? COMPOSER_COLLAPSED_WIDTH : COMPOSER_EXPANDED_WIDTH;
const composerFlowWidth = composerWidth / safeZoom;
const viewportMargin = COMPOSER_VIEWPORT_MARGIN / safeZoom;
const inlineComposerPosition = context.node && selectedNodeDimensions
const inlineComposerPosition = context.node && selectedNodeDimensions && selectedNodePosition
? (() => {
const centeredLeft = context.node.position.x + selectedNodeDimensions.width / 2 - composerFlowWidth / 2;
const centeredLeft = selectedNodePosition.x + selectedNodeDimensions.width / 2 - composerFlowWidth / 2;
const minLeft = visibleLeft + viewportMargin;
const maxLeft = visibleRight - composerFlowWidth - viewportMargin;
const left = maxLeft >= minLeft
? Math.min(Math.max(centeredLeft, minLeft), maxLeft)
: minLeft;
const belowTop = context.node.position.y + selectedNodeDimensions.height + viewportMargin;
const belowTop = selectedNodePosition.y + selectedNodeDimensions.height + viewportMargin;
return {
left,

Loading…
Cancel
Save