mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-12 08:50:17 +00:00
## Summary Refactor node footer from absolute overlay to inline flow layout, fixing the selection bounding box not encompassing footer buttons and collapsed node dimensions. ## Background The node footer (Enter Subgraph, Advanced, Error buttons) was rendered as an absolute overlay (`absolute top-full`) outside the node body. This caused: 1. **Selection bounding box** did not include footer height — the dashed multi-select border cut through footer buttons 2. **Footer offset compensation** required 3 hardcoded computed classes (`footerStateOutlineBottomClass`, `footerRootBorderBottomClass`, `footerResizeHandleBottomClass`) with magic pixel values (31px, 35px, etc.) that had to stay in sync with CSS ## Solution: Inline Footer with `isolate -z-1` The footer is moved into normal document flow (no longer `absolute top-full`). The key challenge was keeping the footer visually behind the body's rounded bottom edge (the "tuck under" effect) without adding `z-index` to the body — because adding `z-index` to the body creates a stacking context that traps slot connection dots, making them appear behind overlay borders. The solution uses CSS `isolation: isolate` combined with `-z-1` on the footer wrapper: - **`isolate`** creates an independent stacking context for the footer, so internal z-index (Error button `z-10` above Enter button) does not leak to the parent - **`-z-1`** places the entire footer behind the body (`z-index: auto`), achieving the visual overlap without touching the body's stacking behavior - **Slot dots remain free** — the body has no explicit z-index, so slots participate in the root stacking context and are never trapped behind overlay borders This eliminates all 3 footer offset computed classes and their hardcoded pixel values. ## Selection Box: `min-height` on root + unified size path Moving `min-h-(--node-height)` from the body (`node-inner-wrapper`) to the root element makes the footer height naturally included in `node.size` via ResizeObserver → layoutStore → litegraph sync. This means `boundingRect` is automatically correct for expanded nodes — no callbacks or overrides needed. For collapsed nodes, a pre-existing issue (since v1.40) caused `_collapsed_width` to fall back to `NODE_COLLAPSED_WIDTH = 80px` because Vue nodes lack a canvas context for text measurement. The fix lets collapsed dimensions flow through the **same** `batchUpdateNodeBounds` path as expanded nodes — no parallel data structure, no separate accessor, no cache: 1. ResizeObserver writes the collapsed DOM dimensions to `layoutStore.size` via `batchUpdateNodeBounds` 2. `useLayoutSync` syncs `layoutStore.size` → `liteNode.size` as it does for any other size change 3. The expanded size survives the collapse→expand round trip via CSS custom properties — the `isCollapsed` watcher in `LGraphNode.vue` swaps `--node-width` to `--node-width-x` on collapse and restores it on expand 4. `measure()` reads `this.size` directly for Vue collapsed nodes via a one-line gate: `if (!this.flags?.collapsed || LiteGraph.vueNodesMode)`. Legacy behavior is unchanged. ## Changes - **NodeFooter.vue**: `absolute top-full` overlay → inline flow with `isolate -z-1` wrappers, Error/Enter button layering via `-mr-5` + DOM order, reactive props destructuring, static `RADIUS_CLASS` lookup for Tailwind scanning, Vue 3.3+ `defineEmits` property syntax - **LGraphNode.vue**: Move `min-h-(--node-height)` from body to root; remove `footerStateOutlineBottomClass`, `footerRootBorderBottomClass`, `footerResizeHandleBottomClass`, `hasFooter` computed; replace dynamic `beforeShapeClass` interpolation with static `bypassOverlayClass`/`mutedOverlayClass` computeds for Tailwind scanning - **LGraphNode.ts**: `measure()` collapsed branch gated by `|| LiteGraph.vueNodesMode` — Vue mode defers to `this.size`; legacy path unchanged - **useVueNodeResizeTracking.ts**: Collapsed and expanded nodes both flow through `batchUpdateNodeBounds`; narrowed `useVueElementTracking` parameter from `MaybeRefOrGetter<string>` to `string`; `deferredElements.delete(element)` on unmount to prevent memory retention - **selectionBorder.ts**: Unchanged — `createBounds` just works because `boundingRect` is now correct - **12 parameterized E2E tests**: Vue mode (subgraph/regular × expanded/collapsed × bottom-left/bottom-right) + legacy mode (expanded/collapsed × bottom-left/bottom-right), driven by `keyboard.collapse()` (Alt+C) - **Unit tests**: `measure()` branching (legacy fallback, Vue `this.size` usage, expanded parity) - **Shared test helpers**: `repositionNodes`, `KeyboardHelper.collapse`, `measureSelectionBounds`, `assertSelectionEncompassesNodes` ## Review Focus - `isolate -z-1` CSS layering pattern — is this acceptable long-term? - `measure()` collapsed branch gated on `LiteGraph.vueNodesMode` — one-line gate to avoid the canvas-ctx-less fallback in Vue mode - Footer button overlap design (`-mr-5` with DOM order for painting) ## Screenshots <img width="1392" height="800" alt="image" src="https://github.com/user-attachments/assets/abaebff5-bb8c-4b5b-8734-8d44fdee4cb9" /> <img width="1493" height="872" alt="image" src="https://github.com/user-attachments/assets/6b9c77f9-e3ae-4d4e-81dc-acfa9a24c768" /> <img width="813" height="515" alt="image" src="https://github.com/user-attachments/assets/ce15bafb-e157-408c-971b-a650088f316a" /> <img width="1031" height="669" alt="image" src="https://github.com/user-attachments/assets/20fdc336-4bc2-4d47-ab7e-c0cbcee0d150" /> <img width="753" height="525" alt="image" src="https://github.com/user-attachments/assets/2dccbe31-7d18-49bc-9ed4-158b1659fddf" /> <img width="730" height="370" alt="image" src="https://github.com/user-attachments/assets/ab87edfa-a4b4-46f7-86ae-4965a4509b42" /> <img width="1132" height="465" alt="image" src="https://github.com/user-attachments/assets/54643f5b-4a31-4c3d-9475-c433f87aedb0" /> <img width="1102" height="449" alt="image" src="https://github.com/user-attachments/assets/9c045df3-e1f5-481e-b1cb-ead1db1626f5" /> --------- Co-authored-by: github-actions <github-actions@github.com>
236 lines
7.5 KiB
TypeScript
236 lines
7.5 KiB
TypeScript
import { expect } from '@playwright/test'
|
|
import type { Page } from '@playwright/test'
|
|
|
|
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
|
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
|
import { measureSelectionBounds } from '@e2e/fixtures/helpers/boundsUtils'
|
|
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
|
|
|
|
const SUBGRAPH_ID = '2'
|
|
const REGULAR_ID = '3'
|
|
const WORKFLOW = 'selection/subgraph-with-regular-node'
|
|
|
|
type Layout = { ref: [number, number]; target: [number, number] }
|
|
const LAYOUTS: Record<string, Layout> = {
|
|
'bottom-left': { ref: [200, 100], target: [150, 500] },
|
|
'bottom-right': { ref: [100, 100], target: [600, 500] }
|
|
}
|
|
|
|
type NodeType = 'subgraph' | 'regular'
|
|
type NodeState = 'expanded' | 'collapsed'
|
|
type Position = 'bottom-left' | 'bottom-right'
|
|
|
|
function getTargetId(type: NodeType): string {
|
|
return type === 'subgraph' ? SUBGRAPH_ID : REGULAR_ID
|
|
}
|
|
|
|
function getRefId(type: NodeType): string {
|
|
return type === 'subgraph' ? REGULAR_ID : SUBGRAPH_ID
|
|
}
|
|
|
|
async function toggleBypass(comfyPage: ComfyPage, nodeRef: NodeReference) {
|
|
await nodeRef.click('title')
|
|
await comfyPage.keyboard.bypass()
|
|
}
|
|
|
|
async function assertSelectionEncompassesNodes(
|
|
page: Page,
|
|
comfyPage: ComfyPage,
|
|
nodeIds: string[]
|
|
) {
|
|
await comfyPage.canvas.press('Control+a')
|
|
await expect
|
|
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
|
|
.toBe(2)
|
|
await comfyPage.nextFrame()
|
|
|
|
const result = await measureSelectionBounds(page, nodeIds)
|
|
expect(result.selectionBounds).not.toBeNull()
|
|
|
|
const sel = result.selectionBounds!
|
|
const selRight = sel.x + sel.w
|
|
const selBottom = sel.y + sel.h
|
|
|
|
for (const nodeId of nodeIds) {
|
|
const vis = result.nodeVisualBounds[nodeId]
|
|
expect(vis).toBeDefined()
|
|
|
|
expect(sel.x).toBeLessThanOrEqual(vis.x)
|
|
expect(selRight).toBeGreaterThanOrEqual(vis.x + vis.w)
|
|
expect(sel.y).toBeLessThanOrEqual(vis.y)
|
|
expect(selBottom).toBeGreaterThanOrEqual(vis.y + vis.h)
|
|
}
|
|
}
|
|
|
|
test.describe(
|
|
'Selection bounding box (Vue mode)',
|
|
{ tag: ['@canvas', '@node'] },
|
|
() => {
|
|
test.beforeEach(async ({ comfyPage }) => {
|
|
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
|
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
|
await comfyPage.vueNodes.waitForNodes()
|
|
})
|
|
|
|
test.afterEach(async ({ comfyPage }) => {
|
|
await comfyPage.canvasOps.resetView()
|
|
})
|
|
|
|
const vueCases: ReadonlyArray<{
|
|
type: NodeType
|
|
state: NodeState
|
|
pos: Position
|
|
}> = [
|
|
{ type: 'subgraph', state: 'expanded', pos: 'bottom-left' },
|
|
{ type: 'subgraph', state: 'expanded', pos: 'bottom-right' },
|
|
{ type: 'subgraph', state: 'collapsed', pos: 'bottom-left' },
|
|
{ type: 'subgraph', state: 'collapsed', pos: 'bottom-right' },
|
|
{ type: 'regular', state: 'expanded', pos: 'bottom-left' },
|
|
{ type: 'regular', state: 'expanded', pos: 'bottom-right' },
|
|
{ type: 'regular', state: 'collapsed', pos: 'bottom-left' },
|
|
{ type: 'regular', state: 'collapsed', pos: 'bottom-right' }
|
|
]
|
|
|
|
for (const { type, state, pos } of vueCases) {
|
|
test(`${type} node (${state}) at ${pos}: selection bounds encompass node`, async ({
|
|
comfyPage
|
|
}) => {
|
|
const targetId = getTargetId(type)
|
|
const refId = getRefId(type)
|
|
|
|
await comfyPage.nodeOps.repositionNodes({
|
|
[refId]: LAYOUTS[pos].ref,
|
|
[targetId]: LAYOUTS[pos].target
|
|
})
|
|
await comfyPage.nextFrame()
|
|
await comfyPage.vueNodes.waitForNodes()
|
|
await comfyPage.vueNodes.getNodeLocator(targetId).waitFor()
|
|
await comfyPage.vueNodes.getNodeLocator(refId).waitFor()
|
|
|
|
if (state === 'collapsed') {
|
|
const nodeRef = await comfyPage.nodeOps.getNodeRefById(targetId)
|
|
await nodeRef.toggleCollapse()
|
|
await expect.poll(() => nodeRef.isCollapsed()).toBe(true)
|
|
}
|
|
|
|
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
|
|
refId,
|
|
targetId
|
|
])
|
|
})
|
|
}
|
|
}
|
|
)
|
|
|
|
test.describe(
|
|
'Selection bounding box (Vue mode) — collapsed node bypass toggle',
|
|
{ tag: ['@canvas', '@node'] },
|
|
() => {
|
|
test.beforeEach(async ({ comfyPage }) => {
|
|
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
|
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
|
await comfyPage.vueNodes.waitForNodes()
|
|
})
|
|
|
|
test.afterEach(async ({ comfyPage }) => {
|
|
await comfyPage.canvasOps.resetView()
|
|
})
|
|
|
|
test('collapsed node narrows bounding box when bypass is removed', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.nodeOps.repositionNodes({
|
|
[SUBGRAPH_ID]: LAYOUTS['bottom-right'].ref,
|
|
[REGULAR_ID]: LAYOUTS['bottom-right'].target
|
|
})
|
|
await comfyPage.nextFrame()
|
|
await comfyPage.vueNodes.waitForNodes()
|
|
|
|
const nodeRef = await comfyPage.nodeOps.getNodeRefById(REGULAR_ID)
|
|
await toggleBypass(comfyPage, nodeRef)
|
|
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
|
|
await nodeRef.toggleCollapse()
|
|
await expect.poll(() => nodeRef.isCollapsed()).toBe(true)
|
|
|
|
await toggleBypass(comfyPage, nodeRef)
|
|
await expect.poll(() => nodeRef.isBypassed()).toBe(false)
|
|
await comfyPage.nextFrame()
|
|
|
|
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
|
|
SUBGRAPH_ID,
|
|
REGULAR_ID
|
|
])
|
|
})
|
|
|
|
test('collapsed node widens bounding box when bypass is added', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.nodeOps.repositionNodes({
|
|
[SUBGRAPH_ID]: LAYOUTS['bottom-right'].ref,
|
|
[REGULAR_ID]: LAYOUTS['bottom-right'].target
|
|
})
|
|
await comfyPage.nextFrame()
|
|
await comfyPage.vueNodes.waitForNodes()
|
|
|
|
const nodeRef = await comfyPage.nodeOps.getNodeRefById(REGULAR_ID)
|
|
await nodeRef.toggleCollapse()
|
|
await expect.poll(() => nodeRef.isCollapsed()).toBe(true)
|
|
|
|
await toggleBypass(comfyPage, nodeRef)
|
|
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
|
|
await comfyPage.nextFrame()
|
|
|
|
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
|
|
SUBGRAPH_ID,
|
|
REGULAR_ID
|
|
])
|
|
})
|
|
}
|
|
)
|
|
|
|
test.describe(
|
|
'Selection bounding box (legacy mode)',
|
|
{ tag: ['@canvas', '@node'] },
|
|
() => {
|
|
test.beforeEach(async ({ comfyPage }) => {
|
|
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
|
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
|
await comfyPage.nextFrame()
|
|
})
|
|
|
|
test.afterEach(async ({ comfyPage }) => {
|
|
await comfyPage.canvasOps.resetView()
|
|
})
|
|
|
|
const legacyCases: ReadonlyArray<{ state: NodeState; pos: Position }> = [
|
|
{ state: 'expanded', pos: 'bottom-left' },
|
|
{ state: 'expanded', pos: 'bottom-right' },
|
|
{ state: 'collapsed', pos: 'bottom-left' },
|
|
{ state: 'collapsed', pos: 'bottom-right' }
|
|
]
|
|
|
|
for (const { state, pos } of legacyCases) {
|
|
test(`legacy node (${state}) at ${pos}: selection bounds encompass node`, async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.nodeOps.repositionNodes({
|
|
[SUBGRAPH_ID]: LAYOUTS[pos].ref,
|
|
[REGULAR_ID]: LAYOUTS[pos].target
|
|
})
|
|
await comfyPage.nextFrame()
|
|
|
|
if (state === 'collapsed') {
|
|
const nodeRef = await comfyPage.nodeOps.getNodeRefById(REGULAR_ID)
|
|
await nodeRef.toggleCollapse()
|
|
await expect.poll(() => nodeRef.isCollapsed()).toBe(true)
|
|
}
|
|
|
|
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
|
|
SUBGRAPH_ID,
|
|
REGULAR_ID
|
|
])
|
|
})
|
|
}
|
|
}
|
|
)
|