diff --git a/src/components/__tests__/GenerationComposer.test.tsx b/src/components/__tests__/GenerationComposer.test.tsx index b28a1bf7..6941e0ac 100644 --- a/src/components/__tests__/GenerationComposer.test.tsx +++ b/src/components/__tests__/GenerationComposer.test.tsx @@ -427,6 +427,36 @@ describe("GenerationComposer", () => { expect(screen.getByPlaceholderText(/Describe what you want to generate/)).toHaveClass("h-[13.5rem]"); }); + it("positions the selected node editor below grouped nodes using absolute canvas coordinates", () => { + useWorkflowStore.setState({ + nodes: [ + { + id: "group-node-group-1", + type: "group", + position: { x: 400, y: 200 }, + data: { groupId: "group-1" }, + style: { width: 500, height: 360 }, + } as WorkflowNode, + { + ...imageNode("img-1", true), + parentId: "group-node-group-1", + extent: "parent", + expandParent: true, + groupId: "group-1", + position: { x: 100, y: 40 }, + measured: { width: 260, height: 180 }, + } as WorkflowNode, + ], + }); + + render(); + + expect(screen.getByTestId("node-inline-composer")).toHaveStyle({ + left: "108px", + top: "436px", + }); + }); + it("keeps the selected node editor below the node when below would leave the viewport", () => { const originalInnerHeight = window.innerHeight; const originalInnerWidth = window.innerWidth; diff --git a/src/components/composer/GenerationComposer.tsx b/src/components/composer/GenerationComposer.tsx index 4194674b..03d92957 100644 --- a/src/components/composer/GenerationComposer.tsx +++ b/src/components/composer/GenerationComposer.tsx @@ -892,6 +892,30 @@ function getAdapter(nodeType: NodeType | null): ComposerAdapter [item.id, item])); + const visited = new Set(); + + const resolve = (current: WorkflowNode): { x: number; y: number } => { + if (!current.parentId || visited.has(current.id)) return current.position; + visited.add(current.id); + + const parent = nodeById.get(current.parentId); + if (!parent) return current.position; + + const parentPosition = resolve(parent); + return { + x: parentPosition.x + current.position.x, + y: parentPosition.y + current.position.y, + }; + }; + + return resolve(node); +} + function deriveComposerContext(nodes: WorkflowNode[], edges: WorkflowEdge[]): ComposerContext { const selectedNodes = nodes.filter((node) => node.selected); if (selectedNodes.length === 0) { @@ -1702,6 +1726,10 @@ export function GenerationComposer() { () => context.node ? getComposerNodeDimensions(context.node) : null, [context.node] ); + const selectedNodePosition = useMemo( + () => context.node ? getAbsoluteNodePosition(context.node, nodes) : null, + [context.node, nodes] + ); const safeZoom = zoom > 0 ? zoom : 1; const viewportWidth = typeof window === "undefined" ? Number.POSITIVE_INFINITY : window.innerWidth; const visibleLeft = -viewportX / safeZoom; @@ -1709,15 +1737,15 @@ export function GenerationComposer() { const composerWidth = isComposerCollapsed ? COMPOSER_COLLAPSED_WIDTH : COMPOSER_EXPANDED_WIDTH; const composerFlowWidth = composerWidth / safeZoom; const viewportMargin = COMPOSER_VIEWPORT_MARGIN / safeZoom; - const inlineComposerPosition = context.node && selectedNodeDimensions + const inlineComposerPosition = context.node && selectedNodeDimensions && selectedNodePosition ? (() => { - const centeredLeft = context.node.position.x + selectedNodeDimensions.width / 2 - composerFlowWidth / 2; + const centeredLeft = selectedNodePosition.x + selectedNodeDimensions.width / 2 - composerFlowWidth / 2; const minLeft = visibleLeft + viewportMargin; const maxLeft = visibleRight - composerFlowWidth - viewportMargin; const left = maxLeft >= minLeft ? Math.min(Math.max(centeredLeft, minLeft), maxLeft) : minLeft; - const belowTop = context.node.position.y + selectedNodeDimensions.height + viewportMargin; + const belowTop = selectedNodePosition.y + selectedNodeDimensions.height + viewportMargin; return { left,