Skip to content

Commit c7b84f4

Browse files
DrJKLclaudeampagentAustinMroz
authored
refactor: register node geometry at attach, delete useVueNodeLifecycle (#14128)
Phase: move node layout registration to the entity's attach point, so the renderer stops owning node lifetime. Nodes were the only positionable whose layout entry was created by the renderer — groups register in `LGraph.add`, reroutes in their constructor, nodes went through `useVueNodeLifecycle` listening to `node:added` / `node:removed`. That indirection is why entries could outlive the graph that created them, which is what the ownership attempts on the parent branch were working around. ## Changes - Node geometry registers in `LGraph.add` and deregisters in `LGraph.remove`, beside `registerNodeState` / `unregisterNodeState`. - `graphLayoutRegistration` centralizes node, group, and reroute registration and teardown, including graph clear, non-clearing configure, and recursive subgraph release paths. - `initializeFromLiteGraph` split: the seeding half is gone, the view-change reset becomes `clearViewGeometry`; `reset` covers full teardown. - `useVueNodeLifecycle` deleted. `GraphCanvas` directly owns the remaining `useLayoutSync` and mode-switch lifecycle, while legacy-node arrangement moves to `arrangeForLegacyRender`. `setupEmptyGraphListener` disappears with renderer-owned seeding. - Deleted the layout operation log (see below). - Node layout is keyed by `makeScopedLayoutKey(rootGraphId, nodeId)`, the scheme groups and reroutes already used (see below). ## Fixed along the way - **zIndex was seeded two ways that disagreed.** `initializeFromLiteGraph` used the node's index in `_nodes`; the per-node path used `node.order`, which is execution order from `computeExecutionOrder` and unrelated to stacking. Draw order wins. ## Performance Deleting the never-read operation log removed an unbounded per-operation cost that grew with session length, in Vue nodes mode today: | | `moveNode` with an entry | | --- | --- | | 22k operations logged | 197 us/op | | 40k operations logged | 375 us/op | | log deleted | 5.4 us/op, flat (2.9 us/op after 18k more) | That is also what made attach-time registration affordable for the legacy canvas, which now pays for entries it does not read. Dragging 100 selected nodes: 10.8 ms/frame (65% of a 16.7ms frame) before, 0.60 ms/frame (3.6%) after. ## Behaviour change to review `layoutStore` now holds every node in the workflow rather than only the viewed graph. Only tests call `queryNodeAtPoint` / `getNodesInBounds`, and both `getAllNodes` callers only take a max zIndex, so nothing reads across the wider set today. Three tests asserted the policy this reverses (no layout while the renderer is off; seeding stops on dispose; adds to unviewed graphs ignored). They pinned the old design rather than behaviour worth keeping, so they are deleted; the contract still worth holding — add creates an entry, remove drops it — moved next to `LGraph`, with a test that zIndex follows draw order. ## Node layout keys are now root-graph scoped Groups and reroutes were already keyed by `makeScopedLayoutKey(rootGraphId, id)`; nodes were the exception, keyed by a bare `NodeId`. Now that registration is unconditional, that exception is what kept layout from having a bucket to wipe — hence the `// Geometry has no per-rootGraph bucket to wipe, so both branches sweep` comment this removes. Node methods on `LayoutStore` and `LayoutMutations` take a leading `rootGraphId`. `graphId` lifts into `OperationMeta` so all three entity bases inherit it. `parseLayoutKey` splits on the first `:` rather than the last, so node ids containing `:` round-trip; graph ids are UUIDs and never contain one. **This does not fix an id collision — there was never one to fix.** `Subgraph` shares the root graph's `state`, so one counter issues every node id, and root and interior nodes sharing a bucket is safe. What it buys is that a root graph's teardown becomes a single `layoutStore.clearGraph(graphId)` sitting beside the five peer stores already cleared there, instead of layout being the one exception that had to walk the subgraph tree. `unregisterAllGraphLayout` stays for graphs that share the root's bucket — subgraphs, unconfigured graphs, orphaned subgraph release — and now mirrors `unregisterAllNodeStates` exactly. Worth a second opinion: six Vue-node composables now read `useCanvasStore().rootGraphId` where they previously only touched the layout singleton. They are all current-canvas-only paths, and the alternative pushes `rootGraphId!` non-null assertions into `.vue` templates. One consequence of that read is worth knowing about, since it caught me out. In `useNodeLayout` the layout read must stay **dynamic** — the node `v-for` keys on a bare node id, so Vue patches a component across a graph change rather than remounting it, and a captured id would resolve stale. Ref cleanup must be the **opposite**: `rootGraphId` flips before the outgoing graph's components unmount, so reading it in `onUnmounted` released the ref into the incoming bucket and stranded the real one. Captured at setup now, with a test pinning it. Only ids that do not survive a workflow load were affected; ids present in both are patched, never unmounted. ## Downstream stack [#14133](../pull/14133) completes node geometry ownership after this PR establishes attach/detach lifetime. It makes the node `Rectangle` a versioned projection of `layoutStore`, reuses the shared geometry view for compatible mutable `pos` / `size` access, deletes `useLayoutSync`, and replaces writeback with operation-driven resize notifications and canvas invalidation. Review this PR's registration and teardown contract independently of that downstream read-path change: this PR still retains `useLayoutSync` by design. <details> <summary>Things an inattentive agent might call out but which are not valid findings</summary> Each of these has been checked against the code. If you are about to file one, read the rebuttal first. **"Node layout entries are keyed by a bare node id with no graph scope, unlike groups and reroutes."** No longer true — they are scoped by `rootGraphId`, as of the commit above. Note what that did and did not change: root and subgraph-interior nodes still share one bucket, and that is deliberate, because node ids are unique root-wide. Deserialized ids go through `deduplicateSubgraphNodeIds`, called from `LGraph.configure` when `isRootGraph`, which remaps colliding interior ids and patches links and promoted widgets. Runtime-allocated ids come from `++state.lastNodeId`, and `Subgraph.state` returns `rootGraph.state` — one counter for the whole workflow. Clipboard paste and unpack have their own remapping. `basic-subgraph.json` looks like a collision on disk (root `2`, interior `1, 2`) and loads as `2`, `1`, `3`. `LGraph.test.ts` pins both halves: `expect(subgraph.state).toBe(rootGraph.state)` and `'node IDs never collide between root and subgraph'`. A repro that reaches a collision by assigning `node.id` directly before `add()` is not evidence — `LGraph.add`'s duplicate guard is per-graph by design, and no production path assigns ids that way. **"`useLayoutSync` can now resolve a store change against the wrong node, because changes from unviewed graphs flush against the viewed graph."** Same root cause as above, same rebuttal. Ids do not collide, so `getNodeById` cannot resolve to a different node. **"`LGraph.add` now requires an active Pinia instance, breaking headless and extension use."** `layoutStore` is a plain module singleton, not a Pinia store, and `useLayoutMutations` touches nothing else. `LGraph.add` already required Pinia before this PR via `registerNodeState` and `useWidgetValueStore`. Net change to Pinia surface: zero. **"`src/lib/litegraph` importing from `src/renderer/core/layout` violates the layering boundary."** `import-x/no-restricted-paths` deliberately scopes its zones to `base`, `platform`, `workbench`, and `world`; litegraph is outside them. ADR 0001 merged litegraph into the frontend precisely so mutations route through one CRDT-mediated access point. `LGraph.ts` already imported `useLayoutMutations` and `layoutStore`; this PR swaps a raw mutations import for a named registration module, leaving the edge count unchanged. **"Registering layout inside `LGraph.add` is god-object growth on `LGraph` (ADR 0008)."** No methods or properties are added to `LGraph`, `LGraphNode`, `LGraphCanvas`, or `Subgraph`. Existing methods delegate to a module, and every write still lands as a `LayoutOperation` through `applyOperation` — the command pattern is intact. `clearGraph` is likewise a `ClearGraphOperation` dispatched through `applyOperation`, not a side-channel wipe. **"`deleteGroup` and `deleteReroute` gained an existence guard but `deleteNode` did not — inconsistent."** `deleteNode` already had the identical guard before this PR. Its absence from the diff is correct. **"Those existence guards are redundant with a check `applyOperation` already does."** `applyOperation` calls `Y.Map.delete` unconditionally and still bumps `version` and dispatches change listeners. The early return suppresses real churn. **"Moving the group loop after `this.remove(subgraphNode)` in `unpackSubgraph` changes behaviour."** `toSelect` ordering is unchanged, `offsetX` / `offsetY` are not mutated in between, and nothing in the intervening block reads groups. The move is required: `remove()` now triggers `unregisterAllGraphLayout(subgraph)`, which would otherwise run after the unpacked groups registered and drop their entries. **"Populating `layoutStore` with the Vue renderer off breaks the minimap, which picks its data source by asking whether the store has nodes."** That rationale is stale — it survived in a docstring longer than in the code. `MinimapDataSourceFactory` selects on `LiteGraph.vueNodesMode`, and `MinimapDataSource.test.ts` has an explicit regression test forbidding the emptiness check. **"`startSync` can be skipped on the immediate run when the canvas is not ready, and removing `setupEmptyGraphListener` loses the empty-graph deferral."** `startSync` no-ops safely when there is no graph, and the watcher re-fires when `canvasStore.currentGraph` flips from `null`, which is reactive — unlike the old `comfyApp.canvas?.graph` source that `setupEmptyGraphListener` existed to work around. `stopSync` is idempotent. The deferral is obsolete, not lost. **"Dropping the `isInSubgraph` watcher's layout reset breaks subgraph navigation."** `canvasStore.currentGraph` updates on subgraph navigation, so the consolidated watcher covers it. Node membership comes from `nodeDataStore.getGraphNodesFor`, which stays graph-scoped regardless. **"The new e2e spec uses `page.evaluate` instead of user actions."** The regression under test is an extension calling `node.setSize()` and the Vue node not moving. No user gesture reaches that path; driving the resize handle would exercise a different one and pass with the bug present. </details> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Amp <amp@ampcode.com> Co-authored-by: AustinMroz <austin@comfy.org>
1 parent 15db3dd commit c7b84f4

67 files changed

Lines changed: 1948 additions & 1584 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

browser_tests/tests/performance.spec.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,55 @@ test.describe('Performance', { tag: ['@perf'] }, () => {
157157
)
158158
})
159159

