Compare commits

..

11 Commits

Author SHA1 Message Date
bymyself
b2c1f1f51a fix: forward watcher cleanup into searcher
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/9551#discussion_r2901111651
2026-03-07 18:39:05 -08:00
bymyself
eb1153d836 fix: update FormDropdown filteredItems when items prop changes
Replace computed itemsKey with a Symbol ref and add a watcher that
re-runs the searcher whenever the items prop changes. This fixes
the Load Video node not showing videos in the Media Assets panel
dropdown when switching between asset types with identical or empty
item sets.
2026-03-07 16:21:38 -08:00
Alexander Brown
8a5bcde168 fix: prevent non-widget inputs on nested subgraphs from appearing as button widgets (#9542)
## Summary

Fix non-widget inputs on nested subgraphs appearing twice — once as
slots and once as unresolved button widgets.

## Changes

- **What**: Add `getTargetWidget()` guard in the `isSubgraphNode()`
branch of `resolveSubgraphInputTarget`, matching the existing check for
non-subgraph nodes. Non-widget inputs (e.g. AUDIO, IMAGE) now return
`undefined` instead of a bogus promotion entry.

## Review Focus

`resolveSubgraphInputTarget` had an asymmetry: the non-subgraph branch
checked `getTargetWidget()` before returning, but the `isSubgraphNode()`
branch returned unconditionally for every input. For nested subgraphs
where non-widget slots are linked through to inner SubgraphNode inputs,
this created `PromotedWidgetView` entries that failed `resolveDeepest()`
(falling back to `type: 'button'`), while the inputs also rendered as
normal slot circles since `input.widget` was never set by
`_resolveInputWidget` (which correctly skipped them).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9542-fix-prevent-non-widget-inputs-on-nested-subgraphs-from-appearing-as-button-widgets-31c6d73d3650816387c3f97f0385e762)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-03-07 22:58:59 +00:00
AustinMroz
83ffaf30c8 Yet further app fixes (#9523)
- Prevent selection of note nodes
- Prevents selection  of nodes with errors
- A bit broader than the reported "can select missing nodes". A node
with an error is a node that can not execute and thus can not be used in
an app.
- Updates the typeform survey
- Add a collapsible list of all api nodes(/prices) contained in an app.
  - Needs to be prettied up for mobile still.
<img width="322" height="751" alt="image"
src="https://github.com/user-attachments/assets/ebfeeada-9b80-488e-88d6-feaa8bd53629"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9523-Yet-further-app-fixes-31c6d73d365081de9150fbf2d3ec54dd)
by [Unito](https://www.unito.io)
2026-03-07 14:22:15 -08:00
Christian Byrne
2875f897dc fix: remove workspace switching confirmation dialog (#9250)
## Summary

Remove the workspace switching confirmation dialog since switching
workspaces no longer discards unsaved changes.

## Changes

- **What**: Remove `hasUnsavedChanges` check, `dialogService.confirm`
call, and unused imports (`useI18n`, `useWorkflowStore`,
`useDialogService`) from `useWorkspaceSwitch`. Rename
`switchWithConfirmation` to `switchWorkspace`. Update callers
(`WorkspaceSwitcherPopover.vue`, `InviteAcceptedToast.vue`). Remove
`workspace.unsavedChanges` i18n entries from all 12 locale files.
Simplify tests to cover core switching behavior only.

## Review Focus

The confirmation dialog was showing inaccurate information (warning
about discarding unsaved changes when that no longer happens). This is a
pure removal with no new behavior.

<!-- Pipeline-Ticket: COM-15441 -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9250-fix-remove-workspace-switching-confirmation-dialog-3136d73d365081d3b959da22e8f151d1)
by [Unito](https://www.unito.io)
2026-03-07 14:19:05 -08:00
pythongosssss
ec129de63d fix: Prevent corruption of workflow data due to checkState during graph loading (#9531)
## Summary

During workflow loading, the workflow data & active workflow object can
be out of sync, meaning any checkState calls will overwrite data into
the wrong workflow.

Recreation steps:
* Open 2-3 workflows
* Enter builder mode > select step
* Select some different inputs on each
* Quickly tap the shift key (this triggers checkState) while switching
tabs
* After a while, you'll see the wrong inputs on the workflows

Alternatively, register an extension that guarantees to call checkState
during the bad phase, run this in browser devtools and switch tabs:
```
window.app.registerExtension({
  name: 'bad',
  async afterConfigureGraph() {
    window.app.extensionManager.workflow.activeWorkflow.changeTracker.checkState()
  }
})
```

## Changes

- **What**: 
- Add loading graph flag
- Prevent checkState calls while loading
- Prevent app mode data sync while loading

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9531-fix-Prevent-corruption-of-workflow-data-due-to-checkState-during-graph-loading-31c6d73d365081e2ab91d9145bf1d025)
by [Unito](https://www.unito.io)
2026-03-07 12:44:12 -08:00
pythongosssss
1687ca93b3 feat: Integrated tab UI updates (#8516)
## Summary
Next iteration of the integrated tab/top menu

## Changes
- **What**:  
- make integrated default, rename old to legacy
- move feedback to integrated
- fix user icon shapes
- remove comfy cloud text in top bar, move to canvas stats
- add chevron to C logo menu
- move help back to sidebar
   - remove now unused help top positioning code

## Screenshots (if applicable)
<img width="428" height="148" alt="image"
src="https://github.com/user-attachments/assets/725025b7-4982-4f61-be11-8aabb0a1faff"
/>
<img width="264" height="187" alt="image"
src="https://github.com/user-attachments/assets/91fa5e92-df08-4467-9bc5-50a614d9b8aa"
/>
<img width="1169" height="220" alt="image"
src="https://github.com/user-attachments/assets/68c81bea-0cff-48df-8303-a6231a1d2fc4"
/>
<img width="242" height="207" alt="image"
src="https://github.com/user-attachments/assets/5a10f40e-83ae-44c3-9434-3dbe87ba30e2"
/>
<img width="302" height="222" alt="image"
src="https://github.com/user-attachments/assets/27fcc638-5fff-4302-9a1f-066227aafd86"
/>

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-03-07 11:20:01 -08:00
pythongosssss
5bb742ac3a feat/fix: App mode QA fixes (#9530)
## Summary

Additional updates for app mode

## Changes

- **What**:
- Reposition toolbar button in app mode so it doesnt jump between modes
- Move node name to under title on input/output selection
- Change delete asset button text
- Change exit builder button icon

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9530-feat-fix-App-mode-QA-fixes-31c6d73d365081738b5cc5beaf2cbd41)
by [Unito](https://www.unito.io)
2026-03-07 18:54:13 +00:00
Kelly Yang
ca2d61f393 use getLoad3dAsync (#9520)
## Summary

Applying `useLoad3dService().getLoad3dAsync` to fix the broken 3D viewer
scene.

Fix https://github.com/Comfy-Org/ComfyUI_frontend/issues/9495

## Screenshots 

before

<img width="3456" height="2168" alt="665d1b83bf8b23a9ddde1411a34c8df9"
src="https://github.com/user-attachments/assets/6cb190f4-ef13-4fd3-a0c5-2360f056da55"
/>


after

<img width="5120" height="2638" alt="image"
src="https://github.com/user-attachments/assets/154b1a98-bd71-41e2-839d-f0f1f7e5e72e"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9520-use-getLoad3dAsync-31c6d73d365081cf8c24cd89b545ccb4)
by [Unito](https://www.unito.io)
2026-03-07 09:22:35 -08:00
Benjamin Lu
750a2d23e0 chore: standardize on Node 24 (#9521)
## Summary

Standardize the repo's Node contract on 24 while centralizing workflow
resolution through `.nvmrc` so local setup, CI, and package metadata
stay aligned from one version file.

## Changes

- **What**: Add `package.json` `engines.node = 24.x`, switch every
`actions/setup-node` workflow in the repo to `node-version-file:
'.nvmrc'`, and update contributor and Playwright docs to point to
`.nvmrc` as the Node source of truth.

## Review Focus

The workflow behavior should be unchanged apart from sourcing the Node
version from `.nvmrc` instead of repeating literals like `20`, `22`,
`24.x`, or `lts/*`. GitHub's formatter also moved the new `engines`
block to the package metadata section near the end of `package.json`.

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-07 09:06:10 +00:00
Comfy Org PR Bot
6d90bf3537 1.42.0 (#9522)
Minor version increment to 1.42.0

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9522-1-42-0-31c6d73d3650816faa33df8b4dde26fd)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-03-06 23:11:18 -08:00
210 changed files with 3301 additions and 2764 deletions

View File

@@ -23,7 +23,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: lts/*
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Update electron types

View File

@@ -28,7 +28,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: lts/*
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Install dependencies

View File

@@ -27,7 +27,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: lts/*
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Install dependencies

View File

@@ -26,7 +26,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: 'lts/*'
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Install dependencies

View File

@@ -27,7 +27,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: 'lts/*'
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Install dependencies
@@ -82,7 +82,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: 'lts/*'
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Install dependencies

View File

@@ -36,7 +36,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '20'
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Install dependencies for analysis tools

View File

@@ -25,7 +25,7 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 22
node-version-file: '.nvmrc'
- name: Download PR metadata
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12

View File

@@ -28,7 +28,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24.x'
node-version-file: '.nvmrc'
- name: Read desktop-ui version
id: get_version

View File

@@ -91,7 +91,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24.x'
node-version-file: '.nvmrc'
cache: 'pnpm'
registry-url: https://registry.npmjs.org

View File

@@ -82,7 +82,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: lts/*
node-version-file: 'frontend/.nvmrc'
- name: Install dependencies
working-directory: frontend

View File

@@ -26,7 +26,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 'lts/*'
node-version-file: '.nvmrc'
- name: Check version bump type
id: check_version

View File

@@ -26,7 +26,7 @@ jobs:
version: 10
- uses: actions/setup-node@v6
with:
node-version: 'lts/*'
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Get current version

View File

@@ -82,7 +82,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 'lts/*'
node-version-file: '.nvmrc'
cache: 'pnpm'
registry-url: https://registry.npmjs.org

View File

@@ -22,7 +22,7 @@ jobs:
version: 10
- uses: actions/setup-node@v6
with:
node-version: 'lts/*'
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Get current version

View File

@@ -149,7 +149,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: lts/*
node-version-file: '.nvmrc'
- name: Bump version
id: bump-version

View File

@@ -58,7 +58,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24.x'
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Bump desktop-ui version

View File

@@ -35,7 +35,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '20'
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Install dependencies for analysis tools

1
.gitignore vendored
View File

@@ -26,7 +26,6 @@ dist-ssr
.claude/*.local.json
.claude/*.local.md
.claude/*.local.txt
.claude/worktrees
CLAUDE.local.md
# Editor directories and files

View File

@@ -17,7 +17,7 @@ Have another idea? Drop into Discord or open an issue, and let's chat!
### Prerequisites & Technology Stack
- **Required Software**:
- Node.js (v24) and pnpm
- Node.js (see `.nvmrc`, currently v24) and pnpm
- Git for version control
- A running ComfyUI backend instance (otherwise, you can use `pnpm dev:cloud`)

View File

@@ -4,7 +4,7 @@
<template v-if="filter.tasks.length === 0">
<!-- Empty filter -->
<Divider />
<p class="w-full text-center text-neutral-400">
<p class="text-neutral-400 w-full text-center">
{{ $t('maintenance.allOk') }}
</p>
</template>
@@ -25,7 +25,7 @@
<!-- Display: Cards -->
<template v-else>
<div class="pad-y my-4 flex flex-wrap justify-evenly gap-8">
<div class="flex flex-wrap justify-evenly gap-8 pad-y my-4">
<TaskCard
v-for="task in filter.tasks"
:key="task.id"
@@ -45,8 +45,7 @@ import { useConfirm, useToast } from 'primevue'
import ConfirmPopup from 'primevue/confirmpopup'
import Divider from 'primevue/divider'
import { useI18n } from 'vue-i18n'
import { t } from '@/i18n'
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
import type {
MaintenanceFilter,
@@ -56,7 +55,6 @@ import type {
import TaskCard from './TaskCard.vue'
import TaskListItem from './TaskListItem.vue'
const { t } = useI18n()
const toast = useToast()
const confirm = useConfirm()
const taskStore = useMaintenanceTaskStore()
@@ -82,7 +80,8 @@ const executeTask = async (task: MaintenanceTask) => {
toast.add({
severity: 'error',
summary: t('maintenance.error.toastTitle'),
detail: message ?? t('maintenance.error.defaultDescription')
detail: message ?? t('maintenance.error.defaultDescription'),
life: 10_000
})
}

View File

@@ -189,7 +189,8 @@ const completeValidation = async () => {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('maintenance.error.cannotContinue')
detail: t('maintenance.error.cannotContinue'),
life: 5_000
})
}
}

View File

@@ -1,8 +1,8 @@
<template>
<BaseViewTemplate dark hide-language-selector>
<div class="flex h-full flex-col items-center justify-center p-8 2xl:p-16">
<div class="h-full p-8 2xl:p-16 flex flex-col items-center justify-center">
<div
class="flex w-full max-w-[600px] flex-col gap-6 rounded-lg bg-neutral-800 p-6 shadow-lg"
class="bg-neutral-800 rounded-lg shadow-lg p-6 w-full max-w-[600px] flex flex-col gap-6"
>
<h2 class="text-3xl font-semibold text-neutral-100">
{{ $t('install.helpImprove') }}
@@ -15,7 +15,7 @@
<a
href="https://comfy.org/privacy"
target="_blank"
class="text-blue-400 underline hover:text-blue-300"
class="text-blue-400 hover:text-blue-300 underline"
>
{{ $t('install.privacyPolicy') }} </a
>.
@@ -33,7 +33,7 @@
}}
</span>
</div>
<div class="flex justify-end pt-6">
<div class="flex pt-6 justify-end">
<Button
:label="$t('g.ok')"
icon="pi pi-check"
@@ -72,7 +72,8 @@ const updateConsent = async () => {
toast.add({
severity: 'error',
summary: t('install.settings.errorUpdatingConsent'),
detail: t('install.settings.errorUpdatingConsentDetail')
detail: t('install.settings.errorUpdatingConsentDetail'),
life: 3000
})
} finally {
isUpdating.value = false

View File

@@ -27,7 +27,8 @@ cp -r tools/devtools/* /path/to/your/ComfyUI/custom_nodes/ComfyUI_devtools/
### Node.js & Playwright Prerequisites
Ensure you have Node.js v20 or v22 installed. Then, set up the Chromium test driver:
Ensure you have the Node.js version from `.nvmrc` installed (currently v24).
Then, set up the Chromium test driver:
```bash
pnpm exec playwright install chromium --with-deps

View File

@@ -1,47 +0,0 @@
{
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "LoadImage",
"pos": [50, 50],
"size": [315, 314],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{ "name": "IMAGE", "type": "IMAGE", "links": null },
{ "name": "MASK", "type": "MASK", "links": null }
],
"properties": { "Node name for S&R": "LoadImage" },
"widgets_values": ["example.png", "image"]
},
{
"id": 2,
"type": "KSampler",
"pos": [500, 50],
"size": [315, 262],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": null }],
"properties": { "Node name for S&R": "KSampler" },
"widgets_values": [0, "randomize", 20, 8, "euler", "normal", 1]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": { "offset": [0, 0], "scale": 1 }
},
"version": 0.4
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -1,58 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe(
'Image paste priority over stale node metadata',
{ tag: ['@node'] },
() => {
test('Should not paste copied node when a LoadImage node is selected and clipboard has stale node metadata', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('nodes/load_image_with_ksampler')
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
expect(initialCount).toBe(2)
// Copy the KSampler node (puts data-metadata in clipboard)
const ksamplerNodes =
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
await ksamplerNodes[0].copy()
// Select the LoadImage node
const loadImageNodes =
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
await loadImageNodes[0].click('title')
// Simulate pasting when clipboard has stale node metadata (text/html
// with data-metadata) but no image file items. This replicates the bug
// scenario: user copied a node, then copied a web image (which replaces
// clipboard files but may leave stale text/html with node metadata).
await comfyPage.page.evaluate(() => {
const nodeData = { nodes: [{ type: 'KSampler', id: 99 }] }
const base64 = btoa(JSON.stringify(nodeData))
const html =
'<meta charset="utf-8"><div><span data-metadata="' +
base64 +
'"></span></div><span style="white-space:pre-wrap;">Text</span>'
const dataTransfer = new DataTransfer()
dataTransfer.setData('text/html', html)
const event = new ClipboardEvent('paste', {
clipboardData: dataTransfer,
bubbles: true,
cancelable: true
})
document.dispatchEvent(event)
})
await comfyPage.nextFrame()
// Node count should remain the same — stale node metadata should NOT
// be deserialized when a media node is selected.
const finalCount = await comfyPage.nodeOps.getGraphNodesCount()
expect(finalCount).toBe(initialCount)
})
}
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

View File

@@ -1,102 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../fixtures/ComfyPage'
test.describe('Advanced Widget Visibility', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.settings.setSetting(
'Comfy.Node.AlwaysShowAdvancedWidgets',
false
)
// Add a ModelSamplingFlux node which has both advanced (max_shift,
// base_shift) and non-advanced (width, height) widgets.
await comfyPage.page.evaluate(() => {
const node = window.LiteGraph!.createNode('ModelSamplingFlux')!
node.pos = [500, 200]
window.app!.graph.add(node)
})
await comfyPage.vueNodes.waitForNodes()
})
function getNode(
comfyPage: Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
) {
return comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux')
}
function getWidgets(
comfyPage: Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
) {
return getNode(comfyPage).locator('.lg-node-widget')
}
test('should hide advanced widgets by default', async ({ comfyPage }) => {
const node = getNode(comfyPage)
const widgets = getWidgets(comfyPage)
// Non-advanced widgets (width, height) should be visible
await expect(widgets).toHaveCount(2)
await expect(node.getByLabel('width', { exact: true })).toBeVisible()
await expect(node.getByLabel('height', { exact: true })).toBeVisible()
// Advanced widgets should not be rendered
await expect(
node.getByLabel('max_shift', { exact: true })
).not.toBeVisible()
await expect(
node.getByLabel('base_shift', { exact: true })
).not.toBeVisible()
// "Show advanced inputs" button should be present
await expect(node.getByText('Show advanced inputs')).toBeVisible()
})
test('should show advanced widgets when per-node toggle is clicked', async ({
comfyPage
}) => {
const node = getNode(comfyPage)
const widgets = getWidgets(comfyPage)
await expect(widgets).toHaveCount(2)
// Click the toggle button to show advanced widgets
await node.getByText('Show advanced inputs').click()
await expect(widgets).toHaveCount(4)
await expect(node.getByLabel('max_shift', { exact: true })).toBeVisible()
await expect(node.getByLabel('base_shift', { exact: true })).toBeVisible()
// Button text should change to "Hide advanced inputs"
await expect(node.getByText('Hide advanced inputs')).toBeVisible()
// Click again to hide
await node.getByText('Hide advanced inputs').click()
await expect(widgets).toHaveCount(2)
})
test('should show advanced widgets when global setting is enabled', async ({
comfyPage
}) => {
const node = getNode(comfyPage)
const widgets = getWidgets(comfyPage)
await expect(widgets).toHaveCount(2)
// Enable the global setting
await comfyPage.settings.setSetting(
'Comfy.Node.AlwaysShowAdvancedWidgets',
true
)
// All 4 widgets should now be visible
await expect(widgets).toHaveCount(4)
await expect(node.getByLabel('max_shift', { exact: true })).toBeVisible()
await expect(node.getByLabel('base_shift', { exact: true })).toBeVisible()
// The toggle button should not be shown when global setting is active
await expect(node.getByText('Show advanced inputs')).not.toBeVisible()
})
})

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.41.19",
"version": "1.42.0",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -195,6 +195,9 @@
"zip-dir": "^2.0.0",
"zod-to-json-schema": "catalog:"
},
"engines": {
"node": "24.x"
},
"pnpm": {
"overrides": {
"vite": "catalog:"

View File

@@ -166,13 +166,22 @@ describe('TopMenuSection', () => {
})
describe('authentication state', () => {
function createLegacyTabBarWrapper() {
const pinia = createTestingPinia({ createSpy: vi.fn })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.UI.TabBarLayout' ? 'Legacy' : undefined
)
return createWrapper({ pinia })
}
describe('when user is logged in', () => {
beforeEach(() => {
mockData.isLoggedIn = true
})
it('should display CurrentUserButton and not display LoginButton', () => {
const wrapper = createWrapper()
const wrapper = createLegacyTabBarWrapper()
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(true)
expect(wrapper.findComponent(LoginButton).exists()).toBe(false)
})
@@ -186,7 +195,7 @@ describe('TopMenuSection', () => {
describe('on desktop platform', () => {
it('should display LoginButton and not display CurrentUserButton', () => {
mockData.isDesktop = true
const wrapper = createWrapper()
const wrapper = createLegacyTabBarWrapper()
expect(wrapper.findComponent(LoginButton).exists()).toBe(true)
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(false)
})
@@ -194,7 +203,7 @@ describe('TopMenuSection', () => {
describe('on web platform', () => {
it('should not display CurrentUserButton and not display LoginButton', () => {
const wrapper = createWrapper()
const wrapper = createLegacyTabBarWrapper()
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(false)
expect(wrapper.findComponent(LoginButton).exists()).toBe(false)
})

View File

@@ -183,7 +183,7 @@ const isActionbarFloating = computed(
() => isActionbarEnabled.value && !isActionbarDocked.value
)
const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
() => settingStore.get('Comfy.UI.TabBarLayout') !== 'Legacy'
)
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
useQueueFeatureFlags()

View File

@@ -8,9 +8,9 @@ import DraggableList from '@/components/common/DraggableList.vue'
import IoItem from '@/components/builder/IoItem.vue'
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
import WidgetItem from '@/components/rightSidePanel/parameters/WidgetItem.vue'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import {
LGraphEventMode,
@@ -25,10 +25,10 @@ import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteracti
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
import { app } from '@/scripts/app'
import { DOMWidgetImpl } from '@/scripts/domWidget'
import { promptRenameWidget } from '@/utils/widgetUtil'
import { useDialogService } from '@/services/dialogService'
import { useAppMode } from '@/composables/useAppMode'
import { nodeTypeValidForApp, useAppModeStore } from '@/stores/appModeStore'
import { resolveNodeWidget } from '@/utils/litegraphUtil'
import { resolveNode } from '@/utils/litegraphUtil'
import { cn } from '@/utils/tailwindUtil'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
@@ -53,15 +53,18 @@ workflowStore.activeWorkflow?.changeTracker?.reset()
const arrangeInputs = computed(() =>
appModeStore.selectedInputs
.map(([nodeId, widgetName]) => {
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
return node ? { nodeId, widgetName, node, widget } : null
const node = resolveNode(nodeId)
if (!node) return null
const widget = node.widgets?.find((w) => w.name === widgetName)
return { nodeId, widgetName, node, widget }
})
.filter((item): item is NonNullable<typeof item> => item !== null)
)
const inputsWithState = computed(() =>
appModeStore.selectedInputs.map(([nodeId, widgetName]) => {
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
const node = resolveNode(nodeId)
const widget = node?.widgets?.find((w) => w.name === widgetName)
if (!node || !widget) {
return {
nodeId,
@@ -70,12 +73,15 @@ const inputsWithState = computed(() =>
}
}
const input = node.inputs.find((i) => i.widget?.name === widget.name)
const rename = input && (() => renameWidget(widget, input))
return {
nodeId,
widgetName,
label: widget.label,
subLabel: node.title,
rename: () => promptRenameWidget(widget, node, t)
rename
}
})
)
@@ -86,6 +92,20 @@ const outputsWithState = computed<[NodeId, string][]>(() =>
])
)
async function renameWidget(widget: IBaseWidget, input: INodeInputSlot) {
const newLabel = await useDialogService().prompt({
title: t('g.rename'),
message: t('g.enterNewNamePrompt'),
defaultValue: widget.label,
placeholder: widget.name
})
if (newLabel === null) return
widget.label = newLabel || undefined
input.label = newLabel || undefined
widget.callback?.(widget.value)
useCanvasStore().canvas?.setDirty(true)
}
function getHovered(
e: MouseEvent
): undefined | [LGraphNode, undefined] | [LGraphNode, IBaseWidget] {
@@ -106,7 +126,7 @@ function getHovered(
function getBounding(nodeId: NodeId, widgetName?: string) {
if (settingStore.get('Comfy.VueNodes.Enabled')) return undefined
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
const node = app.rootGraph.getNodeById(nodeId)
if (!node) return
const titleOffset =
@@ -119,6 +139,7 @@ function getBounding(nodeId: NodeId, widgetName?: string) {
left: `${node.pos[0]}px`,
top: `${node.pos[1] - titleOffset}px`
}
const widget = node.widgets?.find((w) => w.name === widgetName)
if (!widget) return
const margin = widget instanceof DOMWidgetImpl ? widget.margin : undefined
@@ -157,16 +178,12 @@ function handleClick(e: MouseEvent) {
else appModeStore.selectedOutputs.splice(index, 1)
return
}
if (!isSelectInputsMode.value || widget.options.canvasOnly) return
if (!isSelectInputsMode.value) return
const storeId = isPromotedWidgetView(widget) ? widget.sourceNodeId : node.id
const storeName = isPromotedWidgetView(widget)
? widget.sourceWidgetName
: widget.name
const index = appModeStore.selectedInputs.findIndex(
([nodeId, widgetName]) => storeId == nodeId && storeName === widgetName
([nodeId, widgetName]) => node.id == nodeId && widget.name === widgetName
)
if (index === -1) appModeStore.selectedInputs.push([storeId, storeName])
if (index === -1) appModeStore.selectedInputs.push([node.id, widget.name])
else appModeStore.selectedInputs.splice(index, 1)
}
@@ -249,7 +266,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
<template #label>
<div class="flex gap-3">
{{ t('nodeHelpPage.inputs') }}
<i class="icon-[lucide--info] bg-muted-foreground" />
<i class="icon-[lucide--circle-alert] bg-muted-foreground" />
</div>
</template>
<template #empty>
@@ -304,7 +321,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
<template #label>
<div class="flex gap-3">
{{ t('nodeHelpPage.outputs') }}
<i class="icon-[lucide--info] bg-muted-foreground" />
<i class="icon-[lucide--circle-alert] bg-muted-foreground" />
</div>
</template>
<template #empty>

View File

@@ -1,51 +0,0 @@
<script setup lang="ts">
import {
DialogClose,
DialogContent,
DialogOverlay,
DialogPortal,
DialogRoot,
DialogTitle,
DialogTrigger
} from 'reka-ui'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
defineProps<{ title?: string; to?: string | HTMLElement }>()
const { t } = useI18n()
</script>
<template>
<DialogRoot v-slot="{ close }">
<DialogTrigger as-child>
<slot name="button" />
</DialogTrigger>
<DialogPortal :to>
<DialogOverlay
class="data-[state=open]:animate-overlayShow fixed inset-0 z-30 bg-black/70"
/>
<DialogContent
v-bind="$attrs"
class="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] z-1700 max-h-[85vh] w-[90vw] max-w-[450px] translate-x-[-50%] translate-y-[-50%] rounded-2xl border border-border-subtle bg-base-background p-2 shadow-sm"
>
<div
v-if="title"
class="flex w-full items-center justify-between border-b border-border-subtle px-4"
>
<DialogTitle class="text-sm">{{ title }}</DialogTitle>
<DialogClose as-child>
<Button
:aria-label="t('g.close')"
size="icon"
variant="muted-textonly"
>
<i class="icon-[lucide--x]" />
</Button>
</DialogClose>
</div>
<slot :close />
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>

View File

@@ -54,12 +54,11 @@ defineProps<{ itemClass: string; contentClass: string; item: MenuItem }>()
:disabled="toValue(item.disabled) ?? !item.command"
@select="item.command?.({ originalEvent: $event, item })"
>
<i class="size-5 shrink-0" :class="item.icon" />
<div class="mr-auto truncate" v-text="item.label" />
<i v-if="item.checked" class="icon-[lucide--check] shrink-0" />
<i class="size-5" :class="item.icon" />
{{ item.label }}
<div
v-else-if="item.new"
class="flex shrink-0 items-center rounded-full bg-primary-background px-1 text-xxs leading-none font-bold"
v-if="item.new"
class="ml-auto flex items-center rounded-full bg-primary-background px-1 text-xxs leading-none font-bold"
v-text="t('contextMenu.new')"
/>
</DropdownMenuItem>

View File

@@ -27,7 +27,7 @@ const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
const itemClass = computed(() =>
cn(
'm-1 flex cursor-pointer items-center-safe gap-1 rounded-lg p-2 leading-none data-disabled:pointer-events-none data-disabled:text-muted-foreground data-highlighted:bg-secondary-background-hover',
'm-1 flex cursor-pointer gap-1 rounded-lg p-2 leading-none data-disabled:pointer-events-none data-disabled:text-muted-foreground data-highlighted:bg-secondary-background-hover',
itemProp
)
)

View File

@@ -33,20 +33,19 @@
spellcheck="false"
@blur="handleBlur"
@keyup.enter="handleBlur"
@keydown.up.prevent="updateValueBy(step)"
@keydown.down.prevent="updateValueBy(-step)"
@keydown.page-up.prevent="updateValueBy(10 * step)"
@keydown.page-down.prevent="updateValueBy(-10 * step)"
@dragstart.prevent
/>
<div
ref="swipeElement"
:class="
cn(
'absolute inset-0 z-10 cursor-ew-resize touch-pan-y',
'absolute inset-0 z-10 cursor-ew-resize',
textEdit && 'pointer-events-none hidden'
)
"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"
@pointercancel="resetDrag"
/>
</div>
<slot />
@@ -66,7 +65,7 @@
</template>
<script setup lang="ts">
import { onClickOutside, usePointerSwipe, whenever } from '@vueuse/core'
import { onClickOutside } from '@vueuse/core'
import { computed, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -74,8 +73,8 @@ import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
const {
min = -Number.MAX_VALUE,
max = Number.MAX_VALUE,
min,
max,
step = 1,
disabled = false,
hideButtons = false,
@@ -97,7 +96,6 @@ const modelValue = defineModel<number>({ default: 0 })
const container = useTemplateRef<HTMLDivElement>('container')
const inputField = useTemplateRef<HTMLInputElement>('inputField')
const swipeElement = useTemplateRef('swipeElement')
const textEdit = ref(false)
onClickOutside(container, () => {
@@ -105,11 +103,21 @@ onClickOutside(container, () => {
})
function clamp(value: number): number {
return Math.min(max, Math.max(min, value))
const lo = min ?? -Infinity
const hi = max ?? Infinity
return Math.min(hi, Math.max(lo, value))
}
const canDecrement = computed(() => modelValue.value > min && !disabled)
const canIncrement = computed(() => modelValue.value < max && !disabled)
const canDecrement = computed(
() => modelValue.value > (min ?? -Infinity) && !disabled
)
const canIncrement = computed(
() => modelValue.value < (max ?? Infinity) && !disabled
)
const dragging = ref(false)
const dragDelta = ref(0)
const hasDragged = ref(false)
function handleBlur(e: Event) {
const target = e.target as HTMLInputElement
@@ -127,27 +135,41 @@ function handleBlur(e: Event) {
textEdit.value = false
}
let dragDelta = 0
function handlePointerUp() {
if (isSwiping.value) return
textEdit.value = true
inputField.value?.focus()
inputField.value?.select()
function handlePointerDown(e: PointerEvent) {
if (e.button !== 0) return
if (disabled) return
const target = e.target as HTMLElement
target.setPointerCapture(e.pointerId)
dragging.value = true
dragDelta.value = 0
hasDragged.value = false
}
const { distanceX, isSwiping } = usePointerSwipe(swipeElement, {
onSwipeEnd: () => (dragDelta = 0)
})
function handlePointerMove(e: PointerEvent) {
if (!dragging.value) return
dragDelta.value += e.movementX
const steps = (dragDelta.value / 10) | 0
if (steps === 0) return
hasDragged.value = true
const unclipped = modelValue.value + steps * step
dragDelta.value %= 10
modelValue.value = clamp(unclipped)
}
whenever(distanceX, () => {
if (disabled) return
const delta = ((distanceX.value - dragDelta) / 10) | 0
dragDelta += delta * 10
modelValue.value = clamp(modelValue.value - delta * step)
})
function handlePointerUp() {
if (!dragging.value) return
function updateValueBy(delta: number) {
modelValue.value = Math.min(max, Math.max(min, modelValue.value + delta))
if (!hasDragged.value) {
textEdit.value = true
inputField.value?.focus()
inputField.value?.select()
}
resetDrag()
}
function resetDrag() {
dragging.value = false
dragDelta.value = 0
}
</script>

View File

@@ -1,6 +1,6 @@
<template>
<Avatar
class="bg-interface-panel-selected-surface"
class="aspect-square bg-interface-panel-selected-surface"
:image="photoUrl ?? undefined"
:icon="hasAvatar ? undefined : 'icon-[lucide--user]'"
:pt:icon:class="{ 'size-4': !hasAvatar }"

View File

@@ -178,7 +178,7 @@
v-show="isTemplateVisibleOnDistribution(template)"
:key="template.name"
ref="cardRefs"
size="tall"
size="compact"
variant="ghost"
rounded="lg"
:data-testid="`template-workflow-${template.name}`"
@@ -318,20 +318,6 @@
</Button>
</div>
</div>
<div class="flex">
<span
class="text-neutral flex items-center gap-1.5 text-xs font-bold"
>
<template v-if="isAppTemplate(template)">
<i class="icon-[lucide--panels-top-left]" />
{{ $t('builderToolbar.app', 'App') }}
</template>
<template v-else>
<i class="icon-[lucide--workflow]" />
{{ $t('builderToolbar.nodeGraph', 'Node Graph') }}
</template>
</span>
</div>
</div>
</CardBottom>
</template>
@@ -497,8 +483,6 @@ const {
const getEffectiveSourceModule = (template: TemplateInfo) =>
template.sourceModule || 'default'
const isAppTemplate = (template: TemplateInfo) => template.name.endsWith('.app')
const getBaseThumbnailSrc = (template: TemplateInfo) => {
const sm = getEffectiveSourceModule(template)
return getTemplateThumbnailUrl(template, sm, sm === 'default' ? '1' : '')

View File

@@ -138,7 +138,8 @@ onMounted(async () => {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('toastMessages.failedToFetchLogs')
detail: t('toastMessages.failedToFetchLogs'),
life: 5000
})
}
})

View File

@@ -275,7 +275,8 @@ async function handleBuy() {
toast.add({
severity: 'error',
summary: t('credits.topUp.purchaseError'),
detail: t('credits.topUp.purchaseErrorDetail', { error: errorMessage })
detail: t('credits.topUp.purchaseErrorDetail', { error: errorMessage }),
life: 5000
})
} finally {
loading.value = false

View File

@@ -98,7 +98,8 @@ async function onConfirmCancel() {
toast.add({
severity: 'error',
summary: t('subscription.cancelDialog.failed'),
detail: error instanceof Error ? error.message : t('g.unknownError')
detail: error instanceof Error ? error.message : t('g.unknownError'),
life: 5000
})
} finally {
isLoading.value = false

View File

@@ -50,9 +50,7 @@
{{ t('g.dismiss') }}
</Button>
<Button variant="secondary" size="lg" @click="seeErrors">
{{
appMode ? t('linearMode.error.goto') : t('errorOverlay.seeErrors')
}}
{{ t('errorOverlay.seeErrors') }}
</Button>
</div>
</div>
@@ -71,8 +69,6 @@ import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useErrorGroups } from '@/components/rightSidePanel/errors/useErrorGroups'
defineProps<{ appMode?: boolean }>()
const { t } = useI18n()
const executionErrorStore = useExecutionErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
@@ -98,7 +94,6 @@ function dismiss() {
}
function seeErrors() {
canvasStore.linearMode = false
if (canvasStore.canvas) {
canvasStore.canvas.deselectAll()
canvasStore.updateSelectedItems()

View File

@@ -535,7 +535,7 @@ onMounted(async () => {
// Restore saved workflow and workflow tabs state
await workflowPersistence.initializeWorkflow()
await workflowPersistence.restoreWorkflowTabsState()
workflowPersistence.restoreWorkflowTabsState()
const sharedWorkflowLoadStatus =
await workflowPersistence.loadSharedWorkflowFromUrlIfPresent()

View File

@@ -579,7 +579,8 @@ const onUpdateComfyUI = async (): Promise<void> => {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: error.value || t('helpCenter.updateComfyUIFailed')
detail: error.value || t('helpCenter.updateComfyUIFailed'),
life: 5000
})
return
}
@@ -596,7 +597,8 @@ const onUpdateComfyUI = async (): Promise<void> => {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: err instanceof Error ? err.message : t('g.unknownError')
detail: err instanceof Error ? err.message : t('g.unknownError'),
life: 5000
})
}
}

View File

@@ -5,11 +5,8 @@
v-if="isHelpCenterVisible"
class="help-center-popup"
:class="{
'sidebar-left':
triggerLocation === 'sidebar' && sidebarLocation === 'left',
'sidebar-right':
triggerLocation === 'sidebar' && sidebarLocation === 'right',
'topbar-right': triggerLocation === 'topbar',
'sidebar-left': sidebarLocation === 'left',
'sidebar-right': sidebarLocation === 'right',
'small-sidebar': isSmall
}"
>
@@ -63,7 +60,6 @@ const { isSmall = false } = defineProps<{
const {
isHelpCenterVisible,
triggerLocation,
sidebarLocation,
closeHelpCenter,
handleWhatsNewDismissed
@@ -101,25 +97,6 @@ const {
right: 1rem;
}
.help-center-popup.topbar-right {
top: 2rem;
right: 1rem;
bottom: auto;
animation: slideInDown 0.2s ease-out;
}
@keyframes slideInDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideInUp {
from {
opacity: 0;

View File

@@ -141,7 +141,7 @@ onMounted(async () => {
if (isStandaloneMode && props.modelUrl) {
await viewer.initializeStandaloneViewer(containerRef.value, props.modelUrl)
} else if (props.node) {
const source = useLoad3dService().getLoad3d(props.node)
const source = await useLoad3dService().getLoad3dAsync(props.node)
if (source) {
await viewer.initializeViewer(containerRef.value, source)
}

View File

@@ -1,5 +1,5 @@
import { computed, reactive, ref, toValue, watch } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import { computed, reactive, ref, watch } from 'vue'
import type { Ref } from 'vue'
import Fuse from 'fuse.js'
import type { IFuseOptions } from 'fuse.js'
@@ -227,7 +227,7 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
}
export function useErrorGroups(
searchQuery: MaybeRefOrGetter<string>,
searchQuery: Ref<string>,
t: (key: string) => string
) {
const executionErrorStore = useExecutionErrorStore()
@@ -584,7 +584,7 @@ export function useErrorGroups(
})
const filteredGroups = computed<ErrorGroup[]>(() => {
const query = toValue(searchQuery).trim()
const query = searchQuery.value.trim()
return searchErrorGroups(tabErrorGroups.value, query)
})

View File

@@ -15,9 +15,10 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useDialogService } from '@/services/dialogService'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import { getWidgetDefaultValue, promptWidgetLabel } from '@/utils/widgetUtil'
import { getWidgetDefaultValue } from '@/utils/widgetUtil'
import type { WidgetValue } from '@/utils/widgetUtil'
const {
@@ -41,6 +42,7 @@ const label = defineModel<string>('label', { required: true })
const canvasStore = useCanvasStore()
const favoritedWidgetsStore = useFavoritedWidgetsStore()
const nodeDefStore = useNodeDefStore()
const dialogService = useDialogService()
const { t } = useI18n()
const hasParents = computed(() => parents?.length > 0)
@@ -65,8 +67,15 @@ const isCurrentValueDefault = computed(() => {
})
async function handleRename() {
const newLabel = await promptWidgetLabel(widget, t)
if (newLabel !== null) label.value = newLabel
const newLabel = await dialogService.prompt({
title: t('g.rename'),
message: t('g.enterNewNamePrompt'),
defaultValue: widget.label,
placeholder: widget.name
})
if (newLabel === null) return
label.value = newLabel
}
function handleHideInput() {

View File

@@ -11,12 +11,13 @@
}"
@click="onLogoMenuClick($event)"
>
<div class="flex size-8 items-center justify-center rounded-lg bg-black">
<div class="flex items-center gap-0.5">
<ComfyLogo
alt="ComfyUI Logo"
class="comfyui-logo h-[18px] w-[18px] text-white"
class="comfyui-logo h-[18px] w-[18px]"
mode="fill"
/>
<i class="icon-[lucide--chevron-down] size-3 text-muted-foreground" />
</div>
</div>

View File

@@ -41,7 +41,7 @@
v-if="userStore.isMultiUserServer"
:is-small="isSmall"
/>
<SidebarHelpCenterIcon v-if="!isIntegratedTabBar" :is-small="isSmall" />
<SidebarHelpCenterIcon :is-small="isSmall" />
<SidebarBottomPanelToggleButton v-if="!isCloud" :is-small="isSmall" />
<SidebarShortcutsToggleButton :is-small="isSmall" />
<SidebarSettingsButton :is-small="isSmall" />
@@ -95,9 +95,6 @@ const sidebarLocation = computed<'left' | 'right'>(() =>
settingStore.get('Comfy.Sidebar.Location')
)
const sidebarStyle = computed(() => settingStore.get('Comfy.Sidebar.Style'))
const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
)
const isConnected = computed(
() =>
selectedTab.value ||

View File

@@ -7,7 +7,7 @@
:icon-badge="shouldShowRedDot ? '' : ''"
badge-class="-top-1 -right-1 min-w-2 w-2 h-2 p-0 rounded-full text-[0px] bg-[#ff3b30]"
:is-small="isSmall"
@click="toggleHelpCenter"
@click="toggleHelpCenter()"
/>
</template>

View File

@@ -615,7 +615,8 @@ const enterFolderView = async (asset: AssetItem) => {
toast.add({
severity: 'error',
summary: t('sideToolbar.folderView.errorSummary'),
detail: t('sideToolbar.folderView.errorDetail')
detail: t('sideToolbar.folderView.errorDetail'),
life: 5000
})
exitFolderView()
}
@@ -661,7 +662,8 @@ const copyJobId = async () => {
toast.add({
severity: 'error',
summary: t('mediaAsset.jobIdToast.error'),
detail: t('mediaAsset.jobIdToast.jobIdCopyFailed')
detail: t('mediaAsset.jobIdToast.jobIdCopyFailed'),
life: 3000
})
}
}

View File

@@ -30,7 +30,7 @@
<UserAvatar
v-else
:photo-url="photoURL"
:class="compact && 'size-full'"
:class="compact && 'h-full w-auto'"
/>
<i v-if="showArrow" class="icon-[lucide--chevron-down] size-4 px-1" />

View File

@@ -1,21 +0,0 @@
<template>
<Button
class="comfy-help-center-btn relative text-base-foreground"
variant="textonly"
@click="toggleHelpCenter"
>
<div class="not-md:hidden">{{ $t('menu.helpAndFeedback') }}</div>
<i class="ml-0.5 icon-[lucide--circle-help]" />
<span
v-if="shouldShowRedDot"
class="absolute top-[7px] right-[7px] size-1.5 rounded-full bg-[#ff3b30]"
/>
</Button>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import { useHelpCenter } from '@/composables/useHelpCenter'
const { shouldShowRedDot, toggleHelpCenter } = useHelpCenter('topbar')
</script>

View File

@@ -10,9 +10,8 @@
@mouseup="handleMouseUp"
@click="handleClick"
>
<i v-if="isBuilderState" class="bg-text-subtle icon-[lucide--hammer]" />
<i
v-else-if="workflowOption.workflow.initialMode === 'app'"
v-if="workflowOption.workflow.initialMode === 'app'"
class="icon-[lucide--panels-top-left] bg-primary-background"
/>
<span
@@ -150,11 +149,6 @@ const shouldShowStatusIndicator = computed(() => {
return false
})
const isBuilderState = computed(() => {
const currentMode = props.workflowOption.workflow.activeMode
return typeof currentMode === 'string' && currentMode.startsWith('builder:')
})
const isActiveTab = computed(() => {
return workflowStore.activeWorkflow?.key === props.workflowOption.workflow.key
})

View File

@@ -83,13 +83,18 @@
v-if="isIntegratedTabBar"
class="ml-auto flex shrink-0 items-center gap-2 px-2"
>
<TopMenuHelpButton />
<CurrentUserButton
v-if="isLoggedIn"
:show-arrow="false"
compact
class="grid w-10 shrink-0 p-1"
/>
<Button
v-if="isCloud || isNightly"
v-tooltip="{ value: $t('actionbar.feedbackTooltip'), showDelay: 300 }"
variant="muted-textonly"
size="icon"
class="shrink-0 text-base-foreground"
:aria-label="$t('actionbar.feedback')"
@click="openFeedback"
>
<i class="icon-[lucide--message-square-text]" />
</Button>
<CurrentUserButton v-if="showCurrentUser" compact class="shrink-0 p-1" />
<LoginButton v-else-if="isDesktop" class="p-1" />
</div>
<div v-if="isDesktop" class="window-actions-spacer app-drag shrink-0" />
@@ -102,21 +107,20 @@ import ScrollPanel from 'primevue/scrollpanel'
import SelectButton from 'primevue/selectbutton'
import { computed, nextTick, onUpdated, ref, watch } from 'vue'
import type { WatchStopHandle } from 'vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import LoginButton from '@/components/topbar/LoginButton.vue'
import TopMenuHelpButton from '@/components/topbar/TopMenuHelpButton.vue'
import WorkflowTab from '@/components/topbar/WorkflowTab.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
import { useSettingStore } from '@/platform/settings/settingStore'
import { buildFeedbackUrl } from '@/platform/support/config'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isDesktop } from '@/platform/distribution/types'
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
import { whileMouseDown } from '@/utils/mouseDownUtil'
import WorkflowOverflowMenu from './WorkflowOverflowMenu.vue'
@@ -138,8 +142,14 @@ const commandStore = useCommandStore()
const { isLoggedIn } = useCurrentUser()
const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
() => settingStore.get('Comfy.UI.TabBarLayout') !== 'Legacy'
)
const showCurrentUser = computed(() => isCloud || isLoggedIn.value)
const feedbackUrl = buildFeedbackUrl()
function openFeedback() {
window.open(feedbackUrl, '_blank', 'noopener,noreferrer')
}
const containerRef = ref<HTMLElement | null>(null)
const showOverflowArrows = ref(false)

View File

@@ -24,7 +24,7 @@ function handleWheel(e: WheelEvent) {
let dragging = false
function handleDown(e: PointerEvent) {
if (e.button !== 0 && e.button !== 1) return
if (e.button !== 0) return
const zoomPaneEl = zoomPane.value
if (!zoomPaneEl) return

View File

@@ -2,7 +2,7 @@ import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const buttonVariants = cva({
base: 'relative inline-flex items-center justify-center gap-2 cursor-pointer touch-manipulation whitespace-nowrap appearance-none border-none rounded-md text-sm font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([width]):not([height])]:size-4 [&_svg]:shrink-0',
base: 'relative inline-flex items-center justify-center gap-2 cursor-pointer whitespace-nowrap appearance-none border-none rounded-md text-sm font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([width]):not([height])]:size-4 [&_svg]:shrink-0',
variants: {
variant: {
secondary:

View File

@@ -204,7 +204,7 @@ function safeWidgetMapper(
return {
canvasOnly: widget.options.canvasOnly,
advanced: widget.options?.advanced ?? widget.advanced,
advanced: widget.advanced,
hidden: widget.options.hidden,
read_only: widget.options.read_only
}

View File

@@ -80,12 +80,8 @@ export function showNodeOptions(
}
/**
* Check if the node options menu is currently open
* Hide the node options popover
*/
export function isNodeOptionsOpen(): boolean {
return nodeOptionsInstance?.isOpen.value ?? false
}
interface NodeOptionsInstance {
toggle: (event: Event) => void
show: (event: MouseEvent) => void

View File

@@ -397,7 +397,8 @@ export function useCoreCommands(): ComfyCommand[] {
if (app.canvas.empty) {
toastStore.add({
severity: 'error',
summary: t('toastMessages.emptyCanvas')
summary: t('toastMessages.emptyCanvas'),
life: 3000
})
return
}
@@ -556,7 +557,8 @@ export function useCoreCommands(): ComfyCommand[] {
toastStore.add({
severity: 'error',
summary: t('toastMessages.nothingToQueue'),
detail: t('toastMessages.pleaseSelectOutputNodes')
detail: t('toastMessages.pleaseSelectOutputNodes'),
life: 3000
})
return
}
@@ -569,7 +571,8 @@ export function useCoreCommands(): ComfyCommand[] {
toastStore.add({
severity: 'error',
summary: t('toastMessages.failedToQueue'),
detail: t('toastMessages.failedExecutionPathResolution')
detail: t('toastMessages.failedExecutionPathResolution'),
life: 3000
})
return
}
@@ -599,7 +602,8 @@ export function useCoreCommands(): ComfyCommand[] {
toastStore.add({
severity: 'error',
summary: t('toastMessages.nothingToGroup'),
detail: t('toastMessages.pleaseSelectNodesToGroup')
detail: t('toastMessages.pleaseSelectNodesToGroup'),
life: 3000
})
return
}
@@ -958,7 +962,8 @@ export function useCoreCommands(): ComfyCommand[] {
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: t('manager.notAvailable')
detail: t('manager.notAvailable'),
life: 3000
})
return
}
@@ -1043,7 +1048,8 @@ export function useCoreCommands(): ComfyCommand[] {
toastStore.add({
severity: 'error',
summary: t('toastMessages.cannotCreateSubgraph'),
detail: t('toastMessages.failedToConvertToSubgraph')
detail: t('toastMessages.failedToConvertToSubgraph'),
life: 3000
})
return
}
@@ -1252,7 +1258,8 @@ export function useCoreCommands(): ComfyCommand[] {
summary: t('g.error'),
detail: t('g.commandProhibited', {
command: 'Comfy.Memory.UnloadModels'
})
}),
life: 3000
})
return
}
@@ -1271,7 +1278,8 @@ export function useCoreCommands(): ComfyCommand[] {
summary: t('g.error'),
detail: t('g.commandProhibited', {
command: 'Comfy.Memory.UnloadModelsAndExecutionCache'
})
}),
life: 3000
})
return
}

