Compare commits

..

17 Commits

Author SHA1 Message Date
Luke Mino-Altherr
7a7f6380f0 [backport core/1.41] fix: make zPreviewOutput accept text-only job outputs (#9751)
Backport of #9724 to core/1.41.

Resolves Zod validation crash when a jobs batch contains text-only
preview outputs (e.g. from LLM nodes), which caused the Assets sidebar
to show nothing.

- Makes `filename`, `subfolder`, and `type` optional in `zPreviewOutput`
- Adds `.passthrough()` for extra fields like `content`
- Adds tests for mixed batch parsing and text-only job filtering

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9751-backport-core-1-41-fix-make-zPreviewOutput-accept-text-only-job-outputs-3206d73d365081b384fbd93c78942f9d)
by [Unito](https://www.unito.io)
2026-03-11 12:36:14 -07:00
Comfy Org PR Bot
782599995e 1.41.14 (#9747)
Patch version increment to 1.41.14

**Base branch:** `core/1.41`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9747-1-41-14-3206d73d36508147bc4bdf7aed8d16f8)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <4284322+AustinMroz@users.noreply.github.com>
2026-03-11 11:10:03 -07:00
AustinMroz
078c414cdf [backport core/1.41] feat: show App/Node Graph type indicator on template cards (#9746)
Manual backport of #9695 to core/1.41.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9746-backport-core-1-41-feat-show-App-Node-Graph-type-indicator-on-template-cards-3206d73d36508166a8f9f257357d927f)
by [Unito](https://www.unito.io)

Co-authored-by: Robin Huang <robin.j.huang@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-03-11 11:00:30 -07:00
Comfy Org PR Bot
7c69c5f4c6 [backport core/1.41] Mobile input tweaks (#9710)
Backport of #9686 to `core/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9710-backport-core-1-41-Mobile-input-tweaks-31f6d73d365081048373f3616a84cd51)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-03-09 23:35:03 -07:00
Comfy Org PR Bot
a1af7e454c [backport core/1.41] Use preview downscaling in fewer places (#9682)
Backport of #9678 to `core/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9682-backport-core-1-41-Use-preview-downscaling-in-fewer-places-31e6d73d365081fb845ff568d2088070)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-09 16:16:34 -07:00
Comfy Org PR Bot
0596e36202 [backport core/1.41] Restore hiding of linked inputs in app mode (#9676)
Backport of #9671 to `core/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9676-backport-core-1-41-Restore-hiding-of-linked-inputs-in-app-mode-31e6d73d365081f7bab6ee781b782810)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-03-09 13:42:45 -07:00
Comfy Org PR Bot
bca61c75f2 [backport core/1.41] Always use interior nodeId for app mode (#9674)
Backport of #9669 to `core/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9674-backport-core-1-41-Always-use-interior-nodeId-for-app-mode-31e6d73d365081e1a227edf172c0b822)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-03-09 13:25:16 -07:00
Comfy Org PR Bot
441ffec3bc [backport core/1.41] Even further app fixes (#9663)
Backport of #9617 to `core/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9663-backport-core-1-41-Even-further-app-fixes-31e6d73d365081e0a64bc958cf8d1bbb)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-03-09 09:45:34 -07:00
Comfy Org PR Bot
be14ce3348 [backport core/1.41] fix: prevent showing outputs in app mode when no output nodes configured (#9632)
Backport of #9625 to `core/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9632-backport-core-1-41-fix-prevent-showing-outputs-in-app-mode-when-no-output-nodes-config-31e6d73d3650813b99d6d40fd2db8084)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-03-08 17:44:29 -07:00
Comfy Org PR Bot
ffd334cdaa [backport core/1.41] fix: use previewable output count for asset sidebar badge (#9559)
Backport of #9535 to `core/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9559-backport-core-1-41-fix-use-previewable-output-count-for-asset-sidebar-badge-31d6d73d36508161ac9be3c361dab360)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-07 19:02:39 -08:00
Christian Byrne
98ad2a9672 [backport core/1.41] Yet further app fixes (#9523) (#9566)
Backport of #9523 to core/1.41.

Cherry-pick of merge commit 83ffaf30 with minor conflict in
`src/locales/en/main.json` (added new i18n keys). Resolved by accepting
incoming keys.

**Original PR:** https://github.com/Comfy-Org/ComfyUI_frontend/pull/9523
**Pipeline ticket:** 15e1f241-efaa-4fe5-88ca-4ccc7bfb3345

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9566-backport-core-1-41-Yet-further-app-fixes-9523-31d6d73d3650815c9c06eee813f0e72e)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-03-07 18:19:54 -08:00
Christian Byrne
95e5981f2f [backport core/1.41] feat/fix: App mode further updates (#9545) (#9567)
Backport of #9545 to core/1.41.

Cherry-pick of merge commit c4156d70 applied cleanly.

**Original PR:** https://github.com/Comfy-Org/ComfyUI_frontend/pull/9545
**Pipeline ticket:** 15e1f241-efaa-4fe5-88ca-4ccc7bfb3345

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9567-backport-core-1-41-feat-fix-App-mode-further-updates-9545-31d6d73d3650812394d5cb53af3292e1)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-03-07 18:18:24 -08:00
Christian Byrne
e3287d4c95 [backport core/1.41] feat/fix: App mode QA fixes (#9530) (#9565)
Backport of #9530 to core/1.41.

Cherry-pick of merge commit 5bb742ac applied cleanly.

**Original PR:** https://github.com/Comfy-Org/ComfyUI_frontend/pull/9530
**Pipeline ticket:** 15e1f241-efaa-4fe5-88ca-4ccc7bfb3345

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9565-backport-core-1-41-feat-fix-App-mode-QA-fixes-9530-31d6d73d36508116b377fafed6f5aec5)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-03-07 18:18:20 -08:00
Christian Byrne
d65d8ec06e [backport core/1.41] fix: remove workspace switching confirmation dialog (#9250) (#9564)
Backport of #9250 to core/1.41.

Cherry-pick of merge commit 2875f897 applied cleanly (auto-merge on
locale files).

**Original PR:** https://github.com/Comfy-Org/ComfyUI_frontend/pull/9250
**Pipeline ticket:** 15e1f241-efaa-4fe5-88ca-4ccc7bfb3345

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9564-backport-core-1-41-fix-remove-workspace-switching-confirmation-dialog-9250-31d6d73d3650818b86eeeedc79b3d6c5)
by [Unito](https://www.unito.io)
2026-03-07 18:18:18 -08:00
Christian Byrne
648a964531 [backport core/1.41] fix: prevent non-widget inputs on nested subgraphs from appearing as button widgets (#9542) (#9563)
Backport of #9542 to core/1.41.

Cherry-pick of merge commit 8a5bcde1 applied cleanly.

**Original PR:** https://github.com/Comfy-Org/ComfyUI_frontend/pull/9542
**Pipeline ticket:** 15e1f241-efaa-4fe5-88ca-4ccc7bfb3345

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9563-backport-core-1-41-fix-prevent-non-widget-inputs-on-nested-subgraphs-from-appearing-as-31d6d73d36508154927fd4b7b55ea87f)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-03-07 18:18:15 -08:00
Christian Byrne
0f889a95d9 [backport core/1.41] fix: remove timeouts from error toasts so they persist until dismissed (#9543) (#9562)
Backport of #9543 to core/1.41.

Cherry-pick of merge commit 725a0a2b applied cleanly.

**Original PR:** https://github.com/Comfy-Org/ComfyUI_frontend/pull/9543
**Pipeline ticket:** 15e1f241-efaa-4fe5-88ca-4ccc7bfb3345

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9562-backport-core-1-41-fix-remove-timeouts-from-error-toasts-so-they-persist-until-dismiss-31d6d73d365081caa825ff32ca622b2c)
by [Unito](https://www.unito.io)
2026-03-07 18:18:12 -08:00
Christian Byrne
1fc437bb41 [backport core/1.41] fix: Prevent corruption of workflow data due to checkState during graph loading (#9531) (#9561)
Backport of #9531 to core/1.41.

Cherry-pick of merge commit ec129de6 applied cleanly.

**Original PR:** https://github.com/Comfy-Org/ComfyUI_frontend/pull/9531
**Pipeline ticket:** 15e1f241-efaa-4fe5-88ca-4ccc7bfb3345

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9561-backport-core-1-41-fix-Prevent-corruption-of-workflow-data-due-to-checkState-during-gr-31d6d73d365081aabc05c0409ee49615)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-03-07 18:18:10 -08:00
180 changed files with 1179 additions and 3816 deletions

View File

@@ -23,7 +23,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
node-version: lts/*
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-file: '.nvmrc'
node-version: lts/*
cache: 'pnpm'
- name: Install dependencies

View File

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

View File

@@ -14,7 +14,7 @@ on:
- 'cloud/*'
- 'main'
pull_request:
types: [labeled, synchronize]
types: [labeled]
workflow_dispatch:
permissions: {}
@@ -26,18 +26,11 @@ concurrency:
jobs:
dispatch:
# Fork guard: prevent forks from dispatching to the cloud repo.
# For pull_request events, only dispatch for preview labels.
# - labeled: fires when a label is added; check the added label name.
# - synchronize: fires on push; check existing labels on the PR.
# For pull_request events, only dispatch when the 'preview' label is added.
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
(github.event_name != 'pull_request' ||
(github.event.action == 'labeled' &&
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)) ||
(github.event.action == 'synchronize' &&
(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))))
github.event.label.name == 'preview')
runs-on: ubuntu-latest
steps:
- name: Build client payload

View File

@@ -36,7 +36,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
node-version: '20'
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-file: '.nvmrc'
node-version: 22
- 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-file: '.nvmrc'
node-version: '24.x'
- 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-file: '.nvmrc'
node-version: '24.x'
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-file: 'frontend/.nvmrc'
node-version: lts/*
- 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-file: '.nvmrc'
node-version: 'lts/*'
- 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-file: '.nvmrc'
node-version: 'lts/*'
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-file: '.nvmrc'
node-version: 'lts/*'
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-file: '.nvmrc'
node-version: 'lts/*'
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-file: '.nvmrc'
node-version: lts/*
- 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-file: '.nvmrc'
node-version: '24.x'
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-file: '.nvmrc'
node-version: '20'
cache: 'pnpm'
- name: Install dependencies for analysis tools

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 (see `.nvmrc`, currently v24) and pnpm
- Node.js (v24) and pnpm
- Git for version control
- A running ComfyUI backend instance (otherwise, you can use `pnpm dev:cloud`)

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

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: 90 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 119 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: 29 KiB

After

Width:  |  Height:  |  Size: 30 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: 65 KiB

After

Width:  |  Height:  |  Size: 64 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: 59 KiB

After

Width:  |  Height:  |  Size: 60 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: 106 KiB

After

Width:  |  Height:  |  Size: 107 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: 138 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

View File

@@ -1,62 +0,0 @@
# Release Process
## Bump Types
All releases use `release-version-bump.yaml`. Effects differ by bump type:
| Bump | Target | Creates branches? | GitHub release |
| ---------- | ---------- | ------------------------------------- | ---------------------------- |
| Minor | `main` | `core/` + `cloud/` for previous minor | Published, "latest" |
| Patch | `main` | No | Published, "latest" |
| Patch | `core/X.Y` | No | **Draft** (uncheck "latest") |
| Prerelease | any | No | Draft + prerelease |
**Minor bump** (e.g. 1.41→1.42): freezes the previous minor into `core/1.41`
and `cloud/1.41`, branched from the commit _before_ the bump. Nightly patch
bumps on `main` are convenience snapshots — no branches created.
**Patch on `core/X.Y`**: publishes a hotfix draft release. Must not be marked
"latest" so `main` stays current.
### Dual-homed commits
When a minor bump happens, unreleased commits appear in both places:
```
v1.40.1 ── A ── B ── C ── [bump to 1.41.0]
└── core/1.40
```
A, B, C become v1.41.0 on `main` AND sit on `core/1.40` (where they could
later ship as v1.40.2). Same commits, no divergence — the branch just prevents
1.41+ features from mixing in so ComfyUI can stay on 1.40.x.
## Backporting
1. Add `needs-backport` + version label to the merged PR
2. `pr-backport.yaml` cherry-picks and creates a backport PR
3. Conflicts produce a comment with details and an agent prompt
## Publishing
Merged PRs with the `Release` label trigger `release-draft-create.yaml`,
publishing to GitHub Releases (`dist.zip`), PyPI (`comfyui-frontend-package`),
and npm (`@comfyorg/comfyui-frontend-types`).
## Bi-weekly ComfyUI Integration
`release-biweekly-comfyui.yaml` runs every other Monday — if the next `core/`
branch has unreleased commits, it triggers a patch bump and drafts a PR to
`Comfy-Org/ComfyUI` updating `requirements.txt`.
## Workflows
| Workflow | Purpose |
| ------------------------------- | ------------------------------------------------ |
| `release-version-bump.yaml` | Bump version, create Release PR |
| `release-draft-create.yaml` | Build + publish to GitHub/PyPI/npm |
| `release-branch-create.yaml` | Create `core/` + `cloud/` branches (minor/major) |
| `release-biweekly-comfyui.yaml` | Auto-patch + ComfyUI requirements PR |
| `pr-backport.yaml` | Cherry-pick fixes to stable branches |
| `cloud-backport-tag.yaml` | Tag cloud branch merges |

View File

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

View File

@@ -166,22 +166,13 @@ 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 = createLegacyTabBarWrapper()
const wrapper = createWrapper()
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(true)
expect(wrapper.findComponent(LoginButton).exists()).toBe(false)
})
@@ -195,7 +186,7 @@ describe('TopMenuSection', () => {
describe('on desktop platform', () => {
it('should display LoginButton and not display CurrentUserButton', () => {
mockData.isDesktop = true
const wrapper = createLegacyTabBarWrapper()
const wrapper = createWrapper()
expect(wrapper.findComponent(LoginButton).exists()).toBe(true)
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(false)
})
@@ -203,7 +194,7 @@ describe('TopMenuSection', () => {
describe('on web platform', () => {
it('should not display CurrentUserButton and not display LoginButton', () => {
const wrapper = createLegacyTabBarWrapper()
const wrapper = createWrapper()
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(false)
expect(wrapper.findComponent(LoginButton).exists()).toBe(false)
})

View File

@@ -34,7 +34,17 @@
</Button>
</div>
<div ref="actionbarContainerRef" :class="actionbarContainerClass">
<div
ref="actionbarContainerRef"
:class="
cn(
'actionbar-container pointer-events-auto relative flex h-12 items-center gap-2 rounded-lg border bg-comfy-menu-bg px-2 shadow-interface',
hasAnyError
? 'border-destructive-background-hover'
: 'border-interface-stroke'
)
"
>
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
@@ -45,7 +55,6 @@
<ComfyActionbar
:top-menu-container="actionbarContainerRef"
:queue-overlay-expanded="isQueueOverlayExpanded"
:has-any-error="hasAnyError"
@update:progress-target="updateProgressTarget"
/>
<CurrentUserButton
@@ -61,7 +70,7 @@
@click="() => openShareDialog().catch(toastErrorHandler)"
@pointerenter="prefetchShareDialog"
>
<i class="icon-[comfy--send] size-4" />
<i class="icon-[lucide--share-2] size-4" />
<span class="not-md:hidden">
{{ t('actionbar.share') }}
</span>
@@ -114,7 +123,7 @@
</template>
<script setup lang="ts">
import { useLocalStorage, useMutationObserver } from '@vueuse/core'
import { useLocalStorage } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -136,7 +145,6 @@ import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useActionBarButtonStore } from '@/stores/actionBarButtonStore'
import { useQueueUIStore } from '@/stores/queueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
@@ -160,7 +168,6 @@ const { isLoggedIn } = useCurrentUser()
const { t } = useI18n()
const { toastErrorHandler } = useErrorHandling()
const executionErrorStore = useExecutionErrorStore()
const actionBarButtonStore = useActionBarButtonStore()
const queueUIStore = useQueueUIStore()
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
const { shouldShowRedDot: shouldShowConflictRedDot } =
@@ -175,45 +182,8 @@ const isActionbarEnabled = computed(
const isActionbarFloating = computed(
() => isActionbarEnabled.value && !isActionbarDocked.value
)
/**
* Whether the actionbar container has any visible docked buttons
* (excluding ComfyActionbar, which uses position:fixed when floating
* and does not contribute to the container's visual layout).
*/
const hasDockedButtons = computed(() => {
if (actionBarButtonStore.buttons.length > 0) return true
if (hasLegacyContent.value) return true
if (isLoggedIn.value && !isIntegratedTabBar.value) return true
if (isDesktop && !isIntegratedTabBar.value) return true
if (isCloud && flags.workflowSharingEnabled) return true
if (!isRightSidePanelOpen.value) return true
return false
})
const isActionbarContainerEmpty = computed(
() => isActionbarFloating.value && !hasDockedButtons.value
)
const actionbarContainerClass = computed(() => {
const base =
'actionbar-container pointer-events-auto relative flex h-12 items-center gap-2 rounded-lg border bg-comfy-menu-bg shadow-interface'
if (isActionbarContainerEmpty.value) {
return cn(
base,
'-ml-2 w-0 min-w-0 border-transparent shadow-none',
'has-[.border-dashed]:ml-0 has-[.border-dashed]:w-auto has-[.border-dashed]:min-w-auto',
'has-[.border-dashed]:border-interface-stroke has-[.border-dashed]:pl-2 has-[.border-dashed]:shadow-interface'
)
}
const borderClass =
!isActionbarFloating.value && hasAnyError.value
? 'border-destructive-background-hover'
: 'border-interface-stroke'
return cn(base, 'px-2', borderClass)
})
const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') !== 'Legacy'
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
)
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
useQueueFeatureFlags()
@@ -263,25 +233,6 @@ const rightSidePanelTooltipConfig = computed(() =>
// Maintain support for legacy topbar elements attached by custom scripts
const legacyCommandsContainerRef = ref<HTMLElement>()
const hasLegacyContent = ref(false)
function checkLegacyContent() {
const el = legacyCommandsContainerRef.value
if (!el) {
hasLegacyContent.value = false
return
}
// Mirror the CSS: [&:not(:has(*>*:not(:empty)))]:hidden
hasLegacyContent.value =
el.querySelector(':scope > * > *:not(:empty)') !== null
}
useMutationObserver(legacyCommandsContainerRef, checkLegacyContent, {
childList: true,
subtree: true,
characterData: true
})
onMounted(() => {
if (legacyCommandsContainerRef.value) {
app.menu.element.style.width = 'fit-content'

View File

@@ -119,14 +119,9 @@ import { cn } from '@/utils/tailwindUtil'
import ComfyRunButton from './ComfyRunButton'
const {
topMenuContainer,
queueOverlayExpanded = false,
hasAnyError = false
} = defineProps<{
const { topMenuContainer, queueOverlayExpanded = false } = defineProps<{
topMenuContainer?: HTMLElement | null
queueOverlayExpanded?: boolean
hasAnyError?: boolean
}>()
const emit = defineEmits<{
@@ -440,12 +435,7 @@ const panelClass = computed(() =>
isDragging.value && 'pointer-events-none select-none',
isDocked.value
? 'static border-none bg-transparent p-0'
: [
'fixed shadow-interface',
hasAnyError
? 'border-destructive-background-hover'
: 'border-interface-stroke'
]
: 'fixed shadow-interface'
)
)
</script>

View File

@@ -54,11 +54,12 @@ defineProps<{ itemClass: string; contentClass: string; item: MenuItem }>()
:disabled="toValue(item.disabled) ?? !item.command"
@select="item.command?.({ originalEvent: $event, item })"
>
<i class="size-5" :class="item.icon" />
{{ item.label }}
<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" />
<div
v-if="item.new"
class="ml-auto flex items-center rounded-full bg-primary-background px-1 text-xxs leading-none font-bold"
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-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 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 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',
itemProp
)
)

View File

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

View File

@@ -0,0 +1,95 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import SearchBox from '@/components/common/SearchBox.vue'
import type { ComponentExposed } from 'vue-component-type-helpers'
interface GenericMeta<C> extends Omit<Meta<C>, 'component'> {
component: Omit<ComponentExposed<C>, 'focus'>
}
const meta: GenericMeta<typeof SearchBox> = {
title: 'Components/Input/SearchBox',
component: SearchBox,
tags: ['autodocs'],
argTypes: {
modelValue: {
control: 'text'
},
placeholder: {
control: 'text'
},
showBorder: {
control: 'boolean',
description: 'Toggle border prop'
},
size: {
control: 'select',
options: ['md', 'lg'],
description: 'Size variant of the search box'
},
'onUpdate:modelValue': { action: 'update:modelValue' },
onSearch: { action: 'search' }
},
args: {
modelValue: '',
placeholder: 'Search...',
showBorder: false,
size: 'md'
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: (args) => ({
components: { SearchBox },
setup() {
const searchText = ref('')
return { searchText, args }
},
template: `
<div style="max-width: 320px;">
<SearchBox v-bind="args" v-model="searchText" />
</div>
`
})
}
export const WithBorder: Story = {
...Default,
args: {
showBorder: true
}
}
export const NoBorder: Story = {
...Default,
args: {
showBorder: false
}
}
export const MediumSize: Story = {
...Default,
args: {
size: 'md',
showBorder: false
}
}
export const LargeSize: Story = {
...Default,
args: {
size: 'lg',
showBorder: false
}
}
export const LargeSizeWithBorder: Story = {
...Default,
args: {
size: 'lg',
showBorder: true
}
}

View File

@@ -0,0 +1,193 @@
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import SearchBox from '@/components/common/SearchBox.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
templateWidgets: {
sort: {
searchPlaceholder: 'Search...'
}
}
}
}
})
describe('SearchBox', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
})
afterEach(() => {
vi.restoreAllMocks()
})
const createWrapper = (props = {}) => {
return mount(SearchBox, {
props: {
modelValue: '',
...props
},
global: {
plugins: [i18n]
}
})
}
describe('debounced search functionality', () => {
it('should debounce search input by 300ms', async () => {
const wrapper = createWrapper()
const input = wrapper.find('input')
// Type search query
await input.setValue('test')
// Model should not update immediately
expect(wrapper.emitted('search')).toBeFalsy()
// Advance timers by 299ms (just before debounce delay)
await vi.advanceTimersByTimeAsync(299)
await nextTick()
expect(wrapper.emitted('search')).toBeFalsy()
// Advance timers by 1ms more (reaching 300ms)
await vi.advanceTimersByTimeAsync(1)
await nextTick()
// Model should now be updated
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['test'])
})
it('should reset debounce timer on each keystroke', async () => {
const wrapper = createWrapper()
const input = wrapper.find('input')
// Type first character
await input.setValue('t')
vi.advanceTimersByTime(200)
await nextTick()
// Type second character (should reset timer)
await input.setValue('te')
vi.advanceTimersByTime(200)
await nextTick()
// Type third character (should reset timer again)
await input.setValue('tes')
await vi.advanceTimersByTimeAsync(200)
await nextTick()
// Should not have emitted yet (only 200ms passed since last keystroke)
expect(wrapper.emitted('search')).toBeFalsy()
// Advance final 100ms to reach 300ms
await vi.advanceTimersByTimeAsync(100)
await nextTick()
// Should now emit with final value
expect(wrapper.emitted('search')).toBeTruthy()
expect(wrapper.emitted('search')?.[0]).toEqual(['tes', []])
})
it('should only emit final value after rapid typing', async () => {
const wrapper = createWrapper()
const input = wrapper.find('input')
// Simulate rapid typing
const searchTerms = ['s', 'se', 'sea', 'sear', 'searc', 'search']
for (const term of searchTerms) {
await input.setValue(term)
await vi.advanceTimersByTimeAsync(50) // Less than debounce delay
}
await nextTick()
// Should not have emitted yet
expect(wrapper.emitted('search')).toBeFalsy()
// Complete the debounce delay
await vi.advanceTimersByTimeAsync(350)
await nextTick()
// Should emit only once with final value
expect(wrapper.emitted('search')).toHaveLength(1)
expect(wrapper.emitted('search')?.[0]).toEqual(['search', []])
})
describe('bidirectional model sync', () => {
it('should sync external model changes to internal state', async () => {
const wrapper = createWrapper({ modelValue: 'initial' })
const input = wrapper.find('input')
expect(input.element.value).toBe('initial')
// Update model externally
await wrapper.setProps({ modelValue: 'external update' })
await nextTick()
// Internal state should sync
expect(input.element.value).toBe('external update')
})
})
describe('placeholder', () => {
it('should use custom placeholder when provided', () => {
const wrapper = createWrapper({ placeholder: 'Custom search...' })
const input = wrapper.find('input')
expect(input.attributes('placeholder')).toBe('Custom search...')
expect(input.attributes('aria-label')).toBe('Custom search...')
})
it('should use default placeholder when not provided', () => {
const wrapper = createWrapper()
const input = wrapper.find('input')
expect(input.attributes('placeholder')).toBe('Search...')
expect(input.attributes('aria-label')).toBe('Search...')
})
})
describe('autofocus', () => {
it('should focus input when autofocus is true', async () => {
const wrapper = createWrapper({ autofocus: true })
await nextTick()
const input = wrapper.find('input')
const inputElement = input.element as HTMLInputElement
// Note: In JSDOM, focus() doesn't actually set document.activeElement
// We can only verify that the focus method exists and doesn't throw
expect(inputElement.focus).toBeDefined()
})
it('should not autofocus when autofocus is false', () => {
const wrapper = createWrapper({ autofocus: false })
const input = wrapper.find('input')
expect(document.activeElement).not.toBe(input.element)
})
})
describe('click to focus', () => {
it('should focus input when wrapper is clicked', async () => {
const wrapper = createWrapper()
const wrapperDiv = wrapper.find('[class*="flex"]')
await wrapperDiv.trigger('click')
await nextTick()
// Input should receive focus
const input = wrapper.find('input').element as HTMLInputElement
expect(input.focus).toBeDefined()
})
})
})
})

View File

@@ -0,0 +1,139 @@
<template>
<div
:class="
cn(
'relative flex w-full cursor-text items-center gap-2 bg-comfy-input text-comfy-input-foreground',
customClass,
wrapperStyle
)
"
>
<InputText
ref="inputRef"
v-model="modelValue"
:placeholder
:autofocus
unstyled
:class="
cn(
'absolute inset-0 size-full border-none bg-transparent text-sm outline-none',
isLarge ? 'pl-11' : 'pl-8'
)
"
:aria-label="placeholder"
/>
<Button
v-if="filterIcon"
size="icon"
variant="textonly"
class="filter-button absolute inset-y-0 right-0 m-0 p-0"
@click="$emit('showFilter', $event)"
>
<i :class="filterIcon" />
</Button>
<InputIcon v-if="!modelValue" :class="icon" />
<Button
v-if="modelValue"
:class="cn('clear-button absolute', isLarge ? 'left-2' : 'left-0')"
variant="textonly"
size="icon"
@click="modelValue = ''"
>
<i class="icon-[lucide--x] size-4" />
</Button>
</div>
<div v-if="filters?.length" class="search-filters flex flex-wrap gap-2 pt-2">
<SearchFilterChip
v-for="filter in filters"
:key="filter.id"
:text="filter.text"
:badge="filter.badge"
:badge-class="filter.badgeClass"
@remove="$emit('removeFilter', filter)"
/>
</div>
</template>
<script setup lang="ts" generic="TFilter extends SearchFilter">
import { cn } from '@comfyorg/tailwind-utils'
import { watchDebounced } from '@vueuse/core'
import InputIcon from 'primevue/inputicon'
import InputText from 'primevue/inputtext'
import { computed, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import type { SearchFilter } from './SearchFilterChip.vue'
import SearchFilterChip from './SearchFilterChip.vue'
const {
placeholder = 'Search...',
icon = 'pi pi-search',
debounceTime = 300,
filterIcon,
filters = [],
autofocus = false,
showBorder = false,
size = 'md',
class: customClass
} = defineProps<{
placeholder?: string
icon?: string
debounceTime?: number
filterIcon?: string
filters?: TFilter[]
autofocus?: boolean
showBorder?: boolean
size?: 'md' | 'lg'
class?: string
}>()
const isLarge = computed(() => size === 'lg')
const emit = defineEmits<{
(e: 'search', value: string, filters: TFilter[]): void
(e: 'showFilter', event: Event): void
(e: 'removeFilter', filter: TFilter): void
}>()
const modelValue = defineModel<string>({ required: true })
const inputRef = ref()
defineExpose({
focus: () => {
inputRef.value?.$el?.focus()
}
})
watchDebounced(
modelValue,
(value: string) => {
emit('search', value, filters)
},
{ debounce: debounceTime }
)
const wrapperStyle = computed(() => {
if (showBorder) {
return cn(
'box-border rounded-sm border border-solid border-border-default p-2',
isLarge.value ? 'h-10' : 'h-8'
)
}
// Size-specific classes matching button sizes for consistency
const sizeClasses = {
md: 'h-8 px-2 py-1.5', // Matches button sm size
lg: 'h-10 px-4 py-2' // Matches button md size
}[size]
return cn('rounded-lg', sizeClasses)
})
</script>
<style scoped>
:deep(.p-inputtext) {
--p-form-field-padding-x: 0.625rem;
}
</style>

View File

@@ -0,0 +1,90 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import SearchBoxV2 from './SearchBoxV2.vue'
vi.mock('@vueuse/core', () => ({
watchDebounced: vi.fn(() => vi.fn())
}))
describe('SearchBoxV2', () => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
clear: 'Clear',
searchPlaceholder: 'Search...'
}
}
}
})
function mountComponent(props = {}) {
return mount(SearchBoxV2, {
global: {
plugins: [i18n],
stubs: {
ComboboxRoot: {
template: '<div><slot /></div>'
},
ComboboxAnchor: {
template: '<div><slot /></div>'
},
ComboboxInput: {
template:
'<input :placeholder="placeholder" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
props: ['placeholder', 'modelValue', 'autoFocus']
}
}
},
props: {
modelValue: '',
...props
}
})
}
it('uses i18n placeholder when no placeholder prop provided', () => {
const wrapper = mountComponent()
const input = wrapper.find('input')
expect(input.attributes('placeholder')).toBe('Search...')
})
it('uses custom placeholder when provided', () => {
const wrapper = mountComponent({
placeholder: 'Custom placeholder'
})
const input = wrapper.find('input')
expect(input.attributes('placeholder')).toBe('Custom placeholder')
})
it('shows search icon when search term is empty', () => {
const wrapper = mountComponent({ modelValue: '' })
expect(wrapper.find('i.icon-\\[lucide--search\\]').exists()).toBe(true)
})
it('shows clear button when search term is not empty', () => {
const wrapper = mountComponent({ modelValue: 'test' })
expect(wrapper.find('button').exists()).toBe(true)
})
it('clears search term when clear button is clicked', async () => {
const wrapper = mountComponent({ modelValue: 'test' })
const clearButton = wrapper.find('button')
await clearButton.trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([''])
})
it('applies large size classes when size is lg', () => {
const wrapper = mountComponent({ size: 'lg' })
expect(wrapper.html()).toContain('size-5')
})
it('applies medium size classes when size is md', () => {
const wrapper = mountComponent({ size: 'md' })
expect(wrapper.html()).toContain('size-4')
})
})

View File

@@ -0,0 +1,117 @@
<template>
<div class="flex flex-auto flex-col gap-2">
<ComboboxRoot :ignore-filter="true" :open="false">
<ComboboxAnchor
:class="
cn(
'relative flex w-full cursor-text items-center',
'rounded-lg bg-comfy-input text-comfy-input-foreground',
showBorder &&
'box-border border border-solid border-border-default',
sizeClasses,
className
)
"
>
<i
v-if="!searchTerm"
:class="cn('pointer-events-none absolute left-4', icon, iconClass)"
/>
<Button
v-else
class="absolute left-2"
variant="textonly"
size="icon"
:aria-label="$t('g.clear')"
@click="clearSearch"
>
<i class="icon-[lucide--x] size-4" />
</Button>
<ComboboxInput
ref="inputRef"
v-model="searchTerm"
:class="
cn(
'size-full border-none bg-transparent text-sm outline-none',
inputPadding
)
"
:placeholder="placeholderText"
:auto-focus="autofocus"
/>
</ComboboxAnchor>
</ComboboxRoot>
</div>
</template>
<script setup lang="ts">
import { cn } from '@/utils/tailwindUtil'
import { watchDebounced } from '@vueuse/core'
import { ComboboxAnchor, ComboboxInput, ComboboxRoot } from 'reka-ui'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
const { t } = useI18n()
const {
placeholder,
icon = 'icon-[lucide--search]',
debounceTime = 300,
autofocus = false,
showBorder = false,
size = 'md',
class: className
} = defineProps<{
placeholder?: string
icon?: string
debounceTime?: number
autofocus?: boolean
showBorder?: boolean
size?: 'md' | 'lg'
class?: string
}>()
const emit = defineEmits<{
search: [value: string]
}>()
const searchTerm = defineModel<string>({ required: true })
const inputRef = ref<InstanceType<typeof ComboboxInput> | null>(null)
defineExpose({
focus: () => {
inputRef.value?.$el?.focus()
}
})
const isLarge = computed(() => size === 'lg')
const placeholderText = computed(
() => placeholder ?? t('g.searchPlaceholder', { subject: '' })
)
const sizeClasses = computed(() => {
if (showBorder) {
return isLarge.value ? 'h-10 p-2' : 'h-8 p-2'
}
return isLarge.value ? 'h-12 px-4 py-2' : 'h-10 px-4 py-2'
})
const iconClass = computed(() => (isLarge.value ? 'size-5' : 'size-4'))
const inputPadding = computed(() => (isLarge.value ? 'pl-8' : 'pl-6'))
function clearSearch() {
searchTerm.value = ''
}
watchDebounced(
searchTerm,
(value: string) => {
emit('search', value)
},
{ debounce: debounceTime }
)
</script>

View File

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

View File

@@ -155,93 +155,6 @@ describe('VirtualGrid', () => {
wrapper.unmount()
})
it('emits approach-end for single-column list when scrolled near bottom', async () => {
const items = createItems(50)
mockedWidth.value = 400
mockedHeight.value = 600
mockedScrollY.value = 0
const wrapper = mount(VirtualGrid<TestItem>, {
props: {
items,
gridStyle: {
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr)'
},
defaultItemHeight: 48,
defaultItemWidth: 200,
maxColumns: 1,
bufferRows: 1
},
slots: {
item: `<template #item="{ item }">
<div class="test-item">{{ item.name }}</div>
</template>`
},
attachTo: document.body
})
await nextTick()
expect(wrapper.emitted('approach-end')).toBeUndefined()
// Scroll near the end: 50 items * 48px = 2400px total
// viewRows = ceil(600/48) = 13, buffer = 1
// Need toCol >= items.length - cols*bufferRows = 50 - 1 = 49
// toCol = (offsetRows + bufferRows + viewRows) * cols
// offsetRows = floor(scrollY / 48)
// Need (offsetRows + 1 + 13) * 1 >= 49 → offsetRows >= 35
// scrollY = 35 * 48 = 1680
mockedScrollY.value = 1680
await nextTick()
expect(wrapper.emitted('approach-end')).toBeDefined()
wrapper.unmount()
})
it('does not emit approach-end without maxColumns in single-column layout', async () => {
// Demonstrates the bug: without maxColumns=1, cols is calculated
// from width/itemWidth (400/200 = 2), causing incorrect row math
const items = createItems(50)
mockedWidth.value = 400
mockedHeight.value = 600
mockedScrollY.value = 0
const wrapper = mount(VirtualGrid<TestItem>, {
props: {
items,
gridStyle: {
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr)'
},
defaultItemHeight: 48,
defaultItemWidth: 200,
// No maxColumns — cols will be floor(400/200) = 2
bufferRows: 1
},
slots: {
item: `<template #item="{ item }">
<div class="test-item">{{ item.name }}</div>
</template>`
},
attachTo: document.body
})
await nextTick()
// Same scroll position as the passing test
mockedScrollY.value = 1680
await nextTick()
// With cols=2, toCol = (35+1+13)*2 = 98, which exceeds items.length (50)
// remainingCol = 50-98 = -48, hasMoreToRender = false → isNearEnd = false
// The approach-end never fires at the correct scroll position
expect(wrapper.emitted('approach-end')).toBeUndefined()
wrapper.unmount()
})
it('forces cols to maxColumns when maxColumns is finite', async () => {
mockedWidth.value = 100
mockedHeight.value = 200

View File

@@ -14,10 +14,10 @@
</template>
<template #header>
<SearchInput
<SearchBox
v-model="searchQuery"
size="lg"
class="max-w-96 flex-1"
class="max-w-[384px]"
autofocus
/>
</template>
@@ -178,7 +178,7 @@
v-show="isTemplateVisibleOnDistribution(template)"
:key="template.name"
ref="cardRefs"
size="compact"
size="tall"
variant="ghost"
rounded="lg"
:data-testid="`template-workflow-${template.name}`"
@@ -318,6 +318,20 @@
</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>
@@ -389,7 +403,7 @@ import CardBottom from '@/components/card/CardBottom.vue'
import CardContainer from '@/components/card/CardContainer.vue'
import CardTop from '@/components/card/CardTop.vue'
import SquareChip from '@/components/chip/SquareChip.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import SearchBox from '@/components/common/SearchBox.vue'
import MultiSelect from '@/components/input/MultiSelect.vue'
import SingleSelect from '@/components/input/SingleSelect.vue'
import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue'
@@ -483,6 +497,8 @@ 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

@@ -1,6 +1,6 @@
<template>
<div class="keybinding-panel flex flex-col gap-2">
<SearchInput
<SearchBox
v-model="filters['global'].value"
:placeholder="$t('g.searchPlaceholder', { subject: $t('g.keybindings') })"
/>
@@ -155,7 +155,7 @@ import { useToast } from 'primevue/usetoast'
import { computed, ref, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import SearchBox from '@/components/common/SearchBox.vue'
import Button from '@/components/ui/button/Button.vue'
import { KeyComboImpl } from '@/platform/keybindings/keyCombo'
import { KeybindingImpl } from '@/platform/keybindings/keybinding'

View File

@@ -5,8 +5,11 @@
v-if="isHelpCenterVisible"
class="help-center-popup"
:class="{
'sidebar-left': sidebarLocation === 'left',
'sidebar-right': sidebarLocation === 'right',
'sidebar-left':
triggerLocation === 'sidebar' && sidebarLocation === 'left',
'sidebar-right':
triggerLocation === 'sidebar' && sidebarLocation === 'right',
'topbar-right': triggerLocation === 'topbar',
'small-sidebar': isSmall
}"
>
@@ -60,6 +63,7 @@ const { isSmall = false } = defineProps<{
const {
isHelpCenterVisible,
triggerLocation,
sidebarLocation,
closeHelpCenter,
handleWhatsNewDismissed
@@ -97,6 +101,25 @@ 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

@@ -93,12 +93,13 @@
#header
>
<div class="flex flex-col px-2 pt-2 pb-0">
<SearchInput
<SearchBox
v-if="showSearchBox"
v-model="searchQuery"
:class="showSelectedCount || showClearButton ? 'mb-2' : ''"
:placeholder="searchPlaceholder"
size="sm"
:show-order="true"
:show-border="true"
:place-holder="searchPlaceholder"
/>
<div
v-if="showSelectedCount || showClearButton"
@@ -181,7 +182,7 @@ import MultiSelect from 'primevue/multiselect'
import { computed, useAttrs } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import SearchBox from '@/components/common/SearchBox.vue'
import Button from '@/components/ui/button/Button.vue'
import { usePopoverSizing } from '@/composables/usePopoverSizing'
import { cn } from '@/utils/tailwindUtil'

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 = await useLoad3dService().getLoad3dAsync(props.node)
const source = useLoad3dService().getLoad3d(props.node)
if (source) {
await viewer.initializeViewer(containerRef.value, source)
}

View File

@@ -72,12 +72,12 @@
/>
</div>
<SliderControl
v-model="brushSizeSliderValue"
v-model="brushSize"
class="flex-1"
label=""
:min="0"
:max="1"
:step="0.001"
:min="1"
:max="250"
:step="1"
/>
</div>
@@ -182,26 +182,6 @@ const brushSize = computed({
set: (value: number) => store.setBrushSize(value)
})
const rawSliderValue = ref<number | null>(null)
const brushSizeSliderValue = computed({
get: () => {
if (rawSliderValue.value !== null) {
const cachedSize = Math.round(Math.pow(250, rawSliderValue.value))
if (cachedSize === brushSize.value) {
return rawSliderValue.value
}
}
return Math.log(brushSize.value) / Math.log(250)
},
set: (value: number) => {
rawSliderValue.value = value
const size = Math.round(Math.pow(250, value))
store.setBrushSize(size)
}
})
const brushOpacity = computed({
get: () => store.brushSettings.opacity,
set: (value: number) => store.setBrushOpacity(value)

View File

@@ -1,6 +1,6 @@
<template>
<div class="flex min-w-0 items-center gap-2">
<SearchInput
<SearchBox
v-if="showSearch"
:model-value="searchQuery"
class="min-w-0 flex-1"
@@ -116,7 +116,7 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import SearchBox from '@/components/common/SearchBox.vue'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { jobSortModes } from '@/composables/queue/useJobList'

View File

@@ -11,13 +11,12 @@
}"
@click="onLogoMenuClick($event)"
>
<div class="flex items-center gap-0.5">
<div class="flex size-8 items-center justify-center rounded-lg bg-black">
<ComfyLogo
alt="ComfyUI Logo"
class="comfyui-logo h-[18px] w-[18px]"
class="comfyui-logo h-[18px] w-[18px] text-white"
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 :is-small="isSmall" />
<SidebarHelpCenterIcon v-if="!isIntegratedTabBar" :is-small="isSmall" />
<SidebarBottomPanelToggleButton v-if="!isCloud" :is-small="isSmall" />
<SidebarShortcutsToggleButton :is-small="isSmall" />
<SidebarSettingsButton :is-small="isSmall" />
@@ -95,6 +95,9 @@ 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

@@ -18,8 +18,6 @@
class="flex-1"
:items="assetItems"
:grid-style="listGridStyle"
:max-columns="1"
:default-item-height="48"
@approach-end="emit('approach-end')"
>
<template #item="{ item }">
@@ -35,7 +33,7 @@
tabindex="0"
:aria-label="
t('assetBrowser.ariaLabel.assetCard', {
name: getAssetDisplayName(item.asset),
name: item.asset.name,
type: getAssetMediaType(item.asset)
})
"
@@ -46,7 +44,7 @@
)
"
:preview-url="getAssetPreviewUrl(item.asset)"
:preview-alt="getAssetDisplayName(item.asset)"
:preview-alt="item.asset.name"
:icon-name="iconForMediaType(getAssetMediaType(item.asset))"
:is-video-preview="isVideoAsset(item.asset)"
:primary-text="getAssetPrimaryText(item.asset)"
@@ -135,12 +133,8 @@ const listGridStyle = {
gap: '0.5rem'
}
function getAssetDisplayName(asset: AssetItem): string {
return asset.display_name || asset.name
}
function getAssetPrimaryText(asset: AssetItem): string {
return truncateFilename(getAssetDisplayName(asset))
return truncateFilename(asset.name)
}
function getAssetMediaType(asset: AssetItem) {

View File

@@ -569,7 +569,7 @@ const handleZoomClick = (asset: AssetItem) => {
const dialogStore = useDialogStore()
dialogStore.showDialog({
key: 'asset-3d-viewer',
title: asset.display_name || asset.name,
title: asset.name,
component: Load3dViewerContent,
props: {
modelUrl: asset.preview_url || ''

View File

@@ -21,7 +21,7 @@
</template>
<template #header>
<div class="px-2 2xl:px-4">
<SearchInput
<SearchBox
ref="searchBoxRef"
v-model:model-value="searchQuery"
class="workflows-search-box"
@@ -146,7 +146,7 @@ import { computed, nextTick, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import SearchBox from '@/components/common/SearchBox.vue'
import TextDivider from '@/components/common/TextDivider.vue'
import TreeExplorer from '@/components/common/TreeExplorer.vue'
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'

View File

@@ -22,7 +22,7 @@
</template>
<template #header>
<div class="px-2 2xl:px-4">
<SearchInput
<SearchBox
ref="searchBoxRef"
v-model:model-value="searchQuery"
:placeholder="
@@ -56,7 +56,7 @@
import { Divider } from 'primevue'
import { computed, nextTick, onMounted, ref, toRef, watch } from 'vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import SearchBox from '@/components/common/SearchBox.vue'
import TreeExplorer from '@/components/common/TreeExplorer.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import ElectronDownloadItems from '@/components/sidebar/tabs/modelLibrary/ElectronDownloadItems.vue'

View File

@@ -86,40 +86,18 @@
</template>
<template #header>
<div class="px-2 2xl:px-4">
<div class="flex items-center gap-1">
<SearchInput
ref="searchBoxRef"
v-model="searchQuery"
data-testid="node-library-search"
class="node-lib-search-box"
:placeholder="
$t('g.searchPlaceholder', { subject: $t('g.nodes') })
"
@search="handleSearch"
/>
<Button
variant="textonly"
size="icon"
class="filter-button shrink-0"
:aria-label="$t('g.filter')"
@click="(e: Event) => searchFilter?.toggle(e)"
>
<i class="pi pi-filter" />
</Button>
</div>
<div
v-if="filters?.length"
class="search-filters flex flex-wrap gap-2 pt-2"
>
<SearchFilterChip
v-for="filter in filters"
:key="filter.id"
:text="filter.text"
:badge="filter.badge"
:badge-class="filter.badgeClass"
@remove="onRemoveFilter(filter)"
/>
</div>
<SearchBox
ref="searchBoxRef"
v-model:model-value="searchQuery"
data-testid="node-library-search"
class="node-lib-search-box"
:placeholder="$t('g.searchPlaceholder', { subject: $t('g.nodes') })"
filter-icon="pi pi-filter"
:filters
@search="handleSearch"
@show-filter="($event) => searchFilter?.toggle($event)"
@remove-filter="onRemoveFilter"
/>
<Popover ref="searchFilter" class="ml-[-13px]">
<NodeSearchFilter @add-filter="onAddFilter" />
@@ -177,9 +155,8 @@ import {
} from 'vue'
import { resolveEssentialsDisplayName } from '@/constants/essentialsDisplayNames'
import SearchFilterChip from '@/components/common/SearchFilterChip.vue'
import SearchBox from '@/components/common/SearchBox.vue'
import type { SearchFilter } from '@/components/common/SearchFilterChip.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import TreeExplorer from '@/components/common/TreeExplorer.vue'
import NodePreview from '@/components/node/NodePreview.vue'
import NodeSearchFilter from '@/components/searchbox/NodeSearchFilter.vue'

View File

@@ -69,7 +69,7 @@ vi.mock('./nodeLibrary/NodeDragPreview.vue', () => ({
}
}))
vi.mock('@/components/ui/search-input/SearchInput.vue', () => ({
vi.mock('@/components/common/SearchBoxV2.vue', () => ({
default: {
name: 'SearchBox',
template: '<input data-testid="search-box" />',

View File

@@ -3,7 +3,7 @@
<template #header>
<TabsRoot v-model="selectedTab" class="flex flex-col">
<div class="flex items-center justify-between gap-2 px-2 pb-2 2xl:px-4">
<SearchInput
<SearchBox
ref="searchBoxRef"
v-model="searchQuery"
:placeholder="$t('g.search') + '...'"
@@ -180,7 +180,7 @@ import { computed, nextTick, onMounted, ref, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import { resolveEssentialsDisplayName } from '@/constants/essentialsDisplayNames'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import SearchBox from '@/components/common/SearchBoxV2.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
import { usePerTabState } from '@/composables/usePerTabState'
@@ -253,7 +253,7 @@ const filterOptions = ref<Record<NodeCategoryId, boolean>>({
const { t } = useI18n()
const searchBoxRef = ref<InstanceType<typeof SearchInput> | null>(null)
const searchBoxRef = ref<InstanceType<typeof SearchBox> | null>(null)
const searchQuery = ref('')
const expandedKeysByTab = ref<Record<TabId, string[]>>({
essentials: [],

View File

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

View File

@@ -0,0 +1,21 @@
<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

@@ -83,18 +83,13 @@
v-if="isIntegratedTabBar"
class="ml-auto flex shrink-0 items-center gap-2 px-2"
>
<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" />
<TopMenuHelpButton />
<CurrentUserButton
v-if="isLoggedIn"
:show-arrow="false"
compact
class="grid w-10 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" />
@@ -107,20 +102,21 @@ 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 { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
import { isDesktop } from '@/platform/distribution/types'
import { whileMouseDown } from '@/utils/mouseDownUtil'
import WorkflowOverflowMenu from './WorkflowOverflowMenu.vue'
@@ -142,14 +138,8 @@ const commandStore = useCommandStore()
const { isLoggedIn } = useCurrentUser()
const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') !== 'Legacy'
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
)
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

@@ -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 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 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',
variants: {
variant: {
secondary:

View File

@@ -1,202 +0,0 @@
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, watch } from 'vue'
import { createI18n } from 'vue-i18n'
import SearchInput from './SearchInput.vue'
vi.mock('@vueuse/core', () => ({
watchDebounced: vi.fn((source, cb, opts) => {
let timer: ReturnType<typeof setTimeout> | null = null
return watch(source, (val: string) => {
if (timer) clearTimeout(timer)
timer = setTimeout(() => cb(val), opts?.debounce ?? 300)
})
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
clear: 'Clear',
searchPlaceholder: 'Search...'
}
}
}
})
describe('SearchInput', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
})
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
})
function mountComponent(props = {}) {
return mount(SearchInput, {
global: {
plugins: [i18n],
stubs: {
ComboboxRoot: {
template: '<div><slot /></div>'
},
ComboboxAnchor: {
template: '<div @click="$emit(\'click\')"><slot /></div>',
emits: ['click']
},
ComboboxInput: {
template:
'<input :placeholder="placeholder" :value="modelValue" :autofocus="autoFocus || undefined" @input="$emit(\'update:modelValue\', $event.target.value)" />',
props: ['placeholder', 'modelValue', 'autoFocus']
}
}
},
props: {
modelValue: '',
...props
}
})
}
describe('debounced search', () => {
it('should debounce search input by 300ms', async () => {
const wrapper = mountComponent()
const input = wrapper.find('input')
await input.setValue('test')
expect(wrapper.emitted('search')).toBeFalsy()
await vi.advanceTimersByTimeAsync(299)
await nextTick()
expect(wrapper.emitted('search')).toBeFalsy()
await vi.advanceTimersByTimeAsync(1)
await nextTick()
expect(wrapper.emitted('search')).toEqual([['test']])
})
it('should reset debounce timer on each keystroke', async () => {
const wrapper = mountComponent()
const input = wrapper.find('input')
await input.setValue('t')
vi.advanceTimersByTime(200)
await nextTick()
await input.setValue('te')
vi.advanceTimersByTime(200)
await nextTick()
await input.setValue('tes')
await vi.advanceTimersByTimeAsync(200)
await nextTick()
expect(wrapper.emitted('search')).toBeFalsy()
await vi.advanceTimersByTimeAsync(100)
await nextTick()
expect(wrapper.emitted('search')).toBeTruthy()
expect(wrapper.emitted('search')?.[0]).toEqual(['tes'])
})
it('should only emit final value after rapid typing', async () => {
const wrapper = mountComponent()
const input = wrapper.find('input')
const searchTerms = ['s', 'se', 'sea', 'sear', 'searc', 'search']
for (const term of searchTerms) {
await input.setValue(term)
await vi.advanceTimersByTimeAsync(50)
}
await nextTick()
expect(wrapper.emitted('search')).toBeFalsy()
await vi.advanceTimersByTimeAsync(350)
await nextTick()
expect(wrapper.emitted('search')).toHaveLength(1)
expect(wrapper.emitted('search')?.[0]).toEqual(['search'])
})
})
describe('model sync', () => {
it('should sync external model changes to internal state', async () => {
const wrapper = mountComponent({ modelValue: 'initial' })
const input = wrapper.find('input')
expect(input.element.value).toBe('initial')
await wrapper.setProps({ modelValue: 'external update' })
await nextTick()
expect(input.element.value).toBe('external update')
})
})
describe('placeholder', () => {
it('should use custom placeholder when provided', () => {
const wrapper = mountComponent({ placeholder: 'Custom search...' })
const input = wrapper.find('input')
expect(input.attributes('placeholder')).toBe('Custom search...')
})
it('should use i18n placeholder when not provided', () => {
const wrapper = mountComponent()
const input = wrapper.find('input')
expect(input.attributes('placeholder')).toBe('Search...')
})
})
describe('autofocus', () => {
it('should pass autofocus prop to ComboboxInput', () => {
const wrapper = mountComponent({ autofocus: true })
const input = wrapper.find('input')
expect(input.attributes('autofocus')).toBeDefined()
})
it('should not autofocus by default', () => {
const wrapper = mountComponent()
const input = wrapper.find('input')
expect(input.attributes('autofocus')).toBeUndefined()
})
})
describe('focus method', () => {
it('should expose focus method via ref', () => {
const wrapper = mountComponent()
expect(wrapper.vm.focus).toBeDefined()
})
})
describe('clear button', () => {
it('shows search icon when value is empty', () => {
const wrapper = mountComponent({ modelValue: '' })
expect(wrapper.find('button[aria-label="Clear"]').exists()).toBe(false)
})
it('shows clear button when value is not empty', () => {
const wrapper = mountComponent({ modelValue: 'test' })
expect(wrapper.find('button[aria-label="Clear"]').exists()).toBe(true)
})
it('clears value when clear button is clicked', async () => {
const wrapper = mountComponent({ modelValue: 'test' })
const clearButton = wrapper.find('button')
await clearButton.trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([''])
})
})
})

View File

@@ -1,15 +1,11 @@
<template>
<ComboboxRoot
:ignore-filter="true"
:open="false"
:disabled="disabled"
:class="className"
>
<ComboboxRoot :ignore-filter="true" :open="false" :disabled="disabled">
<ComboboxAnchor
:class="
cn(
searchInputVariants({ size }),
disabled && 'pointer-events-none opacity-50'
disabled && 'pointer-events-none opacity-50',
className
)
"
@click="focus"

View File

@@ -1,77 +0,0 @@
import type {
ComponentPropsAndSlots,
Meta,
StoryObj
} from '@storybook/vue3-vite'
import { computed, ref, toRefs } from 'vue'
import Slider from './Slider.vue'
interface StoryArgs extends ComponentPropsAndSlots<typeof Slider> {
disabled: boolean
}
const meta: Meta<StoryArgs> = {
title: 'Components/Slider',
component: Slider,
tags: ['autodocs'],
parameters: { layout: 'centered' },
argTypes: {
min: { control: 'number' },
max: { control: 'number' },
step: { control: 'number' },
disabled: { control: 'boolean' }
},
args: {
min: 0,
max: 100,
step: 1,
disabled: false
},
decorators: [
(story) => ({
components: { story },
template: '<div class="w-72"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: (args) => ({
components: { Slider },
setup() {
const { min, max, step, disabled } = toRefs(args)
const value = ref([36])
const display = computed(() => value.value[0])
return { value, display, min, max, step, disabled }
},
template: `
<div class="flex items-center gap-4 rounded-lg bg-component-node-widget-background px-3 py-2">
<Slider v-model="value" :min :max :step :disabled class="flex-1" />
<span class="w-14 shrink-0 text-right text-xs text-component-node-foreground">{{ display }}</span>
</div>
`
})
}
export const Disabled: Story = {
args: { disabled: true },
render: (args) => ({
components: { Slider },
setup() {
const { min, max, step, disabled } = toRefs(args)
const value = ref([36])
const display = computed(() => value.value[0])
return { value, display, min, max, step, disabled }
},
template: `
<div class="flex items-center gap-4 rounded-lg bg-component-node-widget-background px-3 py-2">
<Slider v-model="value" :min :max :step :disabled class="flex-1" />
<span class="w-14 shrink-0 text-right text-xs text-component-node-foreground">{{ display }}</span>
</div>
`
})
}

View File

@@ -12,7 +12,7 @@
</template>
<template #header>
<SearchInput v-model="searchQuery" size="lg" class="max-w-96 flex-1" />
<SearchBox v-model="searchQuery" size="lg" class="max-w-[384px]" />
</template>
<template #header-right-area>
@@ -130,7 +130,7 @@ import CardBottom from '@/components/card/CardBottom.vue'
import CardContainer from '@/components/card/CardContainer.vue'
import CardTop from '@/components/card/CardTop.vue'
import SquareChip from '@/components/chip/SquareChip.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import SearchBox from '@/components/common/SearchBox.vue'
import MultiSelect from '@/components/input/MultiSelect.vue'
import SingleSelect from '@/components/input/SingleSelect.vue'
import Button from '@/components/ui/button/Button.vue'

View File

@@ -8,7 +8,7 @@ import CardContainer from '@/components/card/CardContainer.vue'
import CardTop from '@/components/card/CardTop.vue'
import SquareChip from '@/components/chip/SquareChip.vue'
import MultiSelect from '@/components/input/MultiSelect.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import SearchBox from '@/components/common/SearchBox.vue'
import SingleSelect from '@/components/input/SingleSelect.vue'
import type { NavGroupData, NavItemData } from '@/types/navTypes'
import { OnCloseKey } from '@/types/widgetTypes'
@@ -68,7 +68,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
components: {
BaseModalLayout,
LeftSidePanel,
SearchInput,
SearchBox,
MultiSelect,
SingleSelect,
Button,
@@ -186,7 +186,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<!-- Header -->
<template v-if="args.hasHeader" #header>
<SearchInput
<SearchBox
class="max-w-[384px]"
size="lg"
:modelValue="searchQuery"
@@ -309,7 +309,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<!-- Header -->
<template v-if="args.hasHeader" #header>
<SearchInput
<SearchBox
class="max-w-[384px]"
size="lg"
:modelValue="searchQuery"

View File

@@ -5,15 +5,19 @@ 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() {
export function useHelpCenter(
triggerFrom: HelpCenterTriggerLocation = 'sidebar'
) {
const settingStore = useSettingStore()
const releaseStore = useReleaseStore()
const helpCenterStore = useHelpCenterStore()
const { isVisible: isHelpCenterVisible } = storeToRefs(helpCenterStore)
const { isVisible: isHelpCenterVisible, triggerLocation } =
storeToRefs(helpCenterStore)
const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
const conflictDetection = useConflictDetection()
@@ -38,9 +42,9 @@ export function useHelpCenter() {
*/
const toggleHelpCenter = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'sidebar_help_center_toggled'
button_id: `${triggerFrom}_help_center_toggled`
})
helpCenterStore.toggle()
helpCenterStore.toggle(triggerFrom)
}
const closeHelpCenter = () => {
@@ -86,6 +90,7 @@ export function useHelpCenter() {
return {
isHelpCenterVisible,
triggerLocation,
shouldShowRedDot,
sidebarLocation,
toggleHelpCenter,

View File

@@ -1,7 +1,6 @@
import { computed, watch } from 'vue'
import { computed } 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'
@@ -18,20 +17,16 @@ 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,14 +1,21 @@
import { t } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { buildFeedbackUrl } from '@/platform/support/config'
import { getDistribution, ZENDESK_FIELDS } from '@/platform/support/config'
import { useExtensionService } from '@/services/extensionService'
import type { ActionBarButton } from '@/types/comfy'
const feedbackUrl = buildFeedbackUrl()
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 buttons: ActionBarButton[] = [
{
icon: 'icon-[lucide--message-square-text]',
icon: 'icon-[lucide--message-circle-question-mark]',
label: t('actionbar.feedback'),
tooltip: t('actionbar.feedbackTooltip'),
onClick: () => {
@@ -18,10 +25,6 @@ const buttons: ActionBarButton[] = [
]
useExtensionService().registerExtension({
name: 'Comfy.FeedbackButton',
get actionBarButtons() {
return useSettingStore().get('Comfy.UI.TabBarLayout') === 'Legacy'
? buttons
: []
}
name: 'Comfy.Cloud.FeedbackButton',
actionBarButtons: buttons
})

View File

@@ -559,8 +559,6 @@ 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
@@ -5182,10 +5180,8 @@ 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 - (lineCount + 1) * lineHeight
y = y || this.canvas.offsetHeight - 80
ctx.save()
ctx.translate(x, y)
@@ -5193,26 +5189,18 @@ 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,
lineHeight * line++
)
ctx.fillText(`I: ${this.graph.iteration}`, 5, lineHeight * line++)
ctx.fillText(`T: ${this.graph.globaltime.toFixed(2)}s`, 5, 13 * 1)
ctx.fillText(`I: ${this.graph.iteration}`, 5, 13 * 2)
ctx.fillText(
`N: ${this.graph._nodes.length} [${this.visible_nodes.length}]`,
5,
lineHeight * line++
13 * 3
)
ctx.fillText(`V: ${this.graph._version}`, 5, lineHeight * line++)
ctx.fillText(`FPS:${this.fps.toFixed(2)}`, 5, lineHeight * line++)
ctx.fillText(`V: ${this.graph._version}`, 5, 13 * 4)
ctx.fillText(`FPS:${this.fps.toFixed(2)}`, 5, 13 * 5)
} else {
ctx.fillText('No graph selected', 5, lineHeight * line++)
}
if (this.info_text) {
ctx.fillText(this.info_text, 5, lineHeight * line++)
ctx.fillText('No graph selected', 5, 13 * 1)
}
ctx.restore()
}

View File

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

View File

@@ -2,9 +2,7 @@
"actionbar": {
"dockToTop": "إلصق بالأعلى",
"feedback": "ملاحظات",
"feedbackTooltip": "إرسال ملاحظات",
"share": "مشاركة",
"shareTooltip": "مشاركة سير العمل"
"feedbackTooltip": "إرسال ملاحظات"
},
"apiNodesCostBreakdown": {
"costPerRun": "التكلفة لكل تشغيل",
@@ -323,24 +321,18 @@
"y": "ص"
},
"breadcrumbsMenu": {
"app": "التطبيق",
"clearWorkflow": "مسح سير العمل",
"deleteBlueprint": "حذف المخطط",
"deleteWorkflow": "حذف سير العمل",
"duplicate": "تكرار",
"editBuilderMode": "تعديل التطبيق",
"enterAppMode": "الدخول إلى وضع التطبيق",
"enterBuilderMode": "دخول وضع بناء التطبيق",
"enterNewName": "أدخل اسمًا جديدًا",
"enterNodeGraph": "دخول رسم العقد",
"exitAppMode": "الخروج من وضع التطبيق",
"graph": "الرسم البياني",
"missingNodesWarning": "يحتوي سير العمل على عقد غير مدعومة (مظللة باللون الأحمر).",
"share": "مشاركة",
"workflowActions": "إجراءات سير العمل"
},
"builderMenu": {
"enterAppMode": "الدخول إلى وضع التطبيق",
"exitAppBuilder": "الخروج من مُنشئ التطبيق"
},
"builderToolbar": {
@@ -362,7 +354,6 @@
"defaultViewTitle": "تعيين العرض الافتراضي لهذا سير العمل",
"emptyWorkflowPrompt": "هل ترغب في البدء بقالب؟",
"emptyWorkflowTitle": "لا يحتوي سير العمل هذا على أي عقد",
"exitToWorkflow": "الخروج إلى سير العمل",
"inputs": "المدخلات",
"inputsDescription": "اختر المدخلات",
"label": "منشئ التطبيقات",
@@ -516,80 +507,6 @@
"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": "فتح مساحة القص",
@@ -952,7 +869,6 @@
"collapseAll": "طي الكل",
"color": "اللون",
"comfy": "Comfy",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "شعار ComfyOrg",
"comingSoon": "قريباً",
"command": "أمر",
@@ -1426,7 +1342,7 @@
"appBuilder": "منشئ التطبيقات",
"apps": "التطبيقات",
"appsEmptyMessage": "سيتم عرض التطبيقات المحفوظة هنا.\nانقر أدناه لبناء تطبيقك الأول.",
"appsEmptyMessageAction": "انقر أدناه لإنشاء أول تطبيق لك."
"enterAppMode": "الدخول إلى وضع التطبيق"
},
"arrange": {
"atLeastOne": "عقدة واحدة على الأقل",
@@ -1440,18 +1356,14 @@
},
"backToWorkflow": "العودة إلى سير العمل",
"beta": "وضع التطبيق تجريبي - أرسل ملاحظاتك",
"buildAnApp": "أنشئ تطبيقًا",
"builder": {
"exit": "خروج من البناء",
"exitConfirmMessage": "لديك تغييرات غير محفوظة ستفقد\nهل تريد الخروج بدون حفظ؟",
"exitConfirmTitle": "الخروج من بناء التطبيق؟",
"inputPlaceholder": "سيتم عرض المدخلات هنا",
"inputsDesc": "سيتفاعل المستخدمون مع هذه المدخلات ويعدلونها لإنشاء النتائج.",
"inputsExample": "أمثلة: \"تحميل صورة\"، \"موجه نصي\"، \"خطوات\"",
"noInputs": "لم تتم إضافة أي مدخلات بعد",
"noOutputs": "لم تتم إضافة أي عقد إخراج بعد",
"outputPlaceholder": "سيتم عرض عقد الإخراج هنا",
"outputRequiredPlaceholder": "مطلوب عقدة واحدة على الأقل",
"outputsDesc": "وصل عقدة إخراج واحدة على الأقل حتى يتمكن المستخدمون من رؤية النتائج بعد التشغيل.",
"outputsExample": "أمثلة: \"حفظ صورة\" أو \"حفظ فيديو\"",
"promptAddInputs": "انقر على معلمات العقدة لإضافتها هنا كمدخلات",
@@ -1459,15 +1371,12 @@
"title": "وضع بناء التطبيق",
"unknownWidget": "عنصر الواجهة غير مرئي"
},
"cancelThisRun": "إلغاء هذا التشغيل",
"deleteAllAssets": "حذف جميع الأصول من هذه الجلسة",
"downloadAll": "تنزيل الكل",
"dragAndDropImage": "اسحب وأسقط صورة",
"emptyWorkflowExplanation": "سير العمل الخاص بك فارغ. تحتاج إلى بعض العقد أولاً لبدء بناء التطبيق.",
"enterNodeGraph": "دخول مخطط العقد",
"giveFeedback": "إعطاء ملاحظات",
"graphMode": "وضع الرسم البياني",
"hasCreditCost": "يتطلب أرصدة إضافية",
"linearMode": "وضع التطبيق",
"loadTemplate": "تحميل قالب",
"mobileControls": "تعديل وتشغيل",
@@ -1484,8 +1393,6 @@
"controls": "تظهر المخرجات في الأسفل، وعناصر التحكم على اليمين. كل شيء آخر يبقى بعيدًا.",
"getStarted": "انقر على {runButton} للبدء.",
"message": "عرض مبسط يخفي رسم العقد حتى تتمكن من التركيز على الإنشاء.",
"noOutputs": "يحتاج التطبيق إلى {count} على الأقل ليكون قابلاً للاستخدام.",
"oneOutput": "مخرج واحد",
"sharing": "المشاركة سهلة: أنشئ سير العمل الخاص بك، افتح وضع التطبيق، انقر بزر الماوس الأيمن على علامة التبويب، ثم صدّر. عندما يفتح الآخرون ملفك، سيتم تشغيله مباشرة في هذا العرض النظيف. يمكنك مشاركة سير عمل قوي كأداة بسيطة دون الحاجة لفهم مخططات العقد.",
"title": "مرحبًا بك في وضع التطبيق"
}
@@ -1858,6 +1765,7 @@
"execute": "تنفيذ",
"fullscreen": "ملء الشاشة",
"help": "مساعدة",
"helpAndFeedback": "المساعدة والتعليقات",
"hideMenu": "إخفاء القائمة",
"instant": "فوري",
"instantTooltip": "سيتم وضع سير العمل في قائمة الانتظار فور انتهاء التوليد",
@@ -1958,7 +1866,6 @@
"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": "سير العمل السابق المفتوح",
@@ -2130,7 +2037,6 @@
"lotus": "lotus",
"ltxv": "ltxv",
"mask": "قناع",
"math": "رياضيات",
"model": "نموذج",
"model_merging": "دمج النماذج",
"model_patches": "تصحيحات النموذج",
@@ -2234,18 +2140,6 @@
},
"title": "جهازك غير مدعوم"
},
"openSharedWorkflow": {
"author": "المؤلف:",
"copyAssetsAndOpen": "استيراد الأصول وفتح سير العمل",
"copyDescription": "فتح سير العمل سينشئ نسخة جديدة في مساحة العمل الخاصة بك",
"dialogTitle": "فتح سير العمل المشترك",
"importFailed": "فشل استيراد أصول سير العمل",
"loadError": "تعذر تحميل سير العمل المشترك هذا. يرجى المحاولة لاحقًا.",
"nonPublicAssetsWarningLine1": "يأتي هذا سير العمل مع أصول غير عامة.",
"nonPublicAssetsWarningLine2": "سيتم استيراد هذه الأصول إلى مكتبتك عند فتح سير العمل",
"openWithoutImporting": "فتح بدون استيراد",
"openWorkflow": "فتح سير العمل"
},
"painter": {
"background": "الخلفية",
"brush": "فرشاة",
@@ -2704,47 +2598,6 @@
"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": "اختصارات لوحة المفاتيح",
@@ -3376,7 +3229,6 @@
"addedToWorkspace": "تمت إضافتك إلى {workspaceName}",
"inviteAccepted": "تم قبول الدعوة",
"inviteFailed": "فشل في قبول الدعوة",
"switchFailed": "فشل في تبديل مساحة العمل. يرجى المحاولة مرة أخرى.",
"viewWorkspace": "عرض مساحة العمل"
},
"workspaceAuth": {

View File

@@ -1419,25 +1419,6 @@
}
}
},
"ComfyMathExpression": {
"display_name": "تعبير رياضي",
"inputs": {
"expression": {
"name": "تعبير"
},
"values": {
"name": "القيم"
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"tooltip": null
}
}
},
"ComfySwitchNode": {
"display_name": "مفتاح التحويل",
"inputs": {
@@ -15131,64 +15112,6 @@
}
}
},
"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": {

View File

@@ -400,7 +400,7 @@
"name": "تخطيط شريط التبويبات",
"options": {
"Default": "افتراضي",
"Legacy": "تقليدي"
"Integrated": "مُدمج"
},
"tooltip": "يتحكم في تخطيط شريط التبويبات. \"مُدمج\" ينقل عناصر المساعدة والتحكمات الخاصة بالمستخدم إلى منطقة شريط التبويبات."
},

View File

@@ -71,9 +71,6 @@
"Comfy_Canvas_PasteFromClipboard": {
"label": "Paste"
},
"Comfy_Canvas_PasteFromClipboardWithConnect": {
"label": "Paste with Connect"
},
"Comfy_Canvas_ResetView": {
"label": "Reset View"
},

View File

@@ -301,7 +301,6 @@
"1x": "1x",
"2x": "2x",
"beta": "BETA",
"comfyCloud": "Comfy Cloud",
"nightly": "NIGHTLY",
"profile": "Profile",
"noItems": "No items",
@@ -969,6 +968,7 @@
"customNodesManager": "Custom Nodes Manager",
"settings": "Settings",
"help": "Help",
"helpAndFeedback": "Help & Feedback",
"queue": "Queue Panel",
"fullscreen": "Fullscreen"
},
@@ -1330,6 +1330,7 @@
"Rename": "Rename",
"Save": "Save",
"Save As": "Save As",
"Share": "Share",
"Show Settings Dialog": "Show Settings Dialog",
"Set Subgraph Description": "Set Subgraph Description",
"Set Subgraph Search Aliases": "Set Subgraph Search Aliases",
@@ -1596,7 +1597,6 @@
"kandinsky5": "kandinsky5",
"hooks": "hooks",
"combine": "combine",
"math": "math",
"logic": "logic",
"cond single": "cond single",
"context": "context",
@@ -3181,6 +3181,7 @@
"deleteAllAssets": "Delete all assets from this run",
"hasCreditCost": "Requires additional credits",
"viewGraph": "View node graph",
"mobileNoWorkflow": "This workflow hasn't been built for app mode. Try a different one.",
"welcome": {
"title": "App Mode",
"message": "A simplified view that hides the node graph so you can focus on creating.",

View File

@@ -1419,25 +1419,6 @@
}
}
},
"ComfyMathExpression": {
"display_name": "Math Expression",
"inputs": {
"expression": {
"name": "expression"
},
"values": {
"name": "values"
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"tooltip": null
}
}
},
"ComfySwitchNode": {
"display_name": "Switch",
"inputs": {
@@ -15131,64 +15112,6 @@
}
}
},
"TencentModelTo3DUVNode": {
"display_name": "Hunyuan3D: Model to UV",
"description": "Perform UV unfolding on a 3D model to generate UV texture. Input model must have less than 30000 faces.",
"inputs": {
"model_3d": {
"name": "model_3d",
"tooltip": "Input 3D model (GLB, OBJ, or FBX)"
},
"seed": {
"name": "seed",
"tooltip": "Seed controls whether the node should re-run; results are non-deterministic regardless of seed."
},
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
"0": {
"name": "OBJ",
"tooltip": null
},
"1": {
"name": "FBX",
"tooltip": null
}
}
},
"TencentSmartTopologyNode": {
"display_name": "Hunyuan3D: Smart Topology",
"description": "Perform smart retopology on a 3D model. Supports GLB/OBJ formats; max 200MB; recommended for high-poly models.",
"inputs": {
"model_3d": {
"name": "model_3d",
"tooltip": "Input 3D model (GLB or OBJ)"
},
"polygon_type": {
"name": "polygon_type",
"tooltip": "Surface composition type."
},
"face_level": {
"name": "face_level",
"tooltip": "Polygon reduction level."
},
"seed": {
"name": "seed",
"tooltip": "Seed controls whether the node should re-run; results are non-deterministic regardless of seed."
},
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
"0": {
"name": "OBJ",
"tooltip": null
}
}
},
"TencentTextToModelNode": {
"display_name": "Hunyuan3D: Text to Model",
"inputs": {

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