Compare commits
34 Commits
v1.41.14
...
fix/load-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6e5ff1b37 | ||
|
|
2ef354447d | ||
|
|
55789ef0fb | ||
|
|
7add2c03e9 | ||
|
|
c81bc8400c | ||
|
|
af5a72021b | ||
|
|
4e5bb3e540 | ||
|
|
2ccfb822b4 | ||
|
|
370003da94 | ||
|
|
3b5af4960f | ||
|
|
46895ee1a9 | ||
|
|
7f0472fde4 | ||
|
|
24ac6388d7 | ||
|
|
6b6049e48e | ||
|
|
592f992d1d | ||
|
|
76fd80aa98 | ||
|
|
63c36d3f2f | ||
|
|
892a9cf2c5 | ||
|
|
308c22efc6 | ||
|
|
5728d240da | ||
|
|
acf2f4280c | ||
|
|
7ad6994d01 | ||
|
|
2829f78579 | ||
|
|
c4156d7059 | ||
|
|
725a0a2b89 | ||
|
|
8a5bcde168 | ||
|
|
83ffaf30c8 | ||
|
|
2875f897dc | ||
|
|
ec129de63d | ||
|
|
1687ca93b3 | ||
|
|
5bb742ac3a | ||
|
|
ca2d61f393 | ||
|
|
750a2d23e0 | ||
|
|
6d90bf3537 |
@@ -23,7 +23,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: lts/*
|
node-version-file: '.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|
||||||
- name: Update electron types
|
- name: Update electron types
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: lts/*
|
node-version-file: '.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: lts/*
|
node-version-file: '.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
- name: Use Node.js
|
- name: Use Node.js
|
||||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version: 'lts/*'
|
node-version-file: '.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ jobs:
|
|||||||
- name: Use Node.js
|
- name: Use Node.js
|
||||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version: 'lts/*'
|
node-version-file: '.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
@@ -82,7 +82,7 @@ jobs:
|
|||||||
- name: Use Node.js
|
- name: Use Node.js
|
||||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version: 'lts/*'
|
node-version-file: '.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
|||||||
13
.github/workflows/cloud-dispatch-build.yaml
vendored
@@ -14,7 +14,7 @@ on:
|
|||||||
- 'cloud/*'
|
- 'cloud/*'
|
||||||
- 'main'
|
- 'main'
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [labeled]
|
types: [labeled, synchronize]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions: {}
|
permissions: {}
|
||||||
@@ -26,11 +26,18 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
dispatch:
|
dispatch:
|
||||||
# Fork guard: prevent forks from dispatching to the cloud repo.
|
# Fork guard: prevent forks from dispatching to the cloud repo.
|
||||||
# For pull_request events, only dispatch when the 'preview' label is added.
|
# 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.
|
||||||
if: >
|
if: >
|
||||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||||
(github.event_name != 'pull_request' ||
|
(github.event_name != 'pull_request' ||
|
||||||
github.event.label.name == 'preview')
|
(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'))))
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Build client payload
|
- name: Build client payload
|
||||||
|
|||||||
2
.github/workflows/pr-claude-review.yaml
vendored
@@ -36,7 +36,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version-file: '.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|
||||||
- name: Install dependencies for analysis tools
|
- name: Install dependencies for analysis tools
|
||||||
|
|||||||
2
.github/workflows/pr-perf-report.yaml
vendored
@@ -25,7 +25,7 @@ jobs:
|
|||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version-file: '.nvmrc'
|
||||||
|
|
||||||
- name: Download PR metadata
|
- name: Download PR metadata
|
||||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '24.x'
|
node-version-file: '.nvmrc'
|
||||||
|
|
||||||
- name: Read desktop-ui version
|
- name: Read desktop-ui version
|
||||||
id: get_version
|
id: get_version
|
||||||
|
|||||||
2
.github/workflows/publish-desktop-ui.yaml
vendored
@@ -91,7 +91,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '24.x'
|
node-version-file: '.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
registry-url: https://registry.npmjs.org
|
registry-url: https://registry.npmjs.org
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: lts/*
|
node-version-file: 'frontend/.nvmrc'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|||||||
2
.github/workflows/release-branch-create.yaml
vendored
@@ -26,7 +26,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 'lts/*'
|
node-version-file: '.nvmrc'
|
||||||
|
|
||||||
- name: Check version bump type
|
- name: Check version bump type
|
||||||
id: check_version
|
id: check_version
|
||||||
|
|||||||
2
.github/workflows/release-draft-create.yaml
vendored
@@ -26,7 +26,7 @@ jobs:
|
|||||||
version: 10
|
version: 10
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 'lts/*'
|
node-version-file: '.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|
||||||
- name: Get current version
|
- name: Get current version
|
||||||
|
|||||||
2
.github/workflows/release-npm-types.yaml
vendored
@@ -82,7 +82,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 'lts/*'
|
node-version-file: '.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
registry-url: https://registry.npmjs.org
|
registry-url: https://registry.npmjs.org
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/release-pypi-dev.yaml
vendored
@@ -22,7 +22,7 @@ jobs:
|
|||||||
version: 10
|
version: 10
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 'lts/*'
|
node-version-file: '.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|
||||||
- name: Get current version
|
- name: Get current version
|
||||||
|
|||||||
2
.github/workflows/release-version-bump.yaml
vendored
@@ -149,7 +149,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: lts/*
|
node-version-file: '.nvmrc'
|
||||||
|
|
||||||
- name: Bump version
|
- name: Bump version
|
||||||
id: bump-version
|
id: bump-version
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '24.x'
|
node-version-file: '.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|
||||||
- name: Bump desktop-ui version
|
- name: Bump desktop-ui version
|
||||||
|
|||||||
2
.github/workflows/weekly-docs-check.yaml
vendored
@@ -35,7 +35,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version-file: '.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|
||||||
- name: Install dependencies for analysis tools
|
- name: Install dependencies for analysis tools
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ Have another idea? Drop into Discord or open an issue, and let's chat!
|
|||||||
### Prerequisites & Technology Stack
|
### Prerequisites & Technology Stack
|
||||||
|
|
||||||
- **Required Software**:
|
- **Required Software**:
|
||||||
- Node.js (v24) and pnpm
|
- Node.js (see `.nvmrc`, currently v24) and pnpm
|
||||||
- Git for version control
|
- Git for version control
|
||||||
- A running ComfyUI backend instance (otherwise, you can use `pnpm dev:cloud`)
|
- A running ComfyUI backend instance (otherwise, you can use `pnpm dev:cloud`)
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ cp -r tools/devtools/* /path/to/your/ComfyUI/custom_nodes/ComfyUI_devtools/
|
|||||||
|
|
||||||
### Node.js & Playwright Prerequisites
|
### Node.js & Playwright Prerequisites
|
||||||
|
|
||||||
Ensure you have Node.js v20 or v22 installed. Then, set up the Chromium test driver:
|
Ensure you have the Node.js version from `.nvmrc` installed (currently v24).
|
||||||
|
Then, set up the Chromium test driver:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm exec playwright install chromium --with-deps
|
pnpm exec playwright install chromium --with-deps
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 107 KiB |
62
docs/release-process.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# 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 |
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@comfyorg/comfyui-frontend",
|
"name": "@comfyorg/comfyui-frontend",
|
||||||
"version": "1.41.14",
|
"version": "1.42.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Official front-end implementation of ComfyUI",
|
"description": "Official front-end implementation of ComfyUI",
|
||||||
"homepage": "https://comfy.org",
|
"homepage": "https://comfy.org",
|
||||||
@@ -195,6 +195,9 @@
|
|||||||
"zip-dir": "^2.0.0",
|
"zip-dir": "^2.0.0",
|
||||||
"zod-to-json-schema": "catalog:"
|
"zod-to-json-schema": "catalog:"
|
||||||
},
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "24.x"
|
||||||
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"vite": "catalog:"
|
"vite": "catalog:"
|
||||||
|
|||||||
@@ -166,13 +166,22 @@ describe('TopMenuSection', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('authentication state', () => {
|
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', () => {
|
describe('when user is logged in', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockData.isLoggedIn = true
|
mockData.isLoggedIn = true
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should display CurrentUserButton and not display LoginButton', () => {
|
it('should display CurrentUserButton and not display LoginButton', () => {
|
||||||
const wrapper = createWrapper()
|
const wrapper = createLegacyTabBarWrapper()
|
||||||
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(true)
|
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(true)
|
||||||
expect(wrapper.findComponent(LoginButton).exists()).toBe(false)
|
expect(wrapper.findComponent(LoginButton).exists()).toBe(false)
|
||||||
})
|
})
|
||||||
@@ -186,7 +195,7 @@ describe('TopMenuSection', () => {
|
|||||||
describe('on desktop platform', () => {
|
describe('on desktop platform', () => {
|
||||||
it('should display LoginButton and not display CurrentUserButton', () => {
|
it('should display LoginButton and not display CurrentUserButton', () => {
|
||||||
mockData.isDesktop = true
|
mockData.isDesktop = true
|
||||||
const wrapper = createWrapper()
|
const wrapper = createLegacyTabBarWrapper()
|
||||||
expect(wrapper.findComponent(LoginButton).exists()).toBe(true)
|
expect(wrapper.findComponent(LoginButton).exists()).toBe(true)
|
||||||
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(false)
|
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(false)
|
||||||
})
|
})
|
||||||
@@ -194,7 +203,7 @@ describe('TopMenuSection', () => {
|
|||||||
|
|
||||||
describe('on web platform', () => {
|
describe('on web platform', () => {
|
||||||
it('should not display CurrentUserButton and not display LoginButton', () => {
|
it('should not display CurrentUserButton and not display LoginButton', () => {
|
||||||
const wrapper = createWrapper()
|
const wrapper = createLegacyTabBarWrapper()
|
||||||
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(false)
|
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(false)
|
||||||
expect(wrapper.findComponent(LoginButton).exists()).toBe(false)
|
expect(wrapper.findComponent(LoginButton).exists()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -34,17 +34,7 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div ref="actionbarContainerRef" :class="actionbarContainerClass">
|
||||||
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 />
|
<ActionBarButtons />
|
||||||
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
|
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
|
||||||
<div
|
<div
|
||||||
@@ -55,6 +45,7 @@
|
|||||||
<ComfyActionbar
|
<ComfyActionbar
|
||||||
:top-menu-container="actionbarContainerRef"
|
:top-menu-container="actionbarContainerRef"
|
||||||
:queue-overlay-expanded="isQueueOverlayExpanded"
|
:queue-overlay-expanded="isQueueOverlayExpanded"
|
||||||
|
:has-any-error="hasAnyError"
|
||||||
@update:progress-target="updateProgressTarget"
|
@update:progress-target="updateProgressTarget"
|
||||||
/>
|
/>
|
||||||
<CurrentUserButton
|
<CurrentUserButton
|
||||||
@@ -70,7 +61,7 @@
|
|||||||
@click="() => openShareDialog().catch(toastErrorHandler)"
|
@click="() => openShareDialog().catch(toastErrorHandler)"
|
||||||
@pointerenter="prefetchShareDialog"
|
@pointerenter="prefetchShareDialog"
|
||||||
>
|
>
|
||||||
<i class="icon-[lucide--share-2] size-4" />
|
<i class="icon-[comfy--send] size-4" />
|
||||||
<span class="not-md:hidden">
|
<span class="not-md:hidden">
|
||||||
{{ t('actionbar.share') }}
|
{{ t('actionbar.share') }}
|
||||||
</span>
|
</span>
|
||||||
@@ -123,7 +114,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useLocalStorage } from '@vueuse/core'
|
import { useLocalStorage, useMutationObserver } from '@vueuse/core'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
@@ -145,6 +136,7 @@ import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
|||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
import { app } from '@/scripts/app'
|
import { app } from '@/scripts/app'
|
||||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||||
|
import { useActionBarButtonStore } from '@/stores/actionBarButtonStore'
|
||||||
import { useQueueUIStore } from '@/stores/queueStore'
|
import { useQueueUIStore } from '@/stores/queueStore'
|
||||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||||
@@ -168,6 +160,7 @@ const { isLoggedIn } = useCurrentUser()
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { toastErrorHandler } = useErrorHandling()
|
const { toastErrorHandler } = useErrorHandling()
|
||||||
const executionErrorStore = useExecutionErrorStore()
|
const executionErrorStore = useExecutionErrorStore()
|
||||||
|
const actionBarButtonStore = useActionBarButtonStore()
|
||||||
const queueUIStore = useQueueUIStore()
|
const queueUIStore = useQueueUIStore()
|
||||||
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
|
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
|
||||||
const { shouldShowRedDot: shouldShowConflictRedDot } =
|
const { shouldShowRedDot: shouldShowConflictRedDot } =
|
||||||
@@ -182,8 +175,45 @@ const isActionbarEnabled = computed(
|
|||||||
const isActionbarFloating = computed(
|
const isActionbarFloating = computed(
|
||||||
() => isActionbarEnabled.value && !isActionbarDocked.value
|
() => 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(
|
const isIntegratedTabBar = computed(
|
||||||
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
|
() => settingStore.get('Comfy.UI.TabBarLayout') !== 'Legacy'
|
||||||
)
|
)
|
||||||
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
|
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
|
||||||
useQueueFeatureFlags()
|
useQueueFeatureFlags()
|
||||||
@@ -233,6 +263,25 @@ const rightSidePanelTooltipConfig = computed(() =>
|
|||||||
|
|
||||||
// Maintain support for legacy topbar elements attached by custom scripts
|
// Maintain support for legacy topbar elements attached by custom scripts
|
||||||
const legacyCommandsContainerRef = ref<HTMLElement>()
|
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(() => {
|
onMounted(() => {
|
||||||
if (legacyCommandsContainerRef.value) {
|
if (legacyCommandsContainerRef.value) {
|
||||||
app.menu.element.style.width = 'fit-content'
|
app.menu.element.style.width = 'fit-content'
|
||||||
|
|||||||
@@ -119,9 +119,14 @@ import { cn } from '@/utils/tailwindUtil'
|
|||||||
|
|
||||||
import ComfyRunButton from './ComfyRunButton'
|
import ComfyRunButton from './ComfyRunButton'
|
||||||
|
|
||||||
const { topMenuContainer, queueOverlayExpanded = false } = defineProps<{
|
const {
|
||||||
|
topMenuContainer,
|
||||||
|
queueOverlayExpanded = false,
|
||||||
|
hasAnyError = false
|
||||||
|
} = defineProps<{
|
||||||
topMenuContainer?: HTMLElement | null
|
topMenuContainer?: HTMLElement | null
|
||||||
queueOverlayExpanded?: boolean
|
queueOverlayExpanded?: boolean
|
||||||
|
hasAnyError?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -435,7 +440,12 @@ const panelClass = computed(() =>
|
|||||||
isDragging.value && 'pointer-events-none select-none',
|
isDragging.value && 'pointer-events-none select-none',
|
||||||
isDocked.value
|
isDocked.value
|
||||||
? 'static border-none bg-transparent p-0'
|
? 'static border-none bg-transparent p-0'
|
||||||
: 'fixed shadow-interface'
|
: [
|
||||||
|
'fixed shadow-interface',
|
||||||
|
hasAnyError
|
||||||
|
? 'border-destructive-background-hover'
|
||||||
|
: 'border-interface-stroke'
|
||||||
|
]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -54,12 +54,11 @@ defineProps<{ itemClass: string; contentClass: string; item: MenuItem }>()
|
|||||||
:disabled="toValue(item.disabled) ?? !item.command"
|
:disabled="toValue(item.disabled) ?? !item.command"
|
||||||
@select="item.command?.({ originalEvent: $event, item })"
|
@select="item.command?.({ originalEvent: $event, item })"
|
||||||
>
|
>
|
||||||
<i class="size-5 shrink-0" :class="item.icon" />
|
<i class="size-5" :class="item.icon" />
|
||||||
<div class="mr-auto truncate" v-text="item.label" />
|
{{ item.label }}
|
||||||
<i v-if="item.checked" class="icon-[lucide--check] shrink-0" />
|
|
||||||
<div
|
<div
|
||||||
v-else-if="item.new"
|
v-if="item.new"
|
||||||
class="flex shrink-0 items-center rounded-full bg-primary-background px-1 text-xxs leading-none font-bold"
|
class="ml-auto flex items-center rounded-full bg-primary-background px-1 text-xxs leading-none font-bold"
|
||||||
v-text="t('contextMenu.new')"
|
v-text="t('contextMenu.new')"
|
||||||
/>
|
/>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
|
|||||||
|
|
||||||
const itemClass = computed(() =>
|
const itemClass = computed(() =>
|
||||||
cn(
|
cn(
|
||||||
'm-1 flex cursor-pointer items-center-safe gap-1 rounded-lg p-2 leading-none data-disabled:pointer-events-none data-disabled:text-muted-foreground data-highlighted:bg-secondary-background-hover',
|
'm-1 flex cursor-pointer gap-1 rounded-lg p-2 leading-none data-disabled:pointer-events-none data-disabled:text-muted-foreground data-highlighted:bg-secondary-background-hover',
|
||||||
itemProp
|
itemProp
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -33,20 +33,19 @@
|
|||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
@blur="handleBlur"
|
@blur="handleBlur"
|
||||||
@keyup.enter="handleBlur"
|
@keyup.enter="handleBlur"
|
||||||
@keydown.up.prevent="updateValueBy(step)"
|
@dragstart.prevent
|
||||||
@keydown.down.prevent="updateValueBy(-step)"
|
|
||||||
@keydown.page-up.prevent="updateValueBy(10 * step)"
|
|
||||||
@keydown.page-down.prevent="updateValueBy(-10 * step)"
|
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
ref="swipeElement"
|
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
'absolute inset-0 z-10 cursor-ew-resize touch-pan-y',
|
'absolute inset-0 z-10 cursor-ew-resize',
|
||||||
textEdit && 'pointer-events-none hidden'
|
textEdit && 'pointer-events-none hidden'
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
|
@pointerdown="handlePointerDown"
|
||||||
|
@pointermove="handlePointerMove"
|
||||||
@pointerup="handlePointerUp"
|
@pointerup="handlePointerUp"
|
||||||
|
@pointercancel="resetDrag"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<slot />
|
<slot />
|
||||||
@@ -66,7 +65,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onClickOutside, usePointerSwipe, whenever } from '@vueuse/core'
|
import { onClickOutside } from '@vueuse/core'
|
||||||
import { computed, ref, useTemplateRef } from 'vue'
|
import { computed, ref, useTemplateRef } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
@@ -74,8 +73,8 @@ import Button from '@/components/ui/button/Button.vue'
|
|||||||
import { cn } from '@/utils/tailwindUtil'
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
min = -Number.MAX_VALUE,
|
min,
|
||||||
max = Number.MAX_VALUE,
|
max,
|
||||||
step = 1,
|
step = 1,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
hideButtons = false,
|
hideButtons = false,
|
||||||
@@ -97,7 +96,6 @@ const modelValue = defineModel<number>({ default: 0 })
|
|||||||
|
|
||||||
const container = useTemplateRef<HTMLDivElement>('container')
|
const container = useTemplateRef<HTMLDivElement>('container')
|
||||||
const inputField = useTemplateRef<HTMLInputElement>('inputField')
|
const inputField = useTemplateRef<HTMLInputElement>('inputField')
|
||||||
const swipeElement = useTemplateRef('swipeElement')
|
|
||||||
const textEdit = ref(false)
|
const textEdit = ref(false)
|
||||||
|
|
||||||
onClickOutside(container, () => {
|
onClickOutside(container, () => {
|
||||||
@@ -105,11 +103,21 @@ onClickOutside(container, () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function clamp(value: number): number {
|
function clamp(value: number): number {
|
||||||
return Math.min(max, Math.max(min, value))
|
const lo = min ?? -Infinity
|
||||||
|
const hi = max ?? Infinity
|
||||||
|
return Math.min(hi, Math.max(lo, value))
|
||||||
}
|
}
|
||||||
|
|
||||||
const canDecrement = computed(() => modelValue.value > min && !disabled)
|
const canDecrement = computed(
|
||||||
const canIncrement = computed(() => modelValue.value < max && !disabled)
|
() => modelValue.value > (min ?? -Infinity) && !disabled
|
||||||
|
)
|
||||||
|
const canIncrement = computed(
|
||||||
|
() => modelValue.value < (max ?? Infinity) && !disabled
|
||||||
|
)
|
||||||
|
|
||||||
|
const dragging = ref(false)
|
||||||
|
const dragDelta = ref(0)
|
||||||
|
const hasDragged = ref(false)
|
||||||
|
|
||||||
function handleBlur(e: Event) {
|
function handleBlur(e: Event) {
|
||||||
const target = e.target as HTMLInputElement
|
const target = e.target as HTMLInputElement
|
||||||
@@ -127,27 +135,41 @@ function handleBlur(e: Event) {
|
|||||||
textEdit.value = false
|
textEdit.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
let dragDelta = 0
|
function handlePointerDown(e: PointerEvent) {
|
||||||
function handlePointerUp() {
|
if (e.button !== 0) return
|
||||||
if (isSwiping.value) return
|
if (disabled) return
|
||||||
|
const target = e.target as HTMLElement
|
||||||
textEdit.value = true
|
target.setPointerCapture(e.pointerId)
|
||||||
inputField.value?.focus()
|
dragging.value = true
|
||||||
inputField.value?.select()
|
dragDelta.value = 0
|
||||||
|
hasDragged.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const { distanceX, isSwiping } = usePointerSwipe(swipeElement, {
|
function handlePointerMove(e: PointerEvent) {
|
||||||
onSwipeEnd: () => (dragDelta = 0)
|
if (!dragging.value) return
|
||||||
})
|
dragDelta.value += e.movementX
|
||||||
|
const steps = (dragDelta.value / 10) | 0
|
||||||
|
if (steps === 0) return
|
||||||
|
hasDragged.value = true
|
||||||
|
const unclipped = modelValue.value + steps * step
|
||||||
|
dragDelta.value %= 10
|
||||||
|
modelValue.value = clamp(unclipped)
|
||||||
|
}
|
||||||
|
|
||||||
whenever(distanceX, () => {
|
function handlePointerUp() {
|
||||||
if (disabled) return
|
if (!dragging.value) return
|
||||||
const delta = ((distanceX.value - dragDelta) / 10) | 0
|
|
||||||
dragDelta += delta * 10
|
|
||||||
modelValue.value = clamp(modelValue.value - delta * step)
|
|
||||||
})
|
|
||||||
|
|
||||||
function updateValueBy(delta: number) {
|
if (!hasDragged.value) {
|
||||||
modelValue.value = Math.min(max, Math.max(min, modelValue.value + delta))
|
textEdit.value = true
|
||||||
|
inputField.value?.focus()
|
||||||
|
inputField.value?.select()
|
||||||
|
}
|
||||||
|
|
||||||
|
resetDrag()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetDrag() {
|
||||||
|
dragging.value = false
|
||||||
|
dragDelta.value = 0
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<Avatar
|
<Avatar
|
||||||
class="bg-interface-panel-selected-surface"
|
class="aspect-square bg-interface-panel-selected-surface"
|
||||||
:image="photoUrl ?? undefined"
|
:image="photoUrl ?? undefined"
|
||||||
:icon="hasAvatar ? undefined : 'icon-[lucide--user]'"
|
:icon="hasAvatar ? undefined : 'icon-[lucide--user]'"
|
||||||
:pt:icon:class="{ 'size-4': !hasAvatar }"
|
:pt:icon:class="{ 'size-4': !hasAvatar }"
|
||||||
|
|||||||
@@ -155,6 +155,93 @@ describe('VirtualGrid', () => {
|
|||||||
wrapper.unmount()
|
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 () => {
|
it('forces cols to maxColumns when maxColumns is finite', async () => {
|
||||||
mockedWidth.value = 100
|
mockedWidth.value = 100
|
||||||
mockedHeight.value = 200
|
mockedHeight.value = 200
|
||||||
|
|||||||
@@ -14,10 +14,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #header>
|
<template #header>
|
||||||
<SearchBox
|
<SearchInput
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
size="lg"
|
size="lg"
|
||||||
class="max-w-[384px]"
|
class="max-w-96 flex-1"
|
||||||
autofocus
|
autofocus
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -178,7 +178,7 @@
|
|||||||
v-show="isTemplateVisibleOnDistribution(template)"
|
v-show="isTemplateVisibleOnDistribution(template)"
|
||||||
:key="template.name"
|
:key="template.name"
|
||||||
ref="cardRefs"
|
ref="cardRefs"
|
||||||
size="tall"
|
size="compact"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
rounded="lg"
|
rounded="lg"
|
||||||
:data-testid="`template-workflow-${template.name}`"
|
:data-testid="`template-workflow-${template.name}`"
|
||||||
@@ -318,20 +318,6 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</CardBottom>
|
</CardBottom>
|
||||||
</template>
|
</template>
|
||||||
@@ -403,7 +389,7 @@ import CardBottom from '@/components/card/CardBottom.vue'
|
|||||||
import CardContainer from '@/components/card/CardContainer.vue'
|
import CardContainer from '@/components/card/CardContainer.vue'
|
||||||
import CardTop from '@/components/card/CardTop.vue'
|
import CardTop from '@/components/card/CardTop.vue'
|
||||||
import SquareChip from '@/components/chip/SquareChip.vue'
|
import SquareChip from '@/components/chip/SquareChip.vue'
|
||||||
import SearchBox from '@/components/common/SearchBox.vue'
|
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||||
import MultiSelect from '@/components/input/MultiSelect.vue'
|
import MultiSelect from '@/components/input/MultiSelect.vue'
|
||||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||||
import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue'
|
import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue'
|
||||||
@@ -497,8 +483,6 @@ const {
|
|||||||
const getEffectiveSourceModule = (template: TemplateInfo) =>
|
const getEffectiveSourceModule = (template: TemplateInfo) =>
|
||||||
template.sourceModule || 'default'
|
template.sourceModule || 'default'
|
||||||
|
|
||||||
const isAppTemplate = (template: TemplateInfo) => template.name.endsWith('.app')
|
|
||||||
|
|
||||||
const getBaseThumbnailSrc = (template: TemplateInfo) => {
|
const getBaseThumbnailSrc = (template: TemplateInfo) => {
|
||||||
const sm = getEffectiveSourceModule(template)
|
const sm = getEffectiveSourceModule(template)
|
||||||
return getTemplateThumbnailUrl(template, sm, sm === 'default' ? '1' : '')
|
return getTemplateThumbnailUrl(template, sm, sm === 'default' ? '1' : '')
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="keybinding-panel flex flex-col gap-2">
|
<div class="keybinding-panel flex flex-col gap-2">
|
||||||
<SearchBox
|
<SearchInput
|
||||||
v-model="filters['global'].value"
|
v-model="filters['global'].value"
|
||||||
:placeholder="$t('g.searchPlaceholder', { subject: $t('g.keybindings') })"
|
:placeholder="$t('g.searchPlaceholder', { subject: $t('g.keybindings') })"
|
||||||
/>
|
/>
|
||||||
@@ -155,7 +155,7 @@ import { useToast } from 'primevue/usetoast'
|
|||||||
import { computed, ref, watchEffect } from 'vue'
|
import { computed, ref, watchEffect } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import SearchBox from '@/components/common/SearchBox.vue'
|
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
import { KeyComboImpl } from '@/platform/keybindings/keyCombo'
|
import { KeyComboImpl } from '@/platform/keybindings/keyCombo'
|
||||||
import { KeybindingImpl } from '@/platform/keybindings/keybinding'
|
import { KeybindingImpl } from '@/platform/keybindings/keybinding'
|
||||||
|
|||||||
@@ -5,11 +5,8 @@
|
|||||||
v-if="isHelpCenterVisible"
|
v-if="isHelpCenterVisible"
|
||||||
class="help-center-popup"
|
class="help-center-popup"
|
||||||
:class="{
|
:class="{
|
||||||
'sidebar-left':
|
'sidebar-left': sidebarLocation === 'left',
|
||||||
triggerLocation === 'sidebar' && sidebarLocation === 'left',
|
'sidebar-right': sidebarLocation === 'right',
|
||||||
'sidebar-right':
|
|
||||||
triggerLocation === 'sidebar' && sidebarLocation === 'right',
|
|
||||||
'topbar-right': triggerLocation === 'topbar',
|
|
||||||
'small-sidebar': isSmall
|
'small-sidebar': isSmall
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
@@ -63,7 +60,6 @@ const { isSmall = false } = defineProps<{
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
isHelpCenterVisible,
|
isHelpCenterVisible,
|
||||||
triggerLocation,
|
|
||||||
sidebarLocation,
|
sidebarLocation,
|
||||||
closeHelpCenter,
|
closeHelpCenter,
|
||||||
handleWhatsNewDismissed
|
handleWhatsNewDismissed
|
||||||
@@ -101,25 +97,6 @@ const {
|
|||||||
right: 1rem;
|
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 {
|
@keyframes slideInUp {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|||||||
@@ -93,13 +93,12 @@
|
|||||||
#header
|
#header
|
||||||
>
|
>
|
||||||
<div class="flex flex-col px-2 pt-2 pb-0">
|
<div class="flex flex-col px-2 pt-2 pb-0">
|
||||||
<SearchBox
|
<SearchInput
|
||||||
v-if="showSearchBox"
|
v-if="showSearchBox"
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
:class="showSelectedCount || showClearButton ? 'mb-2' : ''"
|
:class="showSelectedCount || showClearButton ? 'mb-2' : ''"
|
||||||
:show-order="true"
|
:placeholder="searchPlaceholder"
|
||||||
:show-border="true"
|
size="sm"
|
||||||
:place-holder="searchPlaceholder"
|
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-if="showSelectedCount || showClearButton"
|
v-if="showSelectedCount || showClearButton"
|
||||||
@@ -182,7 +181,7 @@ import MultiSelect from 'primevue/multiselect'
|
|||||||
import { computed, useAttrs } from 'vue'
|
import { computed, useAttrs } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import SearchBox from '@/components/common/SearchBox.vue'
|
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
import { usePopoverSizing } from '@/composables/usePopoverSizing'
|
import { usePopoverSizing } from '@/composables/usePopoverSizing'
|
||||||
import { cn } from '@/utils/tailwindUtil'
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ onMounted(async () => {
|
|||||||
if (isStandaloneMode && props.modelUrl) {
|
if (isStandaloneMode && props.modelUrl) {
|
||||||
await viewer.initializeStandaloneViewer(containerRef.value, props.modelUrl)
|
await viewer.initializeStandaloneViewer(containerRef.value, props.modelUrl)
|
||||||
} else if (props.node) {
|
} else if (props.node) {
|
||||||
const source = useLoad3dService().getLoad3d(props.node)
|
const source = await useLoad3dService().getLoad3dAsync(props.node)
|
||||||
if (source) {
|
if (source) {
|
||||||
await viewer.initializeViewer(containerRef.value, source)
|
await viewer.initializeViewer(containerRef.value, source)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,12 +72,12 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<SliderControl
|
<SliderControl
|
||||||
v-model="brushSize"
|
v-model="brushSizeSliderValue"
|
||||||
class="flex-1"
|
class="flex-1"
|
||||||
label=""
|
label=""
|
||||||
:min="1"
|
:min="0"
|
||||||
:max="250"
|
:max="1"
|
||||||
:step="1"
|
:step="0.001"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -182,6 +182,26 @@ const brushSize = computed({
|
|||||||
set: (value: number) => store.setBrushSize(value)
|
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({
|
const brushOpacity = computed({
|
||||||
get: () => store.brushSettings.opacity,
|
get: () => store.brushSettings.opacity,
|
||||||
set: (value: number) => store.setBrushOpacity(value)
|
set: (value: number) => store.setBrushOpacity(value)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex min-w-0 items-center gap-2">
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
<SearchBox
|
<SearchInput
|
||||||
v-if="showSearch"
|
v-if="showSearch"
|
||||||
:model-value="searchQuery"
|
:model-value="searchQuery"
|
||||||
class="min-w-0 flex-1"
|
class="min-w-0 flex-1"
|
||||||
@@ -116,7 +116,7 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import SearchBox from '@/components/common/SearchBox.vue'
|
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||||
import Popover from '@/components/ui/Popover.vue'
|
import Popover from '@/components/ui/Popover.vue'
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
import { jobSortModes } from '@/composables/queue/useJobList'
|
import { jobSortModes } from '@/composables/queue/useJobList'
|
||||||
|
|||||||
@@ -11,12 +11,13 @@
|
|||||||
}"
|
}"
|
||||||
@click="onLogoMenuClick($event)"
|
@click="onLogoMenuClick($event)"
|
||||||
>
|
>
|
||||||
<div class="flex size-8 items-center justify-center rounded-lg bg-black">
|
<div class="flex items-center gap-0.5">
|
||||||
<ComfyLogo
|
<ComfyLogo
|
||||||
alt="ComfyUI Logo"
|
alt="ComfyUI Logo"
|
||||||
class="comfyui-logo h-[18px] w-[18px] text-white"
|
class="comfyui-logo h-[18px] w-[18px]"
|
||||||
mode="fill"
|
mode="fill"
|
||||||
/>
|
/>
|
||||||
|
<i class="icon-[lucide--chevron-down] size-3 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
v-if="userStore.isMultiUserServer"
|
v-if="userStore.isMultiUserServer"
|
||||||
:is-small="isSmall"
|
:is-small="isSmall"
|
||||||
/>
|
/>
|
||||||
<SidebarHelpCenterIcon v-if="!isIntegratedTabBar" :is-small="isSmall" />
|
<SidebarHelpCenterIcon :is-small="isSmall" />
|
||||||
<SidebarBottomPanelToggleButton v-if="!isCloud" :is-small="isSmall" />
|
<SidebarBottomPanelToggleButton v-if="!isCloud" :is-small="isSmall" />
|
||||||
<SidebarShortcutsToggleButton :is-small="isSmall" />
|
<SidebarShortcutsToggleButton :is-small="isSmall" />
|
||||||
<SidebarSettingsButton :is-small="isSmall" />
|
<SidebarSettingsButton :is-small="isSmall" />
|
||||||
@@ -95,9 +95,6 @@ const sidebarLocation = computed<'left' | 'right'>(() =>
|
|||||||
settingStore.get('Comfy.Sidebar.Location')
|
settingStore.get('Comfy.Sidebar.Location')
|
||||||
)
|
)
|
||||||
const sidebarStyle = computed(() => settingStore.get('Comfy.Sidebar.Style'))
|
const sidebarStyle = computed(() => settingStore.get('Comfy.Sidebar.Style'))
|
||||||
const isIntegratedTabBar = computed(
|
|
||||||
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
|
|
||||||
)
|
|
||||||
const isConnected = computed(
|
const isConnected = computed(
|
||||||
() =>
|
() =>
|
||||||
selectedTab.value ||
|
selectedTab.value ||
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
:icon-badge="shouldShowRedDot ? '•' : ''"
|
:icon-badge="shouldShowRedDot ? '•' : ''"
|
||||||
badge-class="-top-1 -right-1 min-w-2 w-2 h-2 p-0 rounded-full text-[0px] bg-[#ff3b30]"
|
badge-class="-top-1 -right-1 min-w-2 w-2 h-2 p-0 rounded-full text-[0px] bg-[#ff3b30]"
|
||||||
:is-small="isSmall"
|
:is-small="isSmall"
|
||||||
@click="toggleHelpCenter"
|
@click="toggleHelpCenter()"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
class="flex-1"
|
class="flex-1"
|
||||||
:items="assetItems"
|
:items="assetItems"
|
||||||
:grid-style="listGridStyle"
|
:grid-style="listGridStyle"
|
||||||
|
:max-columns="1"
|
||||||
|
:default-item-height="48"
|
||||||
@approach-end="emit('approach-end')"
|
@approach-end="emit('approach-end')"
|
||||||
>
|
>
|
||||||
<template #item="{ item }">
|
<template #item="{ item }">
|
||||||
@@ -33,7 +35,7 @@
|
|||||||
tabindex="0"
|
tabindex="0"
|
||||||
:aria-label="
|
:aria-label="
|
||||||
t('assetBrowser.ariaLabel.assetCard', {
|
t('assetBrowser.ariaLabel.assetCard', {
|
||||||
name: item.asset.name,
|
name: getAssetDisplayName(item.asset),
|
||||||
type: getAssetMediaType(item.asset)
|
type: getAssetMediaType(item.asset)
|
||||||
})
|
})
|
||||||
"
|
"
|
||||||
@@ -44,7 +46,7 @@
|
|||||||
)
|
)
|
||||||
"
|
"
|
||||||
:preview-url="getAssetPreviewUrl(item.asset)"
|
:preview-url="getAssetPreviewUrl(item.asset)"
|
||||||
:preview-alt="item.asset.name"
|
:preview-alt="getAssetDisplayName(item.asset)"
|
||||||
:icon-name="iconForMediaType(getAssetMediaType(item.asset))"
|
:icon-name="iconForMediaType(getAssetMediaType(item.asset))"
|
||||||
:is-video-preview="isVideoAsset(item.asset)"
|
:is-video-preview="isVideoAsset(item.asset)"
|
||||||
:primary-text="getAssetPrimaryText(item.asset)"
|
:primary-text="getAssetPrimaryText(item.asset)"
|
||||||
@@ -133,8 +135,12 @@ const listGridStyle = {
|
|||||||
gap: '0.5rem'
|
gap: '0.5rem'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAssetDisplayName(asset: AssetItem): string {
|
||||||
|
return asset.display_name || asset.name
|
||||||
|
}
|
||||||
|
|
||||||
function getAssetPrimaryText(asset: AssetItem): string {
|
function getAssetPrimaryText(asset: AssetItem): string {
|
||||||
return truncateFilename(asset.name)
|
return truncateFilename(getAssetDisplayName(asset))
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAssetMediaType(asset: AssetItem) {
|
function getAssetMediaType(asset: AssetItem) {
|
||||||
|
|||||||
@@ -569,7 +569,7 @@ const handleZoomClick = (asset: AssetItem) => {
|
|||||||
const dialogStore = useDialogStore()
|
const dialogStore = useDialogStore()
|
||||||
dialogStore.showDialog({
|
dialogStore.showDialog({
|
||||||
key: 'asset-3d-viewer',
|
key: 'asset-3d-viewer',
|
||||||
title: asset.name,
|
title: asset.display_name || asset.name,
|
||||||
component: Load3dViewerContent,
|
component: Load3dViewerContent,
|
||||||
props: {
|
props: {
|
||||||
modelUrl: asset.preview_url || ''
|
modelUrl: asset.preview_url || ''
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="px-2 2xl:px-4">
|
<div class="px-2 2xl:px-4">
|
||||||
<SearchBox
|
<SearchInput
|
||||||
ref="searchBoxRef"
|
ref="searchBoxRef"
|
||||||
v-model:model-value="searchQuery"
|
v-model:model-value="searchQuery"
|
||||||
class="workflows-search-box"
|
class="workflows-search-box"
|
||||||
@@ -146,7 +146,7 @@ import { computed, nextTick, onMounted, ref } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||||
import SearchBox from '@/components/common/SearchBox.vue'
|
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||||
import TextDivider from '@/components/common/TextDivider.vue'
|
import TextDivider from '@/components/common/TextDivider.vue'
|
||||||
import TreeExplorer from '@/components/common/TreeExplorer.vue'
|
import TreeExplorer from '@/components/common/TreeExplorer.vue'
|
||||||
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
|
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="px-2 2xl:px-4">
|
<div class="px-2 2xl:px-4">
|
||||||
<SearchBox
|
<SearchInput
|
||||||
ref="searchBoxRef"
|
ref="searchBoxRef"
|
||||||
v-model:model-value="searchQuery"
|
v-model:model-value="searchQuery"
|
||||||
:placeholder="
|
:placeholder="
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
import { Divider } from 'primevue'
|
import { Divider } from 'primevue'
|
||||||
import { computed, nextTick, onMounted, ref, toRef, watch } from 'vue'
|
import { computed, nextTick, onMounted, ref, toRef, watch } from 'vue'
|
||||||
|
|
||||||
import SearchBox from '@/components/common/SearchBox.vue'
|
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||||
import TreeExplorer from '@/components/common/TreeExplorer.vue'
|
import TreeExplorer from '@/components/common/TreeExplorer.vue'
|
||||||
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
|
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
|
||||||
import ElectronDownloadItems from '@/components/sidebar/tabs/modelLibrary/ElectronDownloadItems.vue'
|
import ElectronDownloadItems from '@/components/sidebar/tabs/modelLibrary/ElectronDownloadItems.vue'
|
||||||
|
|||||||
@@ -86,18 +86,40 @@
|
|||||||
</template>
|
</template>
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="px-2 2xl:px-4">
|
<div class="px-2 2xl:px-4">
|
||||||
<SearchBox
|
<div class="flex items-center gap-1">
|
||||||
ref="searchBoxRef"
|
<SearchInput
|
||||||
v-model:model-value="searchQuery"
|
ref="searchBoxRef"
|
||||||
data-testid="node-library-search"
|
v-model="searchQuery"
|
||||||
class="node-lib-search-box"
|
data-testid="node-library-search"
|
||||||
:placeholder="$t('g.searchPlaceholder', { subject: $t('g.nodes') })"
|
class="node-lib-search-box"
|
||||||
filter-icon="pi pi-filter"
|
:placeholder="
|
||||||
:filters
|
$t('g.searchPlaceholder', { subject: $t('g.nodes') })
|
||||||
@search="handleSearch"
|
"
|
||||||
@show-filter="($event) => searchFilter?.toggle($event)"
|
@search="handleSearch"
|
||||||
@remove-filter="onRemoveFilter"
|
/>
|
||||||
/>
|
<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>
|
||||||
|
|
||||||
<Popover ref="searchFilter" class="ml-[-13px]">
|
<Popover ref="searchFilter" class="ml-[-13px]">
|
||||||
<NodeSearchFilter @add-filter="onAddFilter" />
|
<NodeSearchFilter @add-filter="onAddFilter" />
|
||||||
@@ -155,8 +177,9 @@ import {
|
|||||||
} from 'vue'
|
} from 'vue'
|
||||||
|
|
||||||
import { resolveEssentialsDisplayName } from '@/constants/essentialsDisplayNames'
|
import { resolveEssentialsDisplayName } from '@/constants/essentialsDisplayNames'
|
||||||
import SearchBox from '@/components/common/SearchBox.vue'
|
import SearchFilterChip from '@/components/common/SearchFilterChip.vue'
|
||||||
import type { SearchFilter } from '@/components/common/SearchFilterChip.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 TreeExplorer from '@/components/common/TreeExplorer.vue'
|
||||||
import NodePreview from '@/components/node/NodePreview.vue'
|
import NodePreview from '@/components/node/NodePreview.vue'
|
||||||
import NodeSearchFilter from '@/components/searchbox/NodeSearchFilter.vue'
|
import NodeSearchFilter from '@/components/searchbox/NodeSearchFilter.vue'
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ vi.mock('./nodeLibrary/NodeDragPreview.vue', () => ({
|
|||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/components/common/SearchBoxV2.vue', () => ({
|
vi.mock('@/components/ui/search-input/SearchInput.vue', () => ({
|
||||||
default: {
|
default: {
|
||||||
name: 'SearchBox',
|
name: 'SearchBox',
|
||||||
template: '<input data-testid="search-box" />',
|
template: '<input data-testid="search-box" />',
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<template #header>
|
<template #header>
|
||||||
<TabsRoot v-model="selectedTab" class="flex flex-col">
|
<TabsRoot v-model="selectedTab" class="flex flex-col">
|
||||||
<div class="flex items-center justify-between gap-2 px-2 pb-2 2xl:px-4">
|
<div class="flex items-center justify-between gap-2 px-2 pb-2 2xl:px-4">
|
||||||
<SearchBox
|
<SearchInput
|
||||||
ref="searchBoxRef"
|
ref="searchBoxRef"
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
:placeholder="$t('g.search') + '...'"
|
:placeholder="$t('g.search') + '...'"
|
||||||
@@ -180,7 +180,7 @@ import { computed, nextTick, onMounted, ref, watchEffect } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import { resolveEssentialsDisplayName } from '@/constants/essentialsDisplayNames'
|
import { resolveEssentialsDisplayName } from '@/constants/essentialsDisplayNames'
|
||||||
import SearchBox from '@/components/common/SearchBoxV2.vue'
|
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||||
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
||||||
import { usePerTabState } from '@/composables/usePerTabState'
|
import { usePerTabState } from '@/composables/usePerTabState'
|
||||||
@@ -253,7 +253,7 @@ const filterOptions = ref<Record<NodeCategoryId, boolean>>({
|
|||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const searchBoxRef = ref<InstanceType<typeof SearchBox> | null>(null)
|
const searchBoxRef = ref<InstanceType<typeof SearchInput> | null>(null)
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const expandedKeysByTab = ref<Record<TabId, string[]>>({
|
const expandedKeysByTab = ref<Record<TabId, string[]>>({
|
||||||
essentials: [],
|
essentials: [],
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
<UserAvatar
|
<UserAvatar
|
||||||
v-else
|
v-else
|
||||||
:photo-url="photoURL"
|
:photo-url="photoURL"
|
||||||
:class="compact && 'size-full'"
|
:class="compact && 'h-full w-auto'"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<i v-if="showArrow" class="icon-[lucide--chevron-down] size-4 px-1" />
|
<i v-if="showArrow" class="icon-[lucide--chevron-down] size-4 px-1" />
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Button
|
|
||||||
class="comfy-help-center-btn relative text-base-foreground"
|
|
||||||
variant="textonly"
|
|
||||||
@click="toggleHelpCenter"
|
|
||||||
>
|
|
||||||
<div class="not-md:hidden">{{ $t('menu.helpAndFeedback') }}</div>
|
|
||||||
<i class="ml-0.5 icon-[lucide--circle-help]" />
|
|
||||||
<span
|
|
||||||
v-if="shouldShowRedDot"
|
|
||||||
class="absolute top-[7px] right-[7px] size-1.5 rounded-full bg-[#ff3b30]"
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
|
||||||
import { useHelpCenter } from '@/composables/useHelpCenter'
|
|
||||||
|
|
||||||
const { shouldShowRedDot, toggleHelpCenter } = useHelpCenter('topbar')
|
|
||||||
</script>
|
|
||||||
@@ -83,13 +83,18 @@
|
|||||||
v-if="isIntegratedTabBar"
|
v-if="isIntegratedTabBar"
|
||||||
class="ml-auto flex shrink-0 items-center gap-2 px-2"
|
class="ml-auto flex shrink-0 items-center gap-2 px-2"
|
||||||
>
|
>
|
||||||
<TopMenuHelpButton />
|
<Button
|
||||||
<CurrentUserButton
|
v-if="isCloud || isNightly"
|
||||||
v-if="isLoggedIn"
|
v-tooltip="{ value: $t('actionbar.feedbackTooltip'), showDelay: 300 }"
|
||||||
:show-arrow="false"
|
variant="muted-textonly"
|
||||||
compact
|
size="icon"
|
||||||
class="grid w-10 shrink-0 p-1"
|
class="shrink-0 text-base-foreground"
|
||||||
/>
|
:aria-label="$t('actionbar.feedback')"
|
||||||
|
@click="openFeedback"
|
||||||
|
>
|
||||||
|
<i class="icon-[lucide--message-square-text]" />
|
||||||
|
</Button>
|
||||||
|
<CurrentUserButton v-if="showCurrentUser" compact class="shrink-0 p-1" />
|
||||||
<LoginButton v-else-if="isDesktop" class="p-1" />
|
<LoginButton v-else-if="isDesktop" class="p-1" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="isDesktop" class="window-actions-spacer app-drag shrink-0" />
|
<div v-if="isDesktop" class="window-actions-spacer app-drag shrink-0" />
|
||||||
@@ -102,21 +107,20 @@ import ScrollPanel from 'primevue/scrollpanel'
|
|||||||
import SelectButton from 'primevue/selectbutton'
|
import SelectButton from 'primevue/selectbutton'
|
||||||
import { computed, nextTick, onUpdated, ref, watch } from 'vue'
|
import { computed, nextTick, onUpdated, ref, watch } from 'vue'
|
||||||
import type { WatchStopHandle } from 'vue'
|
import type { WatchStopHandle } from 'vue'
|
||||||
|
|
||||||
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
||||||
import LoginButton from '@/components/topbar/LoginButton.vue'
|
import LoginButton from '@/components/topbar/LoginButton.vue'
|
||||||
import TopMenuHelpButton from '@/components/topbar/TopMenuHelpButton.vue'
|
|
||||||
import WorkflowTab from '@/components/topbar/WorkflowTab.vue'
|
import WorkflowTab from '@/components/topbar/WorkflowTab.vue'
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||||
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
|
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
|
||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
|
import { buildFeedbackUrl } from '@/platform/support/config'
|
||||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||||
import { useCommandStore } from '@/stores/commandStore'
|
import { useCommandStore } from '@/stores/commandStore'
|
||||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||||
import { isDesktop } from '@/platform/distribution/types'
|
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
|
||||||
import { whileMouseDown } from '@/utils/mouseDownUtil'
|
import { whileMouseDown } from '@/utils/mouseDownUtil'
|
||||||
|
|
||||||
import WorkflowOverflowMenu from './WorkflowOverflowMenu.vue'
|
import WorkflowOverflowMenu from './WorkflowOverflowMenu.vue'
|
||||||
@@ -138,8 +142,14 @@ const commandStore = useCommandStore()
|
|||||||
const { isLoggedIn } = useCurrentUser()
|
const { isLoggedIn } = useCurrentUser()
|
||||||
|
|
||||||
const isIntegratedTabBar = computed(
|
const isIntegratedTabBar = computed(
|
||||||
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
|
() => settingStore.get('Comfy.UI.TabBarLayout') !== 'Legacy'
|
||||||
)
|
)
|
||||||
|
const showCurrentUser = computed(() => isCloud || isLoggedIn.value)
|
||||||
|
|
||||||
|
const feedbackUrl = buildFeedbackUrl()
|
||||||
|
function openFeedback() {
|
||||||
|
window.open(feedbackUrl, '_blank', 'noopener,noreferrer')
|
||||||
|
}
|
||||||
|
|
||||||
const containerRef = ref<HTMLElement | null>(null)
|
const containerRef = ref<HTMLElement | null>(null)
|
||||||
const showOverflowArrows = ref(false)
|
const showOverflowArrows = ref(false)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { VariantProps } from 'cva'
|
|||||||
import { cva } from 'cva'
|
import { cva } from 'cva'
|
||||||
|
|
||||||
export const buttonVariants = cva({
|
export const buttonVariants = cva({
|
||||||
base: 'relative inline-flex items-center justify-center gap-2 cursor-pointer touch-manipulation whitespace-nowrap appearance-none border-none rounded-md text-sm font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([width]):not([height])]:size-4 [&_svg]:shrink-0',
|
base: 'relative inline-flex items-center justify-center gap-2 cursor-pointer whitespace-nowrap appearance-none border-none rounded-md text-sm font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([width]):not([height])]:size-4 [&_svg]:shrink-0',
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
secondary:
|
secondary:
|
||||||
|
|||||||
202
src/components/ui/search-input/SearchInput.test.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
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([''])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<ComboboxRoot :ignore-filter="true" :open="false" :disabled="disabled">
|
<ComboboxRoot
|
||||||
|
:ignore-filter="true"
|
||||||
|
:open="false"
|
||||||
|
:disabled="disabled"
|
||||||
|
:class="className"
|
||||||
|
>
|
||||||
<ComboboxAnchor
|
<ComboboxAnchor
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
searchInputVariants({ size }),
|
searchInputVariants({ size }),
|
||||||
disabled && 'pointer-events-none opacity-50',
|
disabled && 'pointer-events-none opacity-50'
|
||||||
className
|
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
@click="focus"
|
@click="focus"
|
||||||
|
|||||||
77
src/components/ui/slider/Slider.stories.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
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>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #header>
|
<template #header>
|
||||||
<SearchBox v-model="searchQuery" size="lg" class="max-w-[384px]" />
|
<SearchInput v-model="searchQuery" size="lg" class="max-w-96 flex-1" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #header-right-area>
|
<template #header-right-area>
|
||||||
@@ -130,7 +130,7 @@ import CardBottom from '@/components/card/CardBottom.vue'
|
|||||||
import CardContainer from '@/components/card/CardContainer.vue'
|
import CardContainer from '@/components/card/CardContainer.vue'
|
||||||
import CardTop from '@/components/card/CardTop.vue'
|
import CardTop from '@/components/card/CardTop.vue'
|
||||||
import SquareChip from '@/components/chip/SquareChip.vue'
|
import SquareChip from '@/components/chip/SquareChip.vue'
|
||||||
import SearchBox from '@/components/common/SearchBox.vue'
|
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||||
import MultiSelect from '@/components/input/MultiSelect.vue'
|
import MultiSelect from '@/components/input/MultiSelect.vue'
|
||||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import CardContainer from '@/components/card/CardContainer.vue'
|
|||||||
import CardTop from '@/components/card/CardTop.vue'
|
import CardTop from '@/components/card/CardTop.vue'
|
||||||
import SquareChip from '@/components/chip/SquareChip.vue'
|
import SquareChip from '@/components/chip/SquareChip.vue'
|
||||||
import MultiSelect from '@/components/input/MultiSelect.vue'
|
import MultiSelect from '@/components/input/MultiSelect.vue'
|
||||||
import SearchBox from '@/components/common/SearchBox.vue'
|
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||||
import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
||||||
import { OnCloseKey } from '@/types/widgetTypes'
|
import { OnCloseKey } from '@/types/widgetTypes'
|
||||||
@@ -68,7 +68,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
|||||||
components: {
|
components: {
|
||||||
BaseModalLayout,
|
BaseModalLayout,
|
||||||
LeftSidePanel,
|
LeftSidePanel,
|
||||||
SearchBox,
|
SearchInput,
|
||||||
MultiSelect,
|
MultiSelect,
|
||||||
SingleSelect,
|
SingleSelect,
|
||||||
Button,
|
Button,
|
||||||
@@ -186,7 +186,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
|||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<template v-if="args.hasHeader" #header>
|
<template v-if="args.hasHeader" #header>
|
||||||
<SearchBox
|
<SearchInput
|
||||||
class="max-w-[384px]"
|
class="max-w-[384px]"
|
||||||
size="lg"
|
size="lg"
|
||||||
:modelValue="searchQuery"
|
:modelValue="searchQuery"
|
||||||
@@ -309,7 +309,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
|||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<template v-if="args.hasHeader" #header>
|
<template v-if="args.hasHeader" #header>
|
||||||
<SearchBox
|
<SearchInput
|
||||||
class="max-w-[384px]"
|
class="max-w-[384px]"
|
||||||
size="lg"
|
size="lg"
|
||||||
:modelValue="searchQuery"
|
:modelValue="searchQuery"
|
||||||
|
|||||||
@@ -5,19 +5,15 @@ import { useSettingStore } from '@/platform/settings/settingStore'
|
|||||||
import { useTelemetry } from '@/platform/telemetry'
|
import { useTelemetry } from '@/platform/telemetry'
|
||||||
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
||||||
import { useHelpCenterStore } from '@/stores/helpCenterStore'
|
import { useHelpCenterStore } from '@/stores/helpCenterStore'
|
||||||
import type { HelpCenterTriggerLocation } from '@/stores/helpCenterStore'
|
|
||||||
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
|
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
|
||||||
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
|
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
|
||||||
import { useNodeConflictDialog } from '@/workbench/extensions/manager/composables/useNodeConflictDialog'
|
import { useNodeConflictDialog } from '@/workbench/extensions/manager/composables/useNodeConflictDialog'
|
||||||
|
|
||||||
export function useHelpCenter(
|
export function useHelpCenter() {
|
||||||
triggerFrom: HelpCenterTriggerLocation = 'sidebar'
|
|
||||||
) {
|
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
const releaseStore = useReleaseStore()
|
const releaseStore = useReleaseStore()
|
||||||
const helpCenterStore = useHelpCenterStore()
|
const helpCenterStore = useHelpCenterStore()
|
||||||
const { isVisible: isHelpCenterVisible, triggerLocation } =
|
const { isVisible: isHelpCenterVisible } = storeToRefs(helpCenterStore)
|
||||||
storeToRefs(helpCenterStore)
|
|
||||||
const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
|
const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
|
||||||
|
|
||||||
const conflictDetection = useConflictDetection()
|
const conflictDetection = useConflictDetection()
|
||||||
@@ -42,9 +38,9 @@ export function useHelpCenter(
|
|||||||
*/
|
*/
|
||||||
const toggleHelpCenter = () => {
|
const toggleHelpCenter = () => {
|
||||||
useTelemetry()?.trackUiButtonClicked({
|
useTelemetry()?.trackUiButtonClicked({
|
||||||
button_id: `${triggerFrom}_help_center_toggled`
|
button_id: 'sidebar_help_center_toggled'
|
||||||
})
|
})
|
||||||
helpCenterStore.toggle(triggerFrom)
|
helpCenterStore.toggle()
|
||||||
}
|
}
|
||||||
|
|
||||||
const closeHelpCenter = () => {
|
const closeHelpCenter = () => {
|
||||||
@@ -90,7 +86,6 @@ export function useHelpCenter(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
isHelpCenterVisible,
|
isHelpCenterVisible,
|
||||||
triggerLocation,
|
|
||||||
shouldShowRedDot,
|
shouldShowRedDot,
|
||||||
sidebarLocation,
|
sidebarLocation,
|
||||||
toggleHelpCenter,
|
toggleHelpCenter,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { computed } from 'vue'
|
import { computed, watch } from 'vue'
|
||||||
|
|
||||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||||
import { useExtensionService } from '@/services/extensionService'
|
import { useExtensionService } from '@/services/extensionService'
|
||||||
import type { TopbarBadge } from '@/types/comfy'
|
import type { TopbarBadge } from '@/types/comfy'
|
||||||
|
|
||||||
@@ -17,16 +18,20 @@ const badges = computed<TopbarBadge[]>(() => {
|
|||||||
tooltip: alert.tooltip
|
tooltip: alert.tooltip
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always add cloud badge last (furthest right)
|
|
||||||
result.push({
|
|
||||||
icon: 'icon-[lucide--cloud]',
|
|
||||||
text: 'Comfy Cloud'
|
|
||||||
})
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const canvasStore = useCanvasStore()
|
||||||
|
watch(
|
||||||
|
() => canvasStore.canvas,
|
||||||
|
(canvas) => {
|
||||||
|
if (canvas) {
|
||||||
|
canvas.info_text = t('g.comfyCloud')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
useExtensionService().registerExtension({
|
useExtensionService().registerExtension({
|
||||||
name: 'Comfy.Cloud.Badges',
|
name: 'Comfy.Cloud.Badges',
|
||||||
get topbarBadges() {
|
get topbarBadges() {
|
||||||
|
|||||||
@@ -1,21 +1,14 @@
|
|||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
import { getDistribution, ZENDESK_FIELDS } from '@/platform/support/config'
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
|
import { buildFeedbackUrl } from '@/platform/support/config'
|
||||||
import { useExtensionService } from '@/services/extensionService'
|
import { useExtensionService } from '@/services/extensionService'
|
||||||
import type { ActionBarButton } from '@/types/comfy'
|
import type { ActionBarButton } from '@/types/comfy'
|
||||||
|
|
||||||
const ZENDESK_BASE_URL = 'https://support.comfy.org/hc/en-us/requests/new'
|
const feedbackUrl = buildFeedbackUrl()
|
||||||
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[] = [
|
const buttons: ActionBarButton[] = [
|
||||||
{
|
{
|
||||||
icon: 'icon-[lucide--message-circle-question-mark]',
|
icon: 'icon-[lucide--message-square-text]',
|
||||||
label: t('actionbar.feedback'),
|
label: t('actionbar.feedback'),
|
||||||
tooltip: t('actionbar.feedbackTooltip'),
|
tooltip: t('actionbar.feedbackTooltip'),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
@@ -25,6 +18,10 @@ const buttons: ActionBarButton[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
useExtensionService().registerExtension({
|
useExtensionService().registerExtension({
|
||||||
name: 'Comfy.Cloud.FeedbackButton',
|
name: 'Comfy.FeedbackButton',
|
||||||
actionBarButtons: buttons
|
get actionBarButtons() {
|
||||||
|
return useSettingStore().get('Comfy.UI.TabBarLayout') === 'Legacy'
|
||||||
|
? buttons
|
||||||
|
: []
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -559,6 +559,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
|||||||
clear_background_color: string
|
clear_background_color: string
|
||||||
render_only_selected: boolean
|
render_only_selected: boolean
|
||||||
show_info: boolean
|
show_info: boolean
|
||||||
|
/** Additional text appended to the canvas info overlay (rendered by {@link renderInfo}). */
|
||||||
|
info_text: string | undefined
|
||||||
allow_dragcanvas: boolean
|
allow_dragcanvas: boolean
|
||||||
allow_dragnodes: boolean
|
allow_dragnodes: boolean
|
||||||
allow_interaction: boolean
|
allow_interaction: boolean
|
||||||
@@ -5180,8 +5182,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
|||||||
* draws some useful stats in the corner of the canvas
|
* draws some useful stats in the corner of the canvas
|
||||||
*/
|
*/
|
||||||
renderInfo(ctx: CanvasRenderingContext2D, x: number, y: number): void {
|
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
|
x = x || 10
|
||||||
y = y || this.canvas.offsetHeight - 80
|
y = y || this.canvas.offsetHeight - (lineCount + 1) * lineHeight
|
||||||
|
|
||||||
ctx.save()
|
ctx.save()
|
||||||
ctx.translate(x, y)
|
ctx.translate(x, y)
|
||||||
@@ -5189,18 +5193,26 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
|||||||
ctx.font = `10px ${LiteGraph.DEFAULT_FONT}`
|
ctx.font = `10px ${LiteGraph.DEFAULT_FONT}`
|
||||||
ctx.fillStyle = '#888'
|
ctx.fillStyle = '#888'
|
||||||
ctx.textAlign = 'left'
|
ctx.textAlign = 'left'
|
||||||
|
let line = 1
|
||||||
if (this.graph) {
|
if (this.graph) {
|
||||||
ctx.fillText(`T: ${this.graph.globaltime.toFixed(2)}s`, 5, 13 * 1)
|
ctx.fillText(
|
||||||
ctx.fillText(`I: ${this.graph.iteration}`, 5, 13 * 2)
|
`T: ${this.graph.globaltime.toFixed(2)}s`,
|
||||||
|
5,
|
||||||
|
lineHeight * line++
|
||||||
|
)
|
||||||
|
ctx.fillText(`I: ${this.graph.iteration}`, 5, lineHeight * line++)
|
||||||
ctx.fillText(
|
ctx.fillText(
|
||||||
`N: ${this.graph._nodes.length} [${this.visible_nodes.length}]`,
|
`N: ${this.graph._nodes.length} [${this.visible_nodes.length}]`,
|
||||||
5,
|
5,
|
||||||
13 * 3
|
lineHeight * line++
|
||||||
)
|
)
|
||||||
ctx.fillText(`V: ${this.graph._version}`, 5, 13 * 4)
|
ctx.fillText(`V: ${this.graph._version}`, 5, lineHeight * line++)
|
||||||
ctx.fillText(`FPS:${this.fps.toFixed(2)}`, 5, 13 * 5)
|
ctx.fillText(`FPS:${this.fps.toFixed(2)}`, 5, lineHeight * line++)
|
||||||
} else {
|
} else {
|
||||||
ctx.fillText('No graph selected', 5, 13 * 1)
|
ctx.fillText('No graph selected', 5, lineHeight * line++)
|
||||||
|
}
|
||||||
|
if (this.info_text) {
|
||||||
|
ctx.fillText(this.info_text, 5, lineHeight * line++)
|
||||||
}
|
}
|
||||||
ctx.restore()
|
ctx.restore()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,9 @@
|
|||||||
"Comfy_Canvas_PasteFromClipboard": {
|
"Comfy_Canvas_PasteFromClipboard": {
|
||||||
"label": "لصق"
|
"label": "لصق"
|
||||||
},
|
},
|
||||||
|
"Comfy_Canvas_PasteFromClipboardWithConnect": {
|
||||||
|
"label": "لصق مع الاتصال"
|
||||||
|
},
|
||||||
"Comfy_Canvas_ResetView": {
|
"Comfy_Canvas_ResetView": {
|
||||||
"label": "إعادة تعيين العرض"
|
"label": "إعادة تعيين العرض"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
"actionbar": {
|
"actionbar": {
|
||||||
"dockToTop": "إلصق بالأعلى",
|
"dockToTop": "إلصق بالأعلى",
|
||||||
"feedback": "ملاحظات",
|
"feedback": "ملاحظات",
|
||||||
"feedbackTooltip": "إرسال ملاحظات"
|
"feedbackTooltip": "إرسال ملاحظات",
|
||||||
|
"share": "مشاركة",
|
||||||
|
"shareTooltip": "مشاركة سير العمل"
|
||||||
},
|
},
|
||||||
"apiNodesCostBreakdown": {
|
"apiNodesCostBreakdown": {
|
||||||
"costPerRun": "التكلفة لكل تشغيل",
|
"costPerRun": "التكلفة لكل تشغيل",
|
||||||
@@ -321,18 +323,24 @@
|
|||||||
"y": "ص"
|
"y": "ص"
|
||||||
},
|
},
|
||||||
"breadcrumbsMenu": {
|
"breadcrumbsMenu": {
|
||||||
|
"app": "التطبيق",
|
||||||
"clearWorkflow": "مسح سير العمل",
|
"clearWorkflow": "مسح سير العمل",
|
||||||
"deleteBlueprint": "حذف المخطط",
|
"deleteBlueprint": "حذف المخطط",
|
||||||
"deleteWorkflow": "حذف سير العمل",
|
"deleteWorkflow": "حذف سير العمل",
|
||||||
"duplicate": "تكرار",
|
"duplicate": "تكرار",
|
||||||
|
"editBuilderMode": "تعديل التطبيق",
|
||||||
"enterAppMode": "الدخول إلى وضع التطبيق",
|
"enterAppMode": "الدخول إلى وضع التطبيق",
|
||||||
"enterBuilderMode": "دخول وضع بناء التطبيق",
|
"enterBuilderMode": "دخول وضع بناء التطبيق",
|
||||||
"enterNewName": "أدخل اسمًا جديدًا",
|
"enterNewName": "أدخل اسمًا جديدًا",
|
||||||
|
"enterNodeGraph": "دخول رسم العقد",
|
||||||
"exitAppMode": "الخروج من وضع التطبيق",
|
"exitAppMode": "الخروج من وضع التطبيق",
|
||||||
|
"graph": "الرسم البياني",
|
||||||
"missingNodesWarning": "يحتوي سير العمل على عقد غير مدعومة (مظللة باللون الأحمر).",
|
"missingNodesWarning": "يحتوي سير العمل على عقد غير مدعومة (مظللة باللون الأحمر).",
|
||||||
|
"share": "مشاركة",
|
||||||
"workflowActions": "إجراءات سير العمل"
|
"workflowActions": "إجراءات سير العمل"
|
||||||
},
|
},
|
||||||
"builderMenu": {
|
"builderMenu": {
|
||||||
|
"enterAppMode": "الدخول إلى وضع التطبيق",
|
||||||
"exitAppBuilder": "الخروج من مُنشئ التطبيق"
|
"exitAppBuilder": "الخروج من مُنشئ التطبيق"
|
||||||
},
|
},
|
||||||
"builderToolbar": {
|
"builderToolbar": {
|
||||||
@@ -354,6 +362,7 @@
|
|||||||
"defaultViewTitle": "تعيين العرض الافتراضي لهذا سير العمل",
|
"defaultViewTitle": "تعيين العرض الافتراضي لهذا سير العمل",
|
||||||
"emptyWorkflowPrompt": "هل ترغب في البدء بقالب؟",
|
"emptyWorkflowPrompt": "هل ترغب في البدء بقالب؟",
|
||||||
"emptyWorkflowTitle": "لا يحتوي سير العمل هذا على أي عقد",
|
"emptyWorkflowTitle": "لا يحتوي سير العمل هذا على أي عقد",
|
||||||
|
"exitToWorkflow": "الخروج إلى سير العمل",
|
||||||
"inputs": "المدخلات",
|
"inputs": "المدخلات",
|
||||||
"inputsDescription": "اختر المدخلات",
|
"inputsDescription": "اختر المدخلات",
|
||||||
"label": "منشئ التطبيقات",
|
"label": "منشئ التطبيقات",
|
||||||
@@ -507,6 +516,80 @@
|
|||||||
"red": "أحمر",
|
"red": "أحمر",
|
||||||
"yellow": "أصفر"
|
"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": {
|
"commands": {
|
||||||
"clear": "مسح سير العمل",
|
"clear": "مسح سير العمل",
|
||||||
"clipspace": "فتح مساحة القص",
|
"clipspace": "فتح مساحة القص",
|
||||||
@@ -869,6 +952,7 @@
|
|||||||
"collapseAll": "طي الكل",
|
"collapseAll": "طي الكل",
|
||||||
"color": "اللون",
|
"color": "اللون",
|
||||||
"comfy": "Comfy",
|
"comfy": "Comfy",
|
||||||
|
"comfyCloud": "Comfy Cloud",
|
||||||
"comfyOrgLogoAlt": "شعار ComfyOrg",
|
"comfyOrgLogoAlt": "شعار ComfyOrg",
|
||||||
"comingSoon": "قريباً",
|
"comingSoon": "قريباً",
|
||||||
"command": "أمر",
|
"command": "أمر",
|
||||||
@@ -1342,7 +1426,7 @@
|
|||||||
"appBuilder": "منشئ التطبيقات",
|
"appBuilder": "منشئ التطبيقات",
|
||||||
"apps": "التطبيقات",
|
"apps": "التطبيقات",
|
||||||
"appsEmptyMessage": "سيتم عرض التطبيقات المحفوظة هنا.\nانقر أدناه لبناء تطبيقك الأول.",
|
"appsEmptyMessage": "سيتم عرض التطبيقات المحفوظة هنا.\nانقر أدناه لبناء تطبيقك الأول.",
|
||||||
"enterAppMode": "الدخول إلى وضع التطبيق"
|
"appsEmptyMessageAction": "انقر أدناه لإنشاء أول تطبيق لك."
|
||||||
},
|
},
|
||||||
"arrange": {
|
"arrange": {
|
||||||
"atLeastOne": "عقدة واحدة على الأقل",
|
"atLeastOne": "عقدة واحدة على الأقل",
|
||||||
@@ -1356,14 +1440,18 @@
|
|||||||
},
|
},
|
||||||
"backToWorkflow": "العودة إلى سير العمل",
|
"backToWorkflow": "العودة إلى سير العمل",
|
||||||
"beta": "وضع التطبيق تجريبي - أرسل ملاحظاتك",
|
"beta": "وضع التطبيق تجريبي - أرسل ملاحظاتك",
|
||||||
|
"buildAnApp": "أنشئ تطبيقًا",
|
||||||
"builder": {
|
"builder": {
|
||||||
"exit": "خروج من البناء",
|
"exit": "خروج من البناء",
|
||||||
"exitConfirmMessage": "لديك تغييرات غير محفوظة ستفقد\nهل تريد الخروج بدون حفظ؟",
|
"exitConfirmMessage": "لديك تغييرات غير محفوظة ستفقد\nهل تريد الخروج بدون حفظ؟",
|
||||||
"exitConfirmTitle": "الخروج من بناء التطبيق؟",
|
"exitConfirmTitle": "الخروج من بناء التطبيق؟",
|
||||||
|
"inputPlaceholder": "سيتم عرض المدخلات هنا",
|
||||||
"inputsDesc": "سيتفاعل المستخدمون مع هذه المدخلات ويعدلونها لإنشاء النتائج.",
|
"inputsDesc": "سيتفاعل المستخدمون مع هذه المدخلات ويعدلونها لإنشاء النتائج.",
|
||||||
"inputsExample": "أمثلة: \"تحميل صورة\"، \"موجه نصي\"، \"خطوات\"",
|
"inputsExample": "أمثلة: \"تحميل صورة\"، \"موجه نصي\"، \"خطوات\"",
|
||||||
"noInputs": "لم تتم إضافة أي مدخلات بعد",
|
"noInputs": "لم تتم إضافة أي مدخلات بعد",
|
||||||
"noOutputs": "لم تتم إضافة أي عقد إخراج بعد",
|
"noOutputs": "لم تتم إضافة أي عقد إخراج بعد",
|
||||||
|
"outputPlaceholder": "سيتم عرض عقد الإخراج هنا",
|
||||||
|
"outputRequiredPlaceholder": "مطلوب عقدة واحدة على الأقل",
|
||||||
"outputsDesc": "وصل عقدة إخراج واحدة على الأقل حتى يتمكن المستخدمون من رؤية النتائج بعد التشغيل.",
|
"outputsDesc": "وصل عقدة إخراج واحدة على الأقل حتى يتمكن المستخدمون من رؤية النتائج بعد التشغيل.",
|
||||||
"outputsExample": "أمثلة: \"حفظ صورة\" أو \"حفظ فيديو\"",
|
"outputsExample": "أمثلة: \"حفظ صورة\" أو \"حفظ فيديو\"",
|
||||||
"promptAddInputs": "انقر على معلمات العقدة لإضافتها هنا كمدخلات",
|
"promptAddInputs": "انقر على معلمات العقدة لإضافتها هنا كمدخلات",
|
||||||
@@ -1371,12 +1459,15 @@
|
|||||||
"title": "وضع بناء التطبيق",
|
"title": "وضع بناء التطبيق",
|
||||||
"unknownWidget": "عنصر الواجهة غير مرئي"
|
"unknownWidget": "عنصر الواجهة غير مرئي"
|
||||||
},
|
},
|
||||||
|
"cancelThisRun": "إلغاء هذا التشغيل",
|
||||||
|
"deleteAllAssets": "حذف جميع الأصول من هذه الجلسة",
|
||||||
"downloadAll": "تنزيل الكل",
|
"downloadAll": "تنزيل الكل",
|
||||||
"dragAndDropImage": "اسحب وأسقط صورة",
|
"dragAndDropImage": "اسحب وأسقط صورة",
|
||||||
"emptyWorkflowExplanation": "سير العمل الخاص بك فارغ. تحتاج إلى بعض العقد أولاً لبدء بناء التطبيق.",
|
"emptyWorkflowExplanation": "سير العمل الخاص بك فارغ. تحتاج إلى بعض العقد أولاً لبدء بناء التطبيق.",
|
||||||
"enterNodeGraph": "دخول مخطط العقد",
|
"enterNodeGraph": "دخول مخطط العقد",
|
||||||
"giveFeedback": "إعطاء ملاحظات",
|
"giveFeedback": "إعطاء ملاحظات",
|
||||||
"graphMode": "وضع الرسم البياني",
|
"graphMode": "وضع الرسم البياني",
|
||||||
|
"hasCreditCost": "يتطلب أرصدة إضافية",
|
||||||
"linearMode": "وضع التطبيق",
|
"linearMode": "وضع التطبيق",
|
||||||
"loadTemplate": "تحميل قالب",
|
"loadTemplate": "تحميل قالب",
|
||||||
"mobileControls": "تعديل وتشغيل",
|
"mobileControls": "تعديل وتشغيل",
|
||||||
@@ -1393,6 +1484,8 @@
|
|||||||
"controls": "تظهر المخرجات في الأسفل، وعناصر التحكم على اليمين. كل شيء آخر يبقى بعيدًا.",
|
"controls": "تظهر المخرجات في الأسفل، وعناصر التحكم على اليمين. كل شيء آخر يبقى بعيدًا.",
|
||||||
"getStarted": "انقر على {runButton} للبدء.",
|
"getStarted": "انقر على {runButton} للبدء.",
|
||||||
"message": "عرض مبسط يخفي رسم العقد حتى تتمكن من التركيز على الإنشاء.",
|
"message": "عرض مبسط يخفي رسم العقد حتى تتمكن من التركيز على الإنشاء.",
|
||||||
|
"noOutputs": "يحتاج التطبيق إلى {count} على الأقل ليكون قابلاً للاستخدام.",
|
||||||
|
"oneOutput": "مخرج واحد",
|
||||||
"sharing": "المشاركة سهلة: أنشئ سير العمل الخاص بك، افتح وضع التطبيق، انقر بزر الماوس الأيمن على علامة التبويب، ثم صدّر. عندما يفتح الآخرون ملفك، سيتم تشغيله مباشرة في هذا العرض النظيف. يمكنك مشاركة سير عمل قوي كأداة بسيطة دون الحاجة لفهم مخططات العقد.",
|
"sharing": "المشاركة سهلة: أنشئ سير العمل الخاص بك، افتح وضع التطبيق، انقر بزر الماوس الأيمن على علامة التبويب، ثم صدّر. عندما يفتح الآخرون ملفك، سيتم تشغيله مباشرة في هذا العرض النظيف. يمكنك مشاركة سير عمل قوي كأداة بسيطة دون الحاجة لفهم مخططات العقد.",
|
||||||
"title": "مرحبًا بك في وضع التطبيق"
|
"title": "مرحبًا بك في وضع التطبيق"
|
||||||
}
|
}
|
||||||
@@ -1765,7 +1858,6 @@
|
|||||||
"execute": "تنفيذ",
|
"execute": "تنفيذ",
|
||||||
"fullscreen": "ملء الشاشة",
|
"fullscreen": "ملء الشاشة",
|
||||||
"help": "مساعدة",
|
"help": "مساعدة",
|
||||||
"helpAndFeedback": "المساعدة والتعليقات",
|
|
||||||
"hideMenu": "إخفاء القائمة",
|
"hideMenu": "إخفاء القائمة",
|
||||||
"instant": "فوري",
|
"instant": "فوري",
|
||||||
"instantTooltip": "سيتم وضع سير العمل في قائمة الانتظار فور انتهاء التوليد",
|
"instantTooltip": "سيتم وضع سير العمل في قائمة الانتظار فور انتهاء التوليد",
|
||||||
@@ -1866,6 +1958,7 @@
|
|||||||
"Open Sign In Dialog": "فتح نافذة تسجيل الدخول",
|
"Open Sign In Dialog": "فتح نافذة تسجيل الدخول",
|
||||||
"Open extra_model_paths_yaml": "فتح ملف extra_model_paths.yaml",
|
"Open extra_model_paths_yaml": "فتح ملف extra_model_paths.yaml",
|
||||||
"Paste": "لصق",
|
"Paste": "لصق",
|
||||||
|
"Paste with Connect": "لصق مع الاتصال",
|
||||||
"Pin/Unpin Selected Items": "تثبيت/إلغاء تثبيت العناصر المحددة",
|
"Pin/Unpin Selected Items": "تثبيت/إلغاء تثبيت العناصر المحددة",
|
||||||
"Pin/Unpin Selected Nodes": "تثبيت/إلغاء تثبيت العقد المحددة",
|
"Pin/Unpin Selected Nodes": "تثبيت/إلغاء تثبيت العقد المحددة",
|
||||||
"Previous Opened Workflow": "سير العمل السابق المفتوح",
|
"Previous Opened Workflow": "سير العمل السابق المفتوح",
|
||||||
@@ -2037,6 +2130,7 @@
|
|||||||
"lotus": "lotus",
|
"lotus": "lotus",
|
||||||
"ltxv": "ltxv",
|
"ltxv": "ltxv",
|
||||||
"mask": "قناع",
|
"mask": "قناع",
|
||||||
|
"math": "رياضيات",
|
||||||
"model": "نموذج",
|
"model": "نموذج",
|
||||||
"model_merging": "دمج النماذج",
|
"model_merging": "دمج النماذج",
|
||||||
"model_patches": "تصحيحات النموذج",
|
"model_patches": "تصحيحات النموذج",
|
||||||
@@ -2140,6 +2234,18 @@
|
|||||||
},
|
},
|
||||||
"title": "جهازك غير مدعوم"
|
"title": "جهازك غير مدعوم"
|
||||||
},
|
},
|
||||||
|
"openSharedWorkflow": {
|
||||||
|
"author": "المؤلف:",
|
||||||
|
"copyAssetsAndOpen": "استيراد الأصول وفتح سير العمل",
|
||||||
|
"copyDescription": "فتح سير العمل سينشئ نسخة جديدة في مساحة العمل الخاصة بك",
|
||||||
|
"dialogTitle": "فتح سير العمل المشترك",
|
||||||
|
"importFailed": "فشل استيراد أصول سير العمل",
|
||||||
|
"loadError": "تعذر تحميل سير العمل المشترك هذا. يرجى المحاولة لاحقًا.",
|
||||||
|
"nonPublicAssetsWarningLine1": "يأتي هذا سير العمل مع أصول غير عامة.",
|
||||||
|
"nonPublicAssetsWarningLine2": "سيتم استيراد هذه الأصول إلى مكتبتك عند فتح سير العمل",
|
||||||
|
"openWithoutImporting": "فتح بدون استيراد",
|
||||||
|
"openWorkflow": "فتح سير العمل"
|
||||||
|
},
|
||||||
"painter": {
|
"painter": {
|
||||||
"background": "الخلفية",
|
"background": "الخلفية",
|
||||||
"brush": "فرشاة",
|
"brush": "فرشاة",
|
||||||
@@ -2598,6 +2704,47 @@
|
|||||||
"default": "افتراضي",
|
"default": "افتراضي",
|
||||||
"round": "دائري"
|
"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": {
|
"shortcuts": {
|
||||||
"essentials": "أساسي",
|
"essentials": "أساسي",
|
||||||
"keyboardShortcuts": "اختصارات لوحة المفاتيح",
|
"keyboardShortcuts": "اختصارات لوحة المفاتيح",
|
||||||
@@ -3229,6 +3376,7 @@
|
|||||||
"addedToWorkspace": "تمت إضافتك إلى {workspaceName}",
|
"addedToWorkspace": "تمت إضافتك إلى {workspaceName}",
|
||||||
"inviteAccepted": "تم قبول الدعوة",
|
"inviteAccepted": "تم قبول الدعوة",
|
||||||
"inviteFailed": "فشل في قبول الدعوة",
|
"inviteFailed": "فشل في قبول الدعوة",
|
||||||
|
"switchFailed": "فشل في تبديل مساحة العمل. يرجى المحاولة مرة أخرى.",
|
||||||
"viewWorkspace": "عرض مساحة العمل"
|
"viewWorkspace": "عرض مساحة العمل"
|
||||||
},
|
},
|
||||||
"workspaceAuth": {
|
"workspaceAuth": {
|
||||||
|
|||||||
@@ -1419,6 +1419,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"ComfyMathExpression": {
|
||||||
|
"display_name": "تعبير رياضي",
|
||||||
|
"inputs": {
|
||||||
|
"expression": {
|
||||||
|
"name": "تعبير"
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"name": "القيم"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"outputs": {
|
||||||
|
"0": {
|
||||||
|
"tooltip": null
|
||||||
|
},
|
||||||
|
"1": {
|
||||||
|
"tooltip": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"ComfySwitchNode": {
|
"ComfySwitchNode": {
|
||||||
"display_name": "مفتاح التحويل",
|
"display_name": "مفتاح التحويل",
|
||||||
"inputs": {
|
"inputs": {
|
||||||
@@ -15112,6 +15131,64 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"TencentModelTo3DUVNode": {
|
||||||
|
"description": "تنفيذ فك UV لنموذج ثلاثي الأبعاد لإنشاء نسيج UV. يجب أن يحتوي النموذج المُدخل على أقل من ٣٠٬٠٠٠ وجه.",
|
||||||
|
"display_name": "Hunyuan3D: من نموذج إلى UV",
|
||||||
|
"inputs": {
|
||||||
|
"control_after_generate": {
|
||||||
|
"name": "التحكم بعد التوليد"
|
||||||
|
},
|
||||||
|
"model_3d": {
|
||||||
|
"name": "نموذج_ثلاثي_الأبعاد",
|
||||||
|
"tooltip": "إدخال نموذج ثلاثي الأبعاد (GLB، OBJ، أو FBX)"
|
||||||
|
},
|
||||||
|
"seed": {
|
||||||
|
"name": "البذرة",
|
||||||
|
"tooltip": "تتحكم البذرة فيما إذا كان يجب إعادة تشغيل العقدة؛ النتائج غير حتمية بغض النظر عن البذرة."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"outputs": {
|
||||||
|
"0": {
|
||||||
|
"name": "OBJ",
|
||||||
|
"tooltip": null
|
||||||
|
},
|
||||||
|
"1": {
|
||||||
|
"name": "FBX",
|
||||||
|
"tooltip": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"TencentSmartTopologyNode": {
|
||||||
|
"description": "تنفيذ إعادة طوبولوجيا ذكية لنموذج ثلاثي الأبعاد. يدعم صيغ GLB/OBJ؛ الحد الأقصى ٢٠٠ ميجابايت؛ يُوصى به للنماذج عالية التفاصيل.",
|
||||||
|
"display_name": "Hunyuan3D: طوبولوجيا ذكية",
|
||||||
|
"inputs": {
|
||||||
|
"control_after_generate": {
|
||||||
|
"name": "التحكم بعد التوليد"
|
||||||
|
},
|
||||||
|
"face_level": {
|
||||||
|
"name": "مستوى الوجوه",
|
||||||
|
"tooltip": "مستوى تقليل المضلعات."
|
||||||
|
},
|
||||||
|
"model_3d": {
|
||||||
|
"name": "نموذج_ثلاثي_الأبعاد",
|
||||||
|
"tooltip": "إدخال نموذج ثلاثي الأبعاد (GLB أو OBJ)"
|
||||||
|
},
|
||||||
|
"polygon_type": {
|
||||||
|
"name": "نوع المضلع",
|
||||||
|
"tooltip": "نوع تركيب السطح."
|
||||||
|
},
|
||||||
|
"seed": {
|
||||||
|
"name": "البذرة",
|
||||||
|
"tooltip": "تتحكم البذرة فيما إذا كان يجب إعادة تشغيل العقدة؛ النتائج غير حتمية بغض النظر عن البذرة."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"outputs": {
|
||||||
|
"0": {
|
||||||
|
"name": "OBJ",
|
||||||
|
"tooltip": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"TencentTextToModelNode": {
|
"TencentTextToModelNode": {
|
||||||
"display_name": "Hunyuan3D: من نص إلى نموذج (احترافي)",
|
"display_name": "Hunyuan3D: من نص إلى نموذج (احترافي)",
|
||||||
"inputs": {
|
"inputs": {
|
||||||
|
|||||||
@@ -400,7 +400,7 @@
|
|||||||
"name": "تخطيط شريط التبويبات",
|
"name": "تخطيط شريط التبويبات",
|
||||||
"options": {
|
"options": {
|
||||||
"Default": "افتراضي",
|
"Default": "افتراضي",
|
||||||
"Integrated": "مُدمج"
|
"Legacy": "تقليدي"
|
||||||
},
|
},
|
||||||
"tooltip": "يتحكم في تخطيط شريط التبويبات. \"مُدمج\" ينقل عناصر المساعدة والتحكمات الخاصة بالمستخدم إلى منطقة شريط التبويبات."
|
"tooltip": "يتحكم في تخطيط شريط التبويبات. \"مُدمج\" ينقل عناصر المساعدة والتحكمات الخاصة بالمستخدم إلى منطقة شريط التبويبات."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -71,6 +71,9 @@
|
|||||||
"Comfy_Canvas_PasteFromClipboard": {
|
"Comfy_Canvas_PasteFromClipboard": {
|
||||||
"label": "Paste"
|
"label": "Paste"
|
||||||
},
|
},
|
||||||
|
"Comfy_Canvas_PasteFromClipboardWithConnect": {
|
||||||
|
"label": "Paste with Connect"
|
||||||
|
},
|
||||||
"Comfy_Canvas_ResetView": {
|
"Comfy_Canvas_ResetView": {
|
||||||
"label": "Reset View"
|
"label": "Reset View"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -301,6 +301,7 @@
|
|||||||
"1x": "1x",
|
"1x": "1x",
|
||||||
"2x": "2x",
|
"2x": "2x",
|
||||||
"beta": "BETA",
|
"beta": "BETA",
|
||||||
|
"comfyCloud": "Comfy Cloud",
|
||||||
"nightly": "NIGHTLY",
|
"nightly": "NIGHTLY",
|
||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
"noItems": "No items",
|
"noItems": "No items",
|
||||||
@@ -968,7 +969,6 @@
|
|||||||
"customNodesManager": "Custom Nodes Manager",
|
"customNodesManager": "Custom Nodes Manager",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"help": "Help",
|
"help": "Help",
|
||||||
"helpAndFeedback": "Help & Feedback",
|
|
||||||
"queue": "Queue Panel",
|
"queue": "Queue Panel",
|
||||||
"fullscreen": "Fullscreen"
|
"fullscreen": "Fullscreen"
|
||||||
},
|
},
|
||||||
@@ -1330,7 +1330,6 @@
|
|||||||
"Rename": "Rename",
|
"Rename": "Rename",
|
||||||
"Save": "Save",
|
"Save": "Save",
|
||||||
"Save As": "Save As",
|
"Save As": "Save As",
|
||||||
"Share": "Share",
|
|
||||||
"Show Settings Dialog": "Show Settings Dialog",
|
"Show Settings Dialog": "Show Settings Dialog",
|
||||||
"Set Subgraph Description": "Set Subgraph Description",
|
"Set Subgraph Description": "Set Subgraph Description",
|
||||||
"Set Subgraph Search Aliases": "Set Subgraph Search Aliases",
|
"Set Subgraph Search Aliases": "Set Subgraph Search Aliases",
|
||||||
@@ -1597,6 +1596,7 @@
|
|||||||
"kandinsky5": "kandinsky5",
|
"kandinsky5": "kandinsky5",
|
||||||
"hooks": "hooks",
|
"hooks": "hooks",
|
||||||
"combine": "combine",
|
"combine": "combine",
|
||||||
|
"math": "math",
|
||||||
"logic": "logic",
|
"logic": "logic",
|
||||||
"cond single": "cond single",
|
"cond single": "cond single",
|
||||||
"context": "context",
|
"context": "context",
|
||||||
@@ -3181,7 +3181,6 @@
|
|||||||
"deleteAllAssets": "Delete all assets from this run",
|
"deleteAllAssets": "Delete all assets from this run",
|
||||||
"hasCreditCost": "Requires additional credits",
|
"hasCreditCost": "Requires additional credits",
|
||||||
"viewGraph": "View node graph",
|
"viewGraph": "View node graph",
|
||||||
"mobileNoWorkflow": "This workflow hasn't been built for app mode. Try a different one.",
|
|
||||||
"welcome": {
|
"welcome": {
|
||||||
"title": "App Mode",
|
"title": "App Mode",
|
||||||
"message": "A simplified view that hides the node graph so you can focus on creating.",
|
"message": "A simplified view that hides the node graph so you can focus on creating.",
|
||||||
|
|||||||
@@ -1419,6 +1419,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"ComfyMathExpression": {
|
||||||
|
"display_name": "Math Expression",
|
||||||
|
"inputs": {
|
||||||
|
"expression": {
|
||||||
|
"name": "expression"
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"name": "values"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"outputs": {
|
||||||
|
"0": {
|
||||||
|
"tooltip": null
|
||||||
|
},
|
||||||
|
"1": {
|
||||||
|
"tooltip": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"ComfySwitchNode": {
|
"ComfySwitchNode": {
|
||||||
"display_name": "Switch",
|
"display_name": "Switch",
|
||||||
"inputs": {
|
"inputs": {
|
||||||
@@ -15112,6 +15131,64 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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": {
|
"TencentTextToModelNode": {
|
||||||
"display_name": "Hunyuan3D: Text to Model",
|
"display_name": "Hunyuan3D: Text to Model",
|
||||||
"inputs": {
|
"inputs": {
|
||||||
|
|||||||