View File

@@ -5,19 +5,15 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
import { useHelpCenterStore } from '@/stores/helpCenterStore'
import type { HelpCenterTriggerLocation } from '@/stores/helpCenterStore'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
import { useNodeConflictDialog } from '@/workbench/extensions/manager/composables/useNodeConflictDialog'
export function useHelpCenter(
triggerFrom: HelpCenterTriggerLocation = 'sidebar'
) {
export function useHelpCenter() {
const settingStore = useSettingStore()
const releaseStore = useReleaseStore()
const helpCenterStore = useHelpCenterStore()
const { isVisible: isHelpCenterVisible, triggerLocation } =
storeToRefs(helpCenterStore)
const { isVisible: isHelpCenterVisible } = storeToRefs(helpCenterStore)
const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
const conflictDetection = useConflictDetection()
@@ -42,9 +38,9 @@ export function useHelpCenter(
*/
const toggleHelpCenter = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: `${triggerFrom}_help_center_toggled`
button_id: 'sidebar_help_center_toggled'
})
helpCenterStore.toggle(triggerFrom)
helpCenterStore.toggle()
}
const closeHelpCenter = () => {
@@ -90,7 +86,6 @@ export function useHelpCenter(
return {
isHelpCenterVisible,
triggerLocation,
shouldShowRedDot,
sidebarLocation,
toggleHelpCenter,

View File

@@ -595,34 +595,6 @@ describe('usePaste', () => {
)
})
})
it('should skip node metadata paste when a media node is selected', async () => {
const mockNode = createMockLGraphNode({
is_selected: true,
pasteFile: vi.fn(),
pasteFiles: vi.fn()
})
mockCanvas.current_node = mockNode
vi.mocked(isImageNode).mockReturnValue(true)
usePaste()
const nodeData = { nodes: [{ type: 'KSampler' }] }
const encoded = btoa(JSON.stringify(nodeData))
const html = `<div data-metadata="${encoded}"></div>`
const dataTransfer = new DataTransfer()
dataTransfer.setData('text/html', html)
dataTransfer.setData('text/plain', 'some text')
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
document.dispatchEvent(event)
await vi.waitFor(() => {
expect(mockCanvas._deserializeItems).not.toHaveBeenCalled()
expect(mockCanvas.pasteFromClipboard).toHaveBeenCalled()
})
})
})
describe('cloneDataTransfer', () => {

View File

@@ -229,10 +229,7 @@ export const usePaste = () => {
return
}
}
const isMediaNodeSelected =
isImageNodeSelected || isVideoNodeSelected || isAudioNodeSelected
if (!isMediaNodeSelected && pasteClipboardItems(data)) return
if (pasteClipboardItems(data)) return
// No image found. Look for node data
data = data.getData('text/plain')

View File

@@ -81,7 +81,8 @@ function getParentNodes(): SubgraphNode[] {
useToastStore().add({
severity: 'error',
summary: t('g.error'),
detail: t('subgraphStore.promoteOutsideSubgraph')
detail: t('subgraphStore.promoteOutsideSubgraph'),
life: 2000
})
return []
}

View File

@@ -1,6 +1,7 @@
import { computed } from 'vue'
import { computed, watch } from 'vue'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import { t } from '@/i18n'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExtensionService } from '@/services/extensionService'
import type { TopbarBadge } from '@/types/comfy'
@@ -17,16 +18,20 @@ const badges = computed<TopbarBadge[]>(() => {
tooltip: alert.tooltip
})
}
// Always add cloud badge last (furthest right)
result.push({
icon: 'icon-[lucide--cloud]',
text: 'Comfy Cloud'
})
return result
})
const canvasStore = useCanvasStore()
watch(
() => canvasStore.canvas,
(canvas) => {
if (canvas) {
canvas.info_text = t('g.comfyCloud')
}
},
{ immediate: true }
)
useExtensionService().registerExtension({
name: 'Comfy.Cloud.Badges',
get topbarBadges() {

View File

@@ -1,21 +1,14 @@
import { t } from '@/i18n'
import { getDistribution, ZENDESK_FIELDS } from '@/platform/support/config'
import { useSettingStore } from '@/platform/settings/settingStore'
import { buildFeedbackUrl } from '@/platform/support/config'
import { useExtensionService } from '@/services/extensionService'
import type { ActionBarButton } from '@/types/comfy'
const ZENDESK_BASE_URL = 'https://support.comfy.org/hc/en-us/requests/new'
const ZENDESK_FEEDBACK_FORM_ID = '43066738713236'
const distribution = getDistribution()
const params = new URLSearchParams({
ticket_form_id: ZENDESK_FEEDBACK_FORM_ID,
[ZENDESK_FIELDS.DISTRIBUTION]: distribution
})
const feedbackUrl = `${ZENDESK_BASE_URL}?${params.toString()}`
const feedbackUrl = buildFeedbackUrl()
const buttons: ActionBarButton[] = [
{
icon: 'icon-[lucide--message-circle-question-mark]',
icon: 'icon-[lucide--message-square-text]',
label: t('actionbar.feedback'),
tooltip: t('actionbar.feedbackTooltip'),
onClick: () => {
@@ -25,6 +18,10 @@ const buttons: ActionBarButton[] = [
]
useExtensionService().registerExtension({
name: 'Comfy.Cloud.FeedbackButton',
actionBarButtons: buttons
name: 'Comfy.FeedbackButton',
get actionBarButtons() {
return useSettingStore().get('Comfy.UI.TabBarLayout') === 'Legacy'
? buttons
: []
}
})

View File

@@ -204,7 +204,8 @@ import { electronAPI as getElectronAPI } from '@/utils/envUtil'
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: t('desktopUpdate.errorInstallingUpdate')
detail: t('desktopUpdate.errorInstallingUpdate'),
life: 10_000
})
}
}
@@ -213,7 +214,8 @@ import { electronAPI as getElectronAPI } from '@/utils/envUtil'
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: t('desktopUpdate.errorCheckingUpdate')
detail: t('desktopUpdate.errorCheckingUpdate'),
life: 10_000
})
}
}

