Compare commits

...

20 Commits

Author SHA1 Message Date
jaeone94
4a66381353 Merge remote-tracking branch 'origin/main' into refactor/node-footer-inline-clean
# Conflicts:
#	browser_tests/fixtures/helpers/NodeOperationsHelper.ts
2026-04-10 10:04:56 +09:00
Kelly Yang
bbb07053c4 test: add E2E tests for CanvasModeSelector toolbar component (#10934)
Adds `browser_tests/tests/canvasModeSelector.spec.ts`, covering the
canvas toolbar mode-selector component that was introduced with no E2E
coverage.
  Covers:
- Trigger button: toolbar visibility, `aria-expanded` state, icon
reflects active mode
- Popover lifecycle: open on click, close on re-click / item selection /
Escape
- Mode switching: clicking Hand/Select drives `canvas.state.readOnly`;
clicking the active item is a no-op
- ARIA state: `aria-checked` and roving `tabindex` track active mode,
including state driven by external commands
- Keyboard navigation: ArrowDown/Up with wraparound, Escape restores
focus to trigger — all using `toBeFocused()` retrying assertions
- Focus management: popover auto-focuses the checked item on open
- Keybinding integration: `H` / `V` keys update both
`canvas.state.readOnly` and the trigger icon
- Shortcut hint display: both menu items render non-empty key-sequence
hints
  22 tests across 7 `describe` groups. All selectors are ARIA-driven.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10934-test-add-E2E-tests-for-CanvasModeSelector-toolbar-component-33b6d73d3650819cb2cfdca22bf0b9a5)
