mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-10 06:20:03 +00:00
Compare commits
20 Commits
coverage-s
...
refactor/n
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a66381353 | ||
|
|
bbb07053c4 | ||
|
|
97fca566fb | ||
|
|
c6b8883e61 | ||
|
|
8487c13f14 | ||
|
|
1cf0cd55fd | ||
|
|
9cb0d21fd7 | ||
|
|
82997f88c5 | ||
|
|
c462c8d061 | ||
|
|
77fd8c67fc | ||
|
|
47c190a08d | ||
|
|
9fa048fa6a | ||
|
|
d434f770dc | ||
|
|
87f394ac77 | ||
|
|
8c7e629021 | ||
|
|
117b4e152f | ||
|
|
ef1a64141a | ||
|
|
ebe23e682c | ||
|
|
3f1839b6b1 | ||
|
|
e676c33c78 |
116
browser_tests/assets/selection/subgraph-with-regular-node.json
Normal file
116
browser_tests/assets/selection/subgraph-with-regular-node.json
Normal file
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"id": "selection-bbox-test",
|
||||
"revision": 0,
|
||||
"last_node_id": 3,
|
||||
"last_link_id": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "e5fb1765-9323-4548-801a-5aead34d879e",
|
||||
"pos": [300, 200],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [1]
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [800, 200],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "latent",
|
||||
"type": "LATENT",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [512, 512, 1]
|
||||
}
|
||||
],
|
||||
"links": [[1, 2, 0, 3, 0, "LATENT"]],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "e5fb1765-9323-4548-801a-5aead34d879e",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 1,
|
||||
"lastLinkId": 0,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Test Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [100, 200, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [500, 200, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "c5cc99d8-a2b6-4bf3-8be7-d4949ef736cd",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [],
|
||||
"pos": { "0": 200, "1": 220 }
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "9bd488b9-e907-4c95-a7a4-85c5597a87af",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"linkIds": [],
|
||||
"pos": { "0": 520, "1": 220 }
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [],
|
||||
"groups": [],
|
||||
"links": [],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type {
|
||||
ComfyWorkflowJSON,
|
||||
NodeId
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
|
||||
import type { Position, Size } from '@e2e/fixtures/types'
|
||||
@@ -114,6 +117,27 @@ export class NodeOperationsHelper {
|
||||
}
|
||||
}
|
||||
|
||||
async getSerializedGraph(): Promise<ComfyWorkflowJSON> {
|
||||
return this.page.evaluate(
|
||||
() => window.app!.graph.serialize() as ComfyWorkflowJSON
|
||||
)
|
||||
}
|
||||
|
||||
async loadGraph(data: ComfyWorkflowJSON): Promise<void> {
|
||||
await this.page.evaluate(
|
||||
(d) => window.app!.loadGraphData(d, true, true, null),
|
||||
data
|
||||
)
|
||||
}
|
||||
|
||||
async repositionNodes(
|
||||
positions: Record<string, [number, number]>
|
||||
): Promise<void> {
|
||||
const data = await this.getSerializedGraph()
|
||||
applyNodePositions(data, positions)
|
||||
await this.loadGraph(data)
|
||||
}
|
||||
|
||||
async resizeNode(
|
||||
nodePos: Position,
|
||||
nodeSize: Size,
|
||||
@@ -188,3 +212,13 @@ export class NodeOperationsHelper {
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
}
|
||||
|
||||
function applyNodePositions(
|
||||
data: ComfyWorkflowJSON,
|
||||
positions: Record<string, [number, number]>
|
||||
): void {
|
||||
for (const node of data.nodes) {
|
||||
const pos = positions[String(node.id)]
|
||||
if (pos) node.pos = pos
|
||||
}
|
||||
}
|
||||
|
||||
112
browser_tests/fixtures/helpers/boundsUtils.ts
Normal file
112
browser_tests/fixtures/helpers/boundsUtils.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
export interface CanvasRect {
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
|
||||
export interface MeasureResult {
|
||||
selectionBounds: CanvasRect | null
|
||||
nodeVisualBounds: Record<string, CanvasRect>
|
||||
}
|
||||
|
||||
// Must match createBounds(selectedItems, 10) in src/extensions/core/selectionBorder.ts:19
|
||||
const SELECTION_PADDING = 10
|
||||
|
||||
export async function measureSelectionBounds(
|
||||
page: Page,
|
||||
nodeIds: string[]
|
||||
): Promise<MeasureResult> {
|
||||
return page.evaluate(
|
||||
({ ids, padding }) => {
|
||||
const canvas = window.app!.canvas
|
||||
const ds = canvas.ds
|
||||
|
||||
const selectedItems = canvas.selectedItems
|
||||
let minX = Infinity
|
||||
let minY = Infinity
|
||||
let maxX = -Infinity
|
||||
let maxY = -Infinity
|
||||
for (const item of selectedItems) {
|
||||
const rect = item.boundingRect
|
||||
// For collapsed nodes, use DOM element size (matches selectionBorder.ts
|
||||
// which reads layoutStore.collapsedSize in Vue mode)
|
||||
const id = 'id' in item ? String(item.id) : null
|
||||
const isCollapsed =
|
||||
'flags' in item &&
|
||||
!!(item as { flags?: { collapsed?: boolean } }).flags?.collapsed
|
||||
const el =
|
||||
id && isCollapsed
|
||||
? document.querySelector(`[data-node-id="${id}"]`)
|
||||
: null
|
||||
const w = el instanceof HTMLElement ? el.offsetWidth : rect[2]
|
||||
const h = el instanceof HTMLElement ? el.offsetHeight : rect[3]
|
||||
|
||||
minX = Math.min(minX, rect[0])
|
||||
minY = Math.min(minY, rect[1])
|
||||
maxX = Math.max(maxX, rect[0] + w)
|
||||
maxY = Math.max(maxY, rect[1] + h)
|
||||
}
|
||||
const selectionBounds =
|
||||
selectedItems.size > 0
|
||||
? {
|
||||
x: minX - padding,
|
||||
y: minY - padding,
|
||||
w: maxX - minX + 2 * padding,
|
||||
h: maxY - minY + 2 * padding
|
||||
}
|
||||
: null
|
||||
|
||||
const canvasEl = canvas.canvas as HTMLCanvasElement
|
||||
const canvasRect = canvasEl.getBoundingClientRect()
|
||||
const nodeVisualBounds: Record<
|
||||
string,
|
||||
{ x: number; y: number; w: number; h: number }
|
||||
> = {}
|
||||
|
||||
for (const id of ids) {
|
||||
const nodeEl = document.querySelector(
|
||||
`[data-node-id="${id}"]`
|
||||
) as HTMLElement | null
|
||||
|
||||
// Legacy mode: no Vue DOM element, use boundingRect directly
|
||||
if (!nodeEl) {
|
||||
const node = window.app!.graph._nodes.find(
|
||||
(n: { id: number | string }) => String(n.id) === id
|
||||
)
|
||||
if (node) {
|
||||
const rect = node.boundingRect
|
||||
nodeVisualBounds[id] = {
|
||||
x: rect[0],
|
||||
y: rect[1],
|
||||
w: rect[2],
|
||||
h: rect[3]
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const domRect = nodeEl.getBoundingClientRect()
|
||||
const footerEls = nodeEl.querySelectorAll(
|
||||
'[data-testid="subgraph-enter-button"], [data-testid="node-footer"]'
|
||||
)
|
||||
let bottom = domRect.bottom
|
||||
for (const footerEl of footerEls) {
|
||||
bottom = Math.max(bottom, footerEl.getBoundingClientRect().bottom)
|
||||
}
|
||||
|
||||
nodeVisualBounds[id] = {
|
||||
x: (domRect.left - canvasRect.left) / ds.scale - ds.offset[0],
|
||||
y: (domRect.top - canvasRect.top) / ds.scale - ds.offset[1],
|
||||
w: domRect.width / ds.scale,
|
||||
h: (bottom - domRect.top) / ds.scale
|
||||
}
|
||||
}
|
||||
|
||||
return { selectionBounds, nodeVisualBounds }
|
||||
},
|
||||
{ ids: nodeIds, padding: SELECTION_PADDING }
|
||||
) as Promise<MeasureResult>
|
||||
}
|
||||
@@ -332,6 +332,18 @@ export class NodeReference {
|
||||
async isCollapsed() {
|
||||
return !!(await this.getFlags()).collapsed
|
||||
}
|
||||
/** Deterministic setter using node.collapse() API (not a toggle). */
|
||||
async setCollapsed(collapsed: boolean) {
|
||||
await this.comfyPage.page.evaluate(
|
||||
([id, collapsed]) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
if (!node) throw new Error('Node not found')
|
||||
if (node.collapsed !== collapsed) node.collapse(true)
|
||||
},
|
||||
[this.id, collapsed] as const
|
||||
)
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
async isBypassed() {
|
||||
return (await this.getProperty<number | null | undefined>('mode')) === 4
|
||||
}
|
||||
|
||||
275
browser_tests/tests/canvasModeSelector.spec.ts
Normal file
275
browser_tests/tests/canvasModeSelector.spec.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
const getLocators = (page: Page) => ({
|
||||
trigger: page.getByRole('button', { name: 'Canvas Mode' }),
|
||||
menu: page.getByRole('menu', { name: 'Canvas Mode' }),
|
||||
selectItem: page.getByRole('menuitemradio', { name: 'Select' }),
|
||||
handItem: page.getByRole('menuitemradio', { name: 'Hand' })
|
||||
})
|
||||
|
||||
const MODES = [
|
||||
{
|
||||
label: 'Select',
|
||||
activateCommand: 'Comfy.Canvas.Unlock',
|
||||
isReadOnly: false,
|
||||
iconPattern: /lucide--mouse-pointer-2/
|
||||
},
|
||||
{
|
||||
label: 'Hand',
|
||||
activateCommand: 'Comfy.Canvas.Lock',
|
||||
isReadOnly: true,
|
||||
iconPattern: /lucide--hand/
|
||||
}
|
||||
]
|
||||
|
||||
test.describe('CanvasModeSelector', { tag: '@canvas' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
|
||||
await comfyPage.command.executeCommand('Comfy.Canvas.Unlock')
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
test.describe('Trigger button', () => {
|
||||
test('visible in canvas toolbar with ARIA markup', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { trigger } = getLocators(comfyPage.page)
|
||||
await expect(trigger).toBeVisible()
|
||||
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
|
||||
for (const mode of MODES) {
|
||||
test(`shows ${mode.label}-mode icon on trigger button`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.command.executeCommand(mode.activateCommand)
|
||||
await comfyPage.nextFrame()
|
||||
const { trigger } = getLocators(comfyPage.page)
|
||||
const modeIcon = trigger.locator('i[aria-hidden="true"]').first()
|
||||
await expect(modeIcon).toHaveClass(mode.iconPattern)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test.describe('Popover lifecycle', () => {
|
||||
test('opens when trigger is clicked', async ({ comfyPage }) => {
|
||||
const { trigger, menu } = getLocators(comfyPage.page)
|
||||
await trigger.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(menu).toBeVisible()
|
||||
await expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
||||
})
|
||||
|
||||
test('closes when trigger is clicked again', async ({ comfyPage }) => {
|
||||
const { trigger, menu } = getLocators(comfyPage.page)
|
||||
await trigger.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(menu).toBeVisible()
|
||||
await trigger.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(menu).not.toBeVisible()
|
||||
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
|
||||
test('closes after a mode item is selected', async ({ comfyPage }) => {
|
||||
const { trigger, menu, handItem } = getLocators(comfyPage.page)
|
||||
await trigger.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(menu).toBeVisible()
|
||||
await handItem.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(menu).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('closes when Escape is pressed', async ({ comfyPage }) => {
|
||||
const { trigger, menu, selectItem } = getLocators(comfyPage.page)
|
||||
await trigger.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(menu).toBeVisible()
|
||||
await selectItem.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(menu).not.toBeVisible()
|
||||
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Mode switching', () => {
|
||||
for (const mode of MODES) {
|
||||
test(`clicking "${mode.label}" sets canvas readOnly=${mode.isReadOnly}`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
if (!mode.isReadOnly) {
|
||||
await comfyPage.command.executeCommand('Comfy.Canvas.Lock')
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
const { trigger, menu, selectItem, handItem } = getLocators(
|
||||
comfyPage.page
|
||||
)
|
||||
const item = mode.isReadOnly ? handItem : selectItem
|
||||
await trigger.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(menu).toBeVisible()
|
||||
await item.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect
|
||||
.poll(() => comfyPage.canvasOps.isReadOnly())
|
||||
.toBe(mode.isReadOnly)
|
||||
})
|
||||
}
|
||||
|
||||
test('clicking the currently active item is a no-op', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
expect(
|
||||
await comfyPage.canvasOps.isReadOnly(),
|
||||
'Precondition: canvas starts in Select mode'
|
||||
).toBe(false)
|
||||
const { trigger, menu, selectItem } = getLocators(comfyPage.page)
|
||||
await trigger.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(menu).toBeVisible()
|
||||
await selectItem.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect.poll(() => comfyPage.canvasOps.isReadOnly()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('ARIA state', () => {
|
||||
test('aria-checked marks Select active on default load', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { trigger, menu, selectItem, handItem } = getLocators(
|
||||
comfyPage.page
|
||||
)
|
||||
await trigger.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(menu).toBeVisible()
|
||||
await expect(selectItem).toHaveAttribute('aria-checked', 'true')
|
||||
await expect(handItem).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
|
||||
for (const mode of MODES) {
|
||||
test(`tabindex=0 is on the active "${mode.label}" item`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.command.executeCommand(mode.activateCommand)
|
||||
await comfyPage.nextFrame()
|
||||
const { trigger, menu, selectItem, handItem } = getLocators(
|
||||
comfyPage.page
|
||||
)
|
||||
await trigger.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(menu).toBeVisible()
|
||||
|
||||
const activeItem = mode.isReadOnly ? handItem : selectItem
|
||||
const inactiveItem = mode.isReadOnly ? selectItem : handItem
|
||||
|
||||
await expect(activeItem).toHaveAttribute('tabindex', '0')
|
||||
await expect(inactiveItem).toHaveAttribute('tabindex', '-1')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test.describe('Keyboard navigation', () => {
|
||||
test('ArrowDown moves focus from Select to Hand', async ({ comfyPage }) => {
|
||||
const { trigger, menu, selectItem, handItem } = getLocators(
|
||||
comfyPage.page
|
||||
)
|
||||
await trigger.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(menu).toBeVisible()
|
||||
await selectItem.press('ArrowDown')
|
||||
await expect(handItem).toBeFocused()
|
||||
})
|
||||
|
||||
test('Escape closes popover and restores focus to trigger', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { trigger, menu, selectItem, handItem } = getLocators(
|
||||
comfyPage.page
|
||||
)
|
||||
await trigger.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(menu).toBeVisible()
|
||||
await selectItem.press('ArrowDown')
|
||||
await handItem.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(menu).not.toBeVisible()
|
||||
await expect(trigger).toBeFocused()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Focus management on open', () => {
|
||||
for (const mode of MODES) {
|
||||
test(`auto-focuses the checked "${mode.label}" item on open`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.command.executeCommand(mode.activateCommand)
|
||||
await comfyPage.nextFrame()
|
||||
const { trigger, menu, selectItem, handItem } = getLocators(
|
||||
comfyPage.page
|
||||
)
|
||||
const item = mode.isReadOnly ? handItem : selectItem
|
||||
await trigger.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(menu).toBeVisible()
|
||||
await expect(item).toBeFocused()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test.describe('Keybinding integration', { tag: '@keyboard' }, () => {
|
||||
test("'H' locks canvas and updates trigger icon to Hand", async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
expect(
|
||||
await comfyPage.canvasOps.isReadOnly(),
|
||||
'Precondition: canvas starts unlocked'
|
||||
).toBe(false)
|
||||
await comfyPage.canvas.press('KeyH')
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.canvasOps.isReadOnly()).toBe(true)
|
||||
const { trigger } = getLocators(comfyPage.page)
|
||||
const modeIcon = trigger.locator('i[aria-hidden="true"]').first()
|
||||
await expect(modeIcon).toHaveClass(/lucide--hand/)
|
||||
})
|
||||
|
||||
test("'V' unlocks canvas and updates trigger icon to Select", async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.command.executeCommand('Comfy.Canvas.Lock')
|
||||
await comfyPage.nextFrame()
|
||||
expect(
|
||||
await comfyPage.canvasOps.isReadOnly(),
|
||||
'Precondition: canvas starts locked'
|
||||
).toBe(true)
|
||||
await comfyPage.canvas.press('KeyV')
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.canvasOps.isReadOnly()).toBe(false)
|
||||
const { trigger } = getLocators(comfyPage.page)
|
||||
const modeIcon = trigger.locator('i[aria-hidden="true"]').first()
|
||||
await expect(modeIcon).toHaveClass(/lucide--mouse-pointer-2/)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Shortcut hint display', () => {
|
||||
test('menu items show non-empty keyboard shortcut text', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { trigger, menu, selectItem, handItem } = getLocators(
|
||||
comfyPage.page
|
||||
)
|
||||
await trigger.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(menu).toBeVisible()
|
||||
const selectHint = selectItem.getByTestId('shortcut-hint')
|
||||
const handHint = handItem.getByTestId('shortcut-hint')
|
||||
|
||||
await expect(selectHint).not.toBeEmpty()
|
||||
await expect(handHint).not.toBeEmpty()
|
||||
})
|
||||
})
|
||||
})
|
||||
151
browser_tests/tests/selectionBoundingBox.spec.ts
Normal file
151
browser_tests/tests/selectionBoundingBox.spec.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { measureSelectionBounds } from '../fixtures/helpers/boundsUtils'
|
||||
|
||||
const SUBGRAPH_ID = '2'
|
||||
const REGULAR_ID = '3'
|
||||
const WORKFLOW = 'selection/subgraph-with-regular-node'
|
||||
|
||||
const REF_POS: [number, number] = [100, 100]
|
||||
const TARGET_POSITIONS: Record<string, [number, number]> = {
|
||||
'bottom-left': [50, 500],
|
||||
'bottom-right': [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 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 nodeTypes: NodeType[] = ['subgraph', 'regular']
|
||||
const nodeStates: NodeState[] = ['expanded', 'collapsed']
|
||||
const positions: Position[] = ['bottom-left', 'bottom-right']
|
||||
|
||||
for (const type of nodeTypes) {
|
||||
for (const state of nodeStates) {
|
||||
for (const pos of positions) {
|
||||
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]: REF_POS,
|
||||
[targetId]: TARGET_POSITIONS[pos]
|
||||
})
|
||||
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.setCollapsed(true)
|
||||
}
|
||||
|
||||
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
|
||||
refId,
|
||||
targetId
|
||||
])
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
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 nodeStates: NodeState[] = ['expanded', 'collapsed']
|
||||
const positions: Position[] = ['bottom-left', 'bottom-right']
|
||||
|
||||
for (const state of nodeStates) {
|
||||
for (const pos of positions) {
|
||||
test(`legacy node (${state}) at ${pos}: selection bounds encompass node`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.repositionNodes({
|
||||
[SUBGRAPH_ID]: REF_POS,
|
||||
[REGULAR_ID]: TARGET_POSITIONS[pos]
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
if (state === 'collapsed') {
|
||||
const nodeRef = await comfyPage.nodeOps.getNodeRefById(REGULAR_ID)
|
||||
await nodeRef.setCollapsed(true)
|
||||
}
|
||||
|
||||
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
|
||||
SUBGRAPH_ID,
|
||||
REGULAR_ID
|
||||
])
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 94 KiB |
@@ -134,6 +134,9 @@ export type {
|
||||
DeleteHubWorkflowErrors,
|
||||
DeleteHubWorkflowResponse,
|
||||
DeleteHubWorkflowResponses,
|
||||
DeleteMonitoringTasksSubpathData,
|
||||
DeleteMonitoringTasksSubpathErrors,
|
||||
DeleteMonitoringTasksSubpathResponses,
|
||||
DeleteSecretData,
|
||||
DeleteSecretError,
|
||||
DeleteSecretErrors,
|
||||
@@ -196,6 +199,9 @@ export type {
|
||||
GetAllSettingsErrors,
|
||||
GetAllSettingsResponse,
|
||||
GetAllSettingsResponses,
|
||||
GetApiViewVideoAliasData,
|
||||
GetApiViewVideoAliasErrors,
|
||||
GetApiViewVideoAliasResponses,
|
||||
GetAssetByIdData,
|
||||
GetAssetByIdError,
|
||||
GetAssetByIdErrors,
|
||||
@@ -236,6 +242,8 @@ export type {
|
||||
GetDeletionRequestErrors,
|
||||
GetDeletionRequestResponse,
|
||||
GetDeletionRequestResponses,
|
||||
GetExtensionsData,
|
||||
GetExtensionsResponses,
|
||||
GetFeaturesData,
|
||||
GetFeaturesResponse,
|
||||
GetFeaturesResponses,
|
||||
@@ -254,6 +262,9 @@ export type {
|
||||
GetGlobalSubgraphsErrors,
|
||||
GetGlobalSubgraphsResponse,
|
||||
GetGlobalSubgraphsResponses,
|
||||
GetHealthData,
|
||||
GetHealthErrors,
|
||||
GetHealthResponses,
|
||||
GetHistoryData,
|
||||
GetHistoryError,
|
||||
GetHistoryErrors,
|
||||
@@ -317,6 +328,12 @@ export type {
|
||||
GetModelsInFolderErrors,
|
||||
GetModelsInFolderResponse,
|
||||
GetModelsInFolderResponses,
|
||||
GetMonitoringTasksData,
|
||||
GetMonitoringTasksErrors,
|
||||
GetMonitoringTasksResponses,
|
||||
GetMonitoringTasksSubpathData,
|
||||
GetMonitoringTasksSubpathErrors,
|
||||
GetMonitoringTasksSubpathResponses,
|
||||
GetMyHubProfileData,
|
||||
GetMyHubProfileError,
|
||||
GetMyHubProfileErrors,
|
||||
@@ -325,11 +342,19 @@ export type {
|
||||
GetNodeInfoData,
|
||||
GetNodeInfoResponse,
|
||||
GetNodeInfoResponses,
|
||||
GetOpenapiSpecData,
|
||||
GetOpenapiSpecResponses,
|
||||
GetPaymentPortalData,
|
||||
GetPaymentPortalError,
|
||||
GetPaymentPortalErrors,
|
||||
GetPaymentPortalResponse,
|
||||
GetPaymentPortalResponses,
|
||||
GetPprofData,
|
||||
GetPprofProfileData,
|
||||
GetPprofProfileResponses,
|
||||
GetPprofResponses,
|
||||
GetPprofTraceData,
|
||||
GetPprofTraceResponses,
|
||||
GetPromptInfoData,
|
||||
GetPromptInfoError,
|
||||
GetPromptInfoErrors,
|
||||
@@ -345,11 +370,6 @@ export type {
|
||||
GetQueueInfoErrors,
|
||||
GetQueueInfoResponse,
|
||||
GetQueueInfoResponses,
|
||||
GetRawLogsData,
|
||||
GetRawLogsError,
|
||||
GetRawLogsErrors,
|
||||
GetRawLogsResponse,
|
||||
GetRawLogsResponses,
|
||||
GetRemoteAssetMetadataData,
|
||||
GetRemoteAssetMetadataError,
|
||||
GetRemoteAssetMetadataErrors,
|
||||
@@ -365,6 +385,9 @@ export type {
|
||||
GetSettingByKeyErrors,
|
||||
GetSettingByKeyResponse,
|
||||
GetSettingByKeyResponses,
|
||||
GetStaticExtensionsData,
|
||||
GetStaticExtensionsErrors,
|
||||
GetStaticExtensionsResponses,
|
||||
GetSystemStatsData,
|
||||
GetSystemStatsError,
|
||||
GetSystemStatsErrors,
|
||||
@@ -375,6 +398,8 @@ export type {
|
||||
GetTaskErrors,
|
||||
GetTaskResponse,
|
||||
GetTaskResponses,
|
||||
GetTemplateProxyData,
|
||||
GetTemplateProxyErrors,
|
||||
GetUserData,
|
||||
GetUserdataData,
|
||||
GetUserdataError,
|
||||
@@ -397,6 +422,23 @@ export type {
|
||||
GetUserErrors,
|
||||
GetUserResponse,
|
||||
GetUserResponses,
|
||||
GetUsersRawData,
|
||||
GetUsersRawErrors,
|
||||
GetUsersRawResponses,
|
||||
GetVhsQueryVideoData,
|
||||
GetVhsQueryVideoErrors,
|
||||
GetVhsQueryVideoResponses,
|
||||
GetVhsViewAudioData,
|
||||
GetVhsViewAudioErrors,
|
||||
GetVhsViewAudioResponses,
|
||||
GetVhsViewVideoData,
|
||||
GetVhsViewVideoErrors,
|
||||
GetVhsViewVideoResponses,
|
||||
GetViewCompatAliasData,
|
||||
GetViewCompatAliasErrors,
|
||||
GetViewCompatAliasResponses,
|
||||
GetWebsocketData,
|
||||
GetWebsocketErrors,
|
||||
GetWorkflowContentData,
|
||||
GetWorkflowContentError,
|
||||
GetWorkflowContentErrors,
|
||||
@@ -526,7 +568,6 @@ export type {
|
||||
ListWorkspacesResponse2,
|
||||
ListWorkspacesResponses,
|
||||
LogsResponse,
|
||||
LogsSubscribeRequest,
|
||||
ManageHistoryData,
|
||||
ManageHistoryError,
|
||||
ManageHistoryErrors,
|
||||
@@ -560,6 +601,11 @@ export type {
|
||||
PostAssetsFromWorkflowErrors,
|
||||
PostAssetsFromWorkflowResponse,
|
||||
PostAssetsFromWorkflowResponses,
|
||||
PostMonitoringTasksSubpathData,
|
||||
PostMonitoringTasksSubpathErrors,
|
||||
PostMonitoringTasksSubpathResponses,
|
||||
PostPprofSymbolData,
|
||||
PostPprofSymbolResponses,
|
||||
PostUserdataFileData,
|
||||
PostUserdataFileError,
|
||||
PostUserdataFileErrors,
|
||||
@@ -593,7 +639,6 @@ export type {
|
||||
QueueInfo,
|
||||
QueueManageRequest,
|
||||
QueueManageResponse,
|
||||
RawLogsResponse,
|
||||
RemoveAssetTagsData,
|
||||
RemoveAssetTagsError,
|
||||
RemoveAssetTagsErrors,
|
||||
@@ -649,11 +694,6 @@ export type {
|
||||
SubscribeResponse,
|
||||
SubscribeResponse2,
|
||||
SubscribeResponses,
|
||||
SubscribeToLogsData,
|
||||
SubscribeToLogsError,
|
||||
SubscribeToLogsErrors,
|
||||
SubscribeToLogsResponse,
|
||||
SubscribeToLogsResponses,
|
||||
SubscriptionDuration,
|
||||
SubscriptionTier,
|
||||
SystemStatsResponse,
|
||||
|
||||
533
packages/ingest-types/src/types.gen.ts
generated
533
packages/ingest-types/src/types.gen.ts
generated
@@ -1961,35 +1961,6 @@ export type SystemStatsResponse = {
|
||||
}>
|
||||
}
|
||||
|
||||
export type LogsSubscribeRequest = {
|
||||
/**
|
||||
* Whether to enable or disable log subscription
|
||||
*/
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw logs response with entries and size
|
||||
*/
|
||||
export type RawLogsResponse = {
|
||||
entries?: Array<{
|
||||
/**
|
||||
* Log message
|
||||
*/
|
||||
m?: string
|
||||
}>
|
||||
size?: {
|
||||
/**
|
||||
* Terminal column size
|
||||
*/
|
||||
cols?: number
|
||||
/**
|
||||
* Terminal row size
|
||||
*/
|
||||
rows?: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* System logs response
|
||||
*/
|
||||
@@ -5276,7 +5247,7 @@ export type GetLogsData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: never
|
||||
url: '/internal/logs'
|
||||
url: '/api/logs'
|
||||
}
|
||||
|
||||
export type GetLogsErrors = {
|
||||
@@ -5297,67 +5268,6 @@ export type GetLogsResponses = {
|
||||
|
||||
export type GetLogsResponse = GetLogsResponses[keyof GetLogsResponses]
|
||||
|
||||
export type GetRawLogsData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: never
|
||||
url: '/internal/logs/raw'
|
||||
}
|
||||
|
||||
export type GetRawLogsErrors = {
|
||||
/**
|
||||
* Unauthorized
|
||||
*/
|
||||
401: ErrorResponse
|
||||
}
|
||||
|
||||
export type GetRawLogsError = GetRawLogsErrors[keyof GetRawLogsErrors]
|
||||
|
||||
export type GetRawLogsResponses = {
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
200: RawLogsResponse
|
||||
}
|
||||
|
||||
export type GetRawLogsResponse = GetRawLogsResponses[keyof GetRawLogsResponses]
|
||||
|
||||
export type SubscribeToLogsData = {
|
||||
body: LogsSubscribeRequest
|
||||
path?: never
|
||||
query?: never
|
||||
url: '/internal/logs/subscribe'
|
||||
}
|
||||
|
||||
export type SubscribeToLogsErrors = {
|
||||
/**
|
||||
* Bad request
|
||||
*/
|
||||
400: ErrorResponse
|
||||
/**
|
||||
* Unauthorized
|
||||
*/
|
||||
401: ErrorResponse
|
||||
}
|
||||
|
||||
export type SubscribeToLogsError =
|
||||
SubscribeToLogsErrors[keyof SubscribeToLogsErrors]
|
||||
|
||||
export type SubscribeToLogsResponses = {
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
200: {
|
||||
/**
|
||||
* Whether logs subscription is enabled
|
||||
*/
|
||||
enabled?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export type SubscribeToLogsResponse =
|
||||
SubscribeToLogsResponses[keyof SubscribeToLogsResponses]
|
||||
|
||||
export type GetSystemStatsData = {
|
||||
body?: never
|
||||
path?: never
|
||||
@@ -7692,3 +7602,444 @@ export type GetPublishedWorkflowResponses = {
|
||||
|
||||
export type GetPublishedWorkflowResponse =
|
||||
GetPublishedWorkflowResponses[keyof GetPublishedWorkflowResponses]
|
||||
|
||||
export type GetExtensionsData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: never
|
||||
url: '/api/extensions'
|
||||
}
|
||||
|
||||
export type GetExtensionsResponses = {
|
||||
/**
|
||||
* JSON array of extension file paths
|
||||
*/
|
||||
200: unknown
|
||||
}
|
||||
|
||||
export type GetVhsViewVideoData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query: {
|
||||
/**
|
||||
* Name of the video file to view
|
||||
*/
|
||||
filename: string
|
||||
/**
|
||||
* Type of file (e.g., output, input, temp)
|
||||
*/
|
||||
type?: string
|
||||
/**
|
||||
* Subfolder path where the file is located
|
||||
*/
|
||||
subfolder?: string
|
||||
}
|
||||
url: '/api/vhs/viewvideo'
|
||||
}
|
||||
|
||||
export type GetVhsViewVideoErrors = {
|
||||
/**
|
||||
* Unauthorized
|
||||
*/
|
||||
401: unknown
|
||||
}
|
||||
|
||||
export type GetVhsViewVideoResponses = {
|
||||
/**
|
||||
* Video stream
|
||||
*/
|
||||
200: unknown
|
||||
}
|
||||
|
||||
export type GetVhsViewAudioData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query: {
|
||||
/**
|
||||
* Name of the audio file to view
|
||||
*/
|
||||
filename: string
|
||||
/**
|
||||
* Type of file (e.g., output, input, temp)
|
||||
*/
|
||||
type?: string
|
||||
/**
|
||||
* Subfolder path where the file is located
|
||||
*/
|
||||
subfolder?: string
|
||||
}
|
||||
url: '/api/vhs/viewaudio'
|
||||
}
|
||||
|
||||
export type GetVhsViewAudioErrors = {
|
||||
/**
|
||||
* Unauthorized
|
||||
*/
|
||||
401: unknown
|
||||
}
|
||||
|
||||
export type GetVhsViewAudioResponses = {
|
||||
/**
|
||||
* Audio stream
|
||||
*/
|
||||
200: unknown
|
||||
}
|
||||
|
||||
export type GetVhsQueryVideoData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query: {
|
||||
/**
|
||||
* Name of the video file to query
|
||||
*/
|
||||
filename: string
|
||||
}
|
||||
url: '/api/vhs/queryvideo'
|
||||
}
|
||||
|
||||
export type GetVhsQueryVideoErrors = {
|
||||
/**
|
||||
* Unauthorized
|
||||
*/
|
||||
401: unknown
|
||||
}
|
||||
|
||||
export type GetVhsQueryVideoResponses = {
|
||||
/**
|
||||
* Video metadata
|
||||
*/
|
||||
200: unknown
|
||||
}
|
||||
|
||||
export type GetUsersRawData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: never
|
||||
url: '/api/users'
|
||||
}
|
||||
|
||||
export type GetUsersRawErrors = {
|
||||
/**
|
||||
* Unauthorized
|
||||
*/
|
||||
401: unknown
|
||||
}
|
||||
|
||||
export type GetUsersRawResponses = {
|
||||
/**
|
||||
* User list
|
||||
*/
|
||||
200: unknown
|
||||
}
|
||||
|
||||
export type GetApiViewVideoAliasData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query: {
|
||||
/**
|
||||
* Name of the file to view (see `/api/view` for the full handler contract)
|
||||
*/
|
||||
filename: string
|
||||
}
|
||||
url: '/api/viewvideo'
|
||||
}
|
||||
|
||||
export type GetApiViewVideoAliasErrors = {
|
||||
/**
|
||||
* Unauthorized
|
||||
*/
|
||||
401: unknown
|
||||
}
|
||||
|
||||
export type GetApiViewVideoAliasResponses = {
|
||||
/**
|
||||
* File stream
|
||||
*/
|
||||
200: unknown
|
||||
}
|
||||
|
||||
export type GetViewCompatAliasData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query: {
|
||||
/**
|
||||
* Name of the file to view (see `/api/view` for the full handler contract)
|
||||
*/
|
||||
filename: string
|
||||
}
|
||||
url: '/view'
|
||||
}
|
||||
|
||||
export type GetViewCompatAliasErrors = {
|
||||
/**
|
||||
* Unauthorized
|
||||
*/
|
||||
401: unknown
|
||||
}
|
||||
|
||||
export type GetViewCompatAliasResponses = {
|
||||
/**
|
||||
* File stream
|
||||
*/
|
||||
200: unknown
|
||||
}
|
||||
|
||||
export type GetWebsocketData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: {
|
||||
/**
|
||||
* Stable client identifier used to associate the WebSocket
|
||||
* connection with the frontend session. If omitted, the server
|
||||
* generates one.
|
||||
*
|
||||
*/
|
||||
clientId?: string
|
||||
}
|
||||
url: '/ws'
|
||||
}
|
||||
|
||||
export type GetWebsocketErrors = {
|
||||
/**
|
||||
* Unauthorized
|
||||
*/
|
||||
401: unknown
|
||||
}
|
||||
|
||||
export type GetTemplateProxyData = {
|
||||
body?: never
|
||||
path: {
|
||||
path: string
|
||||
}
|
||||
query?: never
|
||||
url: '/templates/{path}'
|
||||
}
|
||||
|
||||
export type GetTemplateProxyErrors = {
|
||||
/**
|
||||
* Template not found
|
||||
*/
|
||||
404: unknown
|
||||
}
|
||||
|
||||
export type GetHealthData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: never
|
||||
url: '/health'
|
||||
}
|
||||
|
||||
export type GetHealthErrors = {
|
||||
/**
|
||||
* Service is unhealthy
|
||||
*/
|
||||
503: unknown
|
||||
}
|
||||
|
||||
export type GetHealthResponses = {
|
||||
/**
|
||||
* Service is healthy
|
||||
*/
|
||||
200: unknown
|
||||
}
|
||||
|
||||
export type GetOpenapiSpecData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: never
|
||||
url: '/openapi'
|
||||
}
|
||||
|
||||
export type GetOpenapiSpecResponses = {
|
||||
/**
|
||||
* OpenAPI specification document
|
||||
*/
|
||||
200: unknown
|
||||
}
|
||||
|
||||
export type GetMonitoringTasksData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: never
|
||||
url: '/monitoring/tasks'
|
||||
}
|
||||
|
||||
export type GetMonitoringTasksErrors = {
|
||||
/**
|
||||
* Unauthorized
|
||||
*/
|
||||
401: unknown
|
||||
/**
|
||||
* Forbidden
|
||||
*/
|
||||
403: unknown
|
||||
}
|
||||
|
||||
export type GetMonitoringTasksResponses = {
|
||||
/**
|
||||
* HTML dashboard
|
||||
*/
|
||||
200: unknown
|
||||
}
|
||||
|
||||
export type DeleteMonitoringTasksSubpathData = {
|
||||
body?: never
|
||||
path: {
|
||||
path: string
|
||||
}
|
||||
query?: never
|
||||
url: '/monitoring/tasks/{path}'
|
||||
}
|
||||
|
||||
export type DeleteMonitoringTasksSubpathErrors = {
|
||||
/**
|
||||
* Unauthorized
|
||||
*/
|
||||
401: unknown
|
||||
/**
|
||||
* Forbidden
|
||||
*/
|
||||
403: unknown
|
||||
}
|
||||
|
||||
export type DeleteMonitoringTasksSubpathResponses = {
|
||||
/**
|
||||
* Deletion result
|
||||
*/
|
||||
200: unknown
|
||||
}
|
||||
|
||||
export type GetMonitoringTasksSubpathData = {
|
||||
body?: never
|
||||
path: {
|
||||
path: string
|
||||
}
|
||||
query?: never
|
||||
url: '/monitoring/tasks/{path}'
|
||||
}
|
||||
|
||||
export type GetMonitoringTasksSubpathErrors = {
|
||||
/**
|
||||
* Unauthorized
|
||||
*/
|
||||
401: unknown
|
||||
/**
|
||||
* Forbidden
|
||||
*/
|
||||
403: unknown
|
||||
}
|
||||
|
||||
export type GetMonitoringTasksSubpathResponses = {
|
||||
/**
|
||||
* Subpath response (asynqmon-determined content type)
|
||||
*/
|
||||
200: unknown
|
||||
}
|
||||
|
||||
export type PostMonitoringTasksSubpathData = {
|
||||
body?: never
|
||||
path: {
|
||||
path: string
|
||||
}
|
||||
query?: never
|
||||
url: '/monitoring/tasks/{path}'
|
||||
}
|
||||
|
||||
export type PostMonitoringTasksSubpathErrors = {
|
||||
/**
|
||||
* Unauthorized
|
||||
*/
|
||||
401: unknown
|
||||
/**
|
||||
* Forbidden
|
||||
*/
|
||||
403: unknown
|
||||
}
|
||||
|
||||
export type PostMonitoringTasksSubpathResponses = {
|
||||
/**
|
||||
* Action result
|
||||
*/
|
||||
200: unknown
|
||||
}
|
||||
|
||||
export type GetPprofData = {
|
||||
body?: never
|
||||
path: {
|
||||
path: string
|
||||
}
|
||||
query?: never
|
||||
url: '/debug/pprof/{path}'
|
||||
}
|
||||
|
||||
export type GetPprofResponses = {
|
||||
/**
|
||||
* Profile data
|
||||
*/
|
||||
200: unknown
|
||||
}
|
||||
|
||||
export type GetPprofProfileData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: never
|
||||
url: '/debug/pprof/profile'
|
||||
}
|
||||
|
||||
export type GetPprofProfileResponses = {
|
||||
/**
|
||||
* CPU profile data
|
||||
*/
|
||||
200: unknown
|
||||
}
|
||||
|
||||
export type GetPprofTraceData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: never
|
||||
url: '/debug/pprof/trace'
|
||||
}
|
||||
|
||||
export type GetPprofTraceResponses = {
|
||||
/**
|
||||
* Execution trace data
|
||||
*/
|
||||
200: unknown
|
||||
}
|
||||
|
||||
export type PostPprofSymbolData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: never
|
||||
url: '/debug/pprof/symbol'
|
||||
}
|
||||
|
||||
export type PostPprofSymbolResponses = {
|
||||
/**
|
||||
* Resolved symbols
|
||||
*/
|
||||
200: unknown
|
||||
}
|
||||
|
||||
export type GetStaticExtensionsData = {
|
||||
body?: never
|
||||
path: {
|
||||
path: string
|
||||
}
|
||||
query?: never
|
||||
url: '/extensions/{path}'
|
||||
}
|
||||
|
||||
export type GetStaticExtensionsErrors = {
|
||||
/**
|
||||
* File not found
|
||||
*/
|
||||
404: unknown
|
||||
}
|
||||
|
||||
export type GetStaticExtensionsResponses = {
|
||||
/**
|
||||
* Static file
|
||||
*/
|
||||
200: unknown
|
||||
}
|
||||
|
||||
197
packages/ingest-types/src/zod.gen.ts
generated
197
packages/ingest-types/src/zod.gen.ts
generated
@@ -1070,29 +1070,6 @@ export const zSystemStatsResponse = z.object({
|
||||
)
|
||||
})
|
||||
|
||||
export const zLogsSubscribeRequest = z.object({
|
||||
enabled: z.boolean()
|
||||
})
|
||||
|
||||
/**
|
||||
* Raw logs response with entries and size
|
||||
*/
|
||||
export const zRawLogsResponse = z.object({
|
||||
entries: z
|
||||
.array(
|
||||
z.object({
|
||||
m: z.string().optional()
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
size: z
|
||||
.object({
|
||||
cols: z.number().int().optional(),
|
||||
rows: z.number().int().optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* System logs response
|
||||
*/
|
||||
@@ -2248,30 +2225,6 @@ export const zGetLogsData = z.object({
|
||||
*/
|
||||
export const zGetLogsResponse = zLogsResponse
|
||||
|
||||
export const zGetRawLogsData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
export const zGetRawLogsResponse = zRawLogsResponse
|
||||
|
||||
export const zSubscribeToLogsData = z.object({
|
||||
body: zLogsSubscribeRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
export const zSubscribeToLogsResponse = z.object({
|
||||
enabled: z.boolean().optional()
|
||||
})
|
||||
|
||||
export const zGetSystemStatsData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -3031,3 +2984,153 @@ export const zGetPublishedWorkflowData = z.object({
|
||||
* Published workflow details with asset statuses
|
||||
*/
|
||||
export const zGetPublishedWorkflowResponse = zPublishedWorkflowDetail
|
||||
|
||||
export const zGetExtensionsData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zGetVhsViewVideoData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.object({
|
||||
filename: z.string(),
|
||||
type: z.string().optional(),
|
||||
subfolder: z.string().optional()
|
||||
})
|
||||
})
|
||||
|
||||
export const zGetVhsViewAudioData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.object({
|
||||
filename: z.string(),
|
||||
type: z.string().optional(),
|
||||
subfolder: z.string().optional()
|
||||
})
|
||||
})
|
||||
|
||||
export const zGetVhsQueryVideoData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.object({
|
||||
filename: z.string()
|
||||
})
|
||||
})
|
||||
|
||||
export const zGetUsersRawData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zGetApiViewVideoAliasData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.object({
|
||||
filename: z.string()
|
||||
})
|
||||
})
|
||||
|
||||
export const zGetViewCompatAliasData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.object({
|
||||
filename: z.string()
|
||||
})
|
||||
})
|
||||
|
||||
export const zGetWebsocketData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z
|
||||
.object({
|
||||
clientId: z.string().optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
export const zGetTemplateProxyData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
path: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zGetHealthData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zGetOpenapiSpecData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zGetMonitoringTasksData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zDeleteMonitoringTasksSubpathData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
path: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zGetMonitoringTasksSubpathData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
path: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zPostMonitoringTasksSubpathData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
path: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zGetPprofData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
path: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zGetPprofProfileData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zGetPprofTraceData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zPostPprofSymbolData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zGetStaticExtensionsData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
path: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
@@ -55,9 +55,11 @@
|
||||
<i class="icon-[lucide--mouse-pointer-2] size-4" aria-hidden="true" />
|
||||
<span>{{ $t('graphCanvasMenu.select') }}</span>
|
||||
</div>
|
||||
<span class="text-[9px] text-text-primary">{{
|
||||
unlockCommandText
|
||||
}}</span>
|
||||
<span
|
||||
class="text-[9px] text-text-primary"
|
||||
data-testid="shortcut-hint"
|
||||
>{{ unlockCommandText }}</span
|
||||
>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -76,7 +78,11 @@
|
||||
<i class="icon-[lucide--hand] size-4" aria-hidden="true" />
|
||||
<span>{{ $t('graphCanvasMenu.hand') }}</span>
|
||||
</div>
|
||||
<span class="text-[9px] text-text-primary">{{ lockCommandText }}</span>
|
||||
<span
|
||||
class="text-[9px] text-text-primary"
|
||||
data-testid="shortcut-hint"
|
||||
>{{ lockCommandText }}</span
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
</Popover>
|
||||
|
||||
@@ -159,7 +159,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import PuzzleIcon from '@/components/icons/PuzzleIcon.vue'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { isCloud, isDesktop } from '@/platform/distribution/types'
|
||||
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { ReleaseNote } from '@/platform/updates/common/releaseService'
|
||||
@@ -299,9 +299,18 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
type: 'item',
|
||||
icon: 'icon-[lucide--clipboard-pen]',
|
||||
label: t('helpCenter.feedback'),
|
||||
showExternalIcon: isCloud || isNightly,
|
||||
action: () => {
|
||||
trackResourceClick('help_feedback', false)
|
||||
void commandStore.execute('Comfy.ContactSupport')
|
||||
trackResourceClick('help_feedback', isCloud || isNightly)
|
||||
if (isCloud || isNightly) {
|
||||
window.open(
|
||||
'https://form.typeform.com/to/q7azbWPi',
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
} else {
|
||||
void commandStore.execute('Comfy.ContactSupport')
|
||||
}
|
||||
emit('close')
|
||||
}
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createSharedComposable } from '@vueuse/core'
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
|
||||
import { LiteGraph } from '../lib/litegraph/src/litegraph'
|
||||
|
||||
@@ -25,6 +26,9 @@ function useVueFeatureFlagsIndividual() {
|
||||
shouldRenderVueNodes,
|
||||
() => {
|
||||
LiteGraph.vueNodesMode = shouldRenderVueNodes.value
|
||||
LiteGraph.getCollapsedSize = shouldRenderVueNodes.value
|
||||
? (nodeId) => layoutStore.getNodeCollapsedSize(String(nodeId))
|
||||
: undefined
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { t } from '@/i18n'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { buildFeedbackUrl } from '@/platform/support/config'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import type { ActionBarButton } from '@/types/comfy'
|
||||
|
||||
const feedbackUrl = buildFeedbackUrl()
|
||||
const TYPEFORM_SURVEY_URL = 'https://form.typeform.com/to/q7azbWPi'
|
||||
|
||||
const buttons: ActionBarButton[] = [
|
||||
{
|
||||
@@ -12,7 +11,7 @@ const buttons: ActionBarButton[] = [
|
||||
label: t('actionbar.feedback'),
|
||||
tooltip: t('actionbar.feedbackTooltip'),
|
||||
onClick: () => {
|
||||
window.open(feedbackUrl, '_blank', 'noopener,noreferrer')
|
||||
window.open(TYPEFORM_SURVEY_URL, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -143,9 +143,10 @@ app.registerExtension({
|
||||
throw new Error(err)
|
||||
}
|
||||
const data = await resp.json()
|
||||
const serverName = data.name ?? name
|
||||
const subfolder = data.subfolder ?? 'webcam'
|
||||
return `${subfolder}/${serverName} [temp]`
|
||||
const serverName = data.name || name
|
||||
const subfolder = data.subfolder || 'webcam'
|
||||
const type = data.type || 'temp'
|
||||
return `${subfolder}/${serverName} [${type}]`
|
||||
}
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
|
||||
@@ -2092,16 +2092,22 @@ export class LGraphNode
|
||||
out[2] = this.size[0]
|
||||
out[3] = this.size[1] + titleHeight
|
||||
} else {
|
||||
if (ctx) ctx.font = this.innerFontStyle
|
||||
this._collapsed_width = Math.min(
|
||||
this.size[0],
|
||||
ctx
|
||||
? cachedMeasureText(ctx, this.getTitle() ?? '') +
|
||||
LiteGraph.NODE_TITLE_HEIGHT * 2
|
||||
: 0
|
||||
)
|
||||
out[2] = this._collapsed_width || LiteGraph.NODE_COLLAPSED_WIDTH
|
||||
out[3] = LiteGraph.NODE_TITLE_HEIGHT
|
||||
const collapsedSize = LiteGraph.getCollapsedSize?.(this.id)
|
||||
if (collapsedSize) {
|
||||
out[2] = collapsedSize.width
|
||||
out[3] = collapsedSize.height
|
||||
} else {
|
||||
if (ctx) ctx.font = this.innerFontStyle
|
||||
this._collapsed_width = Math.min(
|
||||
this.size[0],
|
||||
ctx
|
||||
? cachedMeasureText(ctx, this.getTitle() ?? '') +
|
||||
LiteGraph.NODE_TITLE_HEIGHT * 2
|
||||
: 0
|
||||
)
|
||||
out[2] = this._collapsed_width || LiteGraph.NODE_COLLAPSED_WIDTH
|
||||
out[3] = LiteGraph.NODE_TITLE_HEIGHT
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -355,6 +355,15 @@ export class LiteGraphGlobal {
|
||||
*/
|
||||
vueNodesMode: boolean = false
|
||||
|
||||
/**
|
||||
* Optional accessor for collapsed node dimensions in Vue mode.
|
||||
* Set by the Vue layer to provide DOM-measured collapsed sizes
|
||||
* that measure() can use instead of canvas text measurement.
|
||||
*/
|
||||
getCollapsedSize?: (
|
||||
nodeId: string | number
|
||||
) => { width: number; height: number } | undefined
|
||||
|
||||
// Special Rendering Values pulled out of app.ts patches
|
||||
nodeOpacity = 1
|
||||
nodeLightness: number | undefined = undefined
|
||||
|
||||
@@ -155,6 +155,7 @@ LiteGraphGlobal {
|
||||
"dialog_close_on_mouse_leave_delay": 500,
|
||||
"distance": [Function],
|
||||
"do_add_triggers_slots": false,
|
||||
"getCollapsedSize": undefined,
|
||||
"highlight_selected_group": true,
|
||||
"isInsideRectangle": [Function],
|
||||
"leftMouseClickBehavior": "panning",
|
||||
|
||||
@@ -34,6 +34,7 @@ import type {
|
||||
NodeId,
|
||||
NodeLayout,
|
||||
Point,
|
||||
Size,
|
||||
RerouteId,
|
||||
RerouteLayout,
|
||||
ResizeNodeOperation,
|
||||
@@ -241,8 +242,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
get: () => {
|
||||
track()
|
||||
const ynode = this.ynodes.get(nodeId)
|
||||
const layout = ynode ? yNodeToLayout(ynode) : null
|
||||
return layout
|
||||
return ynode ? yNodeToLayout(ynode) : null
|
||||
},
|
||||
set: (newLayout: NodeLayout | null) => {
|
||||
if (newLayout === null) {
|
||||
@@ -1546,6 +1546,29 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
|
||||
this.currentSource = originalSource
|
||||
}
|
||||
|
||||
updateNodeCollapsedSize(nodeId: NodeId, size: Size): void {
|
||||
const ynode = this.ynodes.get(nodeId)
|
||||
if (!ynode) return
|
||||
this.ydoc.transact(() => {
|
||||
ynode.set('collapsedSize', size)
|
||||
}, this.currentActor)
|
||||
this.nodeTriggers.get(nodeId)?.()
|
||||
}
|
||||
|
||||
getNodeCollapsedSize(nodeId: NodeId): Size | undefined {
|
||||
return this.ynodes.get(nodeId)?.get('collapsedSize') as Size | undefined
|
||||
}
|
||||
|
||||
clearNodeCollapsedSize(nodeId: NodeId): void {
|
||||
const ynode = this.ynodes.get(nodeId)
|
||||
if (ynode?.has('collapsedSize')) {
|
||||
this.ydoc.transact(() => {
|
||||
ynode.delete('collapsedSize')
|
||||
}, this.currentActor)
|
||||
this.nodeTriggers.get(nodeId)?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
|
||||
@@ -50,6 +50,9 @@ export interface NodeLayout {
|
||||
visible: boolean
|
||||
// Computed bounds for hit testing
|
||||
bounds: Bounds
|
||||
// Collapsed node dimensions (Vue mode only, separate from size to
|
||||
// preserve expanded size across collapse/expand cycles)
|
||||
collapsedSize?: Size
|
||||
}
|
||||
|
||||
export interface SlotLayout {
|
||||
|
||||
@@ -21,6 +21,7 @@ export function layoutToYNode(layout: NodeLayout): NodeLayoutMap {
|
||||
ynode.set('zIndex', layout.zIndex)
|
||||
ynode.set('visible', layout.visible)
|
||||
ynode.set('bounds', layout.bounds)
|
||||
if (layout.collapsedSize) ynode.set('collapsedSize', layout.collapsedSize)
|
||||
return ynode
|
||||
}
|
||||
|
||||
@@ -34,7 +35,7 @@ function getOr<K extends keyof NodeLayout>(
|
||||
}
|
||||
|
||||
export function yNodeToLayout(ynode: NodeLayoutMap): NodeLayout {
|
||||
return {
|
||||
const layout: NodeLayout = {
|
||||
id: getOr(ynode, 'id', NODE_LAYOUT_DEFAULTS.id),
|
||||
position: getOr(ynode, 'position', NODE_LAYOUT_DEFAULTS.position),
|
||||
size: getOr(ynode, 'size', NODE_LAYOUT_DEFAULTS.size),
|
||||
@@ -42,4 +43,8 @@ export function yNodeToLayout(ynode: NodeLayoutMap): NodeLayout {
|
||||
visible: getOr(ynode, 'visible', NODE_LAYOUT_DEFAULTS.visible),
|
||||
bounds: getOr(ynode, 'bounds', NODE_LAYOUT_DEFAULTS.bounds)
|
||||
}
|
||||
const collapsedSize = ynode.get('collapsedSize')
|
||||
if (collapsedSize)
|
||||
layout.collapsedSize = collapsedSize as NodeLayout['collapsedSize']
|
||||
return layout
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, toValue } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
@@ -176,13 +176,7 @@ describe('LGraphNode', () => {
|
||||
it('should call resize tracking composable with node ID', () => {
|
||||
renderLGraphNode({ nodeData: mockNodeData })
|
||||
|
||||
expect(useVueElementTracking).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
'node'
|
||||
)
|
||||
const idArg = vi.mocked(useVueElementTracking).mock.calls[0]?.[0]
|
||||
const id = toValue(idArg)
|
||||
expect(id).toEqual('test-node-123')
|
||||
expect(useVueElementTracking).toHaveBeenCalledWith('test-node-123', 'node')
|
||||
})
|
||||
|
||||
it('should render with data-node-id attribute', () => {
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
cn(
|
||||
'group/node lg-node absolute isolate text-sm',
|
||||
'flex flex-col contain-layout contain-style',
|
||||
isRerouteNode ? 'h-(--node-height)' : 'min-w-(--min-node-width)',
|
||||
isRerouteNode
|
||||
? 'h-(--node-height)'
|
||||
: 'min-h-(--node-height) min-w-(--min-node-width)',
|
||||
cursorClass,
|
||||
isSelected && 'outline-node-component-outline',
|
||||
executing && 'outline-node-stroke-executing',
|
||||
@@ -55,8 +57,7 @@
|
||||
hasAnyError ? '-inset-[7px]' : '-inset-[3px]',
|
||||
isSelected
|
||||
? 'border-node-component-outline'
|
||||
: 'border-node-stroke-executing',
|
||||
footerStateOutlineBottomClass
|
||||
: 'border-node-stroke-executing'
|
||||
)
|
||||
"
|
||||
/>
|
||||
@@ -66,18 +67,18 @@
|
||||
cn(
|
||||
'pointer-events-none absolute border border-solid border-component-node-border',
|
||||
rootBorderShapeClass,
|
||||
hasAnyError ? '-inset-1' : 'inset-0',
|
||||
footerRootBorderBottomClass
|
||||
hasAnyError ? '-inset-1' : 'inset-0'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<div
|
||||
data-node-body
|
||||
data-testid="node-inner-wrapper"
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-1 flex-col border border-solid border-transparent bg-node-component-header-surface',
|
||||
'w-(--node-width)',
|
||||
!isRerouteNode && 'min-h-(--node-height) min-w-(--min-node-width)',
|
||||
!isRerouteNode && 'min-w-(--min-node-width)',
|
||||
shapeClass,
|
||||
hasAnyError && 'ring-4 ring-destructive-background',
|
||||
{
|
||||
@@ -196,7 +197,6 @@
|
||||
:is-subgraph="!!lgraphNode?.isSubgraphNode()"
|
||||
:has-any-error="hasAnyError"
|
||||
:show-errors-tab-enabled="showErrorsTabEnabled"
|
||||
:is-collapsed="isCollapsed"
|
||||
:show-advanced-inputs-button="showAdvancedInputsButton"
|
||||
:show-advanced-state="showAdvancedState"
|
||||
:header-color="applyLightThemeColor(nodeData?.color)"
|
||||
@@ -222,8 +222,6 @@
|
||||
cn(
|
||||
baseResizeHandleClasses,
|
||||
handle.positionClasses,
|
||||
(handle.corner === 'SE' || handle.corner === 'SW') &&
|
||||
footerResizeHandleBottomClass,
|
||||
handle.cursorClass,
|
||||
'group-hover/node:opacity-100'
|
||||
)
|
||||
@@ -271,6 +269,7 @@ import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { hasUnpromotedWidgets } from '@/core/graph/subgraph/promotionUtils'
|
||||
import { st } from '@/i18n'
|
||||
import type { CompassCorners } from '@/lib/litegraph/src/interfaces'
|
||||
import {
|
||||
LGraphCanvas,
|
||||
LGraphEventMode,
|
||||
@@ -316,7 +315,6 @@ import {
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { isTransparent } from '@/utils/colorUtil'
|
||||
|
||||
import type { CompassCorners } from '@/lib/litegraph/src/interfaces'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { MIN_NODE_WIDTH } from '@/renderer/core/layout/transform/graphRenderTransform'
|
||||
|
||||
@@ -346,7 +344,7 @@ const { handleNodeCollapse, handleNodeTitleUpdate, handleNodeRightClick } =
|
||||
useNodeEventHandlers()
|
||||
const { bringNodeToFront } = useNodeZIndex()
|
||||
|
||||
useVueElementTracking(() => nodeData.id, 'node')
|
||||
useVueElementTracking(String(nodeData.id), 'node')
|
||||
|
||||
const { selectedNodeIds } = storeToRefs(useCanvasStore())
|
||||
const isSelected = computed(() => {
|
||||
@@ -566,30 +564,6 @@ const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState(
|
||||
}
|
||||
)
|
||||
|
||||
const hasFooter = computed(() => {
|
||||
return !!(
|
||||
(hasAnyError.value && showErrorsTabEnabled.value) ||
|
||||
lgraphNode.value?.isSubgraphNode() ||
|
||||
(!lgraphNode.value?.isSubgraphNode() &&
|
||||
(showAdvancedState.value || showAdvancedInputsButton.value))
|
||||
)
|
||||
})
|
||||
|
||||
// Footer offset computed classes
|
||||
|
||||
const footerStateOutlineBottomClass = computed(() =>
|
||||
hasFooter.value ? '-bottom-[35px]' : ''
|
||||
)
|
||||
|
||||
const footerRootBorderBottomClass = computed(() =>
|
||||
hasFooter.value ? '-bottom-8' : ''
|
||||
)
|
||||
|
||||
const footerResizeHandleBottomClass = computed(() => {
|
||||
if (!hasFooter.value) return ''
|
||||
return hasAnyError.value ? 'bottom-[-31px]' : 'bottom-[-35px]'
|
||||
})
|
||||
|
||||
const cursorClass = computed(() => {
|
||||
if (nodeData.flags?.pinned) return 'cursor-default'
|
||||
return layoutStore.isDraggingVueNodes.value
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<template>
|
||||
<!-- Case 1: Subgraph + Error (Dual Tabs) -->
|
||||
<template v-if="isSubgraph && hasAnyError && showErrorsTabEnabled">
|
||||
<div
|
||||
v-if="isSubgraph && hasAnyError && showErrorsTabEnabled"
|
||||
:class="errorWrapperStyles"
|
||||
>
|
||||
<Button
|
||||
variant="textonly"
|
||||
:class="
|
||||
cn(
|
||||
getTabStyles(false),
|
||||
errorTabWidth,
|
||||
'-z-5 bg-destructive-background text-white hover:bg-destructive-background-hover'
|
||||
tabStyles,
|
||||
'z-10 box-border w-1/2 rounded-none bg-destructive-background pt-9 pb-4 text-white hover:bg-destructive-background-hover',
|
||||
errorRadiusClass
|
||||
)
|
||||
"
|
||||
@click.stop="$emit('openErrors')"
|
||||
@@ -23,37 +26,38 @@
|
||||
data-testid="subgraph-enter-button"
|
||||
:class="
|
||||
cn(
|
||||
getTabStyles(true),
|
||||
enterTabFullWidth,
|
||||
'-z-10 bg-node-component-header-surface'
|
||||
tabStyles,
|
||||
'-ml-5 box-border w-[calc(50%+20px)] rounded-none bg-node-component-header-surface pt-9 pb-4 pl-5',
|
||||
enterRadiusClass
|
||||
)
|
||||
"
|
||||
:style="headerColorStyle"
|
||||
@click.stop="$emit('enterSubgraph')"
|
||||
>
|
||||
<div class="ml-auto flex h-full w-1/2 items-center justify-center gap-2">
|
||||
<div class="flex size-full items-center justify-center gap-2">
|
||||
<span class="truncate">{{ t('g.enter') }}</span>
|
||||
<i class="icon-[comfy--workflow] size-4 shrink-0" />
|
||||
</div>
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Case 1b: Advanced + Error (Dual Tabs, Regular Nodes) -->
|
||||
<template
|
||||
<div
|
||||
v-else-if="
|
||||
!isSubgraph &&
|
||||
hasAnyError &&
|
||||
showErrorsTabEnabled &&
|
||||
(showAdvancedInputsButton || showAdvancedState)
|
||||
"
|
||||
:class="errorWrapperStyles"
|
||||
>
|
||||
<Button
|
||||
variant="textonly"
|
||||
:class="
|
||||
cn(
|
||||
getTabStyles(false),
|
||||
errorTabWidth,
|
||||
'-z-5 bg-destructive-background text-white hover:bg-destructive-background-hover'
|
||||
tabStyles,
|
||||
'z-10 box-border w-1/2 rounded-none bg-destructive-background pt-9 pb-4 text-white hover:bg-destructive-background-hover',
|
||||
errorRadiusClass
|
||||
)
|
||||
"
|
||||
@click.stop="$emit('openErrors')"
|
||||
@@ -68,15 +72,15 @@
|
||||
variant="textonly"
|
||||
:class="
|
||||
cn(
|
||||
getTabStyles(true),
|
||||
enterTabFullWidth,
|
||||
'-z-10 bg-node-component-header-surface'
|
||||
tabStyles,
|
||||
'-ml-5 box-border w-[calc(50%+20px)] rounded-none bg-node-component-header-surface pt-9 pb-4 pl-5',
|
||||
enterRadiusClass
|
||||
)
|
||||
"
|
||||
:style="headerColorStyle"
|
||||
@click.stop="$emit('toggleAdvanced')"
|
||||
>
|
||||
<div class="ml-auto flex h-full w-1/2 items-center justify-center gap-2">
|
||||
<div class="flex size-full items-center justify-center gap-2">
|
||||
<span class="truncate">{{
|
||||
showAdvancedState
|
||||
? t('rightSidePanel.hideAdvancedShort')
|
||||
@@ -91,17 +95,20 @@
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Case 2: Error Only (Full Width) -->
|
||||
<template v-else-if="hasAnyError && showErrorsTabEnabled">
|
||||
<div
|
||||
v-else-if="hasAnyError && showErrorsTabEnabled"
|
||||
:class="errorWrapperStyles"
|
||||
>
|
||||
<Button
|
||||
variant="textonly"
|
||||
:class="
|
||||
cn(
|
||||
getTabStyles(false),
|
||||
enterTabFullWidth,
|
||||
'-z-5 bg-destructive-background text-white hover:bg-destructive-background-hover'
|
||||
tabStyles,
|
||||
'box-border w-full rounded-none bg-destructive-background pt-9 pb-4 text-white hover:bg-destructive-background-hover',
|
||||
footerRadiusClass
|
||||
)
|
||||
"
|
||||
@click.stop="$emit('openErrors')"
|
||||
@@ -111,18 +118,27 @@
|
||||
<i class="icon-[lucide--info] size-4 shrink-0" />
|
||||
</div>
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Case 3: Subgraph only (Full Width) -->
|
||||
<template v-else-if="isSubgraph">
|
||||
<div
|
||||
v-else-if="isSubgraph"
|
||||
:class="
|
||||
cn(
|
||||
footerWrapperBase,
|
||||
hasAnyError ? '-mx-1 -mb-2 w-[calc(100%+8px)] pb-1' : 'w-full'
|
||||
)
|
||||
"
|
||||
>
|
||||
<Button
|
||||
variant="textonly"
|
||||
data-testid="subgraph-enter-button"
|
||||
:class="
|
||||
cn(
|
||||
getTabStyles(true),
|
||||
hasAnyError ? 'w-[calc(100%+8px)]' : 'w-full',
|
||||
'-z-10 bg-node-component-header-surface'
|
||||
tabStyles,
|
||||
'box-border w-full rounded-none bg-node-component-header-surface',
|
||||
hasAnyError ? 'pt-9 pb-4' : 'pt-8 pb-4',
|
||||
footerRadiusClass
|
||||
)
|
||||
"
|
||||
:style="headerColorStyle"
|
||||
@@ -133,37 +149,47 @@
|
||||
<i class="icon-[comfy--workflow] size-4 shrink-0" />
|
||||
</div>
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Case 4: Advanced Footer (Regular Nodes) -->
|
||||
<Button
|
||||
<div
|
||||
v-else-if="showAdvancedInputsButton || showAdvancedState"
|
||||
variant="textonly"
|
||||
:class="
|
||||
cn(
|
||||
getTabStyles(true),
|
||||
hasAnyError ? 'w-[calc(100%+8px)]' : 'w-full',
|
||||
'-z-10 bg-node-component-header-surface'
|
||||
footerWrapperBase,
|
||||
hasAnyError ? '-mx-1 -mb-2 w-[calc(100%+8px)] pb-1' : 'w-full'
|
||||
)
|
||||
"
|
||||
:style="headerColorStyle"
|
||||
@click.stop="$emit('toggleAdvanced')"
|
||||
>
|
||||
<div class="flex size-full items-center justify-center gap-2">
|
||||
<template v-if="showAdvancedState">
|
||||
<span class="truncate">{{
|
||||
t('rightSidePanel.hideAdvancedInputsButton')
|
||||
}}</span>
|
||||
<i class="icon-[lucide--chevron-up] size-4 shrink-0" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="truncate">{{
|
||||
t('rightSidePanel.showAdvancedInputsButton')
|
||||
}}</span>
|
||||
<i class="icon-[lucide--settings-2] size-4 shrink-0" />
|
||||
</template>
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
:class="
|
||||
cn(
|
||||
tabStyles,
|
||||
'box-border w-full rounded-none bg-node-component-header-surface',
|
||||
hasAnyError ? 'pt-9 pb-4' : 'pt-8 pb-4',
|
||||
footerRadiusClass
|
||||
)
|
||||
"
|
||||
:style="headerColorStyle"
|
||||
@click.stop="$emit('toggleAdvanced')"
|
||||
>
|
||||
<div class="flex size-full items-center justify-center gap-2">
|
||||
<template v-if="showAdvancedState">
|
||||
<span class="truncate">{{
|
||||
t('rightSidePanel.hideAdvancedInputsButton')
|
||||
}}</span>
|
||||
<i class="icon-[lucide--chevron-up] size-4 shrink-0" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="truncate">{{
|
||||
t('rightSidePanel.showAdvancedInputsButton')
|
||||
}}</span>
|
||||
<i class="icon-[lucide--settings-2] size-4 shrink-0" />
|
||||
</template>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -179,14 +205,21 @@ interface Props {
|
||||
isSubgraph: boolean
|
||||
hasAnyError: boolean
|
||||
showErrorsTabEnabled: boolean
|
||||
isCollapsed: boolean
|
||||
showAdvancedInputsButton?: boolean
|
||||
showAdvancedState?: boolean
|
||||
headerColor?: string
|
||||
shape?: RenderShape
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const {
|
||||
isSubgraph,
|
||||
hasAnyError,
|
||||
showErrorsTabEnabled,
|
||||
showAdvancedInputsButton,
|
||||
showAdvancedState,
|
||||
headerColor,
|
||||
shape
|
||||
} = defineProps<Props>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'enterSubgraph'): void
|
||||
@@ -194,52 +227,35 @@ defineEmits<{
|
||||
(e: 'toggleAdvanced'): void
|
||||
}>()
|
||||
|
||||
const footerRadiusClass = computed(() => {
|
||||
const isExpanded = props.hasAnyError
|
||||
|
||||
switch (props.shape) {
|
||||
case RenderShape.BOX:
|
||||
return ''
|
||||
case RenderShape.CARD:
|
||||
return isExpanded ? 'rounded-br-[20px]' : 'rounded-br-2xl'
|
||||
default:
|
||||
return isExpanded ? 'rounded-b-[20px]' : 'rounded-b-2xl'
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Returns shared size/position classes for footer tabs
|
||||
* @param isBackground If true, calculates styles for the background/right tab (Enter Subgraph)
|
||||
*/
|
||||
const getTabStyles = (isBackground = false) => {
|
||||
let sizeClasses = ''
|
||||
if (props.isCollapsed) {
|
||||
let pt = 'pt-10'
|
||||
if (isBackground) {
|
||||
pt = props.hasAnyError ? 'pt-10.5' : 'pt-9'
|
||||
}
|
||||
sizeClasses = cn('-mt-7.5 h-15', pt)
|
||||
} else {
|
||||
let pt = 'pt-12.5'
|
||||
if (isBackground) {
|
||||
pt = props.hasAnyError ? 'pt-12.5' : 'pt-11.5'
|
||||
}
|
||||
sizeClasses = cn('-mt-10 h-17.5', pt)
|
||||
}
|
||||
|
||||
return cn(
|
||||
'pointer-events-auto absolute top-full left-0 text-xs',
|
||||
footerRadiusClass.value,
|
||||
sizeClasses,
|
||||
props.hasAnyError ? '-translate-x-1 translate-y-0.5' : 'translate-y-0.5'
|
||||
)
|
||||
function getBottomRadius(
|
||||
nodeShape: RenderShape | undefined,
|
||||
size: string,
|
||||
corners: 'both' | 'right' = 'both'
|
||||
): string {
|
||||
if (nodeShape === RenderShape.BOX) return ''
|
||||
const prefix =
|
||||
nodeShape === RenderShape.CARD || corners === 'right'
|
||||
? 'rounded-br'
|
||||
: 'rounded-b'
|
||||
return `${prefix}-[${size}]`
|
||||
}
|
||||
|
||||
const headerColorStyle = computed(() =>
|
||||
props.headerColor ? { backgroundColor: props.headerColor } : undefined
|
||||
const footerRadiusClass = computed(() =>
|
||||
getBottomRadius(shape, hasAnyError ? '20px' : '17px')
|
||||
)
|
||||
|
||||
// Case 1 context: Split widths
|
||||
const errorTabWidth = 'w-[calc(50%+4px)]'
|
||||
const enterTabFullWidth = 'w-[calc(100%+8px)]'
|
||||
const errorRadiusClass = computed(() => getBottomRadius(shape, '20px'))
|
||||
|
||||
const enterRadiusClass = computed(() => getBottomRadius(shape, '20px', 'right'))
|
||||
|
||||
const tabStyles = 'pointer-events-auto h-9 text-xs'
|
||||
const footerWrapperBase = 'isolate -z-1 -mt-5 box-border flex'
|
||||
const errorWrapperStyles = cn(
|
||||
footerWrapperBase,
|
||||
'-mx-1 -mb-2 w-[calc(100%+8px)] pb-1'
|
||||
)
|
||||
|
||||
const headerColorStyle = computed(() =>
|
||||
headerColor ? { backgroundColor: headerColor } : undefined
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -43,11 +43,14 @@ const testState = vi.hoisted(() => ({
|
||||
nodeLayouts: new Map<NodeId, NodeLayout>(),
|
||||
batchUpdateNodeBounds: vi.fn(),
|
||||
setSource: vi.fn(),
|
||||
syncNodeSlotLayoutsFromDOM: vi.fn()
|
||||
syncNodeSlotLayoutsFromDOM: vi.fn(),
|
||||
updateNodeCollapsedSize: vi.fn(),
|
||||
clearNodeCollapsedSize: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useDocumentVisibility: () => ref<'visible' | 'hidden'>('visible')
|
||||
useDocumentVisibility: () => ref<'visible' | 'hidden'>('visible'),
|
||||
createSharedComposable: <T>(fn: T) => fn
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
@@ -67,7 +70,9 @@ vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
|
||||
batchUpdateNodeBounds: testState.batchUpdateNodeBounds,
|
||||
setSource: testState.setSource,
|
||||
getNodeLayoutRef: (nodeId: NodeId): Ref<NodeLayout | null> =>
|
||||
ref<NodeLayout | null>(testState.nodeLayouts.get(nodeId) ?? null)
|
||||
ref<NodeLayout | null>(testState.nodeLayouts.get(nodeId) ?? null),
|
||||
clearNodeCollapsedSize: testState.clearNodeCollapsedSize,
|
||||
updateNodeCollapsedSize: testState.updateNodeCollapsedSize
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -84,6 +89,7 @@ function createResizeEntry(options?: {
|
||||
left?: number
|
||||
top?: number
|
||||
collapsed?: boolean
|
||||
bodyWidth?: number
|
||||
}) {
|
||||
const {
|
||||
nodeId = 'test-node',
|
||||
@@ -91,7 +97,8 @@ function createResizeEntry(options?: {
|
||||
height = 180,
|
||||
left = 100,
|
||||
top = 200,
|
||||
collapsed = false
|
||||
collapsed = false,
|
||||
bodyWidth
|
||||
} = options ?? {}
|
||||
|
||||
const element = document.createElement('div')
|
||||
@@ -99,6 +106,14 @@ function createResizeEntry(options?: {
|
||||
if (collapsed) {
|
||||
element.dataset.collapsed = ''
|
||||
}
|
||||
if (bodyWidth !== undefined) {
|
||||
const body = document.createElement('div')
|
||||
body.setAttribute('data-node-body', '')
|
||||
Object.defineProperty(body, 'offsetWidth', { value: bodyWidth })
|
||||
element.appendChild(body)
|
||||
}
|
||||
Object.defineProperty(element, 'offsetWidth', { value: width })
|
||||
Object.defineProperty(element, 'offsetHeight', { value: height })
|
||||
const rectSpy = vi.fn(() => new DOMRect(left, top, width, height))
|
||||
element.getBoundingClientRect = rectSpy
|
||||
const boxSizes = [{ inlineSize: width, blockSize: height }]
|
||||
@@ -158,6 +173,8 @@ describe('useVueNodeResizeTracking', () => {
|
||||
testState.batchUpdateNodeBounds.mockReset()
|
||||
testState.setSource.mockReset()
|
||||
testState.syncNodeSlotLayoutsFromDOM.mockReset()
|
||||
testState.updateNodeCollapsedSize.mockReset()
|
||||
testState.clearNodeCollapsedSize.mockReset()
|
||||
resizeObserverState.observe.mockReset()
|
||||
resizeObserverState.unobserve.mockReset()
|
||||
resizeObserverState.disconnect.mockReset()
|
||||
@@ -264,18 +281,96 @@ describe('useVueNodeResizeTracking', () => {
|
||||
expect(testState.syncNodeSlotLayoutsFromDOM).toHaveBeenCalledWith(nodeId)
|
||||
})
|
||||
|
||||
it('resyncs slot anchors for collapsed nodes without writing bounds', () => {
|
||||
it('stores collapsed size and resyncs slots for collapsed nodes', () => {
|
||||
const nodeId = 'test-node'
|
||||
const width = 200
|
||||
const height = 40
|
||||
const { entry, rectSpy } = createResizeEntry({
|
||||
nodeId,
|
||||
width,
|
||||
height,
|
||||
collapsed: true
|
||||
})
|
||||
|
||||
seedNodeLayout({ nodeId, left: 100, top: 200, width: 240, height: 180 })
|
||||
|
||||
resizeObserverState.callback?.([entry], createObserverMock())
|
||||
|
||||
expect(rectSpy).not.toHaveBeenCalled()
|
||||
expect(testState.setSource).not.toHaveBeenCalled()
|
||||
expect(testState.batchUpdateNodeBounds).not.toHaveBeenCalled()
|
||||
expect(testState.updateNodeCollapsedSize).toHaveBeenCalledWith(nodeId, {
|
||||
width,
|
||||
height
|
||||
})
|
||||
expect(testState.syncNodeSlotLayoutsFromDOM).toHaveBeenCalledWith(nodeId)
|
||||
})
|
||||
|
||||
it('uses body element width for collapsed size when inner wrapper exists', () => {
|
||||
const nodeId = 'test-node'
|
||||
const rootWidth = 260
|
||||
const bodyWidth = 200
|
||||
const height = 40
|
||||
const { entry } = createResizeEntry({
|
||||
nodeId,
|
||||
width: rootWidth,
|
||||
height,
|
||||
collapsed: true,
|
||||
bodyWidth
|
||||
})
|
||||
|
||||
seedNodeLayout({ nodeId, left: 100, top: 200, width: 240, height: 180 })
|
||||
|
||||
resizeObserverState.callback?.([entry], createObserverMock())
|
||||
|
||||
expect(testState.updateNodeCollapsedSize).toHaveBeenCalledWith(nodeId, {
|
||||
width: bodyWidth,
|
||||
height
|
||||
})
|
||||
})
|
||||
|
||||
it('clears collapsed size then updates bounds on collapse-to-expand transition', () => {
|
||||
const nodeId = 'test-node'
|
||||
const collapsedWidth = 200
|
||||
const collapsedHeight = 40
|
||||
|
||||
// Seed with smaller size so expand triggers a real bounds update
|
||||
seedNodeLayout({ nodeId, left: 100, top: 200, width: 220, height: 140 })
|
||||
|
||||
// Step 1: Collapse
|
||||
const { entry: collapsedEntry } = createResizeEntry({
|
||||
nodeId,
|
||||
width: collapsedWidth,
|
||||
height: collapsedHeight,
|
||||
left: 100,
|
||||
top: 200,
|
||||
collapsed: true
|
||||
})
|
||||
resizeObserverState.callback?.([collapsedEntry], createObserverMock())
|
||||
|
||||
expect(testState.updateNodeCollapsedSize).toHaveBeenCalledWith(nodeId, {
|
||||
width: collapsedWidth,
|
||||
height: collapsedHeight
|
||||
})
|
||||
|
||||
testState.updateNodeCollapsedSize.mockReset()
|
||||
testState.clearNodeCollapsedSize.mockReset()
|
||||
testState.batchUpdateNodeBounds.mockReset()
|
||||
testState.setSource.mockReset()
|
||||
|
||||
// Step 2: Expand — same node, no collapsed attribute, larger size
|
||||
const { entry: expandedEntry } = createResizeEntry({
|
||||
nodeId,
|
||||
width: 240,
|
||||
height: 180,
|
||||
left: 100,
|
||||
top: 200
|
||||
})
|
||||
resizeObserverState.callback?.([expandedEntry], createObserverMock())
|
||||
|
||||
expect(testState.clearNodeCollapsedSize).toHaveBeenCalledWith(nodeId)
|
||||
expect(testState.updateNodeCollapsedSize).not.toHaveBeenCalled()
|
||||
expect(testState.setSource).toHaveBeenCalledWith(LayoutSource.DOM)
|
||||
expect(testState.batchUpdateNodeBounds).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,8 +8,7 @@
|
||||
* Supports different element types (nodes, slots, widgets, etc.) with
|
||||
* customizable data attributes and update handlers.
|
||||
*/
|
||||
import { getCurrentInstance, onMounted, onUnmounted, toValue, watch } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import { getCurrentInstance, onMounted, onUnmounted, watch } from 'vue'
|
||||
|
||||
import { useDocumentVisibility } from '@vueuse/core'
|
||||
|
||||
@@ -139,25 +138,38 @@ const resizeObserver = new ResizeObserver((entries) => {
|
||||
const nodeId: NodeId | undefined =
|
||||
elementType === 'node' ? elementId : undefined
|
||||
|
||||
// Skip collapsed nodes — their DOM height is just the header, and writing
|
||||
// that back to the layout store would overwrite the stored expanded size.
|
||||
// Collapsed nodes: preserve expanded size but store collapsed
|
||||
// dimensions separately in layoutStore for selection bounds.
|
||||
if (elementType === 'node' && element.dataset.collapsed != null) {
|
||||
if (nodeId) {
|
||||
markElementForFreshMeasurement(element)
|
||||
const body = element.querySelector('[data-node-body]')
|
||||
const collapsedWidth =
|
||||
body instanceof HTMLElement ? body.offsetWidth : element.offsetWidth
|
||||
const collapsedHeight = element.offsetHeight
|
||||
const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value
|
||||
if (nodeLayout) {
|
||||
layoutStore.updateNodeCollapsedSize(nodeId, {
|
||||
width: collapsedWidth,
|
||||
height: collapsedHeight
|
||||
})
|
||||
}
|
||||
nodesNeedingSlotResync.add(nodeId)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Use borderBoxSize when available; fall back to contentRect for older engines/tests
|
||||
// Border box is the border included FULL wxh DOM value.
|
||||
const borderBox = Array.isArray(entry.borderBoxSize)
|
||||
? entry.borderBoxSize[0]
|
||||
: {
|
||||
inlineSize: entry.contentRect.width,
|
||||
blockSize: entry.contentRect.height
|
||||
}
|
||||
const width = Math.max(0, borderBox.inlineSize)
|
||||
const height = Math.max(0, borderBox.blockSize)
|
||||
// Clear stale collapsedSize when node is expanded
|
||||
if (elementType === 'node' && nodeId) {
|
||||
layoutStore.clearNodeCollapsedSize(nodeId)
|
||||
}
|
||||
|
||||
// Measure the full root element (including footer in flow).
|
||||
// min-height is applied to the root, so footer height in node.size
|
||||
// does not accumulate on Vue/legacy mode switching.
|
||||
const width = Math.max(0, element.offsetWidth)
|
||||
const height = Math.max(0, element.offsetHeight)
|
||||
|
||||
const nodeLayout = nodeId
|
||||
? layoutStore.getNodeLayoutRef(nodeId).value
|
||||
: null
|
||||
@@ -281,10 +293,9 @@ const resizeObserver = new ResizeObserver((entries) => {
|
||||
* ```
|
||||
*/
|
||||
export function useVueElementTracking(
|
||||
appIdentifierMaybe: MaybeRefOrGetter<string>,
|
||||
appIdentifier: string,
|
||||
trackingType: string
|
||||
) {
|
||||
const appIdentifier = toValue(appIdentifierMaybe)
|
||||
onMounted(() => {
|
||||
const element = getCurrentInstance()?.proxy?.$el
|
||||
if (!(element instanceof HTMLElement) || !appIdentifier) return
|
||||
|
||||
Reference in New Issue
Block a user