View File

@@ -1,5 +1,6 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { NodeOutputWith } from '@/schemas/apiSchema'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useExtensionService } from '@/services/extensionService'
@@ -28,6 +29,7 @@ useExtensionService().registerExtension({
const toUrl = (record: Record<string, string>) => {
const params = new URLSearchParams(record)
appendCloudResParam(params, record.filename)
return api.apiURL(`/view?${params}${rand}`)
}

View File

@@ -559,6 +559,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
clear_background_color: string
render_only_selected: boolean
show_info: boolean
/** Additional text appended to the canvas info overlay (rendered by {@link renderInfo}). */
info_text: string | undefined
allow_dragcanvas: boolean
allow_dragnodes: boolean
allow_interaction: boolean
@@ -5180,8 +5182,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
* draws some useful stats in the corner of the canvas
*/
renderInfo(ctx: CanvasRenderingContext2D, x: number, y: number): void {
const lineHeight = 13
const lineCount = (this.graph ? 5 : 1) + (this.info_text ? 1 : 0)
x = x || 10
y = y || this.canvas.offsetHeight - 80
y = y || this.canvas.offsetHeight - (lineCount + 1) * lineHeight
ctx.save()
ctx.translate(x, y)
@@ -5189,18 +5193,26 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
ctx.font = `10px ${LiteGraph.DEFAULT_FONT}`
ctx.fillStyle = '#888'
ctx.textAlign = 'left'
let line = 1
if (this.graph) {
ctx.fillText(`T: ${this.graph.globaltime.toFixed(2)}s`, 5, 13 * 1)
ctx.fillText(`I: ${this.graph.iteration}`, 5, 13 * 2)
ctx.fillText(
`T: ${this.graph.globaltime.toFixed(2)}s`,
5,
lineHeight * line++
)
ctx.fillText(`I: ${this.graph.iteration}`, 5, lineHeight * line++)
ctx.fillText(
`N: ${this.graph._nodes.length} [${this.visible_nodes.length}]`,
5,
13 * 3
lineHeight * line++
)
ctx.fillText(`V: ${this.graph._version}`, 5, 13 * 4)
ctx.fillText(`FPS:${this.fps.toFixed(2)}`, 5, 13 * 5)
ctx.fillText(`V: ${this.graph._version}`, 5, lineHeight * line++)
ctx.fillText(`FPS:${this.fps.toFixed(2)}`, 5, lineHeight * line++)
} else {
ctx.fillText('No graph selected', 5, 13 * 1)
ctx.fillText('No graph selected', 5, lineHeight * line++)
}
if (this.info_text) {
ctx.fillText(this.info_text, 5, lineHeight * line++)
}
ctx.restore()
}

View File

@@ -1,12 +1,9 @@
// TODO: Fix these tests after migration
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import {
LGraph,
LGraphNode,
LGraphEventMode,
ExecutableNodeDTO
} from '@/lib/litegraph/src/litegraph'
@@ -252,136 +249,6 @@ describe.skip('ExecutableNodeDTO Output Resolution', () => {
})
})
describe('Muted node output resolution', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('should return undefined for NEVER mode nodes', () => {
const graph = new LGraph()
const node = new LGraphNode('Muted Node')
node.addOutput('out', 'string')
node.mode = LGraphEventMode.NEVER
graph.add(node)
const dto = new ExecutableNodeDTO(node, [], new Map(), undefined)
const resolved = dto.resolveOutput(0, 'string', new Set())
expect(resolved).toBeUndefined()
})
it('should return undefined for muted subgraph nodes without throwing', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'out', type: 'IMAGE' }],
nodeCount: 1
})
const subgraphNode = createTestSubgraphNode(subgraph)
subgraphNode.mode = LGraphEventMode.NEVER
// Empty map simulates executionUtil skipping getInnerNodes() for muted nodes
const nodesByExecutionId = new Map()
const dto = new ExecutableNodeDTO(
subgraphNode,
[],
nodesByExecutionId,
undefined
)
nodesByExecutionId.set(dto.id, dto)
const resolved = dto.resolveOutput(0, 'IMAGE', new Set())
expect(resolved).toBeUndefined()
})
it('should resolve undefined when input is connected to a muted node', () => {
const graph = new LGraph()
const mutedNode = new LGraphNode('Muted Node')
mutedNode.addOutput('result', 'IMAGE')
mutedNode.mode = LGraphEventMode.NEVER
graph.add(mutedNode)
const downstreamNode = new LGraphNode('Downstream')
downstreamNode.addInput('input', 'IMAGE')
graph.add(downstreamNode)
mutedNode.connect(0, downstreamNode, 0)
const nodeDtoMap = new Map()
const mutedDto = new ExecutableNodeDTO(mutedNode, [], nodeDtoMap, undefined)
nodeDtoMap.set(mutedDto.id, mutedDto)
const downstreamDto = new ExecutableNodeDTO(
downstreamNode,
[],
nodeDtoMap,
undefined
)
nodeDtoMap.set(downstreamDto.id, downstreamDto)
const resolved = downstreamDto.resolveInput(0)
expect(resolved).toBeUndefined()
})
})
describe('Bypass node output resolution', () => {
it('should still resolve bypass for BYPASS mode nodes', () => {
const graph = new LGraph()
const upstreamNode = new LGraphNode('Upstream')
upstreamNode.addOutput('out', 'IMAGE')
graph.add(upstreamNode)
const bypassedNode = new LGraphNode('Bypassed')
bypassedNode.addInput('in', 'IMAGE')
bypassedNode.addOutput('out', 'IMAGE')
bypassedNode.mode = LGraphEventMode.BYPASS
graph.add(bypassedNode)
upstreamNode.connect(0, bypassedNode, 0)
const nodeDtoMap = new Map()
const upstreamDto = new ExecutableNodeDTO(
upstreamNode,
[],
nodeDtoMap,
undefined
)
nodeDtoMap.set(upstreamDto.id, upstreamDto)
const bypassedDto = new ExecutableNodeDTO(
bypassedNode,
[],
nodeDtoMap,
undefined
)
nodeDtoMap.set(bypassedDto.id, bypassedDto)
const resolved = bypassedDto.resolveOutput(0, 'IMAGE', new Set())
expect(resolved).toBeDefined()
expect(resolved?.node).toBe(upstreamDto)
})
})
describe('ALWAYS mode node output resolution', () => {
it('should attempt normal resolution for ALWAYS mode nodes', () => {
const graph = new LGraph()
const node = new LGraphNode('Normal Node')
node.addOutput('out', 'IMAGE')
node.mode = LGraphEventMode.ALWAYS
graph.add(node)
const nodeDtoMap = new Map()
const dto = new ExecutableNodeDTO(node, [], nodeDtoMap, undefined)
nodeDtoMap.set(dto.id, dto)
const resolved = dto.resolveOutput(0, 'IMAGE', new Set())
expect(resolved).toBeDefined()
expect(resolved?.node).toBe(dto)
expect(resolved?.origin_slot).toBe(0)
})
})
describe.skip('ExecutableNodeDTO Properties', () => {
it('should provide access to basic properties', () => {
const graph = new LGraph()

View File

@@ -266,9 +266,6 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
}
visited.add(uniqueId)
// Muted nodes produce no output
if (this.mode === LGraphEventMode.NEVER) return
// Upstreamed: Bypass nodes are bypassed using the first input with matching type
if (this.mode === LGraphEventMode.BYPASS) {
// Bypass nodes by finding first input with matching type

View File

@@ -71,6 +71,9 @@
"Comfy_Canvas_PasteFromClipboard": {
"label": "لصق"
},
"Comfy_Canvas_PasteFromClipboardWithConnect": {
"label": "لصق مع الاتصال"
},
"Comfy_Canvas_ResetView": {
"label": "إعادة تعيين العرض"
},

View File

@@ -2,7 +2,9 @@
"actionbar": {
"dockToTop": "إلصق بالأعلى",
"feedback": "ملاحظات",
"feedbackTooltip": "إرسال ملاحظات"
"feedbackTooltip": "إرسال ملاحظات",
"share": "مشاركة",
"shareTooltip": "مشاركة سير العمل"
},
"apiNodesCostBreakdown": {
"costPerRun": "التكلفة لكل تشغيل",
@@ -321,18 +323,24 @@
"y": "ص"
},
"breadcrumbsMenu": {
"app": "التطبيق",
"clearWorkflow": "مسح سير العمل",
"deleteBlueprint": "حذف المخطط",
"deleteWorkflow": "حذف سير العمل",
"duplicate": "تكرار",
"editBuilderMode": "تعديل التطبيق",
"enterAppMode": "الدخول إلى وضع التطبيق",
"enterBuilderMode": "دخول وضع بناء التطبيق",
"enterNewName": "أدخل اسمًا جديدًا",
"enterNodeGraph": "دخول رسم العقد",
"exitAppMode": "الخروج من وضع التطبيق",
"graph": "الرسم البياني",
"missingNodesWarning": "يحتوي سير العمل على عقد غير مدعومة (مظللة باللون الأحمر).",
"share": "مشاركة",
"workflowActions": "إجراءات سير العمل"
},
"builderMenu": {
"enterAppMode": "الدخول إلى وضع التطبيق",
"exitAppBuilder": "الخروج من مُنشئ التطبيق"
},
"builderToolbar": {
@@ -354,6 +362,7 @@
"defaultViewTitle": "تعيين العرض الافتراضي لهذا سير العمل",
"emptyWorkflowPrompt": "هل ترغب في البدء بقالب؟",
"emptyWorkflowTitle": "لا يحتوي سير العمل هذا على أي عقد",
"exitToWorkflow": "الخروج إلى سير العمل",
"inputs": "المدخلات",
"inputsDescription": "اختر المدخلات",
"label": "منشئ التطبيقات",
@@ -507,6 +516,80 @@
"red": "أحمر",
"yellow": "أصفر"
},
"comfyHubProfile": {
"checkingAccess": "جارٍ التحقق من صلاحية النشر...",
"chooseProfilePicture": "اختر صورة الملف الشخصي",
"createProfile": "إنشاء الملف الشخصي",
"createProfileButton": "إنشاء ملفي الشخصي",
"createProfileTitle": "أنشئ ملفك الشخصي على Comfy Hub",
"creatingProfile": "جارٍ إنشاء الملف الشخصي...",
"descriptionLabel": "وصفك",
"descriptionPlaceholder": "أخبر المجتمع عن نفسك...",
"introDescription": "انشر سير عملك، وابنِ محفظتك، واكتشفك ملايين المستخدمين",
"introSubtitle": "لمشاركة سير عملك على ComfyHub، لنقم أولاً بإنشاء ملفك الشخصي.",
"introTitle": "النشر على ComfyHub",
"modalTitle": "إنشاء ملفك الشخصي على ComfyHub",
"nameLabel": "اسمك",
"namePlaceholder": "أدخل اسمك هنا",
"profileCreationNav": "إنشاء الملف الشخصي",
"startPublishingButton": "ابدأ النشر",
"successDescription": "يمكنك الآن رفع سير عملك على صفحة المبدع الخاصة بك",
"successProfileLink": "comfy.com/p/{username}",
"successProfileUrl": "صفحتك الشخصية متاحة الآن على",
"successTitle": "يبدو رائعًا، {'@'}{username}!",
"uploadCover": "+ رفع صورة الغلاف",
"uploadProfilePicture": "+ رفع صورة الملف الشخصي",
"uploadWorkflowButton": "رفع سير عملي",
"usernameLabel": "اسم المستخدم (إجباري)",
"usernamePlaceholder": "@"
},
"comfyHubPublish": {
"back": "رجوع",
"createProfileCta": "إنشاء ملف شخصي",
"createProfileToPublish": "أنشئ ملفًا شخصيًا للنشر على ComfyHub",
"exampleImage": "صورة نموذجية {index}",
"examplesDescription": "أضف حتى {total} صورة نموذجية إضافية",
"maxExamples": "يمكنك اختيار حتى {max} أمثلة",
"next": "التالي",
"publishButton": "النشر على ComfyHub",
"selectAThumbnail": "اختر صورة مصغرة",
"showLessTags": "عرض أقل...",
"showMoreTags": "عرض المزيد...",
"stepDescribe": "وصف سير العمل",
"stepExamples": "إضافة أمثلة للإخراج",
"stepFinish": "إنهاء النشر",
"suggestedTags": "وسوم مقترحة",
"tags": "الوسوم",
"tagsDescription": "اختر الوسوم ليسهل على الآخرين العثور على سير عملك",
"tagsPlaceholder": "أدخل وسومًا تناسب سير عملك لمساعدة الآخرين في العثور عليه مثل #nanobanana أو #anime أو #faceswap",
"thumbnailImage": "صورة",
"thumbnailImageComparison": "مقارنة الصور",
"thumbnailPreview": "معاينة الصورة المصغرة",
"thumbnailVideo": "فيديو",
"title": "النشر على ComfyHub",
"uploadAnImage": "انقر للاستعراض أو اسحب صورة",
"uploadComparison": "رفع صورة قبل وبعد",
"uploadComparisonAfterPrompt": "بعد",
"uploadComparisonBeforePrompt": "قبل",
"uploadExampleImage": "رفع صورة نموذجية",
"uploadPromptClickToBrowse": "انقر للاستعراض أو",
"uploadPromptDropImage": "أسقط صورة هنا",
"uploadPromptDropVideo": "أسقط فيديو هنا",
"uploadThumbnail": "رفع صورة",
"uploadThumbnailHint": "يفضل نسبة 1:1، الحد الأقصى 1080p",
"uploadVideo": "رفع فيديو",
"videoPreview": "معاينة صورة الفيديو المصغرة",
"workflowDescription": "وصف سير العمل",
"workflowDescriptionPlaceholder": "ما الذي يجعل سير عملك مميزًا ومثيرًا؟ كن محددًا حتى يعرف الآخرون ما يمكن توقعه.",
"workflowName": "اسم سير العمل",
"workflowNamePlaceholder": "نصيحة: أدخل اسمًا وصفيًا يسهل البحث عنه",
"workflowType": "نوع سير العمل",
"workflowTypeEditing": "تحرير",
"workflowTypeImageGeneration": "توليد الصور",
"workflowTypePlaceholder": "اختر النوع",
"workflowTypeUpscaling": "تحسين الجودة",
"workflowTypeVideoGeneration": "توليد الفيديو"
},
"commands": {
"clear": "مسح سير العمل",
"clipspace": "فتح مساحة القص",
@@ -1342,7 +1425,7 @@
"appBuilder": "منشئ التطبيقات",
"apps": "التطبيقات",
"appsEmptyMessage": "سيتم عرض التطبيقات المحفوظة هنا.\nانقر أدناه لبناء تطبيقك الأول.",
"enterAppMode": "الدخول إلى وضع التطبيق"
"appsEmptyMessageAction": "انقر أدناه لإنشاء أول تطبيق لك."
},
"arrange": {
"atLeastOne": "عقدة واحدة على الأقل",
@@ -1356,14 +1439,18 @@
},
"backToWorkflow": "العودة إلى سير العمل",
"beta": "وضع التطبيق تجريبي - أرسل ملاحظاتك",
"buildAnApp": "أنشئ تطبيقًا",
"builder": {
"exit": "خروج من البناء",
"exitConfirmMessage": "لديك تغييرات غير محفوظة ستفقد\nهل تريد الخروج بدون حفظ؟",
"exitConfirmTitle": "الخروج من بناء التطبيق؟",
"inputPlaceholder": "سيتم عرض المدخلات هنا",
"inputsDesc": "سيتفاعل المستخدمون مع هذه المدخلات ويعدلونها لإنشاء النتائج.",
"inputsExample": "أمثلة: \"تحميل صورة\"، \"موجه نصي\"، \"خطوات\"",
"noInputs": "لم تتم إضافة أي مدخلات بعد",
"noOutputs": "لم تتم إضافة أي عقد إخراج بعد",
"outputPlaceholder": "سيتم عرض عقد الإخراج هنا",
"outputRequiredPlaceholder": "مطلوب عقدة واحدة على الأقل",
"outputsDesc": "وصل عقدة إخراج واحدة على الأقل حتى يتمكن المستخدمون من رؤية النتائج بعد التشغيل.",
"outputsExample": "أمثلة: \"حفظ صورة\" أو \"حفظ فيديو\"",
"promptAddInputs": "انقر على معلمات العقدة لإضافتها هنا كمدخلات",
@@ -1371,6 +1458,7 @@
"title": "وضع بناء التطبيق",
"unknownWidget": "عنصر الواجهة غير مرئي"
},
"cancelThisRun": "إلغاء هذا التشغيل",
"downloadAll": "تنزيل الكل",
"dragAndDropImage": "اسحب وأسقط صورة",
"emptyWorkflowExplanation": "سير العمل الخاص بك فارغ. تحتاج إلى بعض العقد أولاً لبدء بناء التطبيق.",
@@ -1393,6 +1481,8 @@
"controls": "تظهر المخرجات في الأسفل، وعناصر التحكم على اليمين. كل شيء آخر يبقى بعيدًا.",
"getStarted": "انقر على {runButton} للبدء.",
"message": "عرض مبسط يخفي رسم العقد حتى تتمكن من التركيز على الإنشاء.",
"noOutputs": "يحتاج التطبيق إلى {count} على الأقل ليكون قابلاً للاستخدام.",
"oneOutput": "مخرج واحد",
"sharing": "المشاركة سهلة: أنشئ سير العمل الخاص بك، افتح وضع التطبيق، انقر بزر الماوس الأيمن على علامة التبويب، ثم صدّر. عندما يفتح الآخرون ملفك، سيتم تشغيله مباشرة في هذا العرض النظيف. يمكنك مشاركة سير عمل قوي كأداة بسيطة دون الحاجة لفهم مخططات العقد.",
"title": "مرحبًا بك في وضع التطبيق"
}
@@ -1765,7 +1855,6 @@
"execute": "تنفيذ",
"fullscreen": "ملء الشاشة",
"help": "مساعدة",
"helpAndFeedback": "المساعدة والتعليقات",
"hideMenu": "إخفاء القائمة",
"instant": "فوري",
"instantTooltip": "سيتم وضع سير العمل في قائمة الانتظار فور انتهاء التوليد",
@@ -1866,6 +1955,7 @@
"Open Sign In Dialog": "فتح نافذة تسجيل الدخول",
"Open extra_model_paths_yaml": "فتح ملف extra_model_paths.yaml",
"Paste": "لصق",
"Paste with Connect": "لصق مع الاتصال",
"Pin/Unpin Selected Items": "تثبيت/إلغاء تثبيت العناصر المحددة",
"Pin/Unpin Selected Nodes": "تثبيت/إلغاء تثبيت العقد المحددة",
"Previous Opened Workflow": "سير العمل السابق المفتوح",
@@ -2037,6 +2127,7 @@
"lotus": "lotus",
"ltxv": "ltxv",
"mask": "قناع",
"math": "رياضيات",
"model": "نموذج",
"model_merging": "دمج النماذج",
"model_patches": "تصحيحات النموذج",
@@ -2140,6 +2231,18 @@
},
"title": "جهازك غير مدعوم"
},
"openSharedWorkflow": {
"author": "المؤلف:",
"copyAssetsAndOpen": "استيراد الأصول وفتح سير العمل",
"copyDescription": "فتح سير العمل سينشئ نسخة جديدة في مساحة العمل الخاصة بك",
"dialogTitle": "فتح سير العمل المشترك",
"importFailed": "فشل استيراد أصول سير العمل",
"loadError": "تعذر تحميل سير العمل المشترك هذا. يرجى المحاولة لاحقًا.",
"nonPublicAssetsWarningLine1": "يأتي هذا سير العمل مع أصول غير عامة.",
"nonPublicAssetsWarningLine2": "سيتم استيراد هذه الأصول إلى مكتبتك عند فتح سير العمل",
"openWithoutImporting": "فتح بدون استيراد",
"openWorkflow": "فتح سير العمل"
},
"painter": {
"background": "الخلفية",
"brush": "فرشاة",
@@ -2598,6 +2701,47 @@
"default": "افتراضي",
"round": "دائري"
},
"shareNoOutputs": {
"message": "أنت على وشك مشاركة تطبيق بدون مخرجات. لا يمكن استخدامه حتى يتم توصيل مخرج.\n\nهل ترغب في المشاركة على أي حال؟",
"shareAnyway": "مشاركة على أي حال",
"title": "التطبيق لا يحتوي على مخرجات"
},
"shareWorkflow": {
"acknowledgeCheckbox": "أفهم أن عناصر الوسائط هذه سيتم نشرها وجعلها عامة",
"checkingAssets": "جارٍ التحقق من ظهور الوسائط…",
"comfyHubButton": "رفع إلى ComfyHub",
"comfyHubDescription": "ComfyHub هو مركز مجتمع ComfyUI الرسمي.\nسيكون لسير العمل الخاص بك صفحة عامة يمكن للجميع مشاهدتها.",
"comfyHubTitle": "رفع إلى ComfyHub",
"copyLink": "نسخ",
"createLinkButton": "إنشاء رابط",
"createLinkDescription": "عند إنشاء رابط لسير العمل الخاص بك، ستشارك عناصر الوسائط هذه مع سير العمل",
"createLinkTitle": "مشاركة سير العمل",
"creatingLink": "جارٍ إنشاء الرابط...",
"hasChangesDescription": "لقد أجريت تغييرات منذ آخر نشر لهذا سير العمل.",
"hasChangesTitle": "مشاركة سير العمل",
"inLibrary": "في المكتبة",
"linkCopied": "تم النسخ!",
"loadFailed": "فشل تحميل سير العمل المشترك",
"loadingTitle": "مشاركة سير العمل",
"mediaLabel": "{count} ملف وسائط | {count} ملفات وسائط",
"modelsLabel": "{count} نموذج | {count} نماذج",
"privateAssetsDescription": "يحتوي سير العمل الخاص بك على نماذج و/أو ملفات وسائط خاصة",
"publishToHubTab": "نشر",
"publishedOn": "تم النشر في {date}",
"saveButton": "حفظ سير العمل",
"saveFailedDescription": "فشل حفظ سير العمل. يرجى المحاولة مرة أخرى.",
"saveFailedTitle": "فشل الحفظ",
"saving": "جارٍ الحفظ...",
"shareLinkTab": "مشاركة",
"shareUrlLabel": "رابط المشاركة",
"successDescription": "أي شخص لديه هذا الرابط يمكنه عرض واستخدام سير العمل هذا. إذا قمت بإجراء تغييرات على سير العمل، يمكنك إعادة النشر لتحديث النسخة المشتركة.",
"successTitle": "تم نشر سير العمل بنجاح!",
"unsavedDescription": "يجب عليك حفظ سير العمل قبل المشاركة. احفظه الآن للمتابعة.",
"unsavedTitle": "احفظ سير العمل أولاً",
"updateLinkButton": "تحديث الرابط",
"updatingLink": "جارٍ تحديث الرابط...",
"workflowNameLabel": "اسم سير العمل"
},
"shortcuts": {
"essentials": "أساسي",
"keyboardShortcuts": "اختصارات لوحة المفاتيح",

View File

@@ -1419,6 +1419,25 @@
}
}
},
"ComfyMathExpression": {
"display_name": "تعبير رياضي",
"inputs": {
"expression": {
"name": "تعبير"
},
"values": {
"name": "القيم"
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"tooltip": null
}
}
},
"ComfySwitchNode": {
"display_name": "مفتاح التحويل",
"inputs": {
@@ -15112,6 +15131,64 @@
}
}
},
"TencentModelTo3DUVNode": {
"description": "تنفيذ فك UV لنموذج ثلاثي الأبعاد لإنشاء نسيج UV. يجب أن يحتوي النموذج المُدخل على أقل من ٣٠٬٠٠٠ وجه.",
"display_name": "Hunyuan3D: من نموذج إلى UV",
"inputs": {
"control_after_generate": {
"name": "التحكم بعد التوليد"
},
"model_3d": {
"name": "نموذج_ثلاثي_الأبعاد",
"tooltip": "إدخال نموذج ثلاثي الأبعاد (GLB، OBJ، أو FBX)"
},
"seed": {
"name": "البذرة",
"tooltip": "تتحكم البذرة فيما إذا كان يجب إعادة تشغيل العقدة؛ النتائج غير حتمية بغض النظر عن البذرة."
}
},
"outputs": {
"0": {
"name": "OBJ",
"tooltip": null
},
"1": {
"name": "FBX",
"tooltip": null
}
}
},
"TencentSmartTopologyNode": {
"description": "تنفيذ إعادة طوبولوجيا ذكية لنموذج ثلاثي الأبعاد. يدعم صيغ GLB/OBJ؛ الحد الأقصى ٢٠٠ ميجابايت؛ يُوصى به للنماذج عالية التفاصيل.",
"display_name": "Hunyuan3D: طوبولوجيا ذكية",
"inputs": {
"control_after_generate": {
"name": "التحكم بعد التوليد"
},
"face_level": {
"name": "مستوى الوجوه",
"tooltip": "مستوى تقليل المضلعات."
},
"model_3d": {
"name": "نموذج_ثلاثي_الأبعاد",
"tooltip": "إدخال نموذج ثلاثي الأبعاد (GLB أو OBJ)"
},
"polygon_type": {
"name": "نوع المضلع",
"tooltip": "نوع تركيب السطح."
},
"seed": {
"name": "البذرة",
"tooltip": "تتحكم البذرة فيما إذا كان يجب إعادة تشغيل العقدة؛ النتائج غير حتمية بغض النظر عن البذرة."
}
},
"outputs": {
"0": {
"name": "OBJ",
"tooltip": null
}
}
},
"TencentTextToModelNode": {
"display_name": "Hunyuan3D: من نص إلى نموذج (احترافي)",
"inputs": {

Some files were not shown because too many files have changed in this diff Show More