mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-12 02:19:43 +00:00
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:
co-authored by
Daria Staferova
Claude Opus 4.7
parent
93e2f8629e
commit
053126fc7c
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -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', () => {
|
||||
|
||||
+17
-4
@@ -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) {
|
||||
|
||||
+20
@@ -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,
|
||||
|
||||
+23
@@ -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', () => {
|
||||
|
||||
+10
-2
@@ -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,
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+9
-11
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
-9
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
+50
-6
@@ -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 },
|
||||
|
||||
+71
-18
@@ -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 {
|
||||
|
||||
+483
@@ -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'));
|
||||
});
|
||||
});
|
||||
+565
@@ -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;
|
||||
}
|
||||
+655
-1
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+248
-17
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user