by [Unito](https://www.unito.io)
2026-04-09 20:44:30 -04:00
Christian Byrne
97fca566fb fix: use || instead of ?? and server type in WebcamCapture upload path (#11000)
## Description

Fixes the WebcamCapture image upload path construction that was still
broken on cloud environments after #10220.

### Root cause

The cloud `/upload/image` endpoint returns:
```json
{ "name": "hash.png", "subfolder": "", "type": "input" }
```

The previous fix used `??` (nullish coalescing), which doesn't catch
empty strings:
- `subfolder: ""` → `"" ?? "webcam"` = `""` → path becomes `/hash.png`
(wrong)
- `type` was hardcoded as `[temp]` but cloud stores as `input` → file
not found

### Fix

- `??` → `||` so empty strings fall back to defaults
- Use `data.type` from server response instead of hardcoding `[temp]`

### QA evidence

Prod (cloud/1.42): `ImageDownloadError: the input file
'webcam/1775685296883.png [temp]' doesn't exist`
Staging (cloud/1.42): `ImageDownloadError: Failed to validate images`

### Related

- Fixes the remaining issue from #10220

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11000-fix-use-instead-of-and-server-type-in-WebcamCapture-upload-path-33d6d73d36508156b93cfce0aae8e017)
by [Unito](https://www.unito.io)
2026-04-09 16:08:45 -07:00
Christian Byrne
c6b8883e61 [chore] Update Ingest API types from cloud@48d94b7 (#10925)
## Automated Ingest API Type Update

This PR updates the Ingest API TypeScript types and Zod schemas from the
latest cloud OpenAPI specification.

- Cloud commit: 48d94b7
- Generated using @hey-api/openapi-ts with Zod plugin

These types cover cloud-only endpoints (workspaces, billing, secrets,
assets, tasks, etc.).
Overlapping endpoints shared with the local ComfyUI Python backend are
excluded.

---------

Co-authored-by: MillerMedia <7741082+MillerMedia@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-04-09 14:41:53 -07:00
Christian Byrne
8487c13f14 feat: integrate Typeform survey into feedback button (#10890)
## Summary

Replace Zendesk feedback URLs with Typeform survey (`q7azbWPi`) in the
action bar feedback button and Help Center menu for Cloud/Nightly
distributions.

## Changes

- **What**: 
- `cloudFeedbackTopbarButton.ts`: Replace `buildFeedbackUrl()` (Zendesk)
with direct Typeform survey URL. Remove unused Zendesk import.
- `HelpCenterMenuContent.vue`: Feedback menu item now opens Typeform URL
for Cloud/Nightly builds; falls back to `Comfy.ContactSupport` (Zendesk)
for other distributions. Added external link icon for Cloud/Nightly.
- Help menu item and `Comfy.ContactSupport` command unchanged — support
flows still route to Zendesk.

## Review Focus

- Gating logic: `isCloud || isNightly` correctly limits Typeform
redirect to intended distributions
- Help item intentionally unchanged (support ≠ feedback)

Ticket: COM-17992

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10890-feat-integrate-Typeform-survey-into-feedback-button-33a6d73d36508185abbfe57e7a36b5f7)
by [Unito](https://www.unito.io)
2026-04-09 14:40:52 -07:00
jaeone94
1cf0cd55fd fix: address review feedback on collapsed size handling
- Replace data-testid with dedicated data-node-body attribute for
  production collapsed width measurement
- Remove redundant ?? undefined in getCollapsedSize accessor
- Wrap collapsedSize mutations in ydoc.transact() for consistency
  with other ynode mutations
- Add test coverage for inner wrapper width branch, collapsed-to-expand
  lifecycle, and updateNodeCollapsedSize/clearNodeCollapsedSize assertions
2026-04-08 17:17:57 +09:00
jaeone94
9cb0d21fd7 refactor: route collapsed size through layoutStore and measure()
Per review feedback, replace the parallel collapsedSizes Map with a
first-class collapsedSize field stored directly in ynodes. measure()
reads collapsed dimensions via a LiteGraph.getCollapsedSize accessor
injected by the Vue layer, making boundingRect automatically correct.

- Add LiteGraph.getCollapsedSize accessor (dependency injection)
- measure() uses accessor for collapsed nodes when available
- Store collapsedSize in ynode via mappers (layoutToYNode/yNodeToLayout)
- Remove separate collapsedSizes Map and getter spread-merge
- getNodeCollapsedSize reads directly from ynode (no yNodeToLayout)
- clearNodeCollapsedSize on expand to prevent stale data
- Restore selectionBorder.ts to original createBounds (no special path)
- useVueFeatureFlags injects accessor on Vue mode enable
2026-04-08 16:42:28 +09:00
jaeone94
82997f88c5 Merge branch 'main' into refactor/node-footer-inline-clean 2026-04-07 11:54:45 +09:00
jaeone94
c462c8d061 refactor: address non-blocking review feedback
- Extract footerWrapperBase constant to eliminate third inline duplicate
- Consolidate 3 radius computed into shared getBottomRadius helper
- Narrow useVueElementTracking parameter from MaybeRefOrGetter to string
  (toValue was called eagerly at setup, making reactive updates impossible)
2026-04-07 11:39:46 +09:00
jaeone94
77fd8c67fc fix: restore dragged-node1 screenshot (CI timing fluke) 2026-03-31 21:27:47 +09:00
jaeone94
47c190a08d fix: address code review for layoutStore.collapsedSize
- Clear collapsedSizes in initializeFromLiteGraph to prevent stale
  entries from previous workflows
- Merge collapsedSize in customRef getter instead of setter to ensure
  persistence across reads
- Use trigger() instead of nodeRef.value assignment in
  updateNodeCollapsedSize
2026-03-31 21:14:17 +09:00
github-actions
9fa048fa6a [automated] Update test expectations 2026-03-31 12:12:10 +00:00
jaeone94
d434f770dc test: add legacy mode tests and refactor shared assertion
- Add 4 legacy mode selection bounding box tests (expanded/collapsed
  x bottom-left/bottom-right)
- Extract assertSelectionEncompassesNodes shared helper
- Add boundingRect fallback for legacy nodes without DOM elements
- Rename Vue mode test describe for symmetry
2026-03-31 20:53:26 +09:00
jaeone94
87f394ac77 refactor: replace onBounding with layoutStore.collapsedSize
- Move min-height to root element (removes footer height accumulation)
- Remove onBounding callback and vueBoundsOverrides Map entirely
- Add collapsedSize field to layoutStore for collapsed node dimensions
- selectionBorder reads collapsedSize for collapsed nodes in Vue mode
- No litegraph core changes, no onBounding usage
2026-03-31 20:28:10 +09:00
Dante
8c7e629021 Merge branch 'main' into refactor/node-footer-inline-clean 2026-03-31 09:05:43 +09:00
jaeone94
117b4e152f fix: restore added-node screenshot (CI timing fluke) 2026-03-30 21:47:33 +09:00
github-actions
ef1a64141a [automated] Update test expectations 2026-03-30 12:42:59 +00:00
jaeone94
ebe23e682c fix: invalidate cached measurement on collapse and clarify padding source 2026-03-30 21:25:07 +09:00
jaeone94
3f1839b6b1 refactor: address code review feedback
- Use reactive props destructuring in NodeFooter
- Remove dead isBackground parameter, replace getTabStyles with
  tabStyles constant
- Extract errorWrapperStyles to eliminate 3x class duplication
- Skip vueBoundsOverrides entry when footerHeight is 0
2026-03-30 20:57:48 +09:00
jaeone94
e676c33c78 refactor: inline node footer with isolate -z-1 and onBounding overrides
- Replace absolute overlay footer with inline flow layout
- Use isolate -z-1 on footer wrapper to keep it behind body without
  adding z-index to body (preserving slot stacking freedom)
- Remove footer offset computed classes (footerStateOutlineBottomClass,
  footerRootBorderBottomClass, footerResizeHandleBottomClass, hasFooter)
- Add vueBoundsOverrides Map for DOM-measured footer/collapsed dimensions
- Use onBounding callback to extend boundingRect from vueBoundsOverrides
- Measure body (node-inner-wrapper) for node.size to prevent footer
  height accumulation on Vue/legacy mode switching
- Safe onBounding cleanup (only restore if not wrapped by another)
- Clean up vueBoundsOverrides entries on node unmount
- Add shared test helpers and 8 parameterized E2E tests
2026-03-30 20:41:47 +09:00
27 changed files with 1687 additions and 337 deletions

View 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
}

View File

@@ -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
}
}

View 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>
}

View File

@@ -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
}

View 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()
})
})
})

View 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

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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()
})

View File

@@ -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>

View File

@@ -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')
}
},

View File

@@ -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 }
)

View File

@@ -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')
}
}
]

View File

@@ -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

View File

@@ -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
}
}
}

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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', () => {

View File

@@ -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

View File

@@ -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>

View File

@@ -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()
})
})

View File

@@ -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