feat(editor): Reposition nodes on group collapse/expand (#32128)

Co-authored-by: Daria Staferova <[email protected]>
Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
Svetoslav Dekov
2026-06-15 14:13:46 +00:00
committed by GitHub
co-authored by Daria Staferova Claude Opus 4.7
parent 93e2f8629e
commit 053126fc7c
18 changed files with 2231 additions and 77 deletions
@@ -5732,6 +5732,7 @@ describe('useCanvasOperations', () => {
expect(createGroupSpy).toHaveBeenCalledWith([newId1, newId2], 'My Group', {
markDirty: true,
startCollapsed: true,
});
expect(workflowDocumentStoreInstance.getNextDefaultName).not.toHaveBeenCalled();
});
@@ -5790,6 +5791,7 @@ describe('useCanvasOperations', () => {
expect(workflowDocumentStoreInstance.getNextDefaultName).toHaveBeenCalledWith('My Group');
expect(createGroupSpy).toHaveBeenCalledWith(expect.any(Array), 'My Group 2', {
markDirty: true,
startCollapsed: true,
});
});
@@ -5843,6 +5845,7 @@ describe('useCanvasOperations', () => {
expect(createGroupSpy).toHaveBeenCalledWith(result.nodeGroups?.[0].nodeIds, 'My Group', {
markDirty: false,
startCollapsed: true,
});
});
});
@@ -3007,7 +3007,13 @@ export function useCanvasOperations() {
? workflowDocumentStore.value.getNextDefaultName(group.name)
: group.name;
workflowDocumentStore.value.createGroup(group.nodeIds, name, { markDirty: setStateDirty });
// Imported groups start collapsed: their stored positions describe the
// collapsed layout (push offsets are view-only and not serialized), so
// expanding them without a live push would overlap surrounding nodes.
workflowDocumentStore.value.createGroup(group.nodeIds, name, {
markDirty: setStateDirty,
startCollapsed: true,
});
existingGroupNames.add(name);
}
}
@@ -34,6 +34,18 @@ describe('useWorkflowDocumentNodeGroups', () => {
expect(dirtySpy).not.toHaveBeenCalled();
expect(nodeGroups.allGroups.value).toHaveLength(1);
});
it('carries startCollapsed on the ADD event so the view can skip auto-expand', () => {
const changeSpy = vi.fn();
nodeGroups.onNodeGroupsChange(changeSpy);
const group = nodeGroups.createGroup(['a', 'b'], 'A', { startCollapsed: true });
expect(changeSpy).toHaveBeenCalledWith({
action: 'add',
payload: { group, startCollapsed: true },
});
});
});
describe('getNextDefaultName', () => {
@@ -8,6 +8,10 @@ export type NodeGroupPayload = {
group: IWorkflowGroup;
};
export type NodeGroupAddedPayload = NodeGroupPayload & {
startCollapsed?: boolean;
};
export type NodeGroupRemovedPayload = {
id: string;
};
@@ -19,7 +23,7 @@ export type NodeGroupsSetPayload = {
// Discriminated by `action` so subscribers can narrow `payload` without casts.
export type NodeGroupChangeEvent =
| { action: typeof CHANGE_ACTION.SET; payload: NodeGroupsSetPayload }
| { action: typeof CHANGE_ACTION.ADD; payload: NodeGroupPayload }
| { action: typeof CHANGE_ACTION.ADD; payload: NodeGroupAddedPayload }
| { action: typeof CHANGE_ACTION.UPDATE; payload: NodeGroupPayload }
| { action: typeof CHANGE_ACTION.DELETE; payload: NodeGroupRemovedPayload };
@@ -27,6 +31,11 @@ type NodeGroupMutationOptions = {
markDirty?: boolean;
};
type NodeGroupCreateOptions = NodeGroupMutationOptions & {
/** Start the group collapsed in the canvas view (e.g. imported/pasted groups). */
startCollapsed?: boolean;
};
export function useWorkflowDocumentNodeGroups() {
const groups = ref<Map<string, IWorkflowGroup>>(new Map());
@@ -57,10 +66,14 @@ export function useWorkflowDocumentNodeGroups() {
function applyUpsertGroup(
group: IWorkflowGroup,
action: typeof CHANGE_ACTION.ADD | typeof CHANGE_ACTION.UPDATE,
{ markDirty = true }: NodeGroupMutationOptions = {},
{ markDirty = true, startCollapsed }: NodeGroupCreateOptions = {},
) {
groups.value.set(group.id, group);
void onNodeGroupsChange.trigger({ action, payload: { group } });
if (action === CHANGE_ACTION.ADD) {
void onNodeGroupsChange.trigger({ action, payload: { group, startCollapsed } });
} else {
void onNodeGroupsChange.trigger({ action, payload: { group } });
}
if (markDirty) {
void onStateDirty.trigger();
}
@@ -82,7 +95,7 @@ export function useWorkflowDocumentNodeGroups() {
function createGroup(
nodeIds: string[],
name: string,
options: NodeGroupMutationOptions = {},
options: NodeGroupCreateOptions = {},
): IWorkflowGroup {
const group: IWorkflowGroup = {
id: window.crypto.randomUUID(),
@@ -141,6 +141,16 @@ export const CANVAS_NODE_GROUP_ID_PREFIX = 'group:';
export const CANVAS_NODE_GROUP_HANDLE_LEFT = 'left';
export const CANVAS_NODE_GROUP_HANDLE_RIGHT = 'right';
export function createCanvasGroupNodeId(groupId: string): string {
return `${CANVAS_NODE_GROUP_ID_PREFIX}${groupId}`;
}
export function parseCanvasGroupNodeId(id: string): string | undefined {
return id.startsWith(CANVAS_NODE_GROUP_ID_PREFIX)
? id.slice(CANVAS_NODE_GROUP_ID_PREFIX.length)
: undefined;
}
/**
* The only execution states a group can surface — node-level statuses like
* `crashed` are folded into these during aggregation.
@@ -396,7 +396,7 @@ const {
canGroup: canGroupSelection,
canUngroup: canUngroupSelection,
groupSelection,
ungroupSelection,
selectedGroupIds,
} = useCanvasNodeGroupActions(selectedNodes, {
readOnly: () => props.readOnly || props.suppressInteraction,
});
@@ -476,7 +476,11 @@ const keyMap = computed(() => {
fullKeymap.ctrl_shift_g = {
disabled: () => !canUngroupSelection.value,
run: () => {
ungroupSelection();
// Through the same path as the title-bar button so push effects
// are committed before each group is removed.
for (const groupId of selectedGroupIds.value) {
onCanvasGroupUngroup(groupId);
}
},
};
}
@@ -548,12 +552,35 @@ function onClickNodeAdd(id: string, handle: string) {
emit('click:node:add', id, handle);
}
function onUpdateNodesPosition(events: CanvasNodeMoveEvent[]) {
emit('update:nodes:position', events);
function getStoredNodePositionById(nodeId: string) {
return workflowDocumentStore.value.getNodeById(nodeId)?.position;
}
function onUpdateNodePosition(id: string, position: XYPosition) {
emit('update:node:position', id, position);
commitManualNodePositions([{ id, position }]);
}
function commitManualNodePositions(events: CanvasNodeMoveEvent[]) {
emit(
'update:nodes:position',
injectedNodeGroupView?.settleManualNodePositions(events, getStoredNodePositionById) ?? events,
);
}
// Bake the positions of nodes that `sourceGroupIds` were visually pushing into
// the document, so those targets stay put instead of snapping back when the
// source stops pushing — whether it was moved or removed via ungroup.
function commitPushedPositionsForSourceGroups(sourceGroupIds: string[]) {
if (!injectedNodeGroupView || sourceGroupIds.length === 0) return;
const pushedNodeMoves = injectedNodeGroupView.commitMovedPushSourceEffects(
sourceGroupIds,
(nodeId) => workflowDocumentStore.value.getNodeById(nodeId)?.position,
);
if (pushedNodeMoves.length > 0) {
emit('update:nodes:position', pushedNodeMoves);
}
}
const groupDrag = useCanvasNodeGroupDrag({
@@ -562,7 +589,9 @@ const groupDrag = useCanvasNodeGroupDrag({
getGroupById: (id) => workflowDocumentStore.value.getGroupById(id),
getGroupForNode: (id) => workflowDocumentStore.value.getGroupForNode(id),
isNodeInGroup: (id) => workflowDocumentStore.value.nodeIdToGroupId.has(id),
getNodeVisualOffset: (id) => injectedNodeGroupView?.getVisualOffsetForNode(id) ?? { x: 0, y: 0 },
getNodeDisplaySize: (id) => props.nodeDisplaySizeById?.[id],
onMovedExpandedGroups: commitPushedPositionsForSourceGroups,
});
function onNodeDragStart(event: NodeDragEvent) {
@@ -575,7 +604,7 @@ function onNodeDrag(event: NodeDragEvent) {
function onNodeDragStop(event: NodeDragEvent) {
const moves = groupDrag.processNodeDragStop(event);
if (moves.length > 0) onUpdateNodesPosition(moves);
if (moves.length > 0) commitManualNodePositions(moves);
}
function onSelectionDragStart(event: NodeDragEvent) {
@@ -603,6 +632,16 @@ function onCanvasGroupNameUpdate(groupId: string, name: string) {
}
function onCanvasGroupUngroup(groupId: string) {
// Ungrouping a collapsed group makes its hidden members reappear, so expand
// it first: the expansion pushes overlapping nodes aside, and the commit
// below persists that displacement (the group is gone after, so the push
// can't stay live).
if (injectedNodeGroupView?.isGroupCollapsed(groupId)) {
injectedNodeGroupView.toggleCollapsed(groupId);
}
// Removing the group also removes its push, so commit anything it was
// pushing first — same principle as a newly created group not pushing.
commitPushedPositionsForSourceGroups([groupId]);
workflowDocumentStore.value.deleteGroup(groupId);
}
@@ -634,7 +673,7 @@ function onNodeClick({ event, node }: NodeMouseEvent) {
function onSelectionDragStop(event: NodeDragEvent) {
const moves = groupDrag.processSelectionDragStop(event);
if (moves.length > 0) onUpdateNodesPosition(moves);
if (moves.length > 0) commitManualNodePositions(moves);
}
function onSelectionEnd(event: MouseEvent) {
@@ -23,6 +23,7 @@ import { createEmptyCanvasRenderData, type CanvasRenderData } from '../canvas.ut
import { useCanvasMapping } from '../composables/useCanvasMapping';
import { mapGroupsToVueFlowNodes } from '../composables/useCanvasMapping.groups';
import { NodeGroupViewKey, useCanvasNodeGroupView } from '../composables/useCanvasNodeGroupView';
import { buildNodeGroupLayoutComponents } from '../composables/useCanvasNodeGroupLayout';
import Canvas from './Canvas.vue';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import { useWorkflowDocumentRenderData } from '@/app/stores/workflowDocument/useWorkflowDocumentRenderData';
@@ -124,11 +125,30 @@ const {
isExperimentalNdvActive,
});
const layoutComponents = computed(() =>
// Without grouping enabled or without groups there can be no pushes —
// skip building per-node components.
!isCanvasNodeGroupingEnabled.value || workflowDocumentStore.value.allGroups.length === 0
? []
: buildNodeGroupLayoutComponents({
allGroups: workflowDocumentStore.value.allGroups,
nodes: nodes.value,
getNodeById: (id) => workflowDocumentStore.value.getNodeById(id),
getNodeDisplaySize: (id) => nodeDisplaySizeById.value[id],
isGroupCollapsed: (id) => nodeGroupView.isGroupCollapsed(id),
}),
);
watch(layoutComponents, (components) => nodeGroupView.syncLayoutComponents(components), {
immediate: true,
});
const mappedGroupVueFlowNodes = computed(() =>
mapGroupsToVueFlowNodes({
allGroups: allGroups.value,
getNodeById: (id) => workflowDocumentStore.value.getNodeById(id),
getNodeDisplaySize: (id) => nodeDisplaySizeById.value[id],
getGroupVisualOffset: (id) => nodeGroupView.getVisualOffsetForComponent(id),
isGroupCollapsed: (id) => nodeGroupView.isGroupCollapsed(id),
readOnly: readOnlyRef.value || suppressInteractionRef.value,
getNodeExecutionSnapshot,
@@ -351,6 +351,29 @@ describe('mapGroupsToVueFlowNodes', () => {
expect(Math.abs(out[0].position.x % GRID_SIZE)).toBe(0);
expect(Math.abs(out[0].position.y % GRID_SIZE)).toBe(0);
});
it('applies visual offsets without changing nodesRect from store positions', () => {
const getById = nodeStore(makeNode('a', 100, 200));
const out = mapGroupsToVueFlowNodes({
allGroups: [{ id: 'g1', name: 'G', nodeIds: ['a'] }],
getNodeById: getById,
getGroupVisualOffset: () => ({ x: 50, y: 80 }),
isGroupCollapsed: () => false,
readOnly: false,
getNodeExecutionSnapshot: snapshotGetter(),
});
expect(out[0].position).toEqual({
x: snapToGrid(100 - GROUP_PADDING_X) + 50,
y: snapToGrid(200 - GROUP_PADDING_Y_TOP - GROUP_HEADER_HEIGHT) + 80,
});
expect(out[0].data?.nodesRect).toEqual({
x: 100,
y: 200,
width: 96,
height: 96,
});
});
});
describe('buildCollapsedGroupByNodeId', () => {
@@ -12,6 +12,7 @@ import {
CANVAS_NODE_GROUP_HANDLE_RIGHT,
CANVAS_NODE_GROUP_ID_PREFIX,
CANVAS_NODE_GROUP_TYPE,
createCanvasGroupNodeId,
} from '../canvas.types';
import {
GROUP_HEADER_HEIGHT,
@@ -186,6 +187,7 @@ export interface MapGroupsToVueFlowNodesInputs {
allGroups: IWorkflowGroup[];
getNodeById: (id: string) => INodeUi | undefined;
getNodeDisplaySize?: GetNodeDisplaySize;
getGroupVisualOffset?: (id: string) => { x: number; y: number };
isGroupCollapsed: (id: string) => boolean;
readOnly: boolean;
getNodeExecutionSnapshot: (id: string) => NodeExecutionSnapshot;
@@ -199,6 +201,7 @@ export function mapGroupsToVueFlowNodes({
allGroups,
getNodeById,
getNodeDisplaySize,
getGroupVisualOffset,
isGroupCollapsed,
readOnly,
getNodeExecutionSnapshot,
@@ -219,11 +222,16 @@ export function mapGroupsToVueFlowNodes({
executionStatus: aggregateGroupExecution(group.nodeIds, getNodeExecutionSnapshot),
};
const id = createCanvasGroupNodeId(group.id);
const titleBar = titleBarFromNodesRect(nodesRect, collapsed);
const offset = getGroupVisualOffset?.(id) ?? { x: 0, y: 0 };
out.push({
id: `${CANVAS_NODE_GROUP_ID_PREFIX}${group.id}`,
id,
type: CANVAS_NODE_GROUP_TYPE,
position: titleBar.position,
position: {
x: titleBar.position.x + offset.x,
y: titleBar.position.y + offset.y,
},
width: titleBar.width,
height: GROUP_HEADER_HEIGHT,
draggable: !readOnly,
@@ -178,12 +178,13 @@ export function useCanvasMapping({
rd.renderTypeByNodeId.get(node.id)?.value ??
({ type: node.type, options: {} } as CanvasNodeData['render']),
};
const offset = nodeGroupView?.getVisualOffsetForNode(node.id) ?? { x: 0, y: 0 };
return {
id: node.id,
label: node.name,
type: 'canvas-node',
position: { x: node.position[0], y: node.position[1] },
position: { x: node.position[0] + offset.x, y: node.position[1] + offset.y },
data,
...additionalProperties[node.id],
draggable: node.draggable,
@@ -125,25 +125,23 @@ describe('useCanvasNodeGroupActions', () => {
});
});
describe('ungroupSelection', () => {
it('deletes all groups containing any selected node', () => {
describe('selectedGroupIds', () => {
it('lists each group containing any selected node once', () => {
const groupA = workflowDocumentStore.createGroup(['a', 'b'], 'Group A');
const groupB = workflowDocumentStore.createGroup(['c', 'd'], 'Group B');
workflowDocumentStore.createGroup(['e', 'f'], 'Group C');
const { ungroupSelection } = useCanvasNodeGroupActions(
computed(() => [makeNode('a'), makeNode('c')]),
const { selectedGroupIds } = useCanvasNodeGroupActions(
computed(() => [makeNode('a'), makeNode('b'), makeNode('c')]),
);
const deletedIds = ungroupSelection();
expect(deletedIds).toEqual(expect.arrayContaining([groupA.id, groupB.id]));
expect(workflowDocumentStore.allGroups).toHaveLength(1);
expect(workflowDocumentStore.allGroups[0].name).toBe('Group C');
expect(selectedGroupIds.value).toEqual(expect.arrayContaining([groupA.id, groupB.id]));
expect(selectedGroupIds.value).toHaveLength(2);
});
it('returns an empty array when no selected node belongs to a group', () => {
const { ungroupSelection } = useCanvasNodeGroupActions(computed(() => [makeNode('a')]));
expect(ungroupSelection()).toEqual([]);
it('is empty when no selected node belongs to a group', () => {
const { selectedGroupIds } = useCanvasNodeGroupActions(computed(() => [makeNode('a')]));
expect(selectedGroupIds.value).toEqual([]);
});
});
});
@@ -46,20 +46,11 @@ export function useCanvasNodeGroupActions(
return workflowDocumentStore.value.createGroup(expandedSelectionIds.value, name);
}
function ungroupSelection(): string[] {
const ids = selectedGroupIds.value;
for (const id of ids) {
workflowDocumentStore.value.deleteGroup(id);
}
return ids;
}
return {
canGroup,
canUngroup,
expandedSelectionIds,
selectedGroupIds,
groupSelection,
ungroupSelection,
};
}
@@ -63,6 +63,7 @@ describe('useCanvasNodeGroupDrag', () => {
function setup(opts?: {
groups?: Array<{ id: string; nodeIds: string[] }>;
getNodeDisplaySize?: (id: string) => { width: number; height: number } | undefined;
getNodeVisualOffset?: (id: string) => { x: number; y: number };
}) {
updateNodeMock.mockClear();
findNodeMock.mockReset();
@@ -84,6 +85,7 @@ describe('useCanvasNodeGroupDrag', () => {
getGroupForNode,
isNodeInGroup,
getNodeDisplaySize: opts?.getNodeDisplaySize,
getNodeVisualOffset: opts?.getNodeVisualOffset,
});
return { drag, getGroupForNode, isNodeInGroup };
}
@@ -186,17 +188,33 @@ describe('useCanvasNodeGroupDrag', () => {
]);
});
it('emits moves when drag has zero delta', () => {
// VueFlow fires drag-stop for click-without-drag — emit unchanged
// positions for consistency with single-node behaviour.
it('emits no moves when drag has zero delta', () => {
// VueFlow fires drag-stop for click-without-drag — committing the
// unchanged positions would bake live push offsets into the document.
const { drag } = setup();
const groupNode = makeGroupGraphNode('group:g1', 0, 0);
drag.onNodeDragStart(makeEvent(groupNode));
const moves = drag.processNodeDragStop(makeEvent(groupNode));
// Zero delta; positions are unchanged.
expect(moves).toEqual([]);
});
it('does not bake push offsets when a pushed group title bar is clicked without moving', () => {
const { drag } = setup({ getNodeVisualOffset: () => ({ x: 0, y: 216 }) });
const groupNode = makeGroupGraphNode('group:g1', 0, 0);
drag.onNodeDragStart(makeEvent(groupNode));
const moves = drag.processNodeDragStop(makeEvent(groupNode));
expect(moves).toEqual([]);
});
it('bakes push offsets into member moves when the group actually moved', () => {
const { drag } = setup({ getNodeVisualOffset: () => ({ x: 0, y: 216 }) });
const groupNode = makeGroupGraphNode('group:g1', 0, 0);
drag.onNodeDragStart(makeEvent(groupNode));
groupNode.position = { x: 50, y: 0 };
const moves = drag.processNodeDragStop(makeEvent(groupNode));
expect(moves).toEqual([
{ id: 'a', position: { x: 100, y: 200 } },
{ id: 'b', position: { x: 300, y: 200 } },
{ id: 'a', position: { x: 150, y: 416 } },
{ id: 'b', position: { x: 350, y: 416 } },
]);
});
@@ -294,6 +312,32 @@ describe('useCanvasNodeGroupDrag', () => {
expect(patch.data.nodesRect).toEqual({ x: 120, y: 200, width: 280, height: 100 });
});
it('keeps a pushed group title bar at its visual position while a member is dragged', () => {
const nodeDimensions: Record<string, { width: number; height: number }> = {
a: { width: 100, height: 80 },
b: { width: 100, height: 80 },
};
const { drag } = setup({
getNodeDisplaySize: (id) => nodeDimensions[id],
getNodeVisualOffset: () => ({ x: 0, y: 216 }),
});
findNodeMock.mockImplementation((id: string) => (id === 'group:g1' ? { id } : undefined));
// 'a' is rendered at stored + push offset (100, 416); the drag moved it by (20, 20).
const node = makeRegularGraphNode('a', 120, 436);
drag.onNodeDrag(makeEvent(node));
const updater = updateNodeMock.mock.calls.find((call) => call[0] === 'group:g1')![1];
const patch = updater({ data: {} });
// Unmoved member 'b' contributes its visual position (300, 416), not its
// store position (300, 200) — the title bar must not jump up by the offset.
expect(patch.data.nodesRect).toEqual({ x: 120, y: 416, width: 280, height: 100 });
expect(patch.position).toEqual({
x: snapToGrid(120 - GROUP_PADDING_X),
y: snapToGrid(416 - GROUP_PADDING_Y_TOP - GROUP_HEADER_HEIGHT),
});
});
it('syncs the owning group title bar during multi-node node drag', () => {
const nodeDimensions: Record<string, { width: number; height: number }> = {
a: { width: 100, height: 80 },
@@ -2,7 +2,11 @@ import type { GraphNode, NodeDragEvent } from '@vue-flow/core';
import { useVueFlow } from '@vue-flow/core';
import type { INodeUi } from '@/Interface';
import type { CanvasGroupNodeData, CanvasNodeMoveEvent } from '../canvas.types';
import { CANVAS_NODE_GROUP_ID_PREFIX, isCanvasGroupNode } from '../canvas.types';
import {
createCanvasGroupNodeId,
isCanvasGroupNode,
parseCanvasGroupNodeId,
} from '../canvas.types';
import {
computeNodesRectFromStore,
titleBarFromNodesRect,
@@ -15,12 +19,15 @@ export interface UseCanvasNodeGroupDragDeps {
getGroupById: (groupId: string) => { nodeIds: string[] } | undefined;
getGroupForNode: (nodeId: string) => { id: string; nodeIds: string[] } | undefined;
isNodeInGroup: (nodeId: string) => boolean;
getNodeVisualOffset?: (id: string) => { x: number; y: number };
getNodeDisplaySize?: GetNodeDisplaySize;
onMovedExpandedGroups?: (groupIds: string[]) => void;
}
interface GroupDragSnapshot {
initialGroupPos: { x: number; y: number };
initialNodePositions: Map<string, { x: number; y: number }>;
initialNodeVisualOffsets: Map<string, { x: number; y: number }>;
}
/**
@@ -36,8 +43,8 @@ export function useCanvasNodeGroupDrag(deps: UseCanvasNodeGroupDragDeps) {
let skipPairedNodeDragStop = false;
function getGroupNodeIds(groupVueFlowId: string): string[] {
if (!groupVueFlowId.startsWith(CANVAS_NODE_GROUP_ID_PREFIX)) return [];
const groupId = groupVueFlowId.slice(CANVAS_NODE_GROUP_ID_PREFIX.length);
const groupId = parseCanvasGroupNodeId(groupVueFlowId);
if (groupId === undefined) return [];
return deps.getGroupById(groupId)?.nodeIds ?? [];
}
@@ -45,15 +52,18 @@ export function useCanvasNodeGroupDrag(deps: UseCanvasNodeGroupDragDeps) {
const nodeIds = getGroupNodeIds(groupVueFlowNode.id);
if (nodeIds.length === 0) return;
const initialNodePositions = new Map<string, { x: number; y: number }>();
const initialNodeVisualOffsets = new Map<string, { x: number; y: number }>();
for (const id of nodeIds) {
const node = deps.getNodeById(id);
if (node) {
initialNodePositions.set(id, { x: node.position[0], y: node.position[1] });
initialNodeVisualOffsets.set(id, deps.getNodeVisualOffset?.(id) ?? { x: 0, y: 0 });
}
}
snapshots.set(groupVueFlowNode.id, {
initialGroupPos: { x: groupVueFlowNode.position.x, y: groupVueFlowNode.position.y },
initialNodePositions,
initialNodeVisualOffsets,
});
}
@@ -63,27 +73,35 @@ export function useCanvasNodeGroupDrag(deps: UseCanvasNodeGroupDragDeps) {
const dx = groupVueFlowNode.position.x - snap.initialGroupPos.x;
const dy = groupVueFlowNode.position.y - snap.initialGroupPos.y;
for (const [id, p] of snap.initialNodePositions) {
updateNode(id, { position: { x: p.x + dx, y: p.y + dy } });
const offset = snap.initialNodeVisualOffsets.get(id) ?? { x: 0, y: 0 };
updateNode(id, { position: { x: p.x + offset.x + dx, y: p.y + offset.y + dy } });
}
}
function collectMemberMoves(): {
moves: CanvasNodeMoveEvent[];
movedNodeIds: Set<string>;
movedGroupIds: Set<string>;
} {
const moves: CanvasNodeMoveEvent[] = [];
const movedNodeIds = new Set<string>();
const movedGroupIds = new Set<string>();
for (const [groupVueFlowId, snap] of snapshots) {
const finalPos = finalGroupPositions.get(groupVueFlowId);
if (!finalPos) continue;
const dx = finalPos.x - snap.initialGroupPos.x;
const dy = finalPos.y - snap.initialGroupPos.y;
if (dx !== 0 || dy !== 0) {
const groupId = parseCanvasGroupNodeId(groupVueFlowId);
if (groupId !== undefined) movedGroupIds.add(groupId);
}
for (const [id, p] of snap.initialNodePositions) {
moves.push({ id, position: { x: p.x + dx, y: p.y + dy } });
const offset = snap.initialNodeVisualOffsets.get(id) ?? { x: 0, y: 0 };
moves.push({ id, position: { x: p.x + offset.x + dx, y: p.y + offset.y + dy } });
movedNodeIds.add(id);
}
}
return { moves, movedNodeIds };
return { moves, movedNodeIds, movedGroupIds };
}
function recordFinalPosition(node: GraphNode) {
@@ -105,6 +123,21 @@ export function useCanvasNodeGroupDrag(deps: UseCanvasNodeGroupDragDeps) {
return out;
}
// VueFlow fires drag-stop even for a click without movement. A move that
// matches the node's current visual position (store + push offset) is a
// no-op — committing it would bake live push offsets into the document.
function dropNoopMoves(moves: CanvasNodeMoveEvent[]): CanvasNodeMoveEvent[] {
return moves.filter((move) => {
const node = deps.getNodeById(move.id);
if (!node) return true;
const offset = deps.getNodeVisualOffset?.(move.id) ?? { x: 0, y: 0 };
return (
node.position[0] + offset.x !== move.position.x ||
node.position[1] + offset.y !== move.position.y
);
});
}
// Live push so the title bar tracks the drag — store positions only
// catch up on drag-stop. Same formula as mapping, with live overrides.
function syncGroupBoundsFromNodes(
@@ -132,9 +165,23 @@ export function useCanvasNodeGroupDrag(deps: UseCanvasNodeGroupDragDeps) {
if (groupsToSync.size === 0) return;
for (const group of groupsToSync.values()) {
const groupVueFlowId = `${CANVAS_NODE_GROUP_ID_PREFIX}${group.id}`;
const groupVueFlowId = createCanvasGroupNodeId(group.id);
if (!findNode(groupVueFlowId)) continue; // title bar not yet rendered
// Dragged-node overrides are visual positions; lift the unmoved members
// from store space to visual space too, so a pushed group's title bar
// doesn't jump to the un-pushed rect for the duration of the drag.
for (const nodeId of group.nodeIds) {
if (positionOverrides.has(nodeId)) continue;
const node = deps.getNodeById(nodeId);
if (!node) continue;
const offset = deps.getNodeVisualOffset?.(nodeId) ?? { x: 0, y: 0 };
positionOverrides.set(nodeId, {
x: node.position[0] + offset.x,
y: node.position[1] + offset.y,
});
}
const rect = computeNodesRectFromStore(
group.nodeIds,
deps.getNodeById,
@@ -198,6 +245,17 @@ export function useCanvasNodeGroupDrag(deps: UseCanvasNodeGroupDragDeps) {
handleDragTick([event.node]);
}
// Shared drag-stop tail: report which expanded groups actually moved and
// translate group deltas into member node moves.
function finalizeDragStop(eventNodes: GraphNode[]): CanvasNodeMoveEvent[] {
const { moves: memberMoves, movedNodeIds, movedGroupIds } = collectMemberMoves();
if (movedGroupIds.size > 0) {
deps.onMovedExpandedGroups?.([...movedGroupIds]);
}
reset();
return dropNoopMoves([...memberMoves, ...nonGroupMoves(eventNodes, movedNodeIds)]);
}
function processNodeDragStop(event: NodeDragEvent): CanvasNodeMoveEvent[] {
if (skipPairedNodeDragStop) {
skipPairedNodeDragStop = false;
@@ -207,18 +265,14 @@ export function useCanvasNodeGroupDrag(deps: UseCanvasNodeGroupDragDeps) {
for (const node of event.nodes ?? []) {
if (isCanvasGroupNode(node)) recordFinalPosition(node);
}
const { moves: memberMoves, movedNodeIds } = collectMemberMoves();
reset();
return [...memberMoves, ...nonGroupMoves(event.nodes ?? [], movedNodeIds)];
return finalizeDragStop(event.nodes ?? []);
}
if (!isCanvasGroupNode(event.node) || !snapshots.has(event.node.id)) {
reset();
return nonGroupMoves(event.nodes ?? [], new Set());
return dropNoopMoves(nonGroupMoves(event.nodes ?? [], new Set()));
}
recordFinalPosition(event.node);
const { moves: memberMoves, movedNodeIds } = collectMemberMoves();
reset();
return [...memberMoves, ...nonGroupMoves(event.nodes ?? [], movedNodeIds)];
return finalizeDragStop(event.nodes ?? []);
}
function onSelectionDragStart(event: NodeDragEvent) {
@@ -240,16 +294,15 @@ export function useCanvasNodeGroupDrag(deps: UseCanvasNodeGroupDragDeps) {
reset();
isSelectionBoxDragInProgress = false;
skipPairedNodeDragStop = true;
return nonGroupMoves(event.nodes ?? [], new Set());
return dropNoopMoves(nonGroupMoves(event.nodes ?? [], new Set()));
}
for (const node of event.nodes ?? []) {
if (isCanvasGroupNode(node)) recordFinalPosition(node);
}
const { moves: memberMoves, movedNodeIds } = collectMemberMoves();
reset();
const moves = finalizeDragStop(event.nodes ?? []);
isSelectionBoxDragInProgress = false;
skipPairedNodeDragStop = true;
return [...memberMoves, ...nonGroupMoves(event.nodes ?? [], movedNodeIds)];
return moves;
}
return {
@@ -0,0 +1,483 @@
import { describe, expect, it } from 'vitest';
import type { IWorkflowGroup } from 'n8n-workflow';
import type { INodeUi } from '@/Interface';
import {
aggregateNodeGroupLayoutOffsets,
buildNodeGroupLayoutComponents,
computeNodeGroupLayoutPushes,
type GroupLayoutComponent,
type NodeGroupLayoutComponent,
type NodeLayoutComponent,
} from './useCanvasNodeGroupLayout';
import {
createCanvasGroupNodeId as groupComponentId,
type BoundingBox,
} from '@/features/workflows/canvas/canvas.types';
function makeNode(id: string, x: number, y: number): INodeUi {
return {
id,
name: id,
type: 'n8n-nodes-base.noop',
typeVersion: 1,
position: [x, y],
parameters: {},
disabled: false,
} as INodeUi;
}
function makeGroupComponent(groupId: string, expandedRect: BoundingBox): GroupLayoutComponent {
return {
id: groupComponentId(groupId),
kind: 'group',
groupId,
nodeIds: [`${groupId}-node`],
rect: expandedRect,
collapsedRect: { x: expandedRect.x, y: expandedRect.y, width: 400, height: 40 },
expandedRect,
};
}
function makeNodeComponent(id: string, rect: BoundingBox): NodeLayoutComponent {
return { id, kind: 'node', nodeIds: [id], rect };
}
function nodeStore(nodes: INodeUi[]) {
const byId = new Map(nodes.map((node) => [node.id, node]));
return (id: string) => byId.get(id);
}
describe('useCanvasNodeGroupLayout', () => {
const group: IWorkflowGroup = { id: 'g1', name: 'G1', nodeIds: ['a', 'b'] };
interface LayoutOptions {
ignored?: Map<string, Set<string>>;
order?: string[];
}
function computeOffsets(
components: NodeGroupLayoutComponent[],
expandedGroupIds: Set<string>,
{ ignored, order }: LayoutOptions = {},
) {
return aggregateNodeGroupLayoutOffsets(
computeNodeGroupLayoutPushes({
components,
expandedGroupIds,
ignoredNodeIdsBySourceGroup: ignored,
expandedGroupIdOrder: order,
}),
);
}
function layout(
nodes: INodeUi[],
{
groups = [group],
expanded = new Set(['g1']),
...options
}: LayoutOptions & { groups?: IWorkflowGroup[]; expanded?: Set<string> } = {},
) {
const components = buildNodeGroupLayoutComponents({
allGroups: groups,
nodes,
getNodeById: nodeStore(nodes),
isGroupCollapsed: (id) => !expanded.has(id),
});
return computeOffsets(components, expanded, options);
}
it('pushes components in the right-side region to the right', () => {
const offsets = layout([
makeNode('a', 100, 200),
makeNode('b', 400, 200),
makeNode('x', 520, 210),
]);
expect(offsets.get('x')?.x).toBeGreaterThan(0);
expect(offsets.get('x')?.y).toBe(0);
});
it('pushes components in the lower region down', () => {
const offsets = layout([
makeNode('a', 100, 200),
makeNode('b', 400, 200),
makeNode('x', 120, 320),
]);
expect(offsets.get('x')?.x).toBe(0);
expect(offsets.get('x')?.y).toBeGreaterThan(0);
});
it('moves components in the affected right-side expansion band together', () => {
const offsets = layout([
makeNode('a', 100, 200),
makeNode('b', 400, 200),
makeNode('near', 520, 210),
makeNode('middle', 620, 210),
makeNode('far', 820, 210),
]);
expect(offsets.get('near')).toEqual(offsets.get('middle'));
expect(offsets.get('near')).toEqual(offsets.get('far'));
expect(offsets.get('far')?.x).toBeGreaterThan(0);
});
it('does not move right-side components when nothing overlaps the expanded group', () => {
const offsets = layout([
makeNode('a', 100, 200),
makeNode('b', 400, 200),
makeNode('far', 900, 210),
]);
expect(offsets.has('far')).toBe(false);
});
it('moves lower-lane components together when one lower component overlaps', () => {
const offsets = layout([
makeNode('a', 100, 200),
makeNode('b', 400, 200),
makeNode('near', 120, 320),
makeNode('far', 120, 700),
]);
expect(offsets.get('near')).toEqual(offsets.get('far'));
expect(offsets.get('far')?.y).toBeGreaterThan(0);
});
it('pushes a lower-right component right when it is farther right than below', () => {
const offsets = layout([
makeNode('a', 100, 200),
makeNode('b', 400, 200),
makeNode('x', 520, 260),
]);
expect(offsets.get('x')?.x).toBeGreaterThan(0);
expect(offsets.get('x')?.y).toBe(0);
});
it('moves another group as a whole component', () => {
const otherGroup: IWorkflowGroup = { id: 'g2', name: 'G2', nodeIds: ['c', 'd'] };
const offsets = layout(
[
makeNode('a', 100, 200),
makeNode('b', 400, 200),
makeNode('c', 500, 210),
makeNode('d', 650, 210),
],
{ groups: [group, otherGroup] },
);
expect(offsets.get(groupComponentId('g2'))?.x).toBeGreaterThan(0);
expect(offsets.has('c')).toBe(false);
expect(offsets.has('d')).toBe(false);
});
it('moves a lower right-shifted group down when it is farther below than right', () => {
const lowerGroup: IWorkflowGroup = { id: 'g2', name: 'G2', nodeIds: ['c', 'd'] };
const offsets = layout(
[
makeNode('a', 100, 200),
makeNode('b', 400, 200),
makeNode('c', 500, 380),
makeNode('d', 650, 380),
],
{ groups: [group, lowerGroup] },
);
expect(offsets.get(groupComponentId('g2'))?.x).toBe(0);
expect(offsets.get(groupComponentId('g2'))?.y).toBeGreaterThan(0);
expect(offsets.has('c')).toBe(false);
expect(offsets.has('d')).toBe(false);
});
it('does not move a group above the expanding group', () => {
const upperGroup: IWorkflowGroup = { id: 'g2', name: 'G2', nodeIds: ['c', 'd'] };
const offsets = layout(
[
makeNode('a', 100, 200),
makeNode('b', 400, 200),
makeNode('c', 100, 40),
makeNode('d', 400, 40),
makeNode('lower', 120, 260),
],
{ groups: [group, upperGroup] },
);
expect(offsets.get('lower')?.y).toBeGreaterThan(0);
expect(offsets.has(groupComponentId('g2'))).toBe(false);
});
it('does not move a collapsed chip stacked closely above the expanding group', () => {
// The chip sits fully above the source chip, separated by less than the
// group top padding. Expansion grows down and right, so the chip above
// must stay put instead of jumping below the expanded frame.
const upperChip: IWorkflowGroup = { id: 'g4', name: 'G4', nodeIds: ['c'] };
const offsets = layout(
[makeNode('a', 100, 400), makeNode('b', 400, 400), makeNode('c', 100, 280)],
{ groups: [group, upperChip] },
);
expect(offsets.has(groupComponentId('g4'))).toBe(false);
});
it('moves an above-band collapsed group with the band when its expanded footprint dips into it', () => {
// The chip sits above the expansion band and right of the frame — only
// its expanded footprint (which grows downward) reaches into the band.
// It must ride along with the in-band component by the same delta so
// siblings hanging off the same outputs stay arranged together.
const aboveGroup: IWorkflowGroup = { id: 'g4', name: 'G4', nodeIds: ['c'] };
const offsets = layout(
[
makeNode('a', 100, 200),
makeNode('b', 400, 200),
makeNode('inBand', 520, 210),
makeNode('c', 640, 40),
],
{ groups: [group, aboveGroup] },
);
expect(offsets.get(groupComponentId('g4'))).toEqual(offsets.get('inBand'));
expect(offsets.get(groupComponentId('g4'))?.x).toBeGreaterThan(0);
expect(offsets.get(groupComponentId('g4'))?.y).toBe(0);
expect(offsets.has('c')).toBe(false);
});
it('moves both left collapsed chips down together when the expansion reaches the upper one', () => {
// Source expands over the upper chip; the lower chip shares the same
// column. Both must travel by the same delta — otherwise the upper chip
// lands on the lower one.
const sourceGroup: IWorkflowGroup = { id: 'g5', name: 'G5', nodeIds: ['m1', 'm2'] };
const upperChip: IWorkflowGroup = { id: 'g4', name: 'G4', nodeIds: ['c4'] };
const lowerChip: IWorkflowGroup = { id: 'g3', name: 'G3', nodeIds: ['c3'] };
const offsets = layout(
[
makeNode('m1', 560, 140),
makeNode('m2', 960, 140),
makeNode('c4', 480, 260),
makeNode('c3', 400, 520),
],
{ groups: [sourceGroup, upperChip, lowerChip], expanded: new Set(['g5']) },
);
const upperOffset = offsets.get(groupComponentId('g4'));
expect(upperOffset?.y).toBeGreaterThan(0);
expect(upperOffset?.x).toBe(0);
expect(offsets.get(groupComponentId('g3'))).toEqual(upperOffset);
});
it('does not move an above-band collapsed group whose expanded footprint stays above the band', () => {
const aboveGroup: IWorkflowGroup = { id: 'g4', name: 'G4', nodeIds: ['c'] };
const offsets = layout(
[
makeNode('a', 100, 200),
makeNode('b', 400, 200),
makeNode('inBand', 520, 210),
makeNode('c', 640, -200),
],
{ groups: [group, aboveGroup] },
);
expect(offsets.has('inBand')).toBe(true);
expect(offsets.has(groupComponentId('g4'))).toBe(false);
});
it('pushes a group when its expanded frame overlaps another expanded group', () => {
const components = [
makeGroupComponent('source', { x: 500, y: 100, width: 600, height: 240 }),
makeGroupComponent('target', { x: 80, y: 260, width: 1000, height: 240 }),
];
const offsets = computeOffsets(components, new Set(['source', 'target']));
expect(offsets.get(groupComponentId('target'))?.y).toBeGreaterThan(0);
});
it('does not let a lower expanded group push an already processed upper expanded group', () => {
const components = [
makeGroupComponent('a', { x: 500, y: 0, width: 600, height: 240 }),
makeGroupComponent('b', { x: 150, y: 160, width: 920, height: 240 }),
makeNodeComponent('rightOfB', { x: 600, y: 200, width: 100, height: 100 }),
];
const offsets = computeOffsets(components, new Set(['a', 'b']));
expect(offsets.get(groupComponentId('b'))?.y).toBeGreaterThan(0);
expect(offsets.has(groupComponentId('a'))).toBe(false);
expect(offsets.get('rightOfB')?.x).toBeGreaterThan(0);
});
it('keeps a right push when the pushed group is also expanded', () => {
// No explicit expansion order: relies on the geometric fallback, which
// processes the upper-left source first.
const components = [
makeGroupComponent('target', { x: 800, y: 120, width: 800, height: 240 }),
makeGroupComponent('source', { x: 0, y: 100, width: 1000, height: 240 }),
];
const offsets = computeOffsets(components, new Set(['target', 'source']));
expect(offsets.get(groupComponentId('target'))?.x).toBeGreaterThan(0);
});
it('uses expanded group order to keep an older source pushing a newly expanded target', () => {
// Unlike the geometric-fallback test above, the explicit order makes the
// source process first even though the target sits higher.
const components = [
makeGroupComponent('target', { x: 350, y: 90, width: 800, height: 240 }),
makeGroupComponent('source', { x: 0, y: 100, width: 1000, height: 240 }),
];
const offsets = computeOffsets(components, new Set(['target', 'source']), {
order: ['source', 'target'],
});
expect(offsets.get(groupComponentId('target'))?.x).toBeGreaterThan(0);
expect(offsets.has(groupComponentId('source'))).toBe(false);
});
it('does not move components in distinct regions outside the expanded group bands', () => {
const offsets = layout([
makeNode('a', 100, 200),
makeNode('b', 400, 200),
makeNode('x', 500, 210),
makeNode('other', 900, 800),
]);
expect(offsets.has('x')).toBe(true);
expect(offsets.has('other')).toBe(false);
});
it('pushes a node below a narrow group when it sits under the rendered minimum-width frame', () => {
// A single-node group's content width is far below the rendered minimum
// frame width — the push band must use the rendered footprint, or the
// expanded frame overlaps the node below.
const narrowGroup: IWorkflowGroup = { id: 'g6', name: 'G6', nodeIds: ['a'] };
const offsets = layout([makeNode('a', 100, 200), makeNode('x', 300, 340)], {
groups: [narrowGroup],
expanded: new Set(['g6']),
});
expect(offsets.get('x')?.y).toBeGreaterThan(0);
expect(offsets.get('x')?.x).toBe(0);
});
it('returns no offsets when no group is expanded', () => {
const offsets = layout(
[makeNode('a', 100, 200), makeNode('b', 400, 200), makeNode('x', 560, 210)],
{ expanded: new Set() },
);
expect(offsets.size).toBe(0);
});
it('skips components manually placed while the source group is expanded', () => {
const offsets = layout(
[makeNode('a', 100, 200), makeNode('b', 400, 200), makeNode('x', 520, 210)],
{ ignored: new Map([['g1', new Set(['x'])]]) },
);
expect(offsets.has('x')).toBe(false);
});
it('applies expanded groups in order so later groups use earlier visual offsets', () => {
const lowerGroup: IWorkflowGroup = { id: 'g2', name: 'G2', nodeIds: ['c', 'd'] };
const offsets = layout(
[
makeNode('a', 0, 0),
makeNode('b', 300, 0),
makeNode('c', 0, 80),
makeNode('d', 500, 80),
makeNode('rightOfLower', 430, 320),
],
{ groups: [group, lowerGroup], expanded: new Set(['g1', 'g2']) },
);
expect(offsets.get(groupComponentId('g2'))?.y).toBeGreaterThan(0);
expect(offsets.get('rightOfLower')).toEqual(offsets.get(groupComponentId('g2')));
});
it('uses a pushed expanded source visual position when deciding its later pushes', () => {
const components = [
makeGroupComponent('a', { x: 0, y: 0, width: 1000, height: 240 }),
makeGroupComponent('b', { x: 450, y: 20, width: 600, height: 340 }),
makeNodeComponent('nearOldBPosition', { x: 480, y: 300, width: 100, height: 100 }),
];
const offsets = computeOffsets(components, new Set(['a', 'b']), { order: ['a', 'b'] });
expect(offsets.get(groupComponentId('b'))?.x).toBeGreaterThan(0);
expect(offsets.has('nearOldBPosition')).toBe(false);
});
it('preserves a lower group right push when the lower group was expanded before being pushed down', () => {
const upperGroup: IWorkflowGroup = { id: 'upper', name: 'Upper', nodeIds: ['a', 'b'] };
const lowerGroup: IWorkflowGroup = { id: 'lower', name: 'Lower', nodeIds: ['c', 'd', 'e'] };
const offsets = layout(
[
makeNode('a', 100, 200),
makeNode('b', 400, 200),
makeNode('c', 100, 480),
makeNode('d', 400, 480),
makeNode('e', 650, 480),
makeNode('rightOfLower', 760, 510),
],
{
groups: [upperGroup, lowerGroup],
expanded: new Set(['upper', 'lower']),
order: ['lower', 'upper'],
},
);
const lowerOffset = offsets.get(groupComponentId('lower'));
const rightOfLowerOffset = offsets.get('rightOfLower');
expect(lowerOffset?.y).toBeGreaterThan(0);
expect(offsets.has(groupComponentId('upper'))).toBe(false);
expect(rightOfLowerOffset?.x).toBeGreaterThan(0);
expect(rightOfLowerOffset?.y).toBe(lowerOffset?.y);
});
it('adds the expanded source push even when the target already moved with the source', () => {
const components = [
makeGroupComponent('b', { x: 0, y: 0, width: 700, height: 240 }),
makeGroupComponent('group3', { x: 500, y: 0, width: 700, height: 240 }),
makeNodeComponent('edit25', { x: 1000, y: 20, width: 100, height: 100 }),
];
const offsets = computeOffsets(components, new Set(['b', 'group3']), {
order: ['b', 'group3'],
});
const group3Offset = offsets.get(groupComponentId('group3'));
const edit25Offset = offsets.get('edit25');
expect(group3Offset?.x).toBeGreaterThan(0);
expect(edit25Offset?.x).toBe((group3Offset?.x ?? 0) * 2);
expect(edit25Offset?.y).toBe(0);
});
it('processes stacked expanded groups by visual position instead of document order', () => {
const upperGroup: IWorkflowGroup = { id: 'upper', name: 'Upper', nodeIds: ['a'] };
const lowerGroup: IWorkflowGroup = { id: 'lower', name: 'Lower', nodeIds: ['b'] };
const offsets = layout([makeNode('a', 0, 0), makeNode('b', 0, 80)], {
groups: [lowerGroup, upperGroup],
expanded: new Set(['upper', 'lower']),
});
expect(offsets.get(groupComponentId('lower'))?.y).toBeGreaterThan(0);
});
it('keeps grouped pushed nodes visually stable by deriving the same offset for the group', () => {
const nodes = [
makeNode('a', 100, 200),
makeNode('b', 400, 200),
makeNode('x', 520, 210),
makeNode('y', 680, 210),
];
const ungroupedOffsets = layout(nodes);
const grouped: IWorkflowGroup = { id: 'g2', name: 'Grouped', nodeIds: ['x', 'y'] };
const groupedOffsets = layout(nodes, { groups: [group, grouped] });
expect(groupedOffsets.get(groupComponentId('g2'))).toEqual(ungroupedOffsets.get('x'));
});
});
@@ -0,0 +1,565 @@
import type { IWorkflowGroup } from 'n8n-workflow';
import type { INodeUi } from '@/Interface';
import { GRID_SIZE } from '@/app/utils/nodeViewUtils';
import {
createCanvasGroupNodeId,
type BoundingBox,
} from '@/features/workflows/canvas/canvas.types';
import { checkOverlap } from '@/features/workflows/canvas/canvas.utils';
import {
GROUP_HEADER_HEIGHT,
GROUP_HEADER_WIDTH_COLLAPSED,
GROUP_PADDING_X,
GROUP_PADDING_Y_BOTTOM,
GROUP_PADDING_Y_TOP,
} from '@/features/workflows/canvas/stores/canvasNodeGroups.constants';
import {
computeNodesRectFromStore,
type GetNodeDisplaySize,
} from '@/features/workflows/canvas/composables/useCanvasMapping.groups';
export type NodeGroupLayoutAxis = 'x' | 'y';
export type NodeGroupLayoutOffset = { x: number; y: number };
interface NodeGroupLayoutComponentBase {
id: string;
nodeIds: string[];
rect: BoundingBox;
}
export interface NodeLayoutComponent extends NodeGroupLayoutComponentBase {
kind: 'node';
}
export interface GroupLayoutComponent extends NodeGroupLayoutComponentBase {
kind: 'group';
groupId: string;
collapsedRect: BoundingBox;
expandedRect: BoundingBox;
}
export type NodeGroupLayoutComponent = NodeLayoutComponent | GroupLayoutComponent;
export interface BuildNodeGroupLayoutComponentsInput {
allGroups: IWorkflowGroup[];
nodes: INodeUi[];
getNodeById: (id: string) => INodeUi | undefined;
getNodeDisplaySize?: GetNodeDisplaySize;
isGroupCollapsed: (id: string) => boolean;
}
export interface ComputeNodeGroupLayoutPushesInput {
components: NodeGroupLayoutComponent[];
expandedGroupIds: Set<string>;
expandedGroupIdOrder?: string[];
ignoredNodeIdsBySourceGroup?: Map<string, Set<string>>;
spacing?: number;
}
export interface NodeGroupLayoutPushEntry {
sourceGroupId: string;
componentId: string;
axis: NodeGroupLayoutAxis;
delta: number;
}
const GROUP_REPOSITION_SPACING = GRID_SIZE;
// Uses raw node positions. The rendered title bar is aligned to the grid
// separately (`titleBarFromNodesRect`).
function getGroupRects(memberRect: BoundingBox) {
const x = memberRect.x - GROUP_PADDING_X;
const y = memberRect.y - GROUP_PADDING_Y_TOP - GROUP_HEADER_HEIGHT;
const collapsedRect: BoundingBox = {
x,
y,
width: GROUP_HEADER_WIDTH_COLLAPSED,
height: GROUP_HEADER_HEIGHT,
};
const expandedRect: BoundingBox = {
x,
y,
// The rendered frame never goes below the collapsed header width, so a
// narrow group must claim the same footprint or neighbors overlap it.
width: Math.max(memberRect.width + 2 * GROUP_PADDING_X, GROUP_HEADER_WIDTH_COLLAPSED),
height: GROUP_HEADER_HEIGHT + memberRect.height + GROUP_PADDING_Y_TOP + GROUP_PADDING_Y_BOTTOM,
};
return { collapsedRect, expandedRect };
}
export function buildNodeGroupLayoutComponents({
allGroups,
nodes,
getNodeById,
getNodeDisplaySize,
isGroupCollapsed,
}: BuildNodeGroupLayoutComponentsInput): NodeGroupLayoutComponent[] {
const components: NodeGroupLayoutComponent[] = [];
const groupedNodeIds = new Set<string>();
for (const group of allGroups) {
for (const nodeId of group.nodeIds) groupedNodeIds.add(nodeId);
const hasMember = group.nodeIds.some((id) => getNodeById(id) !== undefined);
if (!hasMember) continue;
const nodesRect = computeNodesRectFromStore(group.nodeIds, getNodeById, getNodeDisplaySize);
const { collapsedRect, expandedRect } = getGroupRects(nodesRect);
components.push({
id: createCanvasGroupNodeId(group.id),
kind: 'group',
groupId: group.id,
nodeIds: [...group.nodeIds],
rect: isGroupCollapsed(group.id) ? collapsedRect : expandedRect,
collapsedRect,
expandedRect,
});
}
for (const node of nodes) {
if (groupedNodeIds.has(node.id)) continue;
components.push({
id: node.id,
kind: 'node',
nodeIds: [node.id],
rect: computeNodesRectFromStore([node.id], getNodeById, getNodeDisplaySize),
});
}
return components;
}
export function mapNodeIdsToComponentIds(components: NodeGroupLayoutComponent[]) {
const index = new Map<string, string>();
for (const component of components) {
for (const nodeId of component.nodeIds) {
index.set(nodeId, component.id);
}
}
return index;
}
export function getOffsetForComponent(
offsets: Map<string, NodeGroupLayoutOffset>,
componentId: string,
): NodeGroupLayoutOffset {
return offsets.get(componentId) ?? { x: 0, y: 0 };
}
function translateRect(rect: BoundingBox, offset: NodeGroupLayoutOffset): BoundingBox {
return { ...rect, x: rect.x + offset.x, y: rect.y + offset.y };
}
function rangesOverlap(aStart: number, aEnd: number, bStart: number, bEnd: number): boolean {
return aStart < bEnd && bStart < aEnd;
}
function overlapsVerticalBand(rect: BoundingBox, band: BoundingBox): boolean {
return rangesOverlap(rect.y, rect.y + rect.height, band.y, band.y + band.height);
}
function overlapsHorizontalBand(rect: BoundingBox, band: BoundingBox): boolean {
return rangesOverlap(rect.x, rect.x + rect.width, band.x, band.x + band.width);
}
function getComponentLaneOrigin(component: NodeGroupLayoutComponent, componentRect: BoundingBox) {
if (component.kind === 'group') {
return {
x: componentRect.x + GROUP_PADDING_X,
y: componentRect.y + GROUP_HEADER_HEIGHT + GROUP_PADDING_Y_TOP,
};
}
return { x: componentRect.x, y: componentRect.y };
}
function getPushTargetAnchorRect(component: NodeGroupLayoutComponent) {
return component.kind === 'group' ? component.expandedRect : component.rect;
}
function getComponentPushLane(
component: NodeGroupLayoutComponent,
componentAnchorRect: BoundingBox,
componentCurrentRect: BoundingBox,
pushSource: PushSourceContext,
targetExpandedAfterSource = false,
): NodeGroupLayoutAxis | null {
const {
collapsedRect: collapsedGroupRect,
expandedRect: expandedGroupRect,
horizontalPushDistance,
verticalPushDistance,
} = pushSource;
const collapsedGroupRight = collapsedGroupRect.x + collapsedGroupRect.width;
const sourceContentRowBottom =
collapsedGroupRect.y + GROUP_HEADER_HEIGHT + GROUP_PADDING_Y_TOP + GROUP_HEADER_HEIGHT;
const collapsedGroupBottom = collapsedGroupRect.y + collapsedGroupRect.height;
const laneOrigin = getComponentLaneOrigin(component, componentAnchorRect);
const componentStartsAtOrBelowSourceTop = componentAnchorRect.y >= collapsedGroupRect.y;
const componentStartsBelowSourceRow = laneOrigin.y >= sourceContentRowBottom;
const componentStartsRightOfCollapsedGroup = laneOrigin.x >= collapsedGroupRight;
// A group target expanded AFTER this source whose chip column sits fully
// left of the source's content is not pushed down: its own, later source
// pass is guaranteed to claim the right lane against this source and push
// it rightward, keeping the freshly expanded group where the user
// expanded it.
const skipDownPush =
targetExpandedAfterSource &&
component.kind === 'group' &&
collapsedGroupRect.x + GROUP_PADDING_X >= componentAnchorRect.x + component.collapsedRect.width;
const componentIntersectsBottomExpansion =
componentAnchorRect.y + componentAnchorRect.height > collapsedGroupBottom;
// Lane extents are directional, following how a group frame grows from its
// chip (rightward and downward):
// - The RIGHT lane's vertical extent uses the inflated anchor: a chip above
// the band whose future frame dips down into it must ride along.
// - The DOWN lane's horizontal extent uses the ACTUAL rect: a chip's
// not-yet-real width must not pull a component that sits left of the
// source into the down lane — only what really occupies the column moves
// (and moves together, so a pushed chip can't land on a lane neighbor).
const componentOverlapsExpandedHeight = overlapsVerticalBand(
componentAnchorRect,
expandedGroupRect,
);
const componentOverlapsExpandedWidth = overlapsHorizontalBand(
componentCurrentRect,
expandedGroupRect,
);
const canPushDownFromLaneOrigin =
verticalPushDistance > 0 &&
componentStartsBelowSourceRow &&
componentOverlapsExpandedWidth &&
!skipDownPush;
const canPushRightFromLaneOrigin =
horizontalPushDistance > 0 &&
componentStartsRightOfCollapsedGroup &&
componentOverlapsExpandedHeight;
if (canPushDownFromLaneOrigin && canPushRightFromLaneOrigin) {
const distanceIntoRightLane = laneOrigin.x - collapsedGroupRight;
const distanceIntoBottomLane = laneOrigin.y - sourceContentRowBottom;
return distanceIntoRightLane >= distanceIntoBottomLane ? 'x' : 'y';
}
if (canPushDownFromLaneOrigin) return 'y';
if (canPushRightFromLaneOrigin) return 'x';
if (
verticalPushDistance > 0 &&
componentStartsAtOrBelowSourceTop &&
componentIntersectsBottomExpansion &&
componentOverlapsExpandedWidth &&
!skipDownPush
) {
return 'y';
}
return null;
}
function addComponentOffset(
offsets: Map<string, NodeGroupLayoutOffset>,
componentId: string,
axis: NodeGroupLayoutAxis,
delta: number,
) {
if (delta === 0) return;
const offset = offsets.get(componentId) ?? { x: 0, y: 0 };
if (axis === 'x') {
offset.x += delta;
} else {
offset.y += delta;
}
offsets.set(componentId, offset);
}
export function aggregateNodeGroupLayoutOffsets(entries: NodeGroupLayoutPushEntry[]) {
const offsets = new Map<string, NodeGroupLayoutOffset>();
for (const entry of entries) {
addComponentOffset(offsets, entry.componentId, entry.axis, entry.delta);
}
return offsets;
}
function isComponentIgnoredForSource(
component: NodeGroupLayoutComponent,
sourceGroupId: string,
ignoredNodeIdsBySourceGroup?: Map<string, Set<string>>,
) {
const ignoredNodeIds = ignoredNodeIdsBySourceGroup?.get(sourceGroupId);
return ignoredNodeIds ? component.nodeIds.some((nodeId) => ignoredNodeIds.has(nodeId)) : false;
}
function getOrderedPushSources(
components: NodeGroupLayoutComponent[],
expandedGroupIds: Set<string>,
expandedGroupIdOrder: string[] = [],
) {
const groupOrderIndex = new Map(expandedGroupIdOrder.map((groupId, index) => [groupId, index]));
return components
.filter(
(component): component is GroupLayoutComponent =>
component.kind === 'group' && expandedGroupIds.has(component.groupId),
)
.sort((a, b) => {
const aOrder = groupOrderIndex.get(a.groupId) ?? Number.POSITIVE_INFINITY;
const bOrder = groupOrderIndex.get(b.groupId) ?? Number.POSITIVE_INFINITY;
if (aOrder !== bOrder) return aOrder - bOrder;
if (a.collapsedRect.y !== b.collapsedRect.y) return a.collapsedRect.y - b.collapsedRect.y;
if (a.collapsedRect.x !== b.collapsedRect.x) return a.collapsedRect.x - b.collapsedRect.x;
return a.id.localeCompare(b.id);
});
}
function didComponentPushSource(
entries: NodeGroupLayoutPushEntry[],
component: NodeGroupLayoutComponent,
sourceComponentId: string,
) {
return (
component.kind === 'group' &&
entries.some(
(entry) =>
entry.sourceGroupId === component.groupId && entry.componentId === sourceComponentId,
)
);
}
interface PushSourceContext {
source: GroupLayoutComponent;
sourceGroupId: string;
collapsedRect: BoundingBox;
expandedRect: BoundingBox;
horizontalPushDistance: number;
verticalPushDistance: number;
spacing: number;
}
interface SourcePushPlan {
pushLaneByComponentId: Map<string, NodeGroupLayoutAxis>;
triggeredPushLanes: Set<NodeGroupLayoutAxis>;
requiredClearanceByLane: Map<NodeGroupLayoutAxis, number>;
}
interface CreateOffsetApplierInput {
entries: NodeGroupLayoutPushEntry[];
offsets: Map<string, NodeGroupLayoutOffset>;
componentsById: Map<string, NodeGroupLayoutComponent>;
sourceGroupId: string;
sourceComponentId: string;
ignoredNodeIdsBySourceGroup?: Map<string, Set<string>>;
}
function getPushSourceContext(
source: GroupLayoutComponent,
sourceOffset: NodeGroupLayoutOffset,
spacing: number,
): PushSourceContext {
const collapsedRect = translateRect(source.collapsedRect, sourceOffset);
const expandedRect = translateRect(source.expandedRect, sourceOffset);
return {
source,
sourceGroupId: source.groupId,
collapsedRect,
expandedRect,
horizontalPushDistance: expandedRect.width - collapsedRect.width + spacing,
verticalPushDistance: expandedRect.height - collapsedRect.height + spacing,
spacing,
};
}
function sourceAlreadyPushedComponent(
entries: NodeGroupLayoutPushEntry[],
sourceGroupId: string,
componentId: string,
axis: NodeGroupLayoutAxis,
) {
return entries.some(
(entry) =>
entry.sourceGroupId === sourceGroupId &&
entry.componentId === componentId &&
entry.axis === axis,
);
}
function createOffsetApplier({
entries,
offsets,
componentsById,
sourceGroupId,
sourceComponentId,
ignoredNodeIdsBySourceGroup,
}: CreateOffsetApplierInput) {
function applyPush(
componentId: string,
axis: NodeGroupLayoutAxis,
delta: number,
propagatedGroupIds = new Set<string>(),
) {
if (delta === 0 || sourceAlreadyPushedComponent(entries, sourceGroupId, componentId, axis))
return;
addComponentOffset(offsets, componentId, axis, delta);
entries.push({ sourceGroupId, componentId, axis, delta });
// A pushed group drags its own previously pushed targets ("followers")
// along so their relative arrangement survives the move.
const component = componentsById.get(componentId);
if (component?.kind !== 'group' || propagatedGroupIds.has(component.groupId)) return;
propagatedGroupIds.add(component.groupId);
const followerIds = new Set(
entries
.filter((entry) => entry.sourceGroupId === component.groupId)
.map((entry) => entry.componentId),
);
for (const followerId of followerIds) {
if (followerId === sourceComponentId || followerId === componentId) continue;
const follower = componentsById.get(followerId);
if (!follower) continue;
if (isComponentIgnoredForSource(follower, sourceGroupId, ignoredNodeIdsBySourceGroup)) {
continue;
}
applyPush(followerId, axis, delta, propagatedGroupIds);
}
}
return applyPush;
}
function findSourcePushPlan(
components: NodeGroupLayoutComponent[],
pushSource: PushSourceContext,
offsets: Map<string, NodeGroupLayoutOffset>,
entries: NodeGroupLayoutPushEntry[],
expandedOrderIndex: Map<string, number>,
ignoredNodeIdsBySourceGroup?: Map<string, Set<string>>,
): SourcePushPlan {
const pushLaneByComponentId = new Map<string, NodeGroupLayoutAxis>();
const triggeredPushLanes = new Set<NodeGroupLayoutAxis>();
const requiredClearanceByLane = new Map<NodeGroupLayoutAxis, number>();
const sourceOrderIndex = expandedOrderIndex.get(pushSource.sourceGroupId);
for (const component of components) {
if (component.id === pushSource.source.id) continue;
if (didComponentPushSource(entries, component, pushSource.source.id)) continue;
if (
isComponentIgnoredForSource(component, pushSource.sourceGroupId, ignoredNodeIdsBySourceGroup)
) {
continue;
}
const targetOffset = getOffsetForComponent(offsets, component.id);
const targetAnchorRect = translateRect(getPushTargetAnchorRect(component), targetOffset);
const targetCurrentRect = translateRect(component.rect, targetOffset);
const targetOrderIndex =
component.kind === 'group' ? expandedOrderIndex.get(component.groupId) : undefined;
const targetExpandedAfterSource =
sourceOrderIndex !== undefined &&
targetOrderIndex !== undefined &&
targetOrderIndex > sourceOrderIndex;
const pushLane = getComponentPushLane(
component,
targetAnchorRect,
targetCurrentRect,
pushSource,
targetExpandedAfterSource,
);
if (!pushLane) continue;
pushLaneByComponentId.set(component.id, pushLane);
if (checkOverlap(targetAnchorRect, pushSource.expandedRect)) {
triggeredPushLanes.add(pushLane);
// The default lane distance (source width/height delta) preserves each
// target's clearance measured from its lane origin, but a group frame
// extends beyond its lane origin (padding/header), so it can still end
// up overlapping. Track the distance the overlapping targets actually
// need to clear the source's expanded frame.
const clearanceDistance =
pushLane === 'x'
? pushSource.expandedRect.x + pushSource.expandedRect.width - targetAnchorRect.x
: pushSource.expandedRect.y + pushSource.expandedRect.height - targetAnchorRect.y;
requiredClearanceByLane.set(
pushLane,
Math.max(requiredClearanceByLane.get(pushLane) ?? 0, clearanceDistance),
);
}
}
return { pushLaneByComponentId, triggeredPushLanes, requiredClearanceByLane };
}
function applySourcePushPlan(
pushSource: PushSourceContext,
pushPlan: SourcePushPlan,
applyPush: ReturnType<typeof createOffsetApplier>,
) {
for (const [componentId, pushLane] of pushPlan.pushLaneByComponentId) {
if (!pushPlan.triggeredPushLanes.has(pushLane)) continue;
const basePushDistance =
pushLane === 'x' ? pushSource.horizontalPushDistance : pushSource.verticalPushDistance;
// Only bump beyond the base distance when it would leave an overlapping
// frame: a base distance that already clears every frame is kept as-is so
// targets keep their established relative positions.
const requiredClearance = pushPlan.requiredClearanceByLane.get(pushLane) ?? 0;
const pushDistance =
basePushDistance >= requiredClearance
? basePushDistance
: requiredClearance + pushSource.spacing;
applyPush(componentId, pushLane, pushDistance);
}
}
export function computeNodeGroupLayoutPushes({
components,
expandedGroupIds,
expandedGroupIdOrder,
ignoredNodeIdsBySourceGroup,
spacing = GROUP_REPOSITION_SPACING,
}: ComputeNodeGroupLayoutPushesInput): NodeGroupLayoutPushEntry[] {
const entries: NodeGroupLayoutPushEntry[] = [];
const sources = getOrderedPushSources(components, expandedGroupIds, expandedGroupIdOrder);
if (sources.length === 0) return entries;
const offsets = new Map<string, NodeGroupLayoutOffset>();
const componentsById = new Map(components.map((component) => [component.id, component]));
const expandedOrderIndex = new Map(
(expandedGroupIdOrder ?? []).map((groupId, index) => [groupId, index]),
);
for (const source of sources) {
const sourceOffset = getOffsetForComponent(offsets, source.id);
const pushSource = getPushSourceContext(source, sourceOffset, spacing);
const pushPlan = findSourcePushPlan(
components,
pushSource,
offsets,
entries,
expandedOrderIndex,
ignoredNodeIdsBySourceGroup,
);
const applyPush = createOffsetApplier({
entries,
offsets,
componentsById,
sourceGroupId: pushSource.sourceGroupId,
sourceComponentId: source.id,
ignoredNodeIdsBySourceGroup,
});
applySourcePushPlan(pushSource, pushPlan, applyPush);
}
return entries;
}
@@ -2,12 +2,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useWorkflowDocumentNodeGroups } from '@/app/stores/workflowDocument/useWorkflowDocumentNodeGroups';
import { LOCAL_STORAGE_CANVAS_GROUP_EXPANDED } from '@/app/constants/localStorage';
import { useCanvasNodeGroupView } from './useCanvasNodeGroupView';
import type { NodeGroupLayoutComponent } from './useCanvasNodeGroupLayout';
function setup(
initialGroups: Array<{ id: string; name: string; nodeIds: string[] }> = [],
{
workflowId = 'wf-test',
isGroupingEnabled,
isGroupingEnabled = () => true,
}: { workflowId?: string; isGroupingEnabled?: () => boolean } = {},
) {
const nodeGroups = useWorkflowDocumentNodeGroups();
@@ -170,6 +171,42 @@ describe('useCanvasNodeGroupView', () => {
expect(view.isGroupCollapsed('g1')).toBe(true);
});
it('keeps a group created with startCollapsed collapsed (imported/pasted groups)', () => {
const { nodeGroups, view } = setup();
const group = nodeGroups.createGroup(['a'], 'Imported', { startCollapsed: true });
expect(view.isGroupCollapsed(group.id)).toBe(true);
});
it('pushes on first expand of a startCollapsed group, unlike a user-created one', () => {
// User-created groups are kept out of the push sources until re-expanded;
// imported groups behave like loaded ones — their first expansion pushes.
const { nodeGroups, view } = setup();
const group = nodeGroups.createGroup(['a'], 'Imported', { startCollapsed: true });
view.syncLayoutComponents([
{
id: `group:${group.id}`,
kind: 'group',
groupId: group.id,
nodeIds: ['a'],
rect: { x: 0, y: 0, width: 400, height: 40 },
collapsedRect: { x: 0, y: 0, width: 400, height: 40 },
expandedRect: { x: 0, y: 0, width: 600, height: 240 },
},
{
id: 'b',
kind: 'node',
nodeIds: ['b'],
rect: { x: 450, y: 10, width: 96, height: 96 },
},
]);
view.toggleCollapsed(group.id);
expect(view.getVisualOffsetForNode('b').x).toBeGreaterThan(0);
});
});
describe('deleteGroup', () => {
@@ -251,4 +288,621 @@ describe('useCanvasNodeGroupView', () => {
expect(dirtySpy).not.toHaveBeenCalled();
});
});
describe('derived visual offsets', () => {
function syncLayout(view: ReturnType<typeof useCanvasNodeGroupView>) {
const components: NodeGroupLayoutComponent[] = [
{
id: 'group:g1',
kind: 'group',
groupId: 'g1',
nodeIds: ['a'],
rect: { x: 0, y: 0, width: 400, height: 40 },
collapsedRect: { x: 0, y: 0, width: 400, height: 40 },
expandedRect: { x: 0, y: 0, width: 600, height: 240 },
},
{
id: 'b',
kind: 'node',
nodeIds: ['b'],
rect: { x: 450, y: 10, width: 96, height: 96 },
},
];
view.syncLayoutComponents(components);
}
function syncLayoutWithTwoPushedNodes(
view: ReturnType<typeof useCanvasNodeGroupView>,
grouped = false,
) {
if (grouped) {
view.syncLayoutComponents([
{
id: 'group:g1',
kind: 'group',
groupId: 'g1',
nodeIds: ['a'],
rect: { x: 0, y: 0, width: 600, height: 240 },
collapsedRect: { x: 0, y: 0, width: 400, height: 40 },
expandedRect: { x: 0, y: 0, width: 600, height: 240 },
},
{
id: 'group:g2',
kind: 'group',
groupId: 'g2',
nodeIds: ['b', 'c'],
rect: { x: 394, y: -70, width: 448, height: 280 },
collapsedRect: { x: 394, y: -70, width: 400, height: 40 },
expandedRect: { x: 394, y: -70, width: 448, height: 280 },
},
]);
return;
}
view.syncLayoutComponents([
{
id: 'group:g1',
kind: 'group',
groupId: 'g1',
nodeIds: ['a'],
rect: { x: 0, y: 0, width: 400, height: 40 },
collapsedRect: { x: 0, y: 0, width: 400, height: 40 },
expandedRect: { x: 0, y: 0, width: 600, height: 240 },
},
{
id: 'b',
kind: 'node',
nodeIds: ['b'],
rect: { x: 450, y: 10, width: 96, height: 96 },
},
{
id: 'c',
kind: 'node',
nodeIds: ['c'],
rect: { x: 650, y: 10, width: 96, height: 96 },
},
]);
}
function syncStackedGroups(
view: ReturnType<typeof useCanvasNodeGroupView>,
{
upperExpanded = false,
lowerExpanded = false,
lowerY = 80,
}: { upperExpanded?: boolean; lowerExpanded?: boolean; lowerY?: number } = {},
) {
view.syncLayoutComponents([
{
id: 'group:g1',
kind: 'group',
groupId: 'g1',
nodeIds: ['a'],
rect: {
x: 0,
y: 0,
width: upperExpanded ? 600 : 400,
height: upperExpanded ? 240 : 40,
},
collapsedRect: { x: 0, y: 0, width: 400, height: 40 },
expandedRect: { x: 0, y: 0, width: 600, height: 240 },
},
{
id: 'group:g2',
kind: 'group',
groupId: 'g2',
nodeIds: ['b'],
rect: {
x: 0,
y: lowerY,
width: lowerExpanded ? 600 : 400,
height: lowerExpanded ? 240 : 40,
},
collapsedRect: { x: 0, y: lowerY, width: 400, height: 40 },
expandedRect: { x: 0, y: lowerY, width: 600, height: 240 },
},
]);
}
it('creates client-only offsets when expanding a group', () => {
const { view } = setup([{ id: 'g1', name: 'A', nodeIds: ['a'] }]);
syncLayout(view);
view.toggleCollapsed('g1');
const offset = view.getVisualOffsetForNode('b');
expect(offset.x).toBeGreaterThan(0);
expect(offset.y).toBe(0);
});
it('keeps a manually dropped node stable by settling it against active push sources', () => {
const { view } = setup([{ id: 'g1', name: 'A', nodeIds: ['a'] }]);
syncLayout(view);
view.toggleCollapsed('g1');
expect(view.getVisualOffsetForNode('b').x).toBeGreaterThan(0);
view.settleManualNodePositions([{ id: 'b', position: { x: 0, y: 0 } }]);
expect(view.getVisualOffsetForNode('b')).toEqual({ x: 0, y: 0 });
});
it('bakes the unmoved members of a pushed group when one member is manually moved', () => {
const { view } = setup([
{ id: 'g1', name: 'Upper', nodeIds: ['a'] },
{ id: 'g2', name: 'Lower', nodeIds: ['b', 'c'] },
]);
view.syncLayoutComponents([
{
id: 'group:g1',
kind: 'group',
groupId: 'g1',
nodeIds: ['a'],
rect: { x: 0, y: 0, width: 400, height: 40 },
collapsedRect: { x: 0, y: 0, width: 400, height: 40 },
expandedRect: { x: 0, y: 0, width: 600, height: 240 },
},
{
id: 'group:g2',
kind: 'group',
groupId: 'g2',
nodeIds: ['b', 'c'],
rect: { x: 0, y: 80, width: 400, height: 40 },
collapsedRect: { x: 0, y: 80, width: 400, height: 40 },
expandedRect: { x: 0, y: 80, width: 600, height: 240 },
},
]);
view.toggleCollapsed('g1');
view.toggleCollapsed('g2');
const offset = view.getVisualOffsetForComponent('group:g2');
expect(offset.y).toBeGreaterThan(0);
const events = view.settleManualNodePositions(
[{ id: 'b', position: { x: 50, y: 500 } }],
(nodeId) => {
if (nodeId === 'b') return [0, 120];
if (nodeId === 'c') return [150, 120];
return undefined;
},
);
// The moved node keeps its drop position; its unmoved sibling is baked
// at its current visual position instead of snapping back un-pushed.
expect(events).toEqual([
{ id: 'b', position: { x: 50, y: 500 } },
{ id: 'c', position: { x: 150 + offset.x, y: 120 + offset.y } },
]);
expect(view.getVisualOffsetForNode('b')).toEqual({ x: 0, y: 0 });
expect(view.getVisualOffsetForNode('c')).toEqual({ x: 0, y: 0 });
});
it('recomputes offsets away on collapse', () => {
const { view } = setup([{ id: 'g1', name: 'A', nodeIds: ['a'] }]);
syncLayout(view);
view.toggleCollapsed('g1');
view.toggleCollapsed('g1');
expect(view.getVisualOffsetForNode('b')).toEqual({ x: 0, y: 0 });
});
it('keeps pushed nodes visually stable when they are grouped', () => {
const { view } = setup([{ id: 'g1', name: 'A', nodeIds: ['a'] }]);
syncLayoutWithTwoPushedNodes(view);
view.toggleCollapsed('g1');
const offsetBeforeGrouping = view.getVisualOffsetForNode('b');
expect(offsetBeforeGrouping.x).toBeGreaterThan(0);
syncLayoutWithTwoPushedNodes(view, true);
expect(view.getVisualOffsetForNode('b')).toEqual(offsetBeforeGrouping);
expect(view.getVisualOffsetForNode('c')).toEqual(offsetBeforeGrouping);
expect(view.getVisualOffsetForComponent('group:g2')).toEqual(offsetBeforeGrouping);
});
it('keeps an incoming push offset when the pushed group is expanded', () => {
const { view } = setup([
{ id: 'g1', name: 'Upper', nodeIds: ['a'] },
{ id: 'g2', name: 'Lower', nodeIds: ['b'] },
]);
view.syncLayoutComponents([
{
id: 'group:g1',
kind: 'group',
groupId: 'g1',
nodeIds: ['a'],
rect: { x: 0, y: 0, width: 400, height: 40 },
collapsedRect: { x: 0, y: 0, width: 400, height: 40 },
expandedRect: { x: 0, y: 0, width: 600, height: 240 },
},
{
id: 'group:g2',
kind: 'group',
groupId: 'g2',
nodeIds: ['b'],
rect: { x: 0, y: 80, width: 400, height: 40 },
collapsedRect: { x: 0, y: 80, width: 400, height: 40 },
expandedRect: { x: 0, y: 80, width: 600, height: 240 },
},
]);
view.toggleCollapsed('g1');
const lowerOffsetAfterUpperExpansion = view.getVisualOffsetForComponent('group:g2');
expect(lowerOffsetAfterUpperExpansion.y).toBeGreaterThan(0);
view.syncLayoutComponents([
{
id: 'group:g1',
kind: 'group',
groupId: 'g1',
nodeIds: ['a'],
rect: { x: 0, y: 0, width: 600, height: 240 },
collapsedRect: { x: 0, y: 0, width: 400, height: 40 },
expandedRect: { x: 0, y: 0, width: 600, height: 240 },
},
{
id: 'group:g2',
kind: 'group',
groupId: 'g2',
nodeIds: ['b'],
rect: { x: 0, y: 80, width: 400, height: 40 },
collapsedRect: { x: 0, y: 80, width: 400, height: 40 },
expandedRect: { x: 0, y: 80, width: 600, height: 240 },
},
]);
view.toggleCollapsed('g2');
expect(view.getVisualOffsetForComponent('group:g2')).toEqual(lowerOffsetAfterUpperExpansion);
expect(view.getVisualOffsetForNode('b')).toEqual(lowerOffsetAfterUpperExpansion);
});
it('keeps an older source push when a right-side pushed group expands above it', () => {
const { view } = setup([
{ id: 'source', name: 'Source', nodeIds: ['sourceNode'] },
{ id: 'target', name: 'Target', nodeIds: ['targetNode'] },
]);
view.syncLayoutComponents([
{
id: 'group:target',
kind: 'group',
groupId: 'target',
nodeIds: ['targetNode'],
rect: { x: 350, y: 90, width: 400, height: 40 },
collapsedRect: { x: 350, y: 90, width: 400, height: 40 },
expandedRect: { x: 350, y: 90, width: 800, height: 240 },
},
{
id: 'group:source',
kind: 'group',
groupId: 'source',
nodeIds: ['sourceNode'],
rect: { x: 0, y: 100, width: 400, height: 40 },
collapsedRect: { x: 0, y: 100, width: 400, height: 40 },
expandedRect: { x: 0, y: 100, width: 1000, height: 240 },
},
]);
view.toggleCollapsed('source');
const targetOffsetAfterSourceExpansion = view.getVisualOffsetForComponent('group:target');
expect(targetOffsetAfterSourceExpansion.x).toBeGreaterThan(0);
view.syncLayoutComponents([
{
id: 'group:target',
kind: 'group',
groupId: 'target',
nodeIds: ['targetNode'],
rect: { x: 350, y: 90, width: 800, height: 240 },
collapsedRect: { x: 350, y: 90, width: 400, height: 40 },
expandedRect: { x: 350, y: 90, width: 800, height: 240 },
},
{
id: 'group:source',
kind: 'group',
groupId: 'source',
nodeIds: ['sourceNode'],
rect: { x: 0, y: 100, width: 1000, height: 240 },
collapsedRect: { x: 0, y: 100, width: 400, height: 40 },
expandedRect: { x: 0, y: 100, width: 1000, height: 240 },
},
]);
view.toggleCollapsed('target');
expect(view.getVisualOffsetForComponent('group:target')).toEqual(
targetOffsetAfterSourceExpansion,
);
expect(view.getVisualOffsetForNode('targetNode')).toEqual(targetOffsetAfterSourceExpansion);
});
it('keeps a lower group pushed when it expands even if document order is lower before upper', () => {
const { view } = setup([
{ id: 'lower', name: 'Lower', nodeIds: ['b'] },
{ id: 'upper', name: 'Upper', nodeIds: ['a'] },
]);
view.syncLayoutComponents([
{
id: 'group:lower',
kind: 'group',
groupId: 'lower',
nodeIds: ['b'],
rect: { x: 0, y: 80, width: 400, height: 40 },
collapsedRect: { x: 0, y: 80, width: 400, height: 40 },
expandedRect: { x: 0, y: 80, width: 600, height: 240 },
},
{
id: 'group:upper',
kind: 'group',
groupId: 'upper',
nodeIds: ['a'],
rect: { x: 0, y: 0, width: 400, height: 40 },
collapsedRect: { x: 0, y: 0, width: 400, height: 40 },
expandedRect: { x: 0, y: 0, width: 600, height: 240 },
},
]);
view.toggleCollapsed('upper');
const lowerOffsetAfterUpperExpansion = view.getVisualOffsetForComponent('group:lower');
expect(lowerOffsetAfterUpperExpansion.y).toBeGreaterThan(0);
view.toggleCollapsed('lower');
view.syncLayoutComponents([
{
id: 'group:lower',
kind: 'group',
groupId: 'lower',
nodeIds: ['b'],
rect: { x: 0, y: 80, width: 600, height: 240 },
collapsedRect: { x: 0, y: 80, width: 400, height: 40 },
expandedRect: { x: 0, y: 80, width: 600, height: 240 },
},
{
id: 'group:upper',
kind: 'group',
groupId: 'upper',
nodeIds: ['a'],
rect: { x: 0, y: 0, width: 600, height: 240 },
collapsedRect: { x: 0, y: 0, width: 400, height: 40 },
expandedRect: { x: 0, y: 0, width: 600, height: 240 },
},
]);
expect(view.getVisualOffsetForComponent('group:lower')).toEqual(
lowerOffsetAfterUpperExpansion,
);
expect(view.getVisualOffsetForNode('b')).toEqual(lowerOffsetAfterUpperExpansion);
});
it('commits visual node positions pushed by a moved source group', () => {
const { view } = setup([
{ id: 'g1', name: 'Upper', nodeIds: ['a'] },
{ id: 'g2', name: 'Lower', nodeIds: ['b'] },
]);
syncStackedGroups(view);
view.toggleCollapsed('g1');
const moves = view.commitMovedPushSourceEffects(['g1'], (nodeId) =>
nodeId === 'b' ? [0, 80] : undefined,
);
expect(moves).toEqual([{ id: 'b', position: { x: 0, y: 296 } }]);
expect(view.getVisualOffsetForNode('b')).toEqual({ x: 0, y: 0 });
});
it('persists the expansion push when a collapsed group is ungrouped (expand → commit → delete)', () => {
// Ungrouping a collapsed group makes its hidden members reappear, which
// can overlap nodes placed over their footprint. The ungroup recipe
// expands the group first so the push algorithm displaces overlapping
// components, commits that displacement, then deletes the group.
const { nodeGroups, view } = setup([{ id: 'g1', name: 'A', nodeIds: ['a'] }]);
syncLayout(view);
view.toggleCollapsed('g1');
const offset = view.getVisualOffsetForNode('b');
expect(offset.x).toBeGreaterThan(0);
const moves = view.commitMovedPushSourceEffects(['g1'], (nodeId) =>
nodeId === 'b' ? [450, 10] : undefined,
);
nodeGroups.deleteGroup('g1');
expect(moves).toEqual([{ id: 'b', position: { x: 450 + offset.x, y: 10 + offset.y } }]);
// Nothing stays live: the persisted position is the final one.
expect(view.getVisualOffsetForNode('b')).toEqual({ x: 0, y: 0 });
});
it('stops using a manually moved expanded group as a push source until re-expanded', () => {
const { view } = setup([{ id: 'source', name: 'Source', nodeIds: ['sourceNode'] }]);
view.syncLayoutComponents([
{
id: 'group:source',
kind: 'group',
groupId: 'source',
nodeIds: ['sourceNode'],
rect: { x: 0, y: 0, width: 400, height: 40 },
collapsedRect: { x: 0, y: 0, width: 400, height: 40 },
expandedRect: { x: 0, y: 0, width: 600, height: 240 },
},
{
id: 'target',
kind: 'node',
nodeIds: ['target'],
rect: { x: 450, y: 10, width: 100, height: 100 },
},
]);
view.toggleCollapsed('source');
expect(view.getVisualOffsetForNode('target').x).toBeGreaterThan(0);
const moves = view.commitMovedPushSourceEffects(['source'], (nodeId) =>
nodeId === 'target' ? [450, 10] : undefined,
);
expect(moves).toHaveLength(1);
expect(moves[0]?.id).toBe('target');
expect(view.getVisualOffsetForNode('target')).toEqual({ x: 0, y: 0 });
view.toggleCollapsed('source');
view.toggleCollapsed('source');
expect(view.getVisualOffsetForNode('target').x).toBeGreaterThan(0);
});
it('commits visual positions for grouped nodes pushed by a moved source', () => {
const { view } = setup([
{ id: 'source', name: 'Source', nodeIds: ['sourceNode'] },
{ id: 'targetGroup', name: 'Target', nodeIds: ['targetA', 'targetB'] },
]);
view.syncLayoutComponents([
{
id: 'group:source',
kind: 'group',
groupId: 'source',
nodeIds: ['sourceNode'],
rect: { x: 0, y: 0, width: 600, height: 240 },
collapsedRect: { x: 0, y: 0, width: 400, height: 40 },
expandedRect: { x: 0, y: 0, width: 600, height: 240 },
},
{
id: 'group:targetGroup',
kind: 'group',
groupId: 'targetGroup',
nodeIds: ['targetA', 'targetB'],
rect: { x: 450, y: 10, width: 400, height: 40 },
collapsedRect: { x: 450, y: 10, width: 400, height: 40 },
expandedRect: { x: 450, y: 10, width: 400, height: 40 },
},
]);
view.toggleCollapsed('source');
const moves = view.commitMovedPushSourceEffects(['source'], (nodeId) => {
if (nodeId === 'targetA') return [450, 10];
if (nodeId === 'targetB') return [600, 10];
return undefined;
});
expect(moves).toEqual([
{ id: 'targetA', position: { x: 666, y: 10 } },
{ id: 'targetB', position: { x: 816, y: 10 } },
]);
});
it('settles the full visual offset of nodes pushed by a manually moved source', () => {
const { view } = setup([
{ id: 'g2', name: 'Group 2', nodeIds: ['g2Node'] },
{ id: 'g1', name: 'Group 1', nodeIds: ['g1Node'] },
]);
view.syncLayoutComponents([
{
id: 'group:g2',
kind: 'group',
groupId: 'g2',
nodeIds: ['g2Node'],
rect: { x: 0, y: 0, width: 700, height: 240 },
collapsedRect: { x: 0, y: 0, width: 400, height: 40 },
expandedRect: { x: 0, y: 0, width: 700, height: 240 },
},
{
id: 'group:g1',
kind: 'group',
groupId: 'g1',
nodeIds: ['g1Node'],
rect: { x: 0, y: 120, width: 800, height: 240 },
collapsedRect: { x: 0, y: 120, width: 400, height: 40 },
expandedRect: { x: 0, y: 120, width: 800, height: 240 },
},
{
id: 'target',
kind: 'node',
nodeIds: ['target'],
rect: { x: 650, y: 340, width: 100, height: 100 },
},
]);
view.toggleCollapsed('g2');
view.toggleCollapsed('g1');
const targetOffset = view.getVisualOffsetForNode('target');
expect(targetOffset.x).toBeGreaterThan(0);
expect(targetOffset.y).toBeGreaterThan(0);
const moves = view.commitMovedPushSourceEffects(['g1'], (nodeId) =>
nodeId === 'target' ? [650, 340] : undefined,
);
expect(moves).toHaveLength(1);
expect(moves[0]?.id).toBe('target');
expect(view.getVisualOffsetForNode('target')).toEqual({ x: 0, y: 0 });
});
it('keeps the push offset stable across a layout re-sync', () => {
const { view } = setup([
{ id: 'g1', name: 'Upper', nodeIds: ['a'] },
{ id: 'g2', name: 'Lower', nodeIds: ['b'] },
]);
syncStackedGroups(view);
view.toggleCollapsed('g1');
const lowerOffsetAfterFirstExpansion = view.getVisualOffsetForComponent('group:g2');
expect(lowerOffsetAfterFirstExpansion.y).toBeGreaterThan(0);
syncStackedGroups(view, { upperExpanded: true });
expect(view.getVisualOffsetForComponent('group:g2')).toEqual(lowerOffsetAfterFirstExpansion);
expect(view.getVisualOffsetForNode('b')).toEqual(lowerOffsetAfterFirstExpansion);
});
it('does not push a moved-away group again when the source group is re-expanded', () => {
const { view } = setup([
{ id: 'g1', name: 'Upper', nodeIds: ['a'] },
{ id: 'g2', name: 'Lower', nodeIds: ['b'] },
]);
syncStackedGroups(view);
view.toggleCollapsed('g1');
const lowerOffsetAfterFirstExpansion = view.getVisualOffsetForComponent('group:g2');
expect(lowerOffsetAfterFirstExpansion.y).toBeGreaterThan(0);
// Dropping the pushed group at visual y=1000 stores y minus the live offset.
const movedAwayY = 1000 - lowerOffsetAfterFirstExpansion.y;
syncStackedGroups(view, { upperExpanded: true, lowerY: movedAwayY });
view.toggleCollapsed('g1');
expect(view.getVisualOffsetForComponent('group:g2')).toEqual({ x: 0, y: 0 });
syncStackedGroups(view, { lowerY: movedAwayY });
view.toggleCollapsed('g1');
expect(view.getVisualOffsetForComponent('group:g2')).toEqual({ x: 0, y: 0 });
});
it('keeps a moved-away pushed group stable when that group is expanded', () => {
const { view } = setup([
{ id: 'g1', name: 'Upper', nodeIds: ['a'] },
{ id: 'g2', name: 'Lower', nodeIds: ['b'] },
]);
syncStackedGroups(view);
view.toggleCollapsed('g1');
const lowerOffsetAfterFirstExpansion = view.getVisualOffsetForComponent('group:g2');
expect(lowerOffsetAfterFirstExpansion.y).toBeGreaterThan(0);
// Dropping the pushed group at visual y=1000 stores y minus the live offset.
const movedAwayY = 1000 - lowerOffsetAfterFirstExpansion.y;
syncStackedGroups(view, { upperExpanded: true, lowerY: movedAwayY });
view.toggleCollapsed('g1');
syncStackedGroups(view, { lowerY: movedAwayY });
view.toggleCollapsed('g2');
syncStackedGroups(view, { lowerExpanded: true, lowerY: movedAwayY });
expect(view.getVisualOffsetForComponent('group:g2')).toEqual({ x: 0, y: 0 });
expect(view.getVisualOffsetForNode('b')).toEqual({ x: 0, y: 0 });
});
});
});
@@ -1,17 +1,31 @@
import { getCurrentScope, onScopeDispose, ref, type InjectionKey } from 'vue';
import { computed, getCurrentScope, onScopeDispose, ref, type InjectionKey } from 'vue';
import { jsonParse } from 'n8n-workflow';
import type { NodeGroupChangeEvent } from '@/app/stores/workflowDocument/useWorkflowDocumentNodeGroups';
import { CHANGE_ACTION } from '@/app/stores/workflowDocument/types';
import { LOCAL_STORAGE_CANVAS_GROUP_EXPANDED } from '@/app/constants/localStorage';
import { isStringArrayRecord } from '@/app/utils/objectUtils';
import {
aggregateNodeGroupLayoutOffsets,
computeNodeGroupLayoutPushes,
getOffsetForComponent,
mapNodeIdsToComponentIds,
type NodeGroupLayoutComponent,
} from './useCanvasNodeGroupLayout';
export interface UseCanvasNodeGroupViewDeps {
workflowId: () => string;
getCurrentGroupIds: () => string[];
onNodeGroupsChange: (handler: (event: NodeGroupChangeEvent) => void) => { off: () => void };
isGroupingEnabled?: () => boolean;
isGroupingEnabled: () => boolean;
}
export interface NodeGroupNodePosition {
id: string;
position: { x: number; y: number };
}
export type GetNodePositionById = (nodeId: string) => [number, number] | undefined;
export type CanvasNodeGroupView = ReturnType<typeof useCanvasNodeGroupView>;
export const NodeGroupViewKey: InjectionKey<CanvasNodeGroupView> = Symbol('nodeGroupView');
@@ -37,6 +51,23 @@ function writeStore(store: ExpandedGroupStore) {
}
}
/** Copy of `ignored` with `nodeIds` added to the set of every group in `groupIds`. */
function withIgnoredNodes(
ignored: Map<string, Set<string>>,
groupIds: Iterable<string>,
nodeIds: Iterable<string>,
): Map<string, Set<string>> {
const next = new Map(ignored);
for (const groupId of groupIds) {
const ignoredNodeIds = new Set(next.get(groupId));
for (const nodeId of nodeIds) {
ignoredNodeIds.add(nodeId);
}
next.set(groupId, ignoredNodeIds);
}
return next;
}
/**
* Canvas view-state for group collapse/expand. Lives separately from the
* workflow document store because collapse is not workflow data — it does
@@ -47,50 +78,245 @@ function writeStore(store: ExpandedGroupStore) {
* reload restores the groups the user had open.
*/
export function useCanvasNodeGroupView(deps: UseCanvasNodeGroupViewDeps) {
const expandedIds = ref<Set<string>>(new Set());
// Source of truth for expand state: group ids in expansion order
// (oldest first). Everything not listed is collapsed.
const expandedGroupIdOrder = ref<string[]>([]);
const expandedIds = computed(() => new Set(expandedGroupIdOrder.value));
const disabledPushSourceGroupIds = ref<Set<string>>(new Set());
const layoutComponents = ref<NodeGroupLayoutComponent[]>([]);
const nodeIdToComponentId = computed(() => mapNodeIdsToComponentIds(layoutComponents.value));
// Nodes whose push offset is already baked into the store —
// prevents pushing them a second time.
const ignoredNodeIdsBySourceGroup = ref<Map<string, Set<string>>>(new Map());
const isGroupingEnabled = () => deps.isGroupingEnabled?.() ?? true;
const pushSourceGroupIds = computed(
() =>
new Set(
[...expandedIds.value].filter((groupId) => !disabledPushSourceGroupIds.value.has(groupId)),
),
);
const pushEntries = computed(() =>
computeNodeGroupLayoutPushes({
components: layoutComponents.value,
expandedGroupIds: pushSourceGroupIds.value,
expandedGroupIdOrder: expandedGroupIdOrder.value,
ignoredNodeIdsBySourceGroup: ignoredNodeIdsBySourceGroup.value,
}),
);
const componentOffsets = computed(() => aggregateNodeGroupLayoutOffsets(pushEntries.value));
function persist() {
writeStore({ ...readStore(), [deps.workflowId()]: Array.from(expandedIds.value) });
writeStore({ ...readStore(), [deps.workflowId()]: [...expandedGroupIdOrder.value] });
}
function clearIgnoredNodesForSourceGroup(id: string) {
if (!ignoredNodeIdsBySourceGroup.value.has(id)) return;
const next = new Map(ignoredNodeIdsBySourceGroup.value);
next.delete(id);
ignoredNodeIdsBySourceGroup.value = next;
}
function removeDeletedGroup(id: string) {
expandedGroupIdOrder.value = expandedGroupIdOrder.value.filter((groupId) => groupId !== id);
disabledPushSourceGroupIds.value = new Set(
[...disabledPushSourceGroupIds.value].filter((groupId) => groupId !== id),
);
clearIgnoredNodesForSourceGroup(id);
persist();
}
// Load the persisted ids, dropping any whose group no longer exists.
function restore(presentIds: Set<string>) {
const stored = readStore()[deps.workflowId()] ?? [];
expandedIds.value = new Set(stored.filter((id) => presentIds.has(id)));
expandedGroupIdOrder.value = stored.filter((id) => presentIds.has(id));
disabledPushSourceGroupIds.value = new Set();
ignoredNodeIdsBySourceGroup.value = new Map();
persist();
}
function setExpanded(id: string, value: boolean) {
function setGroupExpanded(id: string, value: boolean) {
if (expandedIds.value.has(id) === value) return;
if (value) {
// Delete-then-add moves the id to the end so order tracks recency.
expandedIds.value.delete(id);
expandedIds.value.add(id);
expandedGroupIdOrder.value = [...expandedGroupIdOrder.value, id];
} else {
expandedIds.value.delete(id);
clearIgnoredNodesForSourceGroup(id);
expandedGroupIdOrder.value = expandedGroupIdOrder.value.filter((groupId) => groupId !== id);
}
disabledPushSourceGroupIds.value = new Set(
[...disabledPushSourceGroupIds.value].filter((groupId) => groupId !== id),
);
persist();
}
const isGroupCollapsed = (id: string) => isGroupingEnabled() && !expandedIds.value.has(id);
const isGroupCollapsed = (id: string) => deps.isGroupingEnabled() && !expandedIds.value.has(id);
function toggleCollapsed(id: string) {
setExpanded(id, isGroupCollapsed(id));
setGroupExpanded(id, isGroupCollapsed(id));
}
function syncLayoutComponents(components: NodeGroupLayoutComponent[]) {
layoutComponents.value = components;
}
function getVisualOffsetForComponent(componentId: string) {
return getOffsetForComponent(componentOffsets.value, componentId);
}
function getVisualOffsetForNode(nodeId: string) {
const componentId = nodeIdToComponentId.value.get(nodeId);
return componentId ? getVisualOffsetForComponent(componentId) : { x: 0, y: 0 };
}
function ignoreNodesForSourceGroups(nodeIds: string[], sourceGroupIds?: string[]) {
const groupIds = sourceGroupIds ?? [...expandedIds.value];
if (nodeIds.length === 0 || groupIds.length === 0) return;
ignoredNodeIdsBySourceGroup.value = withIgnoredNodes(
ignoredNodeIdsBySourceGroup.value,
groupIds,
nodeIds,
);
}
function getPushedNodeIdsFromSourceGroups(groupIds: string[]) {
const groupIdSet = new Set(groupIds);
const componentIds = new Set(
pushEntries.value
.filter((entry) => groupIdSet.has(entry.sourceGroupId))
.map((entry) => entry.componentId),
);
const nodeIds = new Set<string>();
for (const component of layoutComponents.value) {
if (!componentIds.has(component.id)) continue;
for (const nodeId of component.nodeIds) {
nodeIds.add(nodeId);
}
}
return [...nodeIds];
}
function disablePushSourceGroups(groupIds: string[]) {
const next = new Set(disabledPushSourceGroupIds.value);
for (const groupId of groupIds) {
if (expandedIds.value.has(groupId)) {
next.add(groupId);
}
}
disabledPushSourceGroupIds.value = next;
}
// What the offsets would become if `movedNodeIds` no longer participated
// in any push.
function getSettledPushOffsets(movedNodeIds: Set<string>) {
return aggregateNodeGroupLayoutOffsets(
computeNodeGroupLayoutPushes({
components: layoutComponents.value,
expandedGroupIds: pushSourceGroupIds.value,
expandedGroupIdOrder: expandedGroupIdOrder.value,
ignoredNodeIdsBySourceGroup: withIgnoredNodes(
ignoredNodeIdsBySourceGroup.value,
expandedIds.value,
movedNodeIds,
),
}),
);
}
function settleManualNodePositions(
events: NodeGroupNodePosition[],
getNodePositionById?: GetNodePositionById,
) {
// No expanded groups → no live pushes; nothing to settle or bake.
if (expandedIds.value.size === 0) return events;
const movedNodeIds = new Set(events.map(({ id }) => id));
const bakedMoves: NodeGroupNodePosition[] = [];
if (getNodePositionById && movedNodeIds.size > 0) {
// Ignoring the moved nodes can remove the very overlap that triggered a
// push lane, snapping the other components in that lane back. Bake the
// current visual position of every component whose offset would change
// so they stay put (same principle as commitMovedPushSourceEffects);
// components whose push is still triggered stay live.
const settledOffsets = getSettledPushOffsets(movedNodeIds);
for (const component of layoutComponents.value) {
const offset = getOffsetForComponent(componentOffsets.value, component.id);
if (offset.x === 0 && offset.y === 0) continue;
const settledOffset = getOffsetForComponent(settledOffsets, component.id);
if (settledOffset.x === offset.x && settledOffset.y === offset.y) continue;
for (const nodeId of component.nodeIds) {
// Moved nodes already carry their final visual position in `events`;
// baking them from the not-yet-updated store would revert the drag.
if (movedNodeIds.has(nodeId)) continue;
const position = getNodePositionById(nodeId);
if (!position) continue;
bakedMoves.push({
id: nodeId,
position: { x: position[0] + offset.x, y: position[1] + offset.y },
});
}
}
}
ignoreNodesForSourceGroups([...movedNodeIds, ...bakedMoves.map(({ id }) => id)]);
return [...events, ...bakedMoves];
}
function commitMovedPushSourceEffects(
sourceGroupIds: string[],
getNodePositionById: GetNodePositionById,
) {
const pushedNodeIds = getPushedNodeIdsFromSourceGroups(sourceGroupIds);
const pushedNodeMoves = pushedNodeIds.flatMap<NodeGroupNodePosition>((nodeId) => {
const position = getNodePositionById(nodeId);
if (!position) return [];
const offset = getVisualOffsetForNode(nodeId);
return [
{
id: nodeId,
position: {
x: position[0] + offset.x,
y: position[1] + offset.y,
},
},
];
});
if (pushedNodeMoves.length > 0) {
ignoreNodesForSourceGroups(pushedNodeMoves.map(({ id }) => id));
}
disablePushSourceGroups(sourceGroupIds);
return pushedNodeMoves;
}
// Seed from groups already loaded (the SET event can fire before we subscribe).
restore(new Set(deps.getCurrentGroupIds()));
// SET restores persisted state; ADD expands the new group; DELETE prunes it.
// Default collapse state per change action: SET (workflow load /
// replacement) restores the persisted expanded state; ADD (new group)
// starts expanded, unless flagged `startCollapsed` (imported/pasted
// groups, whose stored positions describe the collapsed layout); DELETE
// removes the id; UPDATE leaves collapse state alone.
const subscription = deps.onNodeGroupsChange((event) => {
if (event.action === CHANGE_ACTION.SET) {
restore(new Set(event.payload.groups.map((group) => group.id)));
} else if (event.action === CHANGE_ACTION.ADD) {
setExpanded(event.payload.group.id, true);
} else if (event.action === CHANGE_ACTION.ADD && !event.payload.startCollapsed) {
setGroupExpanded(event.payload.group.id, true);
// New groups open expanded, but creating one shouldn't shove existing
// nodes/groups around. Keep it out of the active push sources for now —
// a page reload re-enables pushing via the persisted expanded state.
disabledPushSourceGroupIds.value = new Set([
...disabledPushSourceGroupIds.value,
event.payload.group.id,
]);
} else if (event.action === CHANGE_ACTION.DELETE) {
setExpanded(event.payload.id, false);
removeDeletedGroup(event.payload.id);
}
});
@@ -103,5 +329,10 @@ export function useCanvasNodeGroupView(deps: UseCanvasNodeGroupViewDeps) {
return {
isGroupCollapsed,
toggleCollapsed,
syncLayoutComponents,
getVisualOffsetForComponent,
getVisualOffsetForNode,
settleManualNodePositions,
commitMovedPushSourceEffects,
};
}