160+
test('large graph legacy node drag', async ({ comfyPage }) => {
161+
await comfyPage.workflow.loadWorkflow('large-graph-workflow')
162+
163+
// Legacy drags write to layoutStore every frame because registration is
164+
// renderer-independent.
165+
const nodePos = await comfyPage.page.evaluate(() => {
166+
const app = window.app
167+
if (!app) throw new Error('window.app is not available')
168+
169+
const { canvas } = app
170+
const node = app.graph.nodes[0]
171+
if (!node) throw new Error('Graph has no nodes')
172+
173+
canvas.ds.scale = 1
174+
canvas.centerOnNode(node)
175+
const [x, y] = app.canvasPosToClientPos(node.pos)
176+
return { id: node.id, x, y, graphX: node.pos[0] }
177+
})
178+
await comfyPage.nextFrame()
179+
180+
await comfyPage.perf.startMeasuring()
181+
182+
await comfyPage.page.mouse.move(nodePos.x + 40, nodePos.y + 10)
183+
await comfyPage.page.mouse.down()
184+
for (let i = 0; i < 60; i++) {
185+
await comfyPage.page.mouse.move(
186+
nodePos.x + 40 + i * 4,
187+
nodePos.y + 10 + i * 2
188+
)
189+
await comfyPage.nextFrame()
190+
}
191+
await comfyPage.page.mouse.up()
192+
193+
const m = await comfyPage.perf.stopMeasuring('legacy-node-drag')
194+
recordMeasurement(m)
195+
196+
// Verify the measured interaction was a node drag, not a canvas pan.
197+
const movedX = await comfyPage.page.evaluate((id) => {
198+
const node = window.app?.graph.getNodeById(id)
199+
if (!node) throw new Error(`Node ${id} not found`)
200+
return node.pos[0]
201+
}, nodePos.id)
202+
expect(movedX).not.toBeCloseTo(nodePos.graphX, 0)
203+
204+
console.log(
205+
`Legacy node drag: ${m.styleRecalcs} style recalcs, ${m.layouts} layouts, ${m.taskDurationMs.toFixed(1)}ms task`
206+
)
207+
})
208+
160209
test('large graph zoom interaction', async ({ comfyPage }) => {
161210
await comfyPage.workflow.loadWorkflow('large-graph-workflow')
162211

browser_tests/tests/vueNodes/interactions/node/bringToFront.spec.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,12 @@ test.describe(
9191
})
9292
.toBeGreaterThan(0)
9393

94+
const clipZ = await getNodeZIndex(comfyPage, 'CLIP Text Encode')
95+
const allZIndexes = await comfyPage.vueNodes.nodes.evaluateAll((nodes) =>
96+
nodes.map((node) => Number(getComputedStyle(node).zIndex))
97+
)
98+
expect(clipZ).toBe(Math.max(...allZIndexes))
99+
94100
// Screenshot showing CLIP now on top
95101
await expect(comfyPage.canvas).toHaveScreenshot(
96102
'bring-to-front-overlapped-after.png'
Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
import {
2+
comfyExpect as expect,
3+
comfyPageFixture as test
4+
} from '@e2e/fixtures/ComfyPage'
5+
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
6+
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
7+
import type { Point } from '@/lib/litegraph/src/litegraph'
8+
import type { NodeId } from '@/types/nodeId'
9+
import { toNodeId } from '@/types/nodeId'
10+
11+
const LEGACY_TITLE_HEIGHT = 30
12+
13+
interface NodeGeometry {
14+
pos: Point
15+
size: Point
16+
inputs: Point[]
17+
outputs: Point[]
18+
}
19+
20+
/**
21+
* Read slots through the accessors used by `drawConnections`; raw fields
22+
* bypass the store projection under test.
23+
*/
24+
async function readGeometry(
25+
comfyPage: ComfyPage,
26+
nodeId: NodeId
27+
): Promise<NodeGeometry> {
28+
return comfyPage.page.evaluate((id): NodeGeometry => {
29+
const node = window.app?.canvas.graph?.getNodeById(id)
30+
if (!node) throw new Error(`Node ${id} not found`)
31+
32+
return {
33+
pos: [node.pos[0], node.pos[1]],
34+
size: [node.size[0], node.size[1]],
35+
inputs: node.inputs.map((_, i) => node.getInputPos(i)),
36+
outputs: node.outputs.map((_, i) => node.getOutputPos(i))
37+
}
38+
}, nodeId)
39+
}
40+
41+
function slotsOf(geometry: NodeGeometry): Point[] {
42+
return [...geometry.inputs, ...geometry.outputs]
43+
}
44+
45+
function expectSlotsTrackedNode(after: NodeGeometry, before: NodeGeometry) {
46+
const dx = after.pos[0] - before.pos[0]
47+
const dy = after.pos[1] - before.pos[1]
48+
expect(Math.abs(dx) + Math.abs(dy), 'drag moved the node').toBeGreaterThan(1)
49+
50+
const beforeSlots = slotsOf(before)
51+
const afterSlots = slotsOf(after)
52+
expect(afterSlots, 'slot count after drag').toHaveLength(beforeSlots.length)
53+
afterSlots.forEach(([x, y], i) => {
54+
expect(x, `slot ${i} x tracked the node`).toBeCloseTo(
55+
beforeSlots[i][0] + dx,
56+
0
57+
)
58+
expect(y, `slot ${i} y tracked the node`).toBeCloseTo(
59+
beforeSlots[i][1] + dy,
60+
0
61+
)
62+
})
63+
}
64+
65+
function expectGeometryPreserved(
66+
actual: NodeGeometry,
67+
reference: NodeGeometry,
68+
label: string
69+
) {
70+
expect(actual.pos[0], `${label}: x`).toBeCloseTo(reference.pos[0], 0)
71+
expect(actual.pos[1], `${label}: y`).toBeCloseTo(reference.pos[1], 0)
72+
expect(actual.size, `${label}: size`).toEqual(reference.size)
73+
74+
const referenceSlots = slotsOf(reference)
75+
const actualSlots = slotsOf(actual)
76+
expect(actualSlots, `${label}: slot count`).toHaveLength(
77+
referenceSlots.length
78+
)
79+
actualSlots.forEach(([x, y], i) => {
80+
expect(x, `${label}: slot ${i} x`).toBeCloseTo(referenceSlots[i][0], 0)
81+
expect(y, `${label}: slot ${i} y`).toBeCloseTo(referenceSlots[i][1], 0)
82+
})
83+
}
84+
85+
/**
86+
* Renderers compute different slot offsets, so require slots only to remain
87+
* within their node bounds.
88+
*/
89+
function expectSlotsOnNode(geometry: NodeGeometry, label: string) {
90+
const MARGIN = 20
91+
const [x, y] = geometry.pos
92+
const [width, height] = geometry.size
93+
94+
slotsOf(geometry).forEach(([slotX, slotY], i) => {
95+
expect(slotX, `${label}: slot ${i} x within node`).toBeGreaterThanOrEqual(
96+
x - MARGIN
97+
)
98+
expect(slotX, `${label}: slot ${i} x within node`).toBeLessThanOrEqual(
99+
x + width + MARGIN
100+
)
101+
expect(slotY, `${label}: slot ${i} y within node`).toBeGreaterThanOrEqual(
102+
y - LEGACY_TITLE_HEIGHT - MARGIN
103+
)
104+
expect(slotY, `${label}: slot ${i} y within node`).toBeLessThanOrEqual(
105+
y + height + MARGIN
106+
)
107+
})
108+
}
109+
110+
async function setVueMode(comfyPage: ComfyPage, enabled: boolean) {
111+
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', enabled)
112+
if (enabled) await comfyPage.vueNodes.waitForNodes()
113+
await comfyPage.nextFrame()
114+
}
115+
116+
test.describe('Renderer toggle geometry', { tag: ['@vue-nodes'] }, () => {
117+
test('slot geometry survives a Vue to legacy round trip', async ({
118+
comfyPage
119+
}) => {
120+
const nodeId = toNodeId(
121+
await comfyPage.vueNodes.getNodeIdByTitle('KSampler')
122+
)
123+
124+
const before = await readGeometry(comfyPage, nodeId)
125+
expect(
126+
slotsOf(before).length,
127+
'fixture node must have slots for this test to mean anything'
128+
).toBeGreaterThan(0)
129+
130+
const { header } = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
131+
const headerBox = await header.boundingBox()
132+
if (!headerBox) throw new Error('KSampler header not found')
133+
134+
const startX = headerBox.x + headerBox.width / 2
135+
const startY = headerBox.y + headerBox.height / 2
136+
await comfyPage.page.mouse.move(startX, startY)
137+
await comfyPage.page.mouse.down()
138+
await comfyPage.page.mouse.move(startX + 120, startY + 90, { steps: 10 })
139+
await comfyPage.page.mouse.up()
140+
await comfyPage.nextFrame()
141+
142+
const moved = await readGeometry(comfyPage, nodeId)
143+
expectSlotsTrackedNode(moved, before)
144+
145+
await setVueMode(comfyPage, false)
146+
147+
const legacyGeometry = await readGeometry(comfyPage, nodeId)
148+
expect(legacyGeometry.pos[0], 'legacy x').toBeCloseTo(moved.pos[0], 0)
149+
expect(legacyGeometry.pos[1], 'legacy y').toBeCloseTo(moved.pos[1], 0)
150+
expect(legacyGeometry.size, 'legacy size').toEqual(moved.size)
151+
expectSlotsOnNode(legacyGeometry, 'after switching to legacy')
152+
153+
await setVueMode(comfyPage, true)
154+
155+
expectGeometryPreserved(
156+
await readGeometry(comfyPage, nodeId),
157+
moved,
158+
'after switching back to Vue'
159+
)
160+
})
161+
162+
test(
163+
'preserves frontmost order after a legacy drag',
164+
{ tag: ['@node'] },
165+
async ({ comfyPage }) => {
166+
await comfyPage.workflow.loadWorkflow('vueNodes/simple-triple')
167+
await setVueMode(comfyPage, false)
168+
await fitToViewInstant(comfyPage)
169+
170+
const [ksampler] = await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
171+
const [clip] = await comfyPage.nodeOps.getNodeRefsByTitle(
172+
'CLIP Text Encode (Prompt)'
173+
)
174+
const ksamplerPosition = await ksampler.getPosition()
175+
const clipPosition = await clip.getPosition()
176+
177+
await ksampler.dragBy({
178+
x: clipPosition.x - ksamplerPosition.x,
179+
y: clipPosition.y - ksamplerPosition.y
180+
})
181+
await comfyPage.nextFrame()
182+
183+
const lastNodeId = await comfyPage.page.evaluate(
184+
() => window.app?.graph.nodes.at(-1)?.id
185+
)
186+
expect(lastNodeId, 'KSampler is frontmost after the legacy drag').toBe(
187+
ksampler.id
188+
)
189+
190+
await setVueMode(comfyPage, true)
191+
await expect(comfyPage.vueNodes.nodes).toHaveCount(3)
192+
193+
const ksamplerNode = comfyPage.vueNodes.getNodeByTitle('KSampler')
194+
const clipNode = comfyPage.vueNodes.getNodeByTitle('CLIP Text Encode')
195+
await expect
196+
.poll(async () => {
197+
const ksamplerZIndex = await ksamplerNode.evaluate((node) =>
198+
Number(getComputedStyle(node).zIndex)
199+
)
200+
const clipZIndex = await clipNode.evaluate((node) =>
201+
Number(getComputedStyle(node).zIndex)
202+
)
203+
return ksamplerZIndex - clipZIndex
204+
})
205+
.toBeGreaterThan(0)
206+
}
207+
)
208+
209+
test(
210+
'preserves frontmost order when switching to legacy rendering',
211+
{ tag: ['@node'] },
212+
async ({ comfyPage }) => {
213+
await comfyPage.workflow.loadWorkflow('vueNodes/simple-triple')
214+
await fitToViewInstant(comfyPage)
215+
216+
const [ksampler] = await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
217+
const [clip] = await comfyPage.nodeOps.getNodeRefsByTitle(
218+
'CLIP Text Encode (Prompt)'
219+
)
220+
const ksamplerNode = comfyPage.vueNodes.getNodeByTitle('KSampler')
221+
const clipNode = comfyPage.vueNodes.getNodeByTitle('CLIP Text Encode')
222+
const { header } = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
223+
const { header: clipHeader } =
224+
await comfyPage.vueNodes.getFixtureByTitle('CLIP Text Encode')
225+
const headerBox = await header.boundingBox()
226+
const clipBox = await clipHeader.boundingBox()
227+
if (!headerBox || !clipBox) throw new Error('Fixture nodes not found')
228+
229+
await comfyPage.canvasOps.dragAndDrop(
230+
{
231+
x: headerBox.x + headerBox.width / 2,
232+
y: headerBox.y + headerBox.height / 2
233+
},
234+
{
235+
x: clipBox.x + clipBox.width / 2,
236+
y: clipBox.y + clipBox.height / 2
237+
}
238+
)
239+
await comfyPage.nextFrame()
240+
241+
await expect
242+
.poll(async () => {
243+
const ksamplerZIndex = await ksamplerNode.evaluate((node) =>
244+
Number(getComputedStyle(node).zIndex)
245+
)
246+
const clipZIndex = await clipNode.evaluate((node) =>
247+
Number(getComputedStyle(node).zIndex)
248+
)
249+
return ksamplerZIndex - clipZIndex
250+
})
251+
.toBeGreaterThan(0)
252+
253+
await setVueMode(comfyPage, false)
254+
await comfyPage.page.evaluate(() => window.app!.canvas.deselectAll())
255+
expect(await comfyPage.nodeOps.getSelectedNodeIds()).toEqual([])
256+
257+
const ksamplerGeometry = await readGeometry(comfyPage, ksampler.id)
258+
const clipGeometry = await readGeometry(comfyPage, clip.id)
259+
const overlap = {
260+
left: Math.max(ksamplerGeometry.pos[0], clipGeometry.pos[0]),
261+
top: Math.max(
262+
ksamplerGeometry.pos[1] - LEGACY_TITLE_HEIGHT,
263+
clipGeometry.pos[1] - LEGACY_TITLE_HEIGHT
264+
),
265+
right: Math.min(
266+
ksamplerGeometry.pos[0] + ksamplerGeometry.size[0],
267+
clipGeometry.pos[0] + clipGeometry.size[0]
268+
),
269+
bottom: Math.min(ksamplerGeometry.pos[1], clipGeometry.pos[1])
270+
}
271+
if (overlap.left >= overlap.right || overlap.top >= overlap.bottom) {
272+
throw new Error('Fixture nodes do not overlap')
273+
}
274+
275+
const overlapCenter: Point = [
276+
(overlap.left + overlap.right) / 2,
277+
(overlap.top + overlap.bottom) / 2
278+
]
279+
const clickPosition = await comfyPage.canvasOps.convertOffsetToCanvas([
280+
overlapCenter[0],
281+
overlapCenter[1]
282+
])
283+
await comfyPage.canvasOps.mouseClickAt({
284+
x: clickPosition[0],
285+
y: clickPosition[1]
286+
})
287+
288+
expect(await comfyPage.nodeOps.getSelectedNodeIds()).toEqual([
289+
ksampler.id
290+
])
291+
}
292+
)
293+
})

0 commit comments

Comments
 (0)