feat(editor): Improve sticky note behavior during node insertion (#25207)

This commit is contained in:
Sandra Zollner
2026-02-05 16:37:25 +01:00
committed by GitHub
parent fd818b1570
commit be370e096b
5 changed files with 1064 additions and 105 deletions
@@ -5588,6 +5588,469 @@ describe('useCanvasOperations', () => {
expect(nodesToMove).toHaveLength(2);
expect(nodesToMove.find((n) => n.name === 'Start')).toBeUndefined();
});
it('should classify sticky notes as stretch-only when they contain the source node and insertion point is inside the sticky note', () => {
/**
* Visual representation of the test scenario:
*
* Before insertion:
* ┌─────────────────────────────┐
* │ Sticky Note (50, 50) │
* │ │
* │ [Source]────────────────> [Target]
* │ (100, 100) (400, 100)
* │ │
* └─────────────────────────────┘
*
* Insertion point: (250, 100) - between Source and Target
*
* Expected behavior:
* - Sticky should ONLY stretch (not move) because it contains the source node
* - Source node acts as an anchor point
* - Target node will shift right
* - Sticky will expand to accommodate the new node while staying anchored
*/
const sourceNode = createTestNode({ id: 'source', name: 'Source', position: [100, 100] });
const targetNode = createTestNode({ id: 'target', name: 'Target', position: [400, 100] });
const stickyNote = createTestNode({
id: 'sticky',
name: 'Sticky',
type: STICKY_NODE_TYPE,
position: [50, 50],
parameters: { width: 300, height: 200 },
});
const pinia = createTestingPinia({
initialState: {
[STORES.WORKFLOWS]: {
workflow: createTestWorkflow({
nodes: [sourceNode, targetNode, stickyNote],
connections: {
[sourceNode.name]: {
main: [[{ node: targetNode.name, type: NodeConnectionTypes.Main, index: 0 }]],
},
},
}),
},
},
});
setActivePinia(pinia);
const { getNodesToShift } = useCanvasOperations();
const insertPosition: [number, number] = [250, 100];
const { nodesToMove, stickiesToStretch, stickiesToMoveAndStretch } = getNodesToShift(
insertPosition,
'Source',
);
// Sticky containing source node should only stretch (anchored)
expect(stickiesToStretch).toHaveLength(1);
expect(stickiesToStretch[0].id).toBe('sticky');
expect(stickiesToMoveAndStretch).toHaveLength(0);
expect(nodesToMove.find((n) => n.id === 'sticky')).toBeUndefined();
});
it('should classify sticky notes to only move when they are far from the insertion point', () => {
/**
* Visual representation:
*
* Before insertion:
* ┌─────────────┐
* │ Sticky │
* [Source]───────────────────────────> [Target] │ (600, 50) │
* (100, 100) (500, 100) │ │
* └─────────────┘
*
* Insertion point: (250, 100)
*
* Expected behavior:
* - Sticky is entirely to the right and far from insertion point
* - Sticky should ONLY move (no stretching needed)
* - Target node will also shift right
*/
const sourceNode = createTestNode({ id: 'source', name: 'Source', position: [100, 100] });
const targetNode = createTestNode({ id: 'target', name: 'Target', position: [500, 100] });
const stickyNote = createTestNode({
id: 'sticky',
name: 'Sticky',
type: STICKY_NODE_TYPE,
position: [600, 50],
parameters: { width: 200, height: 200 },
});
const pinia = createTestingPinia({
initialState: {
[STORES.WORKFLOWS]: {
workflow: createTestWorkflow({
nodes: [sourceNode, targetNode, stickyNote],
connections: {
[sourceNode.name]: {
main: [[{ node: targetNode.name, type: NodeConnectionTypes.Main, index: 0 }]],
},
},
}),
},
},
});
setActivePinia(pinia);
const { getNodesToShift } = useCanvasOperations();
const insertPosition: [number, number] = [250, 100];
const { nodesToMove, stickiesToStretch, stickiesToMoveAndStretch } = getNodesToShift(
insertPosition,
'Source',
);
// Sticky far from insertion should only move
expect(stickiesToMoveAndStretch).toHaveLength(0);
expect(stickiesToStretch).toHaveLength(0);
expect(nodesToMove).toHaveLength(2); // target + sticky
expect(nodesToMove).toContainEqual(expect.objectContaining({ id: 'target' }));
expect(nodesToMove).toContainEqual(expect.objectContaining({ id: 'sticky' }));
});
it('should not move sticky notes that do not overlap vertically with the insertion area', () => {
/**
* Visual representation:
*
* [Source]──────────────────────> [Target]
* (100, 100) (400, 100)
* ↑
* Insertion: (250, 100)
*
*
*
* ┌─────────────┐
* │ Sticky │
* │ (300, 500) │ ← Far below, no vertical overlap
* │ │
* └─────────────┘
*
* Expected behavior:
* - Sticky is far below the insertion area (no vertical overlap)
* - Sticky should NOT be affected (no move, no stretch)
*/
const sourceNode = createTestNode({ id: 'source', name: 'Source', position: [100, 100] });
const targetNode = createTestNode({ id: 'target', name: 'Target', position: [400, 100] });
const stickyNote = createTestNode({
id: 'sticky',
name: 'Sticky',
type: STICKY_NODE_TYPE,
position: [300, 500],
parameters: { width: 200, height: 200 },
});
const pinia = createTestingPinia({
initialState: {
[STORES.WORKFLOWS]: {
workflow: createTestWorkflow({
nodes: [sourceNode, targetNode, stickyNote],
connections: {
[sourceNode.name]: {
main: [[{ node: targetNode.name, type: NodeConnectionTypes.Main, index: 0 }]],
},
},
}),
},
},
});
setActivePinia(pinia);
const { getNodesToShift } = useCanvasOperations();
const insertPosition: [number, number] = [250, 100];
const { nodesToMove, stickiesToStretch, stickiesToMoveAndStretch } = getNodesToShift(
insertPosition,
'Source',
);
// Sticky not overlapping vertically should not be affected
expect(stickiesToStretch).toHaveLength(0);
expect(stickiesToMoveAndStretch).toHaveLength(0);
expect(nodesToMove).toHaveLength(1); // only target moves
expect(nodesToMove).toContainEqual(expect.objectContaining({ id: 'target' }));
expect(nodesToMove.find((n) => n.id === 'sticky')).toBeUndefined();
});
it('should track associated nodes for sticky notes', () => {
/**
* Visual representation:
*
* ┌─────────┐
* │ Sticky │
* [Source]──────────────────-─────────>│[Target] │
* (100, 100) │(400,100)│
* └─────────┘
* ↑
* Insertion: (250, 100)
*
* Expected behavior:
* - Sticky contains the target node (center proximity check)
* - Target node will move, so sticky will move too
* - Sticky should track Target as an associated node for stretching calculations
*/
const sourceNode = createTestNode({ id: 'source', name: 'Source', position: [100, 100] });
const targetNode = createTestNode({ id: 'target', name: 'Target', position: [400, 100] });
const stickyNote = createTestNode({
id: 'sticky',
name: 'Sticky',
type: STICKY_NODE_TYPE,
position: [370, 70],
parameters: { width: 100, height: 100 },
});
const pinia = createTestingPinia({
initialState: {
[STORES.WORKFLOWS]: {
workflow: createTestWorkflow({
nodes: [sourceNode, targetNode, stickyNote],
connections: {
[sourceNode.name]: {
main: [[{ node: targetNode.name, type: NodeConnectionTypes.Main, index: 0 }]],
},
},
}),
},
},
});
setActivePinia(pinia);
const { getNodesToShift } = useCanvasOperations();
const insertPosition: [number, number] = [250, 100];
const { nodesToMove, stickyAssociatedNodes } = getNodesToShift(insertPosition, 'Source');
// Should track the target node as associated with the sticky
expect(nodesToMove).toHaveLength(2); // target + sticky
expect(nodesToMove).toContainEqual(expect.objectContaining({ id: 'target' }));
expect(nodesToMove).toContainEqual(expect.objectContaining({ id: 'sticky' }));
const associatedNodes = stickyAssociatedNodes.get('sticky');
expect(associatedNodes).toBeDefined();
expect(associatedNodes).toHaveLength(1);
expect(associatedNodes?.[0].id).toBe('target');
});
it('should handle multiple sticky notes with different behaviors', () => {
/**
* Visual representation:
*
* ┌──────────────────────┐ ┌───────┐ ┌────────────┐
* │ Sticky-Anchor │ │Sticky │ │Sticky-Move │
* │ │ │[Tgt] │ │ │
* │ [Source]──────────────────────>│(400) │ │ (600, 50) │
* │ (100, 100) │ └───────┘ │ │
* │ │ └────────────┘
* └──────────────────────┘
* ↑
* Insertion: (250, 100)
*
* Expected behavior:
* - Sticky-Anchor: Contains source node → ONLY stretch (anchored)
* - Sticky-WithTarget: Contains target node that will move → move + track association
* - Sticky-Move: Far to the right → ONLY move
*/
const sourceNode = createTestNode({ id: 'source', name: 'Source', position: [100, 100] });
const targetNode = createTestNode({ id: 'target', name: 'Target', position: [400, 100] });
const stickyAnchor = createTestNode({
id: 'sticky-anchor',
name: 'StickyAnchor',
type: STICKY_NODE_TYPE,
position: [50, 50],
parameters: { width: 300, height: 200 },
});
const stickyWithTarget = createTestNode({
id: 'sticky-with-target',
name: 'StickyWithTarget',
type: STICKY_NODE_TYPE,
position: [370, 70],
parameters: { width: 100, height: 100 },
});
const stickyMove = createTestNode({
id: 'sticky-move',
name: 'StickyMove',
type: STICKY_NODE_TYPE,
position: [600, 50],
parameters: { width: 200, height: 200 },
});
const pinia = createTestingPinia({
initialState: {
[STORES.WORKFLOWS]: {
workflow: createTestWorkflow({
nodes: [sourceNode, targetNode, stickyAnchor, stickyWithTarget, stickyMove],
connections: {
[sourceNode.name]: {
main: [[{ node: targetNode.name, type: NodeConnectionTypes.Main, index: 0 }]],
},
},
}),
},
},
});
setActivePinia(pinia);
const { getNodesToShift } = useCanvasOperations();
const insertPosition: [number, number] = [250, 100];
const { nodesToMove, stickiesToStretch, stickyAssociatedNodes } = getNodesToShift(
insertPosition,
'Source',
);
// Sticky containing source should only stretch
expect(stickiesToStretch).toHaveLength(1);
expect(stickiesToStretch).toContainEqual(expect.objectContaining({ id: 'sticky-anchor' }));
// Sticky containing target should move (and track associated nodes)
expect(nodesToMove).toHaveLength(3); // target + sticky-with-target + sticky-move
expect(nodesToMove).toContainEqual(expect.objectContaining({ id: 'target' }));
expect(nodesToMove).toContainEqual(expect.objectContaining({ id: 'sticky-with-target' }));
expect(stickyAssociatedNodes.get('sticky-with-target')).toHaveLength(1);
// Sticky far to the right should only move
expect(nodesToMove).toContainEqual(expect.objectContaining({ id: 'sticky-move' }));
});
it('should handle sticky notes with nodes at the center boundary', () => {
/**
* Visual representation:
*
* ┌────────────────────┐
* │ Sticky (200, 50) │
* [Source]───│───────────────────────────> [Target]
* (100, 100) │ ↑ │ (400, 100)
* │ Insertion point │
* │ (250, 100) │
* └────────────────────┘
*
* Expected behavior:
* - Sticky overlaps the insertion point (inside its bounds)
* - Sticky does NOT contain source node
* - Sticky should ONLY stretch to accommodate the new node
*/
const sourceNode = createTestNode({ id: 'source', name: 'Source', position: [100, 100] });
const targetNode = createTestNode({ id: 'target', name: 'Target', position: [400, 100] });
const stickyNote = createTestNode({
id: 'sticky',
name: 'Sticky',
type: STICKY_NODE_TYPE,
position: [200, 50],
parameters: { width: 200, height: 200 },
});
const pinia = createTestingPinia({
initialState: {
[STORES.WORKFLOWS]: {
workflow: createTestWorkflow({
nodes: [sourceNode, targetNode, stickyNote],
connections: {
[sourceNode.name]: {
main: [[{ node: targetNode.name, type: NodeConnectionTypes.Main, index: 0 }]],
},
},
}),
},
},
});
setActivePinia(pinia);
const { getNodesToShift } = useCanvasOperations();
const insertPosition: [number, number] = [250, 100];
const { stickiesToStretch } = getNodesToShift(insertPosition, 'Source');
// Sticky overlapping insertion point should stretch
expect(stickiesToStretch).toHaveLength(1);
expect(stickiesToStretch[0].id).toBe('sticky');
});
it('should track multiple associated nodes for sticky stretching', () => {
/**
* Visual representation:
*
* ┌──────────┐
* │ Sticky │
* ┌────────>│[Target1] │
* │ │ (400,80) │
* [Source]──────────────────┤ │ │
* (100, 100) │ │[Target2] │
* └────────>│(400,120) │
* └──────────┘
* ↑
* Insertion: (250, 100)
*
* Expected behavior:
* - Sticky contains BOTH target nodes
* - Both target nodes will move
* - Sticky should track BOTH targets as associated nodes
* - This ensures proper stretching to encompass all associated nodes
*/
const sourceNode = createTestNode({
id: 'source',
name: 'Source',
position: [100, 100],
});
const targetNode1 = createTestNode({
id: 'target1',
name: 'Target1',
position: [400, 80],
});
const targetNode2 = createTestNode({
id: 'target2',
name: 'Target2',
position: [400, 120],
});
const stickyNote = createTestNode({
id: 'sticky',
name: 'Sticky',
type: STICKY_NODE_TYPE,
position: [370, 50],
parameters: { width: 100, height: 150 },
});
const pinia = createTestingPinia({
initialState: {
[STORES.WORKFLOWS]: {
workflow: createTestWorkflow({
nodes: [sourceNode, targetNode1, targetNode2, stickyNote],
connections: {
[sourceNode.name]: {
main: [
[
{ node: targetNode1.name, type: NodeConnectionTypes.Main, index: 0 },
{ node: targetNode2.name, type: NodeConnectionTypes.Main, index: 0 },
],
],
},
},
}),
},
},
});
setActivePinia(pinia);
const { getNodesToShift } = useCanvasOperations();
const insertPosition: [number, number] = [250, 100];
const { nodesToMove, stickyAssociatedNodes } = getNodesToShift(insertPosition, 'Source');
// Should track both target nodes as associated with the sticky
expect(nodesToMove).toHaveLength(3); // target1 + target2 + sticky
expect(nodesToMove).toContainEqual(expect.objectContaining({ id: 'target1' }));
expect(nodesToMove).toContainEqual(expect.objectContaining({ id: 'target2' }));
expect(nodesToMove).toContainEqual(expect.objectContaining({ id: 'sticky' }));
const associatedNodes = stickyAssociatedNodes.get('sticky');
expect(associatedNodes).toBeDefined();
expect(associatedNodes).toHaveLength(2);
expect(associatedNodes?.map((n) => n.id).sort()).toEqual(['target1', 'target2']);
});
});
describe('createConnectionToLastInteractedWithNode - HITL node handling', () => {
@@ -1420,12 +1420,9 @@ export function useCanvasOperations() {
lastInteractedWithNode.position[1] + yOffset,
];
// When inserting via edge plus button, adjust Y to fit within overlapping sticky notes
// When inserting via edge plus button, keep Y aligned to preserve vertical line
if (lastInteractedWithNodeConnection) {
const adjustedY = getYPositionForStickyOverlap(position, nodeSize);
if (adjustedY !== null) {
position = [position[0], adjustedY];
}
position = [position[0], lastInteractedWithNode.position[1]];
}
}
}
@@ -1521,36 +1518,108 @@ export function useCanvasOperations() {
}
/**
* Gets all nodes that should be shifted when inserting a node.
* Determines which nodes and sticky notes need to be moved or stretched when inserting a new node.
*
* Algorithm:
* 1. Find nodes that overlap with or are to the right of insertion area (similar Y)
* 2. Add nodes connected to them (downstream)
* 3. Filter to only include nodes that need to move
* 1. Find regular nodes that overlap with or are to the right of insertion area
* 2. Add downstream connected nodes
* 3. Classify sticky notes based on:
* - Whether they contain the source node (anchored = stretch only)
* - Whether they contain nodes that will move
* - Their position relative to insertion point
*
* @returns Classification of nodes and stickies with their associated nodes for stretching
*/
function getNodesToShift(
insertPosition: XYPosition,
sourceNodeName: string,
): { nodesToMove: INodeUi[]; stickiesToStretch: INodeUi[] } {
nodeSize: [number, number] = DEFAULT_NODE_SIZE,
): {
nodesToMove: INodeUi[];
stickiesToStretch: INodeUi[];
stickiesToMoveAndStretch: INodeUi[];
stickyAssociatedNodes: Map<string, INodeUi[]>;
} {
const allNodes = Object.values(workflowsStore.nodesByName);
const insertX = insertPosition[0];
const insertY = insertPosition[1];
const yTolerance = DEFAULT_NODE_SIZE[1] * 2; // Nodes within ~2 node heights are considered "similar Y"
const getNodeCenter = (node: INodeUi) => ({
x: node.position[0] + DEFAULT_NODE_SIZE[0] / 2,
y: node.position[1] + DEFAULT_NODE_SIZE[1] / 2,
});
const isSimilarY = (node: INodeUi) => Math.abs(node.position[1] - insertY) <= yTolerance;
const overlapsInsertX = (node: INodeUi) => {
const nodeRightEdge = node.position[0] + DEFAULT_NODE_SIZE[0];
return nodeRightEdge > insertX || node.position[0] >= insertX;
};
/** Checks if a node is fully contained within a sticky note's bounds */
const isNodeInsideSticky = (
node: INodeUi,
sticky: INodeUi,
stickyRect: ReturnType<typeof getNodeRect>,
) => {
return (
node.position[0] >= sticky.position[0] &&
node.position[0] + DEFAULT_NODE_SIZE[0] <= sticky.position[0] + stickyRect.width &&
node.position[1] >= sticky.position[1] &&
node.position[1] + DEFAULT_NODE_SIZE[1] <= sticky.position[1] + stickyRect.height
);
};
/** Checks if two centers are within threshold distance (used for node-sticky association) */
const areCentersClose = (
nodeCenter: { x: number; y: number },
stickyCenter: { x: number; y: number },
threshold: { x: number; y: number },
) => {
return (
Math.abs(nodeCenter.x - stickyCenter.x) <= threshold.x &&
Math.abs(nodeCenter.y - stickyCenter.y) <= threshold.y
);
};
/** Determines if a node is associated with a sticky (inside it or centers are close) */
const isNodeAssociatedWithSticky = (
node: INodeUi,
sticky: INodeUi,
stickyRect: ReturnType<typeof getNodeRect>,
stickyCenter: { x: number; y: number },
threshold: { x: number; y: number },
) => {
return (
isNodeInsideSticky(node, sticky, stickyRect) ||
areCentersClose(getNodeCenter(node), stickyCenter, threshold)
);
};
/** Returns all nodes from the given list that are associated with the sticky */
const getAssociatedNodes = (
sticky: INodeUi,
stickyRect: ReturnType<typeof getNodeRect>,
stickyCenter: { x: number; y: number },
threshold: { x: number; y: number },
nodesToCheck: INodeUi[],
) => {
return nodesToCheck.filter((node) =>
isNodeAssociatedWithSticky(node, sticky, stickyRect, stickyCenter, threshold),
);
};
// Step 1: Find initial candidates - nodes that overlap with or are to the right of insertion
// A node overlaps if its right edge extends into the insertion area
const initialCandidates = allNodes.filter((node) => {
if (node.type === STICKY_NODE_TYPE) return false;
if (node.name === sourceNodeName) return false;
const isSimilarY = Math.abs(node.position[1] - insertY) <= yTolerance;
// Node overlaps or is to the right if its right edge is past the insertion X
// or its left edge is at/past the insertion X
const nodeRightEdge = node.position[0] + DEFAULT_NODE_SIZE[0];
const overlapsOrIsToTheRight = nodeRightEdge > insertX || node.position[0] >= insertX;
return isSimilarY && overlapsOrIsToTheRight;
return isSimilarY(node) && overlapsInsertX(node);
});
// Step 2: Add all downstream connected nodes from initial candidates
const candidateNames = new Set(initialCandidates.map((n) => n.name));
const candidateNames = new Set(initialCandidates.map((node) => node.name));
for (const candidate of initialCandidates) {
const downstream = workflowHelpers.getConnectedNodes(
'downstream',
@@ -1564,13 +1633,9 @@ export function useCanvasOperations() {
if (!node) {
return false;
}
const nodeRightEdge = node.position[0] + DEFAULT_NODE_SIZE[0];
// Check if the current node visually overlaps with, or is entirely to the right of,
// the new insertion point (insertX).
const overlapsOrIsToTheRight = nodeRightEdge > insertX || node.position[0] >= insertX;
return overlapsOrIsToTheRight;
return overlapsInsertX(node);
})
.forEach((name) => candidateNames.add(name));
}
@@ -1581,18 +1646,29 @@ export function useCanvasOperations() {
return candidateNames.has(node.name);
});
// Step 4: Find sticky notes that need moving or stretching
// Step 4: Classify sticky notes behavior
// Stickies can: move (shift right), stretch (expand width), or both
// Special case: stickies containing the source node are anchored (stretch only)
const stickiesToStretch: INodeUi[] = [];
const stickiesToMove: INodeUi[] = [];
const stickiesToMoveAndStretch: INodeUi[] = [];
const stickyAssociatedNodes = new Map<string, INodeUi[]>();
const stickyNodes = allNodes.filter((node) => node.type === STICKY_NODE_TYPE);
// Calculate the vertical area that will be affected (insertion point + nodes being moved)
// Calculate the vertical area affected by insertion
const affectedMinY = Math.min(insertY, ...regularNodesToMove.map((n) => n.position[1]));
const affectedMaxY = Math.max(
insertY + DEFAULT_NODE_SIZE[1],
insertY + nodeSize[1],
...regularNodesToMove.map((n) => n.position[1] + DEFAULT_NODE_SIZE[1]),
);
const sourceNode = workflowsStore.nodesByName[sourceNodeName];
const nodeCenterThreshold = {
x: nodeSize[0] / 2,
y: nodeSize[1] / 2,
};
// Process each sticky to determine its behavior
for (const sticky of stickyNodes) {
const stickyRect = getNodeRect(sticky);
const stickyLeftEdge = sticky.position[0];
@@ -1600,108 +1676,136 @@ export function useCanvasOperations() {
const stickyTop = sticky.position[1];
const stickyBottom = stickyTop + stickyRect.height;
const overlapsVertically = !(stickyBottom <= affectedMinY || stickyTop >= affectedMaxY);
const isInsertionInsideSticky = insertX >= stickyLeftEdge && insertX <= stickyRightEdge;
// Sticky should be moved if its left edge is at or past the insertion position
if (stickyLeftEdge >= insertX && overlapsVertically) {
stickiesToMove.push(sticky);
}
// Sticky should be stretched if:
// 1. Its left edge is before the insertion position
// 2. Its right edge extends into or past the insertion position
// 3. It overlaps vertically with the affected area
else if (stickyLeftEdge < insertX && stickyRightEdge >= insertX && overlapsVertically) {
const stickyCenter = {
x: sticky.position[0] + stickyRect.width / 2,
y: sticky.position[1] + stickyRect.height / 2,
};
// Priority 1: Stickies containing the source node are anchored (stretch only)
const sourceNodeInsideSticky =
sourceNode &&
isNodeAssociatedWithSticky(
sourceNode,
sticky,
stickyRect,
stickyCenter,
nodeCenterThreshold,
);
// a left edge before the insertion position
if (sourceNodeInsideSticky && isInsertionInsideSticky) {
const associatedNodes = getAssociatedNodes(
sticky,
stickyRect,
stickyCenter,
nodeCenterThreshold,
regularNodesToMove,
);
stickyAssociatedNodes.set(sticky.id, associatedNodes);
stickiesToStretch.push(sticky);
continue;
}
const associatedNodes = getAssociatedNodes(
sticky,
stickyRect,
stickyCenter,
nodeCenterThreshold,
regularNodesToMove,
);
if (associatedNodes.length > 0) {
stickyAssociatedNodes.set(sticky.id, associatedNodes);
}
const associatedWithMovedNode = associatedNodes.length > 0;
if (!overlapsVertically) {
continue;
}
if (associatedWithMovedNode) {
// Sticky has nodes that will move - check if new node will be close enough to the sticky
const newNodeRightEdge = insertX + nodeSize[0];
// If the new node's right edge is within 2/3 of PUSH_NODES_OFFSET from the sticky's left edge,
// stretch the sticky to include the new node
const isNewNodeCloseToSticky =
newNodeRightEdge > stickyLeftEdge + (2 * PUSH_NODES_OFFSET) / 3;
if (isNewNodeCloseToSticky) {
// New node is close enough to sticky - move AND stretch
stickiesToMoveAndStretch.push(sticky);
} else {
// New node is too far from sticky - just move
stickiesToMove.push(sticky);
}
} else if (isInsertionInsideSticky) {
// Sticky overlaps insertion but has no moving nodes - just stretch
stickiesToStretch.push(sticky);
} else if (stickyLeftEdge >= insertX) {
// Sticky is entirely to the right - move it
stickiesToMove.push(sticky);
}
}
// Combine regular nodes and stickies to move
const nodesToMove = [...regularNodesToMove, ...stickiesToMove];
const nodesToMove = [...regularNodesToMove, ...stickiesToMove, ...stickiesToMoveAndStretch];
return { nodesToMove, stickiesToStretch };
return { nodesToMove, stickiesToStretch, stickiesToMoveAndStretch, stickyAssociatedNodes };
}
/**
* Checks if the insertion position overlaps with a sticky note and returns
* an adjusted Y position that fits within the sticky's content area.
* Only adjusts Y if there's enough space for insertion (no downstream nodes blocking).
* Returns null if no adjustment is needed.
*/
function getYPositionForStickyOverlap(
insertPosition: XYPosition,
nodeSize: [number, number],
): number | null {
const allNodes = Object.values(workflowsStore.nodesByName);
const stickyNodes = allNodes.filter((node) => node.type === STICKY_NODE_TYPE);
const regularNodes = allNodes.filter((node) => node.type !== STICKY_NODE_TYPE);
// Add a small margin to detect nodes that are touching (edge-to-edge)
const margin = GRID_SIZE;
const insertRect = {
x: insertPosition[0],
y: insertPosition[1],
width: nodeSize[0] + margin,
height: nodeSize[1],
};
// First check if there are any regular nodes blocking the insertion position
// If so, downstream nodes will be shifted and we should keep the original Y
for (const node of regularNodes) {
const nodeRect = getNodeRect(node);
if (doRectsOverlap(insertRect, nodeRect)) {
// There's a regular node blocking - don't adjust Y, let shift logic handle it
return null;
}
}
// No regular nodes blocking - check if we need to adjust Y for sticky overlap
for (const sticky of stickyNodes) {
const stickyRect = getNodeRect(sticky);
// Check if insertion position overlaps with this sticky
if (doRectsOverlap(insertRect, stickyRect)) {
// Sticky header takes up approximately 40px (title area)
const STICKY_HEADER_HEIGHT = 40;
const stickyContentTop = sticky.position[1] + STICKY_HEADER_HEIGHT;
const stickyContentBottom = sticky.position[1] + stickyRect.height - GRID_SIZE;
// Calculate the ideal Y position centered in the sticky's content area
const contentHeight = stickyContentBottom - stickyContentTop;
const centeredY = stickyContentTop + (contentHeight - nodeSize[1]) / 2;
// Ensure the position is grid-aligned
return Math.round(centeredY / GRID_SIZE) * GRID_SIZE;
}
}
return null;
}
/**
* Stretches a sticky note by increasing its width
* Stretches a sticky note horizontally to ensure it surrounds the inserted node
* and all associated nodes with padding.
*/
function stretchStickyNote(
sticky: INodeUi,
stretchAmount: number,
insertPosition: XYPosition,
nodeSize: [number, number],
associatedNodes: INodeUi[],
{ trackHistory = false }: { trackHistory?: boolean },
) {
const currentWidth = (sticky.parameters.width as number) || DEFAULT_NODE_SIZE[0];
const newWidth = currentWidth + stretchAmount;
const padding = 20;
const stickyRect = getNodeRect(sticky);
const currentLeft = sticky.position[0];
const currentRight = currentLeft + stickyRect.width;
const newParameters = {
...sticky.parameters,
width: newWidth,
};
// Start with insertion bounds
let targetLeft = insertPosition[0] - padding;
let targetRight = insertPosition[0] + nodeSize[0] + padding;
replaceNodeParameters(sticky.id, sticky.parameters as INodeParameters, newParameters, {
// Expand to include all associated nodes (get fresh positions from store as they may have moved)
for (const node of associatedNodes) {
const updatedNode = workflowsStore.getNodeById(node.id);
if (!updatedNode) continue;
const nodeLeft = updatedNode.position[0] - padding;
const nodeRight = updatedNode.position[0] + DEFAULT_NODE_SIZE[0] + padding;
targetLeft = Math.min(targetLeft, nodeLeft);
targetRight = Math.max(targetRight, nodeRight);
}
const newLeft = Math.min(currentLeft, targetLeft);
const newRight = Math.max(currentRight, targetRight);
const newWidth = newRight - newLeft;
const newParameters: INodeParameters = { ...sticky.parameters, width: newWidth };
replaceNodeParameters(sticky.id, sticky.parameters, newParameters, {
trackHistory,
trackBulk: false,
});
if (newLeft !== currentLeft) {
updateNodePosition(sticky.id, { x: newLeft, y: sticky.position[1] }, { trackHistory });
}
}
/**
* Moves downstream nodes when inserting a node between existing nodes.
* Only moves nodes if there isn't enough space for the new node.
* Sticky notes that overlap the insertion area are stretched instead of moved.
* Stickies at the insertion point are repositioned behind the new node.
*/
function shiftDownstreamNodesPosition(
sourceNodeName: string,
@@ -1732,9 +1836,10 @@ export function useCanvasOperations() {
}
// Get nodes to shift and stickies to stretch
const { nodesToMove, stickiesToStretch } = getNodesToShift(insertPosition, sourceNodeName);
const { nodesToMove, stickiesToStretch, stickiesToMoveAndStretch, stickyAssociatedNodes } =
getNodesToShift(insertPosition, sourceNodeName, nodeSize);
// Move regular nodes to the right
// Move regular nodes and stickies to the right
for (const node of nodesToMove) {
updateNodePosition(
node.id,
@@ -1746,9 +1851,18 @@ export function useCanvasOperations() {
);
}
// Stretch sticky notes instead of moving them
// Stretch stickies that moved and also need to encompass the new node
for (const sticky of stickiesToMoveAndStretch) {
const updatedSticky = workflowsStore.getNodeById(sticky.id);
if (!updatedSticky) continue;
const associatedNodes = stickyAssociatedNodes.get(sticky.id) ?? [];
stretchStickyNote(updatedSticky, insertPosition, nodeSize, associatedNodes, { trackHistory });
}
// Stretch sticky notes that span the insertion area
for (const sticky of stickiesToStretch) {
stretchStickyNote(sticky, margin, { trackHistory });
const associatedNodes = stickyAssociatedNodes.get(sticky.id) ?? [];
stretchStickyNote(sticky, insertPosition, nodeSize, associatedNodes, { trackHistory });
}
}
@@ -55,4 +55,73 @@ test.describe('PAY-4367: Node shifting in cyclic workflows', () => {
await expect(n8n.canvas.nodeByName('HTTP Request')).toBeVisible();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(5);
});
test('should stretch sticky note when inserting node in front of it', async ({ n8n }) => {
// Workflow with a pink sticky note ("Sticky Note14") between Edit Fields and A node
// The sticky should stretch to encompass the new node when inserted close to it
await n8n.start.fromBlankCanvas();
await n8n.canvas.importWorkflow('Bug_node_insertions_sticky.json', 'Sticky Insert Test');
const pinkSticky = n8n.canvas.sticky.getStickies().filter({ hasText: 'Insert here' });
await expect(pinkSticky).toBeVisible();
const stickyBefore = await pinkSticky.boundingBox();
// ACT: Insert node between Edit Fields and A (in front of the pink sticky)
await n8n.canvas.addNodeBetweenNodes('Edit Fields', 'A', 'HTTP Request');
const stickyAfter = await pinkSticky.boundingBox();
// ASSERT: Sticky should have stretched (width increased) to encompass the new node
await expect(n8n.canvas.nodeByName('HTTP Request')).toBeVisible();
expect(stickyAfter?.width).toBeGreaterThan(stickyBefore?.width ?? 0);
const newNode = await n8n.canvas.nodeByName('HTTP Request').boundingBox();
// The new node should be horizontally between the sticky's left and right edges
// (with some tolerance for padding/stretching)
expect(newNode?.x).toBeGreaterThanOrEqual((stickyAfter?.x ?? 0) - 50);
expect((newNode?.x ?? 0) + (newNode?.width ?? 0)).toBeLessThanOrEqual(
(stickyAfter?.x ?? 0) + (stickyAfter?.width ?? 0) + 50,
);
});
test('should not associate node with stickies when inserting between two separate sticky notes', async ({
n8n,
}) => {
// Workflow with two sticky notes: "Sticky Note20" (pink) and "Note for A5" (yellow)
// Inserting a node between "Get a post3" and "A5" should place it in the gap between stickies
// The node should NOT be associated with either sticky (no stretching)
await n8n.start.fromBlankCanvas();
await n8n.canvas.importWorkflow(
'Bug_node_insertions_between_stickies.json',
'Between Stickies Test',
);
const pinkSticky = n8n.canvas.sticky.getStickies().filter({ hasText: 'Insert here' });
const yellowSticky = n8n.canvas.sticky.getStickies().filter({ hasText: 'Note for A' });
await expect(pinkSticky).toBeVisible();
await expect(yellowSticky).toBeVisible();
const pinkStickyBefore = await pinkSticky.boundingBox();
const yellowStickyBefore = await yellowSticky.boundingBox();
// ACT: Insert node between "Get a post3" and "A5" (in the gap between the two stickies)
await n8n.canvas.addNodeBetweenNodes('Get a post3', 'A5', 'HTTP Request');
await expect(n8n.canvas.nodeByName('HTTP Request')).toBeVisible();
const pinkStickyAfter = await pinkSticky.boundingBox();
const yellowStickyAfter = await yellowSticky.boundingBox();
//Stickies should both maintain their width (not stretch to include the new node)
expect(pinkStickyAfter?.width).toBe(pinkStickyBefore?.width);
expect(yellowStickyAfter?.width).toBe(yellowStickyBefore?.width);
const newNode = await n8n.canvas.nodeByName('HTTP Request').boundingBox();
// The new node should be between the pink and yellow stickies
expect(newNode?.x).toBeGreaterThan((pinkStickyAfter?.x ?? 0) + (pinkStickyAfter?.width ?? 0));
expect((newNode?.x ?? 0) + (newNode?.width ?? 0)).toBeLessThan(yellowStickyAfter?.x ?? 0);
});
});
@@ -0,0 +1,167 @@
{
"name": "Bug - node insertions",
"nodes": [
{
"parameters": {
"options": {}
},
"type": "n8n-nodes-base.splitInBatches",
"typeVersion": 3,
"position": [3872, 3056],
"id": "e8bf4c20-c8e8-4fa6-9466-bf63548c7a7a",
"name": "Loop Over Items5"
},
{
"parameters": {
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [4096, 3056],
"id": "544c42bd-155e-416c-abf5-5f15e6a4c0f9",
"name": "Edit Fields2"
},
{
"parameters": {
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [4528, 3056],
"id": "08735c37-aae9-4dd0-afea-4c4f08e37f72",
"name": "A5"
},
{
"parameters": {
"content": "## Note for A",
"height": 224,
"width": 160
},
"type": "n8n-nodes-base.stickyNote",
"position": [4496, 2992],
"typeVersion": 1,
"id": "1ae636b2-eeae-4378-88a0-b4d49c38a4d8",
"name": "Note for A5"
},
{
"parameters": {},
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [4528, 3296],
"id": "13d29e35-b6f1-4128-b656-db970718543d",
"name": "C10"
},
{
"parameters": {},
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [3872, 3328],
"id": "a822d276-6391-49ce-bd15-7eb41b2c5a36",
"name": "C11"
},
{
"parameters": {
"content": "Insert here",
"width": 246,
"color": 3
},
"type": "n8n-nodes-base.stickyNote",
"position": [4176, 3024],
"typeVersion": 1,
"id": "17b0cc51-d867-4a55-b9a1-68625839759f",
"name": "Sticky Note20"
},
{
"parameters": {
"operation": "get"
},
"type": "n8n-nodes-base.reddit",
"typeVersion": 1,
"position": [4304, 3056],
"id": "e1129855-44a1-4967-b271-79c949e9a365",
"name": "Get a post3"
}
],
"pinData": {},
"connections": {
"Loop Over Items5": {
"main": [
[
{
"node": "Edit Fields2",
"type": "main",
"index": 0
}
]
]
},
"Edit Fields2": {
"main": [
[
{
"node": "Get a post3",
"type": "main",
"index": 0
}
]
]
},
"A5": {
"main": [
[
{
"node": "C10",
"type": "main",
"index": 0
}
]
]
},
"C10": {
"main": [
[
{
"node": "C11",
"type": "main",
"index": 0
}
]
]
},
"C11": {
"main": [
[
{
"node": "Loop Over Items5",
"type": "main",
"index": 0
}
]
]
},
"Get a post3": {
"main": [
[
{
"node": "A5",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1",
"binaryMode": "separate",
"availableInMCP": false
},
"versionId": "1362ec5c-52ae-47af-8169-ad6410e3e2eb",
"meta": {
"templateCredsSetupCompleted": true,
"instanceId": "0113851022152b5a0ec6c5db30b5953a2e16759b15b7f941dc4f60ecce106633"
},
"id": "gNUjNMHkr4jvR2MIrzu78",
"tags": []
}
@@ -0,0 +1,146 @@
{
"name": "Bug - node insertions",
"nodes": [
{
"parameters": {
"options": {}
},
"type": "n8n-nodes-base.splitInBatches",
"typeVersion": 3,
"position": [1472, 736],
"id": "16e710d5-b78b-4e57-b044-bc00f0a19dfd",
"name": "Loop Over Items"
},
{
"parameters": {
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [1696, 736],
"id": "3caf8c4e-c605-4939-bd4d-12c20025f9ed",
"name": "Edit Fields"
},
{
"parameters": {
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [1904, 736],
"id": "02cf7192-ce83-4efd-a45d-8ef0f85d24d4",
"name": "A"
},
{
"parameters": {
"content": "## Note for A",
"height": 224,
"width": 160
},
"type": "n8n-nodes-base.stickyNote",
"position": [1872, 672],
"typeVersion": 1,
"id": "f0cd8fa7-4838-49e1-a428-a7109ebc48ad",
"name": "Note for A"
},
{
"parameters": {},
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [1904, 976],
"id": "b51783d1-9ee8-424e-a2f0-a64189920aed",
"name": "C"
},
{
"parameters": {},
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [1472, 1008],
"id": "e0a704c5-4796-456c-b58a-1b8ef0394877",
"name": "C1"
},
{
"parameters": {
"content": "Insert here",
"width": 150,
"color": 3
},
"type": "n8n-nodes-base.stickyNote",
"position": [1776, 704],
"typeVersion": 1,
"id": "e246f382-08ca-4a55-bb2e-af1140c8b15d",
"name": "Sticky Note14"
}
],
"pinData": {},
"connections": {
"Loop Over Items": {
"main": [
[
{
"node": "Edit Fields",
"type": "main",
"index": 0
}
]
]
},
"Edit Fields": {
"main": [
[
{
"node": "A",
"type": "main",
"index": 0
}
]
]
},
"A": {
"main": [
[
{
"node": "C",
"type": "main",
"index": 0
}
]
]
},
"C": {
"main": [
[
{
"node": "C1",
"type": "main",
"index": 0
}
]
]
},
"C1": {
"main": [
[
{
"node": "Loop Over Items",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1",
"binaryMode": "separate",
"availableInMCP": false
},
"versionId": "cfab06c6-1663-4cb5-b6f8-0d1150d0e7d7",
"meta": {
"templateCredsSetupCompleted": true,
"instanceId": "0113851022152b5a0ec6c5db30b5953a2e16759b15b7f941dc4f60ecce106633"
},
"id": "gNUjNMHkr4jvR2MIrzu78",
"tags": []
}