Compare commits

...

22 Commits

Author SHA1 Message Date
snomiao
9ed91c197f [test] Improve shimFiles.test.ts to work with standard dist folder
- Use standard `dist` folder instead of hardcoded `dist-treeshake-*` paths
- Add fallback logic to check multiple dist directory candidates
- Remove local-only comparison test between treeshake-enabled/disabled builds
- Add helper test to verify dist directory is found

This ensures tests work in CI/CD environments where only the standard
`dist` folder exists, while still supporting local debugging builds.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-17 00:55:15 +00:00
sno
c0b80dc750 Merge branch 'main' into sno-enable-treeshake 2025-10-17 09:49:24 +09:00
snomiao
8cc5b52c64 refactor: reorganize GitHub workflows with consistent naming convention (#5891)
## Summary

This PR implements a systematic naming convention for all GitHub
workflows to improve organization and discoverability. All 22 workflows
have been renamed and grouped by logical categories with consistent
prefixes.

## Changes

### Naming Convention
- **`ci-*`**: Continuous Integration workflows (testing, linting,
validation)
- **`pr-*`**: PR-specific automation triggered by labels  
- **`release-*`**: Release management workflows
- **`types-*`**: TypeScript type generation workflows
- **`i18n-*`**: Internationalization workflows

### Key Renames
- `tests-ci.yaml` → `ci-tests-e2e.yaml`
- `vitest-tests.yaml` → `ci-tests-unit.yaml`
- `storybook-and-chromatic-ci.yaml` → `ci-tests-storybook.yaml`
- `auto-backport.yaml` → `pr-backport.yaml`
- `claude-pr-review.yml` → `pr-claude-review.yaml`
- `version-bump.yaml` → `release-version-bump.yaml`
- `publish-frontend-types.yaml` → `release-npm-types.yaml`
- `create-dev-pypi-package.yaml` → `release-pypi-dev.yaml`

### Test Workflow Improvements
- Grouped all test workflows under `ci-tests-*` pattern
- Fork-safe deployment workflows: `ci-tests-e2e-forks.yaml`,
`ci-tests-storybook-forks.yaml`
- Added comments explaining fork deployment security workarounds

### Documentation
- Added comprehensive `.github/workflows/README.md`
- Documents naming conventions, best practices, and workflow
organization
- Includes trigger patterns and external dependencies

## Benefits

1. **Better Organization**: Workflows are now grouped logically by
prefix
2. **Improved Discoverability**: Easy to find related workflows  
3. **Consistent Naming**: All workflows follow the same pattern
4. **Clear Purpose**: Workflow names immediately indicate their function
5. **Maintainable**: README provides guidelines for future workflows

## Test Plan

- [x] All workflow cross-references updated
- [x] Display names match new file names  
- [x] Fork deployment workflows properly reference main workflows
- [x] Release workflows reference correct npm types workflow
- [x] All workflows retain original functionality

🤖 Generated with [Claude Code](https://claude.ai/code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5891-refactor-reorganize-GitHub-workflows-with-consistent-naming-convention-2806d73d365081febe47c7511bf0507e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-10-17 08:32:33 +09:00
Arjan Singh
03681a12bd feat: AssetCard tweaks (#6085)
## Summary

1. fix `preview_url` logic
2. design tweaks for border radius, fallback gradient, and name line
clamping (2 lines)
3. handle image error states by showing gradient
4. misc. refactors

## Screenshots


<img width="1515" height="1087" alt="Screenshot 2025-10-15 at 8 13
41 PM"
src="https://github.com/user-attachments/assets/85642869-d8cb-4ee4-b23d-a381e33fe802"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6085-feat-AssetCard-tweaks-28e6d73d3650818da7a2f4148be48ff7)
by [Unito](https://www.unito.io)
2025-10-16 14:46:50 -07:00
snomiao
5216c0da9f [test] Add comprehensive shim files export tests
Added unit tests to verify that treeshaking doesn't break the public API exposed through shim files in the dist folder. Tests check:
- All core shim files exist (api.js, app.js, changeTracker.js, etc.)
- Each shim file contains proper exports from window.comfyAPI
- UI subdirectory shim files are present and valid
- Metadata subdirectory shim files are present and valid
- Treeshake-enabled and treeshake-disabled builds have identical shim files

This ensures backward compatibility when treeshaking is enabled and prevents breaking changes to the extension API.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-16 15:34:11 +00:00
AustinMroz
9bfc9b740d Add additional check when restoring widgets_values (#6054)
Prior to #6014, I had identified and started on #6015 to resolve what I
believed to be a larger class of issues. While the issue I was solving
produced the same error log on the same line, I misunderstood the
circumstances in which the reported issue would occur. As proxyWidgets
do not have their own value, only linked widgets should have their value
restored from `widgets_values`. This PR adds an additional check both
that the property entry is within bounds, and that the property entry is
for a linked widget.

See #6014
A potential additional issue is still being investigated.
- Additional issue seems unrelated to fix here. Will leave issue open,
but recommend merging this as is to not block v1.28.7

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6054-Add-additional-check-when-restoring-widgets_values-28c6d73d365081489d29fc9723132db0)
by [Unito](https://www.unito.io)
2025-10-15 19:37:41 -07:00
AustinMroz
7355a51282 In update-locales workflow, don't skip ci (#6081)
Adding `[skip ci]` causes release workflows to break and the added
safety of rerunning tests is beneficial for releases.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6081-In-update-locales-workflow-don-t-skip-ci-28e6d73d365081208332ef7b11ebbb52)
by [Unito](https://www.unito.io)
2025-10-15 18:50:23 -07:00
Arjan Singh
7ca8615947 feat(assetService): increase limit to 500 (#6078)
## Summary

Increase asset API limit to 500 to account for additional models being
added.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6078-feat-assetService-increase-limit-to-500-28d6d73d365081539760f986dd5bff23)
by [Unito](https://www.unito.io)
2025-10-15 14:38:10 -07:00
GitHub Action
fdee6a51f6 [automated] Apply ESLint and Prettier fixes 2025-10-15 19:46:38 +00:00
snomiao
4b2c29c320 [bugfix] Fix ESLint config conflict for vite.config.mts
Removed vite.config.mts from tsconfig.json include array to resolve ESLint parsing error. The file is already configured via eslint.config.ts allowDefaultProject, and being in both locations caused a conflict. This fixes the CI lint-and-format check failure.
2025-10-15 19:40:51 +00:00
snomiao
8debaf922b fix: enable treeshaking\n\nRemoved explicit disablement of treeshaking in vite.config.mts by deleting rollupOptions.treeshake: false. Treeshaking is now enabled by default, allowing the bundler to prune unused exports and potentially reduce bundle size. This aligns with Vite/Rollup defaults and does not introduce breaking changes. 2025-10-15 19:40:51 +00:00
Jin Yi
4dab27a84e feat: Improve MediaAssetCard video controls and add gallery view (#6065)
## Summary
- Enhanced video control visibility logic for better UX
- Added fullscreen gallery view with zoom-in button  
- Fixed hover interaction issues with overlays

## Changes

### Video Controls
- **Before**: Controls hidden when not hovering
- **After**: Controls always visible when not playing, hover-based
during playback

### Overlay Behavior  
- **Before**: All overlays hidden during video playback
- **After**: All overlays (actions, tags, layers) show on hover even
during playback

### Gallery View
- Added zoom-in button to top-right corner (all media types except 3D)
- Integrated with existing ResultGallery component
- Gallery closes when clicking dimmed background area

### Bug Fixes
- Fixed hover flicker issue by proper event handling on overlay elements

## Test Plan
- [x] Test video controls visibility (paused vs playing)
- [x] Test overlay hover behavior during video playback
- [x] Test zoom-in button opens gallery view
- [x] Test gallery closes on background click
- [x] Test 3D assets don't show zoom button
- [x] Test in Storybook with various media types

🤖 Generated with [Claude Code](https://claude.ai/code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6065-feat-Improve-MediaAssetCard-video-controls-and-add-gallery-view-28d6d73d3650818c90cfc5d0d00e4826)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-10-15 11:45:55 -07:00
Rizumu Ayaka
97417736be chore: rollup-plugin-visualizer (#6072)
## Summary

Using this tool, will generates dist/stats.html after build, we can see
the parts that are excessively large when packaged.

```bash
pnpm run build:analyze
```

<img width="2486" height="1780" alt="CleanShot 2025-10-15 at 17 25
17@2x"
src="https://github.com/user-attachments/assets/fd3ff5c9-2e7a-44a0-8b9f-4d87664c848c"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6072-chore-rollup-plugin-visualizer-28d6d73d36508184800cf7a67141b782)
by [Unito](https://www.unito.io)
2025-10-15 11:39:12 -07:00
Alexander Piskun
86e6e7bf1f add pricing for new Veo3.1 model (#6074)
## Summary

The prices for Veo3.1 are the same as the existing Veo3, just added a
new model via `||`

## Screenshots (if applicable)

<img width="1125" height="1077" alt="Screenshot From 2025-10-15
20-39-37"
src="https://github.com/user-attachments/assets/441cba83-cdd7-4bae-af0a-259c2dd38a59"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6074-add-pricing-for-new-Veo3-1-model-28d6d73d3650817b8bb8ea7442ff3e1a)
by [Unito](https://www.unito.io)
2025-10-15 11:32:05 -07:00
Johnpaul Chiwetelu
fb3ab88f04 fix Cloudbadge (#6063)
This pull request refactors how top bar badges are handled in the
application, making badge rendering extensible and moving cloud badge
logic into the extension system. The main changes include replacing the
old `CloudBetaBadge` component with a new, generic badge system,
introducing a Pinia store for badge management, and updating the
extension API to support top bar badges.

**Badge System Refactor and Extensibility**

* Replaced the hardcoded `CloudBetaBadge` with a new `TopbarBadges`
component, which dynamically renders badges from the store instead of
relying on the `isCloud` flag in `TopMenubar.vue`.
[[1]](diffhunk://#diff-b7d7bf1028f09fb907c09edf27631214d005c93b80eaff7cf15cfd53671b1e8aL9-R9)
[[2]](diffhunk://#diff-b7d7bf1028f09fb907c09edf27631214d005c93b80eaff7cf15cfd53671b1e8aL43-R48)

* Renamed and refactored `CloudBetaBadge.vue` to `TopbarBadge.vue`,
making it accept a generic `badge` prop and removing i18n logic from the
component.

* Added a new `TopbarBadges.vue` component to render all badges from the
`topbarBadgeStore`.

**Badge Data Management**

* Introduced a new Pinia store `topbarBadgeStore` that aggregates top
bar badges from all extensions, enabling dynamic badge management.

**Extension System Integration**

* Updated the extension API (`ComfyExtension` interface) to support a
new `topbarBadges` property, allowing extensions to contribute badges to
the top bar.

* Added a core extension (`cloudBadge.ts`) that registers a "Comfy
Cloud" beta badge when running in a cloud environment, using the new
badge system.
[[1]](diffhunk://#diff-b7818ca9daae2411d56695777160b8132507f2a3ff4f700d2510453c8833ca75R1-R16)
[[2]](diffhunk://#diff-236993d9e4213efe96d267c75c3292d32b93aa4dd6c3318d26a397e0ae56bc87R2)

**Type Definitions**

* Added a new `TopbarBadge` type to `comfy.ts` to define the structure
for top bar badges, supporting optional labels.
2025-10-15 05:27:19 +01:00
Terry Jia
476d6df1ca fix mask editor bug under vueNodes (#5953)
## Summary

fix mask editor issues on vueNodes

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5953-fix-mask-editor-bug-under-vueNodes-2856d73d3650810aa8a2e1a94c4d97a6)
by [Unito](https://www.unito.io)
2025-10-14 17:42:36 -07:00
Johnpaul Chiwetelu
7caad10e93 Badge for cloud environment (#6048)
This pull request introduces a new system for displaying
environment-specific badges in the application's top bar, with a focus
on supporting a "Comfy Cloud" badge in production environments. The
changes include new badge types, extension support, UI components, and
environment detection logic to ensure badges are only shown in
appropriate contexts.

**Topbar Badge System**

* Added a new `TopbarBadge` type and support for topbar badges in the
`ComfyExtension` interface to allow extensions to specify badges for the
top bar.
[[1]](diffhunk://#diff-c29886a1b0c982c6fff3545af0ca8ec269876c2cf3948f867d08c14032c04d66R24-R31)
[[2]](diffhunk://#diff-c29886a1b0c982c6fff3545af0ca8ec269876c2cf3948f867d08c14032c04d66R85-R88)
* Created a Pinia store `topbarBadgeStore` to aggregate topbar badges
from all registered extensions for display.

**UI Integration**

* Added a new `TopbarBadges.vue` component to render topbar badges and
integrated it into the top menu bar UI.
[[1]](diffhunk://#diff-6f460b1398fd033a2059daca1f991c74ce572cef86046a3726d1b1a70a3a4325R1-R32)
[[2]](diffhunk://#diff-b7d7bf1028f09fb907c09edf27631214d005c93b80eaff7cf15cfd53671b1e8aL5-R14)
* Updated CSS variables and menu styling to support the new badge
visuals.
[[1]](diffhunk://#diff-71b6b57a56095b04e47c797a5016149b76b27971cab04b93f033f1f846e0f5a0R88-R89)
[[2]](diffhunk://#diff-b7d7bf1028f09fb907c09edf27631214d005c93b80eaff7cf15cfd53671b1e8aL5-R14)

**Environment Detection and Extension Registration**

* Added a runtime environment detection utility to determine if the app
is running in production or staging, replacing the previous build-time
constant approach.
* Registered a new `cloudBadge` extension that conditionally adds a
"Comfy Cloud" badge with a "BETA" label when running in production.
[[1]](diffhunk://#diff-b7818ca9daae2411d56695777160b8132507f2a3ff4f700d2510453c8833ca75R1-R15)
[[2]](diffhunk://#diff-236993d9e4213efe96d267c75c3292d32b93aa4dd6c3318d26a397e0ae56bc87R2)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6048-Badge-for-cloud-environment-28c6d73d365081188050ece527c3c8f3)
by [Unito](https://www.unito.io)

<img width="996" height="897" alt="Screenshot 2025-10-14 at 20 02 40"
src="https://github.com/user-attachments/assets/5a3258c5-87fc-46ae-ad23-7669696cb8b6"
/>
2025-10-15 00:44:32 +01:00
Christian Byrne
6ea96f071e [style] update design of keybinding badges in menus (#6059)
## Summary

Updates the keybinding badges in the context menu (from selection
toolbox and right click on Vue node) to align with [the
design](https://www.figma.com/design/vALUV83vIdBzEsTJAhQgXq/Comfy-Design-System?node-id=3128-104039&m=dev)
exactly, including using the tokens from Figma variables.


https://github.com/user-attachments/assets/e37492f7-81a8-4598-bebb-56eb86b5dc56

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6059-style-update-design-of-keybinding-badges-in-menus-28c6d73d3650817784c8d8afac9ed8b8)
by [Unito](https://www.unito.io)
2025-10-14 16:02:04 -07:00
Christian Byrne
10af2300fa rework minimap, toolbox, and menu designs with unified theming (#6038)
## Summary

This PR redesigns the graph canvas interface components including
minimap, toolbox, and menu systems with updated spacing, colors, and
interaction patterns - using the design tokens directly from Figma,
which can be used elsewhere going forward.

There are some other changes to the designs, outlined
[here](https://www.notion.so/comfy-org/Update-Minimap-Menu-v2-2886d73d365080e88e12f8df027019c0):

- [x]  Update/standardize the padding between viewport and toolbox
- [x] Update toolbox component’s style to match the other floating menus
style (border radius, height, padding and follow theme colors)
- [x]  Expose the minimap button
- [x]  Remove the focus button and delete it’s keybinding
- [x]  Group the hand and the default cursor buttons


https://github.com/user-attachments/assets/92542e60-c32d-4a21-a6f6-e72837a70b17

## Review Focus

New CSS variables for cross-component theming consistency and
CanvasModeSelector component extraction for improved code organization.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6038-rework-minimap-toolbox-and-menu-designs-with-unified-theming-28b6d73d36508191a0c6cf8036d965c4)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-10-14 14:26:07 -07:00
Makki Shizu
2058967761 Fix Simplified Chinese Translation (#6039)
## Summary
Revise some traditional Chinese to simplified Chinese.
<!-- One sentence describing what changed and why. -->

## Changes

- **What**: <!-- Core functionality added/modified -->
- **Breaking**: <!-- Any breaking changes (if none, remove this line)
-->
- **Dependencies**: <!-- New dependencies (if none, remove this line)
-->

## Review Focus

<!-- Critical design decisions or edge cases that need attention -->

<!-- If this PR fixes an issue, uncomment and update the line below -->
<!-- Fixes #ISSUE_NUMBER -->

## Screenshots (if applicable)

<!-- Add screenshots or video recording to help explain your changes -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6039-Fix-Simplified-Chinese-Translation-28b6d73d365081aebba5f60893b75cb3)
by [Unito](https://www.unito.io)
2025-10-14 13:33:55 -07:00
Christian Byrne
d1af7c8256 fix terminal style (#6056)
## Summary

After #5292, at certain browser zoom levels, the xterm terminal did not
fill horizontal space properly, showing a gap with mismatched background
colors (`#171717` viewport vs `black` terminal content).

Fixes https://github.com/Comfy-Org/ComfyUI_frontend/issues/6049

## Problem

The hardcoded `#171717` terminal theme background only affected the
xterm viewport, while terminal content remained black, causing a visible
color mismatch at low zoom levels where the gap was more apparent.

Fixed by keeping original theme when not on desktop distribution.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6056-fix-terminal-style-28c6d73d36508132b521e5767f41d540)
by [Unito](https://www.unito.io)
2025-10-14 13:26:35 -07:00
Christian Byrne
5bc7c8a5c2 add aria labels on vue node widgets (2/2) (#6037)
## Summary

Continuation of https://github.com/Comfy-Org/ComfyUI_frontend/pull/6032

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6037-add-aria-labels-on-vue-node-widgets-2-2-28b6d73d365081d68795f5dfaca0b89a)
by [Unito](https://www.unito.io)
2025-10-14 11:33:15 -07:00
107 changed files with 1311 additions and 519 deletions

21
.github/workflows/README.md vendored Normal file
View File

@@ -0,0 +1,21 @@
# GitHub Workflows
## Naming Convention
Workflow files follow a consistent naming pattern: `<prefix>-<descriptive-name>.yaml`
### Category Prefixes
| Prefix | Purpose | Example |
| ---------- | ----------------------------------- | ------------------------------------ |
| `ci-` | Testing, linting, validation | `ci-tests-e2e.yaml` |
| `release-` | Version management, publishing | `release-version-bump.yaml` |
| `pr-` | PR automation (triggered by labels) | `pr-claude-review.yaml` |
| `api-` | External Api type generation | `api-update-registry-api-types.yaml` |
| `i18n-` | Internationalization updates | `i18n-update-core.yaml` |
## Documentation
Each workflow file contains comments explaining its purpose, triggers, and behavior. For specific details about what each workflow does, refer to the comments at the top of each `.yaml` file.
For GitHub Actions documentation, see [Events that trigger workflows](https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows).

View File

@@ -1,4 +1,5 @@
name: Update Electron Types
name: 'Api: Update Electron API Types'
description: 'When upstream electron API is updated, click dispatch to update the TypeScript type definitions in this repo'
on:
workflow_dispatch:

View File

@@ -1,4 +1,5 @@
name: Update ComfyUI-Manager API Types
name: 'Api: Update Manager API Types'
description: 'When upstream ComfyUI-Manager API is updated, click dispatch to update the TypeScript type definitions in this repo'
on:
# Manual trigger

View File

@@ -1,4 +1,5 @@
name: Update Comfy Registry API Types
name: 'Api: Update Registry API Types'
description: 'When upstream comfy-api is updated, click dispatch to update the TypeScript type definitions in this repo'
on:
# Manual trigger

View File

@@ -1,4 +1,5 @@
name: Validate JSON
name: "CI: JSON Validation"
description: "Validates JSON syntax in all tracked .json files (excluding tsconfig*.json) using jq"
on:
push:

View File

@@ -1,4 +1,5 @@
name: Lint and Format
name: "CI: Lint Format"
description: "Linting and code formatting validation for pull requests"
on:
pull_request:

View File

@@ -1,4 +1,5 @@
name: Devtools Python Check
name: "CI: Python Validation"
description: "Validates Python code in tools/devtools directory"
on:
pull_request:

View File

@@ -1,8 +1,9 @@
name: PR Playwright Deploy (Forks)
name: "CI: Tests E2E (Deploy for Forks)"
description: "Deploys test results from forked PRs (forks can't access deployment secrets)"
on:
workflow_run:
workflows: ["Tests CI"]
workflows: ["CI: Tests E2E"]
types: [requested, completed]
env:

View File

@@ -1,4 +1,5 @@
name: Tests CI
name: "CI: Tests E2E"
description: "End-to-end testing with Playwright across multiple browsers, deploys test reports to Cloudflare Pages"
on:
push:

View File

@@ -1,8 +1,9 @@
name: PR Storybook Deploy (Forks)
name: "CI: Tests Storybook (Deploy for Forks)"
description: "Deploys Storybook previews from forked PRs (forks can't access deployment secrets)"
on:
workflow_run:
workflows: ['Storybook and Chromatic CI']
workflows: ["CI: Tests Storybook"]
types: [requested, completed]
env:

View File

@@ -1,6 +1,5 @@
name: Storybook and Chromatic CI
# - [Automate Chromatic with GitHub Actions • Chromatic docs]( https://www.chromatic.com/docs/github-actions/ )
name: "CI: Tests Storybook"
description: "Builds Storybook and runs visual regression testing via Chromatic, deploys previews to Cloudflare Pages"
on:
workflow_dispatch: # Allow manual triggering

View File

@@ -1,4 +1,5 @@
name: Vitest Tests
name: "CI: Tests Unit"
description: "Unit and component testing with Vitest"
on:
push:

View File

@@ -1,4 +1,5 @@
name: Update Locales
name: "i18n: Update Core"
description: "Generates and updates translations for core ComfyUI components using OpenAI"
on:
# Manual dispatch for urgent translation updates
@@ -54,5 +55,5 @@ jobs:
# Apply the stashed changes if any
git stash pop || true
git add src/locales/
git diff --staged --quiet || git commit -m "Update locales [skip ci]"
git diff --staged --quiet || git commit -m "Update locales"
git push origin HEAD:${{ github.head_ref }}

View File

@@ -1,4 +1,4 @@
name: Update Locales for given custom node repository
name: i18n Update Custom Nodes
on:
workflow_dispatch:

View File

@@ -1,4 +1,4 @@
name: Update Node Definitions Locales
name: i18n Update Nodes
on:
workflow_dispatch:

View File

@@ -1,4 +1,4 @@
name: Auto Backport
name: PR Backport
on:
pull_request_target:

View File

@@ -1,4 +1,5 @@
name: Claude PR Review
name: "PR: Claude Review"
description: "AI-powered code review triggered by adding the 'claude-review' label to a PR"
permissions:
contents: read

View File

@@ -1,5 +1,5 @@
# Setting test expectation screenshots for Playwright
name: Update Playwright Expectations
name: "PR: Update Playwright Expectations"
on:
pull_request:

View File

@@ -1,4 +1,4 @@
name: Create Release Branch
name: Release Branch Create
on:
pull_request:

View File

@@ -1,4 +1,4 @@
name: Create Release Draft
name: Release Draft Create
on:
pull_request:
@@ -126,7 +126,7 @@ jobs:
publish_types:
needs: build
uses: ./.github/workflows/publish-frontend-types.yaml
uses: ./.github/workflows/release-npm-types.yaml
with:
version: ${{ needs.build.outputs.version }}
ref: ${{ github.event.pull_request.merge_commit_sha }}

View File

@@ -1,4 +1,4 @@
name: Publish Frontend Types
name: Release NPM Types
on:
workflow_dispatch:

View File

@@ -1,4 +1,4 @@
name: Create Dev PyPI Package
name: Release PyPI Dev
on:
workflow_dispatch:

View File

@@ -1,4 +1,5 @@
name: Version Bump
name: "Release: Version Bump"
description: "Manual workflow to increment package version with semantic versioning support"
on:
workflow_dispatch:

1
.gitignore vendored
View File

@@ -19,6 +19,7 @@ yarn.lock
node_modules
dist
/dist*/
dist-ssr
*.local
# Claude configuration

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

@@ -39,15 +39,15 @@ test.describe('Graph Canvas Menu', () => {
)
})
test('Focus mode button is clickable and has correct test id', async ({
test('Toggle minimap button is clickable and has correct test id', async ({
comfyPage
}) => {
const focusButton = comfyPage.page.getByTestId('focus-mode-button')
await expect(focusButton).toBeVisible()
await expect(focusButton).toBeEnabled()
const minimapButton = comfyPage.page.getByTestId('toggle-minimap-button')
await expect(minimapButton).toBeVisible()
await expect(minimapButton).toBeEnabled()
// Test that the button can be clicked without error
await focusButton.click()
await minimapButton.click()
await comfyPage.nextFrame()
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -3,10 +3,10 @@ import { expect } from '@playwright/test'
import type { Position } from '@vueuse/core'
import {
type ComfyPage,
comfyPageFixture as test,
testComfySnapToGridGridSize
} from '../fixtures/ComfyPage'
import type { ComfyPage } from '../fixtures/ComfyPage'
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
test.beforeEach(async ({ comfyPage }) => {
@@ -786,16 +786,8 @@ test.describe('Viewport settings', () => {
// Screenshot the canvas element
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', true)
// Open zoom controls dropdown first
const zoomControlsButton = comfyPage.page.getByTestId(
'zoom-controls-button'
)
await zoomControlsButton.click()
const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button')
await toggleButton.click()
// close zoom menu
await zoomControlsButton.click()
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', false)
await comfyPage.menu.topbar.saveWorkflow('Workflow A')

View File

@@ -35,12 +35,6 @@ test.describe('Minimap', () => {
})
test('Validate minimap toggle button state', async ({ comfyPage }) => {
// Open zoom controls dropdown first
const zoomControlsButton = comfyPage.page.getByTestId(
'zoom-controls-button'
)
await zoomControlsButton.click()
const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button')
await expect(toggleButton).toBeVisible()
@@ -51,13 +45,6 @@ test.describe('Minimap', () => {
test('Validate minimap can be toggled off and on', async ({ comfyPage }) => {
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
// Open zoom controls dropdown first
const zoomControlsButton = comfyPage.page.getByTestId(
'zoom-controls-button'
)
await zoomControlsButton.click()
const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button')
await expect(minimapContainer).toBeVisible()
@@ -67,22 +54,10 @@ test.describe('Minimap', () => {
await expect(minimapContainer).not.toBeVisible()
// Open zoom controls dropdown again
await zoomControlsButton.click()
await comfyPage.nextFrame()
await expect(toggleButton).toContainText('Show Minimap')
await toggleButton.click()
await comfyPage.nextFrame()
await expect(minimapContainer).toBeVisible()
// Open zoom controls dropdown again to verify button text
await zoomControlsButton.click()
await comfyPage.nextFrame()
await expect(toggleButton).toContainText('Hide Minimap')
})
test('Validate minimap keyboard shortcut Alt+M', async ({ comfyPage }) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -11,6 +11,7 @@
"build:desktop": "nx build @comfyorg/desktop-ui",
"build-storybook": "storybook build",
"build:types": "nx build --config vite.types.config.mts && node scripts/prepare-types.js",
"build:analyze": "cross-env ANALYZE_BUNDLE=true pnpm build",
"build": "cross-env NODE_OPTIONS='--max-old-space-size=8192' pnpm typecheck && nx build",
"collect-i18n": "pnpm exec playwright test --config=playwright.i18n.config.ts",
"dev:desktop": "nx dev @comfyorg/desktop-ui",
@@ -88,6 +89,7 @@
"nx": "catalog:",
"postcss-html": "catalog:",
"prettier": "catalog:",
"rollup-plugin-visualizer": "catalog:",
"storybook": "catalog:",
"stylelint": "catalog:",
"tailwindcss": "catalog:",

View File

@@ -24,11 +24,14 @@
--text-xxs: 0.625rem;
--text-xxs--line-height: calc(1 / 0.625);
/* Spacing */
--spacing-xs: 8px;
/* Font Families */
--font-inter: 'Inter', sans-serif;
/* Palette Colors */
--color-charcoal-100: #55565e;
--color-charcoal-100: #171718;
--color-charcoal-200: #494a50;
--color-charcoal-300: #3c3d42;
--color-charcoal-400: #313235;
@@ -85,6 +88,10 @@
--color-bypass: #6a246a;
--color-error: #962a2a;
--color-comfy-menu-secondary: var(--comfy-menu-secondary-bg);
--text-xxxs: 0.5625rem;
--text-xxxs--line-height: calc(1 / 0.5625);
--color-blue-selection: rgb(from var(--color-blue-100) r g b / 0.3);
--color-node-hover-100: rgb(from var(--color-charcoal-100) r g b/ 0.15);
--color-node-hover-200: rgb(from var(--color-charcoal-100) r g b/ 0.1);
@@ -146,8 +153,18 @@
--accent-primary: var(--color-charcoal-700);
--backdrop: var(--color-white);
--button-hover-surface: var(--color-gray-200);
--button-active-surface: var(--color-gray-400);
--dialog-surface: var(--color-neutral-200);
--interface-menu-component-surface-hovered: var(--color-gray-200);
--interface-menu-component-surface-selected: var(--color-gray-400);
--interface-menu-keybind-surface-default: var(--color-gray-500);
--interface-panel-surface: var(--color-pure-white);
--interface-stroke: var(--color-gray-300);
--nav-background: var(--color-pure-white);
--node-border: var(--color-gray-300);
--node-component-border: var(--color-gray-400);
--node-component-disabled: var(--color-alpha-stone-100-20);
--node-component-executing: var(--color-blue-500);
--node-component-header: var(--fg-color);
--node-component-header-icon: var(--color-stone-200);
@@ -159,7 +176,7 @@
--node-component-slot-dot-outline: var(--color-black);
--node-component-slot-text: var(--color-stone-200);
--node-component-surface-highlight: var(--color-stone-100);
--node-component-surface-hovered: var(--color-charcoal-400);
--node-component-surface-hovered: var(--color-gray-200);
--node-component-surface-selected: var(--color-charcoal-200);
--node-component-surface: var(--color-white);
--node-component-tooltip: var(--color-charcoal-700);
@@ -170,18 +187,30 @@
from var(--color-zinc-500) r g b / 10%
);
--node-component-widget-skeleton-surface: var(--color-zinc-300);
--node-component-disabled: var(--color-alpha-stone-100-20);
--node-divider: var(--color-sand-100);
--node-icon-disabled: var(--color-alpha-gray-500-50);
--node-stroke: var(--color-gray-400);
--node-stroke-selected: var(--color-accent-primary);
--node-stroke-error: var(--color-error);
--node-stroke-executing: var(--color-blue-100);
--text-secondary: var(--color-stone-100);
--text-primary: var(--color-charcoal-700);
--input-surface: rgba(0, 0, 0, 0.15);
}
.dark-theme {
--accent-primary: var(--color-pure-white);
--backdrop: var(--color-neutral-900);
--button-hover-surface: var(--color-charcoal-600);
--button-active-surface: var(--color-charcoal-600);
--dialog-surface: var(--color-neutral-700);
--interface-menu-component-surface-hovered: var(--color-charcoal-400);
--interface-menu-component-surface-selected: var(--color-charcoal-300);
--interface-menu-keybind-surface-default: var(--color-charcoal-200);
--interface-panel-surface: var(--color-charcoal-100);
--interface-stroke: var(--color-charcoal-400);
--nav-background: var(--color-charcoal-100);
--node-border: var(--color-charcoal-500);
--node-component-border: var(--color-stone-200);
--node-component-border-error: var(--color-danger-100);
--node-component-border-executing: var(--color-blue-500);
@@ -194,7 +223,7 @@
--node-component-slot-dot-outline: var(--color-white);
--node-component-slot-text: var(--color-slate-200);
--node-component-surface-highlight: var(--color-slate-100);
--node-component-surface-hovered: var(--color-charcoal-400);
--node-component-surface-hovered: var(--color-charcoal-600);
--node-component-surface-selected: var(--color-charcoal-200);
--node-component-surface: var(--color-charcoal-800);
--node-component-tooltip: var(--color-white);
@@ -202,16 +231,29 @@
--node-component-tooltip-surface: var(--color-charcoal-800);
--node-component-widget-skeleton-surface: var(--color-zinc-800);
--node-component-disabled: var(--color-alpha-charcoal-600-30);
--node-divider: var(--color-charcoal-500);
--node-icon-disabled: var(--color-alpha-stone-100-20);
--node-stroke: var(--color-stone-200);
--node-stroke-selected: var(--color-pure-white);
--node-stroke-error: var(--color-error);
--node-stroke-executing: var(--color-blue-100);
--text-secondary: var(--color-slate-100);
--text-primary: var(--color-pure-white);
--input-surface: rgba(130, 130, 130, 0.1);
}
@theme inline {
--color-backdrop: var(--backdrop);
--color-button-hover-surface: var(--button-hover-surface);
--color-button-active-surface: var(--button-active-surface);
--color-dialog-surface: var(--dialog-surface);
--color-interface-menu-component-surface-hovered: var(--interface-menu-component-surface-hovered);
--color-interface-menu-component-surface-selected: var(--interface-menu-component-surface-selected);
--color-interface-menu-keybind-surface-default: var(--interface-menu-keybind-surface-default);
--color-interface-panel-surface: var(--interface-panel-surface);
--color-interface-stroke: var(--interface-stroke);
--color-nav-background: var(--nav-background);
--color-node-border: var(--node-border);
--color-node-component-border: var(--node-component-border);
--color-node-component-executing: var(--node-component-executing);
--color-node-component-header: var(--node-component-header);
@@ -244,11 +286,15 @@
--node-component-widget-skeleton-surface
);
--color-node-component-disabled: var(--node-component-disabled);
--color-node-divider: var(--node-divider);
--color-node-icon-disabled: var(--node-icon-disabled);
--color-node-stroke: var(--node-stroke);
--color-node-stroke-selected: var(--node-stroke-selected);
--color-node-stroke-error: var(--node-stroke-error);
--color-node-stroke-executing: var(--node-stroke-executing);
--color-text-secondary: var(--text-secondary);
--color-text-primary: var(--text-primary);
--color-input-surface: var(--input-surface);
}
@custom-variant dark-theme {

34
pnpm-lock.yaml generated
View File

@@ -201,6 +201,9 @@ catalogs:
primevue:
specifier: ^4.2.5
version: 4.2.5
rollup-plugin-visualizer:
specifier: ^6.0.4
version: 6.0.4
storybook:
specifier: ^9.1.6
version: 9.1.6
@@ -591,6 +594,9 @@ importers:
prettier:
specifier: 'catalog:'
version: 3.3.2
rollup-plugin-visualizer:
specifier: 'catalog:'
version: 6.0.4(rollup@4.22.4)
storybook:
specifier: 'catalog:'
version: 9.1.6(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
@@ -6668,6 +6674,19 @@ packages:
rfdc@1.4.1:
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
rollup-plugin-visualizer@6.0.4:
resolution: {integrity: sha512-q8Q7J/6YofkmaGW1sH/fPRAz37x/+pd7VBuaUU7lwvOS/YikuiiEU9jeb9PH8XHiq50XFrUsBbOxeAMYQ7KZkg==}
engines: {node: '>=18'}
hasBin: true
peerDependencies:
rolldown: 1.x || ^1.0.0-beta
rollup: 2.x || 3.x || 4.x
peerDependenciesMeta:
rolldown:
optional: true
rollup:
optional: true
rollup@4.22.4:
resolution: {integrity: sha512-vD8HJ5raRcWOyymsR6Z3o6+RzfEPCnVLMFJ6vRslO1jt4LO6dUo5Qnpg7y4RkZFM2DMe3WUirkI5c16onjrc6A==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
@@ -6822,6 +6841,10 @@ packages:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
source-map@0.7.6:
resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
engines: {node: '>= 12'}
speakingurl@14.0.1:
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
engines: {node: '>=0.10.0'}
@@ -14733,6 +14756,15 @@ snapshots:
rfdc@1.4.1: {}
rollup-plugin-visualizer@6.0.4(rollup@4.22.4):
dependencies:
open: 8.4.2
picomatch: 4.0.3
source-map: 0.7.6
yargs: 17.7.2
optionalDependencies:
rollup: 4.22.4
rollup@4.22.4:
dependencies:
'@types/estree': 1.0.5
@@ -14924,6 +14956,8 @@ snapshots:
source-map@0.6.1: {}
source-map@0.7.6: {}
speakingurl@14.0.1: {}
sprintf-js@1.0.3: {}

View File

@@ -68,6 +68,7 @@ catalog:
prettier: ^3.3.2
primeicons: ^7.0.0
primevue: ^4.2.5
rollup-plugin-visualizer: ^6.0.4
storybook: ^9.1.6
stylelint: ^16.24.0
tailwindcss: ^4.1.12

View File

@@ -0,0 +1,126 @@
<template>
<Button
ref="buttonRef"
severity="secondary"
class="group h-8 rounded-none! bg-interface-panel-surface p-0 transition-none! hover:rounded-lg! hover:bg-button-hover-surface!"
:style="buttonStyles"
@click="toggle"
>
<template #default>
<div class="flex items-center gap-1 pr-0.5">
<div
class="rounded-lg bg-button-active-surface p-2 group-hover:bg-button-hover-surface"
>
<i :class="currentModeIcon" class="block h-4 w-4" />
</div>
<i class="icon-[lucide--chevron-down] block h-4 w-4 pr-1.5" />
</div>
</template>
</Button>
<Popover
ref="popover"
:auto-z-index="true"
:base-z-index="1000"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="popoverPt"
>
<div class="flex flex-col gap-1">
<div
class="flex cursor-pointer items-center justify-between px-3 py-2 text-sm hover:bg-node-component-surface-hovered"
@click="setMode('select')"
>
<div class="flex items-center gap-2">
<i class="icon-[lucide--mouse-pointer-2] h-4 w-4" />
<span>{{ $t('graphCanvasMenu.select') }}</span>
</div>
<span class="text-[9px] text-text-primary">{{
unlockCommandText
}}</span>
</div>
<div
class="flex cursor-pointer items-center justify-between rounded px-3 py-2 text-sm hover:bg-node-component-surface-hovered"
@click="setMode('hand')"
>
<div class="flex items-center gap-2">
<i class="icon-[lucide--hand] h-4 w-4" />
<span>{{ $t('graphCanvasMenu.hand') }}</span>
</div>
<span class="text-[9px] text-text-primary">{{ lockCommandText }}</span>
</div>
</div>
</Popover>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Popover from 'primevue/popover'
import { computed, ref } from 'vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
interface Props {
buttonStyles?: Record<string, string>
}
defineProps<Props>()
const buttonRef = ref<InstanceType<typeof Button>>()
const popover = ref<InstanceType<typeof Popover>>()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const isCanvasReadOnly = computed(() => canvasStore.canvas?.read_only ?? false)
const currentModeIcon = computed(() =>
isCanvasReadOnly.value
? 'icon-[lucide--hand]'
: 'icon-[lucide--mouse-pointer-2]'
)
const unlockCommandText = computed(() =>
commandStore
.formatKeySequence(commandStore.getCommand('Comfy.Canvas.Unlock'))
.toUpperCase()
)
const lockCommandText = computed(() =>
commandStore
.formatKeySequence(commandStore.getCommand('Comfy.Canvas.Lock'))
.toUpperCase()
)
const toggle = (event: Event) => {
const el = (buttonRef.value as any)?.$el || buttonRef.value
popover.value?.toggle(event, el)
}
const setMode = (mode: 'select' | 'hand') => {
if (mode === 'select' && isCanvasReadOnly.value) {
void commandStore.execute('Comfy.Canvas.Unlock')
} else if (mode === 'hand' && !isCanvasReadOnly.value) {
void commandStore.execute('Comfy.Canvas.Lock')
}
popover.value?.hide()
}
const popoverPt = computed(() => ({
root: {
class: 'absolute z-50 -translate-y-2'
},
content: {
class: [
'mb-2 text-text-primary',
'shadow-lg border border-node-border',
'bg-nav-background',
'rounded-lg',
'p-2 px-3',
'min-w-39',
'select-none'
]
}
}))
</script>

View File

@@ -10,41 +10,15 @@
></div>
<ButtonGroup
class="p-buttongroup-vertical absolute right-2 bottom-4 p-1 md:right-4"
class="absolute right-2 bottom-2 z-[1200] flex-row gap-1 border-[1px] border-node-border bg-interface-panel-surface p-2"
:style="stringifiedMinimapStyles.buttonGroupStyles"
@wheel="canvasInteractions.handleWheel"
>
<Button
v-tooltip.top="selectTooltip"
:style="stringifiedMinimapStyles.buttonStyles"
severity="secondary"
:aria-label="selectTooltip"
:pressed="isCanvasReadOnly"
icon="i-material-symbols:pan-tool-outline"
:class="selectButtonClass"
@click="() => commandStore.execute('Comfy.Canvas.Unlock')"
>
<template #icon>
<i class="icon-[lucide--mouse-pointer-2]" />
</template>
</Button>
<CanvasModeSelector
:button-styles="stringifiedMinimapStyles.buttonStyles"
/>
<Button
v-tooltip.top="handTooltip"
severity="secondary"
:aria-label="handTooltip"
:pressed="isCanvasUnlocked"
:class="handButtonClass"
:style="stringifiedMinimapStyles.buttonStyles"
@click="() => commandStore.execute('Comfy.Canvas.Lock')"
>
<template #icon>
<i class="icon-[lucide--hand]" />
</template>
</Button>
<!-- vertical line with bg E1DED5 -->
<div class="mx-2 my-1 w-px bg-[#E1DED5] dark-theme:bg-[#2E3037]" />
<div class="h-[27px] w-[1px] self-center bg-node-divider" />
<Button
v-tooltip.top="fitViewTooltip"
@@ -52,11 +26,11 @@
icon="pi pi-expand"
:aria-label="fitViewTooltip"
:style="stringifiedMinimapStyles.buttonStyles"
class="hover:bg-[#E7E6E6]! dark-theme:hover:bg-[#444444]!"
class="h-8 w-8 bg-interface-panel-surface p-0 hover:bg-button-hover-surface!"
@click="() => commandStore.execute('Comfy.Canvas.FitView')"
>
<template #icon>
<i class="icon-[lucide--focus]" />
<i class="icon-[lucide--focus] h-4 w-4" />
</template>
</Button>
@@ -71,26 +45,26 @@
:style="stringifiedMinimapStyles.buttonStyles"
@click="toggleModal"
>
<span class="inline-flex text-xs">
<span class="inline-flex items-center gap-1 px-2 text-xs">
<span>{{ canvasStore.appScalePercentage }}%</span>
<i class="icon-[lucide--chevron-down]" />
<i class="icon-[lucide--chevron-down] h-4 w-4" />
</span>
</Button>
<div class="mx-2 my-1 w-px bg-[#E1DED5] dark-theme:bg-[#2E3037]" />
<div class="h-[27px] w-[1px] self-center bg-node-divider" />
<Button
ref="focusButton"
v-tooltip.top="focusModeTooltip"
ref="minimapButton"
v-tooltip.top="minimapTooltip"
severity="secondary"
:aria-label="focusModeTooltip"
data-testid="focus-mode-button"
:aria-label="minimapTooltip"
data-testid="toggle-minimap-button"
:style="stringifiedMinimapStyles.buttonStyles"
:class="focusButtonClass"
@click="() => commandStore.execute('Workspace.ToggleFocusMode')"
:class="minimapButtonClass"
@click="() => commandStore.execute('Comfy.Canvas.ToggleMinimap')"
>
<template #icon>
<i class="icon-[lucide--lightbulb]" />
<i class="icon-[lucide--map] h-4 w-4" />
</template>
</Button>
@@ -111,7 +85,7 @@
@click="() => commandStore.execute('Comfy.Canvas.ToggleLinkVisibility')"
>
<template #icon>
<i class="icon-[lucide--route-off]" />
<i class="icon-[lucide--route-off] h-4 w-4" />
</template>
</Button>
</ButtonGroup>
@@ -131,8 +105,8 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import CanvasModeSelector from './CanvasModeSelector.vue'
import ZoomControlsModal from './modals/ZoomControlsModal.vue'
const { t } = useI18n()
@@ -141,21 +115,16 @@ const { formatKeySequence } = useCommandStore()
const canvasStore = useCanvasStore()
const settingStore = useSettingStore()
const canvasInteractions = useCanvasInteractions()
const workspaceStore = useWorkspaceStore()
const minimap = useMinimap()
const { isModalVisible, toggleModal, hideModal, hasActivePopup } =
useZoomControls()
const stringifiedMinimapStyles = computed(() => {
const buttonGroupKeys = ['backgroundColor', 'borderRadius', '']
const buttonKeys = ['backgroundColor', 'borderRadius']
const buttonGroupKeys = ['borderRadius']
const buttonKeys = ['borderRadius']
const additionalButtonStyles = {
border: 'none',
width: '35px',
height: '35px',
'margin-right': '2px',
'margin-left': '2px'
border: 'none'
}
const containerStyles = minimap.containerStyles.value
@@ -176,72 +145,56 @@ const stringifiedMinimapStyles = computed(() => {
})
// Computed properties for reactive states
const isCanvasReadOnly = computed(() => canvasStore.canvas?.read_only ?? false)
const isCanvasUnlocked = computed(() => !isCanvasReadOnly.value)
const linkHidden = computed(
() => settingStore.get('Comfy.LinkRenderMode') === LiteGraph.HIDDEN_LINK
)
// Computed properties for command text
const unlockCommandText = computed(() =>
formatKeySequence(
commandStore.getCommand('Comfy.Canvas.Unlock')
).toUpperCase()
)
const lockCommandText = computed(() =>
formatKeySequence(commandStore.getCommand('Comfy.Canvas.Lock')).toUpperCase()
)
const fitViewCommandText = computed(() =>
formatKeySequence(
commandStore.getCommand('Comfy.Canvas.FitView')
).toUpperCase()
)
const focusCommandText = computed(() =>
const minimapCommandText = computed(() =>
formatKeySequence(
commandStore.getCommand('Workspace.ToggleFocusMode')
commandStore.getCommand('Comfy.Canvas.ToggleMinimap')
).toUpperCase()
)
// Computed properties for button classes and states
const selectButtonClass = computed(() =>
isCanvasUnlocked.value
? 'not-active:dark-theme:bg-[#262729]! not-active:bg-[#E7E6E6]!'
: ''
)
const handButtonClass = computed(() =>
isCanvasReadOnly.value
? 'not-active:dark-theme:bg-[#262729]! not-active:bg-[#E7E6E6]!'
: ''
)
const zoomButtonClass = computed(() => [
'w-16!',
isModalVisible.value
? 'not-active:dark-theme:bg-[#262729]! not-active:bg-[#E7E6E6]!'
: '',
'dark-theme:hover:bg-[#262729]! hover:bg-[#E7E6E6]!'
'bg-interface-panel-surface',
isModalVisible.value ? 'not-active:bg-button-active-surface!' : '',
'hover:bg-button-hover-surface!',
'p-0',
'h-8',
'w-15'
])
const focusButtonClass = computed(() => ({
'dark-theme:hover:bg-[#262729]! hover:bg-[#E7E6E6]!': true,
'not-active:dark-theme:bg-[#262729]! not-active:bg-[#E7E6E6]!':
workspaceStore.focusMode
const minimapButtonClass = computed(() => ({
'bg-interface-panel-surface': true,
'hover:bg-button-hover-surface!': true,
'not-active:bg-button-active-surface!': settingStore.get(
'Comfy.Minimap.Visible'
),
'p-0': true,
'w-8': true,
'h-8': true
}))
// Computed properties for tooltip and aria-label texts
const selectTooltip = computed(
() => `${t('graphCanvasMenu.select')} (${unlockCommandText.value})`
)
const handTooltip = computed(
() => `${t('graphCanvasMenu.hand')} (${lockCommandText.value})`
)
const fitViewTooltip = computed(
() => `${t('graphCanvasMenu.fitView')} (${fitViewCommandText.value})`
)
const focusModeTooltip = computed(
() => `${t('graphCanvasMenu.focusMode')} (${focusCommandText.value})`
)
const fitViewTooltip = computed(() => {
const label = t('graphCanvasMenu.fitView')
const shortcut = fitViewCommandText.value
return shortcut ? `${label} (${shortcut})` : label
})
const minimapTooltip = computed(() => {
const label = settingStore.get('Comfy.Minimap.Visible')
? t('zoomControls.hideMinimap')
: t('zoomControls.showMinimap')
const shortcut = minimapCommandText.value
return shortcut ? `${label} (${shortcut})` : label
})
const linkVisibilityTooltip = computed(() =>
linkHidden.value
? t('graphCanvasMenu.showLinks')
@@ -253,10 +206,12 @@ const linkVisibilityAriaLabel = computed(() =>
: t('graphCanvasMenu.hideLinks')
)
const linkVisibleClass = computed(() => [
linkHidden.value
? 'not-active:dark-theme:bg-[#262729]! not-active:bg-[#E7E6E6]!'
: '',
'dark-theme:hover:bg-[#262729]! hover:bg-[#E7E6E6]!'
'bg-interface-panel-surface',
linkHidden.value ? 'not-active:bg-button-active-surface!' : '',
'hover:bg-button-hover-surface!',
'p-0',
'w-8',
'h-8'
])
onMounted(() => {
@@ -267,19 +222,3 @@ onBeforeUnmount(() => {
canvasStore.cleanupScaleSync()
})
</script>
<style scoped>
.p-buttongroup-vertical {
display: flex;
flex-direction: row;
z-index: 1200;
border-radius: var(--p-button-border-radius);
overflow: hidden;
border: 1px solid var(--p-panel-border-color);
}
.p-buttongroup-vertical .p-button {
margin: 0;
border-radius: 0;
}
</style>

View File

@@ -7,11 +7,10 @@
<Transition name="slide-up">
<Panel
v-if="visible"
class="selection-toolbox pointer-events-auto rounded-lg"
:style="`backgroundColor: ${containerStyles.backgroundColor};`"
class="selection-toolbox pointer-events-auto rounded-lg border border-interface-stroke bg-interface-panel-surface"
:pt="{
header: 'hidden',
content: 'p-1 h-10 flex flex-row gap-1'
content: 'p-2 h-12 flex flex-row gap-1'
}"
@wheel="canvasInteractions.forwardEventToCanvas"
>
@@ -65,7 +64,6 @@ import { useSelectionToolboxPosition } from '@/composables/canvas/useSelectionTo
import { useSelectionState } from '@/composables/graph/useSelectionState'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
import { useExtensionService } from '@/services/extensionService'
import { useCommandStore } from '@/stores/commandStore'
import type { ComfyCommandImpl } from '@/stores/commandStore'
@@ -78,8 +76,6 @@ const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const extensionService = useExtensionService()
const canvasInteractions = useCanvasInteractions()
const minimap = useMinimap()
const containerStyles = minimap.containerStyles
const toolboxRef = ref<HTMLElement | undefined>()
const { visible } = useSelectionToolboxPosition(toolboxRef)

View File

@@ -1,118 +1,51 @@
<template>
<div
v-if="visible"
class="absolute right-2 bottom-[66px] z-1300 flex w-[250px] justify-center border-0! bg-inherit! md:right-11"
class="absolute right-2 bottom-[66px] z-1300 flex w-[250px] justify-center border-0! bg-inherit!"
>
<div
class="w-4/5 rounded-lg border border-gray-200 bg-white p-4 shadow-lg dark-theme:border-gray-700 dark-theme:bg-[#2b2b2b]"
class="w-4/5 rounded-lg border border-node-border bg-interface-panel-surface p-2 text-text-primary shadow-lg select-none"
:style="filteredMinimapStyles"
@click.stop
>
<div>
<Button
severity="secondary"
text
:pt="{
root: {
class:
'flex items-center justify-between cursor-pointer p-2 rounded w-full text-left hover:bg-transparent! focus:bg-transparent! active:bg-transparent!'
},
label: {
class: 'flex flex-col items-start w-full'
}
}"
<div class="flex flex-col gap-1">
<div
class="flex cursor-pointer items-center justify-between rounded px-3 py-2 text-sm hover:bg-node-component-surface-hovered"
@mousedown="startRepeat('Comfy.Canvas.ZoomIn')"
@mouseup="stopRepeat"
@mouseleave="stopRepeat"
>
<template #default>
<span class="block text-sm font-medium">{{
$t('graphCanvasMenu.zoomIn')
}}</span>
<span class="block text-sm text-gray-500">{{
zoomInCommandText
}}</span>
</template>
</Button>
<span class="font-medium">{{ $t('graphCanvasMenu.zoomIn') }}</span>
<span class="text-[9px] text-text-primary">{{
zoomInCommandText
}}</span>
</div>
<Button
severity="secondary"
text
:pt="{
root: {
class:
'flex items-center justify-between cursor-pointer p-2 rounded w-full text-left hover:bg-transparent! focus:bg-transparent! active:bg-transparent!'
},
label: {
class: 'flex flex-col items-start w-full'
}
}"
<div
class="flex cursor-pointer items-center justify-between rounded px-3 py-2 text-sm hover:bg-node-component-surface-hovered"
@mousedown="startRepeat('Comfy.Canvas.ZoomOut')"
@mouseup="stopRepeat"
@mouseleave="stopRepeat"
>
<template #default>
<span class="block text-sm font-medium">{{
$t('graphCanvasMenu.zoomOut')
}}</span>
<span class="block text-sm text-gray-500">{{
zoomOutCommandText
}}</span>
</template>
</Button>
<span class="font-medium">{{ $t('graphCanvasMenu.zoomOut') }}</span>
<span class="text-[9px] text-text-primary">{{
zoomOutCommandText
}}</span>
</div>
<Button
severity="secondary"
text
:pt="{
root: {
class:
'flex items-center justify-between cursor-pointer p-2 rounded w-full text-left hover:bg-transparent! focus:bg-transparent! active:bg-transparent!'
},
label: {
class: 'flex flex-col items-start w-full'
}
}"
<div
class="flex cursor-pointer items-center justify-between rounded px-3 py-2 text-sm hover:bg-node-component-surface-hovered"
@click="executeCommand('Comfy.Canvas.FitView')"
>
<template #default>
<span class="block text-sm font-medium">{{
$t('zoomControls.zoomToFit')
}}</span>
<span class="block text-sm text-gray-500">{{
zoomToFitCommandText
}}</span>
</template>
</Button>
<hr class="mb-1 border-[#E1DED5] dark-theme:border-[#2E3037]" />
<Button
severity="secondary"
text
data-testid="toggle-minimap-button"
:pt="{
root: {
class:
'flex items-center justify-between cursor-pointer p-2 rounded w-full text-left hover:bg-transparent! focus:bg-transparent! active:bg-transparent!'
},
label: {
class: 'flex flex-col items-start w-full'
}
}"
@click="executeCommand('Comfy.Canvas.ToggleMinimap')"
>
<template #default>
<span class="block text-sm font-medium">{{
minimapToggleText
}}</span>
<span class="block text-sm text-gray-500">{{
showMinimapCommandText
}}</span>
</template>
</Button>
<hr class="mt-1 border-[#E1DED5] dark-theme:border-[#2E3037]" />
<span class="font-medium">{{ $t('zoomControls.zoomToFit') }}</span>
<span class="text-[9px] text-text-primary">{{
zoomToFitCommandText
}}</span>
</div>
<div
ref="zoomInputContainer"
class="zoomInputContainer flex items-center rounded bg-[#E7E6E6] p-2 px-2 focus-within:bg-[#F3F3F3] dark-theme:bg-[#8282821A]"
class="zoomInputContainer flex items-center gap-1 rounded bg-input-surface p-2"
>
<InputNumber
ref="zoomInput"
@@ -122,12 +55,12 @@
:show-buttons="false"
:use-grouping="false"
:unstyled="true"
input-class="flex-1 bg-transparent border-none outline-hidden text-sm shadow-none my-0 "
input-class="bg-transparent border-none outline-hidden text-sm shadow-none my-0 w-full"
fluid
@input="applyZoom"
@keyup.enter="applyZoom"
/>
<span class="-ml-4 text-sm text-gray-500">%</span>
<span class="flex-shrink-0 text-sm text-text-primary">%</span>
</div>
</div>
</div>
@@ -136,18 +69,14 @@
<script setup lang="ts">
import type { InputNumberInputEvent } from 'primevue'
import { Button, InputNumber } from 'primevue'
import { InputNumber } from 'primevue'
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
import { useCommandStore } from '@/stores/commandStore'
const { t } = useI18n()
const minimap = useMinimap()
const settingStore = useSettingStore()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const { formatKeySequence } = useCommandStore()
@@ -158,19 +87,8 @@ interface Props {
const props = defineProps<Props>()
const emit = defineEmits<{
close: []
}>()
const interval = ref<number | null>(null)
// Computed properties for reactive states
const minimapToggleText = computed(() =>
settingStore.get('Comfy.Minimap.Visible')
? t('zoomControls.hideMinimap')
: t('zoomControls.showMinimap')
)
const applyZoom = (val: InputNumberInputEvent) => {
const inputValue = val.value as number
if (isNaN(inputValue) || inputValue < 1 || inputValue > 1000) {
@@ -181,9 +99,6 @@ const applyZoom = (val: InputNumberInputEvent) => {
const executeCommand = (command: string) => {
void commandStore.execute(command)
if (command === 'Comfy.Canvas.ToggleMinimap') {
emit('close')
}
}
const startRepeat = (command: string) => {
@@ -215,9 +130,6 @@ const zoomOutCommandText = computed(() =>
const zoomToFitCommandText = computed(() =>
formatKeySequence(commandStore.getCommand('Comfy.Canvas.FitView'))
)
const showMinimapCommandText = computed(() =>
formatKeySequence(commandStore.getCommand('Comfy.Canvas.ToggleMinimap'))
)
const zoomInput = ref<InstanceType<typeof InputNumber> | null>(null)
const zoomInputContainer = ref<HTMLDivElement | null>(null)
@@ -236,10 +148,6 @@ watch(
</script>
<style>
.zoomInputContainer:focus-within {
border: 1px solid rgb(204 204 204);
}
.dark-theme .zoomInputContainer:focus-within {
border: 1px solid rgb(204 204 204);
border: 1px solid var(--color-pure-white);
}
</style>

View File

@@ -6,12 +6,15 @@
<div
v-else
role="button"
class="flex cursor-pointer items-center gap-2 rounded px-3 py-1.5 text-left text-sm hover:bg-gray-100 dark-theme:hover:bg-zinc-700"
class="group flex cursor-pointer items-center gap-2 rounded px-3 py-1.5 text-left text-sm text-text-primary hover:bg-interface-menu-component-surface-hovered"
@click="handleClick"
>
<i v-if="option.icon" :class="[option.icon, 'h-4 w-4']" />
<span class="flex-1">{{ option.label }}</span>
<span v-if="option.shortcut" class="text-xs opacity-60">
<span
v-if="option.shortcut"
class="flex h-3.5 min-w-3.5 items-center justify-center rounded bg-interface-menu-keybind-surface-default px-1 py-0 text-xxs"
>
{{ option.shortcut }}
</span>
<i

View File

@@ -28,7 +28,6 @@
:key="`submenu-${option.label}`"
:ref="(el) => setSubmenuRef(`submenu-${option.label}`, el)"
:option="option"
:container-styles="containerStyles"
@submenu-click="handleSubmenuClick"
/>
</div>
@@ -55,7 +54,6 @@ import type {
} from '@/composables/graph/useMoreOptionsMenu'
import { useSubmenuPositioning } from '@/composables/graph/useSubmenuPositioning'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
import MenuOptionItem from './MenuOptionItem.vue'
import SubmenuPopover from './SubmenuPopover.vue'
@@ -75,8 +73,6 @@ const currentSubmenu = ref<string | null>(null)
const { menuOptions, menuOptionsWithSubmenu, bump } = useMoreOptionsMenu()
const { toggleSubmenu, hideAllSubmenus } = useSubmenuPositioning()
const canvasInteractions = useCanvasInteractions()
const minimap = useMinimap()
const containerStyles = minimap.containerStyles
let lastLogTs = 0
const LOG_INTERVAL = 120 // ms
@@ -264,11 +260,9 @@ const pt = computed(() => ({
content: {
class: [
'mt-2 text-neutral dark-theme:text-white rounded-lg',
'shadow-lg border border-zinc-200 dark-theme:border-zinc-700'
],
style: {
backgroundColor: containerStyles.value.backgroundColor
}
'shadow-lg border border-zinc-200 dark-theme:border-zinc-700',
'bg-interface-panel-surface'
]
}
}))

View File

@@ -56,13 +56,6 @@ import { useNodeCustomization } from '@/composables/graph/useNodeCustomization'
interface Props {
option: MenuOption
containerStyles: {
width: string
height: string
backgroundColor: string
border: string
borderRadius: string
}
}
interface Emits {
@@ -117,11 +110,9 @@ const submenuPt = computed(() => ({
content: {
class: [
'text-neutral dark-theme:text-white rounded-lg',
'shadow-lg border border-zinc-200 dark-theme:border-zinc-700'
],
style: {
backgroundColor: props.containerStyles.backgroundColor
}
'shadow-lg border border-zinc-200 dark-theme:border-zinc-700',
'bg-interface-panel-surface'
]
}
}))
</script>

View File

@@ -2,15 +2,16 @@
<div>
<div
v-show="showTopMenu && workflowTabsPosition === 'Topbar'"
class="z-1001 flex h-[38px] w-full content-end"
class="z-1001 flex h-9.5 w-full content-end"
style="background: var(--border-color)"
>
<WorkflowTabs />
<TopbarBadges />
</div>
<div
v-show="showTopMenu"
ref="topMenuRef"
class="comfyui-menu flex items-center"
class="comfyui-menu flex items-center bg-gray-100"
:class="{ dropzone: isDropZone, 'dropzone-active': isDroppable }"
>
<CommandMenubar />
@@ -44,9 +45,10 @@ import { app } from '@/scripts/app'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { electronAPI, isElectron, isNativeWindow } from '@/utils/envUtil'
import TopbarBadges from './TopbarBadges.vue'
const workspaceState = useWorkspaceStore()
const settingStore = useSettingStore()
const menuSetting = computed(() => settingStore.get('Comfy.UseNewMenu'))
const betaMenuEnabled = computed(() => menuSetting.value !== 'Disabled')
const showTopMenu = computed(

View File

@@ -0,0 +1,20 @@
<template>
<div class="flex items-center gap-2 bg-comfy-menu-secondary px-3">
<div
v-if="badge.label"
class="rounded-full bg-white px-1.5 py-0.5 text-xxxs font-semibold text-black"
>
{{ badge.label }}
</div>
<div class="font-inter text-sm font-extrabold text-slate-100">
{{ badge.text }}
</div>
</div>
</template>
<script setup lang="ts">
import type { TopbarBadge } from '@/types/comfy'
defineProps<{
badge: TopbarBadge
}>()
</script>

View File

@@ -0,0 +1,17 @@
<template>
<div class="flex">
<TopbarBadge
v-for="badge in topbarBadgeStore.badges"
:key="badge.text"
:badge
/>
</div>
</template>
<script lang="ts" setup>
import { useTopbarBadgeStore } from '@/stores/topbarBadgeStore'
import TopbarBadge from './TopbarBadge.vue'
const topbarBadgeStore = useTopbarBadgeStore()
</script>

View File

@@ -5,12 +5,14 @@ import { debounce } from 'es-toolkit/compat'
import type { Ref } from 'vue'
import { markRaw, onMounted, onUnmounted } from 'vue'
import { isDesktop } from '@/platform/distribution/types'
export function useTerminal(element: Ref<HTMLElement | undefined>) {
const fitAddon = new FitAddon()
const terminal = markRaw(
new Terminal({
convertEol: true,
theme: { background: '#171717' }
theme: isDesktop ? { background: '#171717' } : undefined
})
)
terminal.loadAddon(fitAddon)

View File

@@ -1141,9 +1141,15 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
const generateAudio =
String(generateAudioWidget.value).toLowerCase() === 'true'
if (model.includes('veo-3.0-fast-generate-001')) {
if (
model.includes('veo-3.0-fast-generate-001') ||
model.includes('veo-3.1-fast-generate')
) {
return generateAudio ? '$1.20/Run' : '$0.80/Run'
} else if (model.includes('veo-3.0-generate-001')) {
} else if (
model.includes('veo-3.0-generate-001') ||
model.includes('veo-3.1-generate')
) {
return generateAudio ? '$3.20/Run' : '$1.60/Run'
}

View File

@@ -162,12 +162,6 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
},
commandId: 'Workspace.ToggleBottomPanelTab.logs-terminal'
},
{
combo: {
key: 'f'
},
commandId: 'Workspace.ToggleFocusMode'
},
{
combo: {
key: 'e',

View File

@@ -119,6 +119,7 @@ const onConfigure = function (
this.properties.proxyWidgets = serialisedNode.properties.proxyWidgets
const parsed = parseProxyWidgets(serialisedNode.properties.proxyWidgets)
serialisedNode.widgets_values?.forEach((v, index) => {
if (parsed[index]?.[0] !== '-1') return
const widget = this.widgets.find((w) => w.name == parsed[index][1])
if (v !== null && widget) widget.value = v
})

View File

@@ -0,0 +1,16 @@
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useExtensionService } from '@/services/extensionService'
useExtensionService().registerExtension({
name: 'Comfy.CloudBadge',
// Only show badge when running in cloud environment
topbarBadges: isCloud
? [
{
label: t('g.beta'),
text: 'Comfy Cloud'
}
]
: undefined
})

View File

@@ -1,3 +1,5 @@
import { isCloud } from '@/platform/distribution/types'
import './clipspace'
import './contextMenuFilter'
import './dynamicPrompts'
@@ -21,3 +23,7 @@ import './uploadAudio'
import './uploadImage'
import './webcamCapture'
import './widgetInputs'
if (isCloud) {
import('./cloudBadge')
}

View File

@@ -17,6 +17,11 @@
"chart": "Chart",
"chartLowercase": "chart",
"file": "file",
"selectedFile": "Selected file",
"none": "None",
"markdown": "markdown",
"content": "content",
"audioProgress": "Audio progress",
"viewImageOfTotal": "View image {index} of {total}",
"viewVideoOfTotal": "View video {index} of {total}",
"imagePreview": "Image preview - Use arrow keys to navigate between images",
@@ -199,7 +204,8 @@
"volume": "Volume",
"halfSpeed": "0.5x",
"1x": "1x",
"2x": "2x"
"2x": "2x",
"beta": "BETA"
},
"manager": {
"title": "Custom Nodes Manager",

View File

@@ -171,10 +171,10 @@
"label": "自定义节点(测试版)"
},
"Comfy_Manager_CustomNodesManager_ShowLegacyCustomNodesMenu": {
"label": "自訂節點(舊版)"
"label": "自定义节点(旧版)"
},
"Comfy_Manager_ShowLegacyManagerMenu": {
"label": "管理員選單(舊版)"
"label": "管理员菜单(旧版)"
},
"Comfy_Manager_ShowMissingPacks": {
"label": "安装缺失的包"
@@ -279,13 +279,13 @@
"label": "切换日志底部面板"
},
"Workspace_ToggleBottomPanelTab_shortcuts-essentials": {
"label": "切基本下方面板"
"label": "切基本下方面板"
},
"Workspace_ToggleBottomPanelTab_shortcuts-view-controls": {
"label": "切換檢視控制底部面板"
"label": "切换检视控制底部面板"
},
"Workspace_ToggleBottomPanel_Shortcuts": {
"label": "示快捷鍵對話框"
"label": "示快捷键对话框"
},
"Workspace_ToggleFocusMode": {
"label": "切换焦点模式"

View File

@@ -84,6 +84,51 @@ export const NonInteractive: Story = {
}
}
export const WithPreviewImage: Story = {
args: {
asset: createAssetData({
preview_url: '/assets/images/comfy-logo-single.svg'
}),
interactive: true
},
decorators: [
() => ({
template:
'<div class="p-8 bg-gray-50 dark-theme:bg-gray-900 max-w-96"><story /></div>'
})
],
parameters: {
docs: {
description: {
story: 'AssetCard with a preview image displayed.'
}
}
}
}
export const FallbackGradient: Story = {
args: {
asset: createAssetData({
preview_url: undefined
}),
interactive: true
},
decorators: [
() => ({
template:
'<div class="p-8 bg-gray-50 dark-theme:bg-gray-900 max-w-96"><story /></div>'
})
],
parameters: {
docs: {
description: {
story:
'AssetCard showing fallback gradient when no preview image is available.'
}
}
}
}
export const EdgeCases: Story = {
render: () => ({
components: { AssetCard },

View File

@@ -4,38 +4,29 @@
data-component-id="AssetCard"
:data-asset-id="asset.id"
v-bind="elementProps"
:class="
cn(
// Base layout and container styles (always applied)
'rounded-xl overflow-hidden transition-all duration-200',
interactive && 'group',
// Button-specific styles
interactive && [
'appearance-none bg-transparent p-0 m-0 font-inherit text-inherit outline-none cursor-pointer text-left',
'bg-gray-100 dark-theme:bg-charcoal-800',
'hover:bg-gray-200 dark-theme:hover:bg-charcoal-600',
'border-none',
'focus:outline-solid outline-blue-100 outline-4'
],
// Div-specific styles
!interactive && 'bg-gray-100 dark-theme:bg-charcoal-800'
)
"
:class="cardClasses"
@click="interactive && $emit('select', asset)"
@keydown.enter="interactive && $emit('select', asset)"
>
<div class="relative aspect-square w-full overflow-hidden">
<div class="relative aspect-square w-full overflow-hidden rounded-xl">
<img
v-if="shouldShowImage"
:src="asset.preview_url"
class="h-full w-full object-contain"
/>
<div
class="flex h-full w-full items-center justify-center bg-gradient-to-br from-indigo-500 via-purple-500 to-pink-600"
v-else
class="flex h-full w-full items-center justify-center bg-gradient-to-br from-gray-400 via-gray-800 to-charcoal-400"
></div>
<AssetBadgeGroup :badges="asset.badges" />
</div>
<div :class="cn('p-4 h-32 flex flex-col justify-between')">
<div>
<h3
:id="titleId"
:class="
cn(
'mb-2 m-0 text-base font-semibold overflow-hidden text-ellipsis whitespace-nowrap',
'mb-2 m-0 text-base font-semibold line-clamp-2 wrap-anywhere',
'text-slate-800',
'dark-theme:text-white'
)
@@ -44,6 +35,7 @@
{{ asset.name }}
</h3>
<p
:id="descId"
:class="
cn(
'm-0 text-sm leading-6 overflow-hidden [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box]',
@@ -83,7 +75,8 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useImage } from '@vueuse/core'
import { computed, useId } from 'vue'
import AssetBadgeGroup from '@/platform/assets/components/AssetBadgeGroup.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
@@ -94,13 +87,51 @@ const props = defineProps<{
interactive?: boolean
}>()
const titleId = useId()
const descId = useId()
const { error } = useImage({
src: props.asset.preview_url ?? '',
alt: props.asset.name
})
const shouldShowImage = computed(() => props.asset.preview_url && !error.value)
const cardClasses = computed(() => {
const base = [
'rounded-xl',
'overflow-hidden',
'transition-all',
'duration-200'
]
if (!props.interactive) {
return cn(...base, 'bg-gray-100 dark-theme:bg-charcoal-800')
}
return cn(
...base,
'group',
'appearance-none bg-transparent p-0 m-0',
'font-inherit text-inherit outline-none cursor-pointer text-left',
'bg-gray-100 dark-theme:bg-charcoal-800',
'hover:bg-gray-200 dark-theme:hover:bg-charcoal-600',
'border-none',
'focus:outline-solid outline-blue-100 outline-4'
)
})
const elementProps = computed(() =>
props.interactive
? {
type: 'button',
'aria-label': `Select asset ${props.asset.name}`
'aria-labelledby': titleId,
'aria-describedby': descId
}
: {
'aria-labelledby': titleId,
'aria-describedby': descId
}
: {}
)
defineEmits<{

View File

@@ -1,11 +1,32 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import { useMediaAssetGalleryStore } from '../composables/useMediaAssetGalleryStore'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
import MediaAssetCard from './MediaAssetCard.vue'
const meta: Meta<typeof MediaAssetCard> = {
title: 'AssetLibrary/MediaAssetCard',
title: 'Platform/Assets/MediaAssetCard',
component: MediaAssetCard,
decorators: [
() => ({
components: { ResultGallery },
setup() {
const galleryStore = useMediaAssetGalleryStore()
return { galleryStore }
},
template: `
<div>
<story />
<ResultGallery
v-model:active-index="galleryStore.activeIndex"
:all-gallery-items="galleryStore.items"
/>
</div>
`
})
],
argTypes: {
context: {
control: 'select',

View File

@@ -33,7 +33,7 @@
:is="getTopComponent(asset.kind)"
:asset="asset"
:context="context"
@view="actions.viewAsset(asset!.id)"
@view="handleZoomClick"
@download="actions.downloadAsset(asset!.id)"
@play="actions.playAsset(asset!.id)"
@video-playing-state-changed="isVideoPlaying = $event"
@@ -41,31 +41,48 @@
/>
</template>
<!-- Actions overlay (top-left) - show on hover or when menu is open, but not when video is playing -->
<!-- Actions overlay (top-left) - show on hover or when menu is open -->
<template v-if="showActionsOverlay" #top-left>
<MediaAssetActions @menu-state-changed="isMenuOpen = $event" />
<MediaAssetActions
@menu-state-changed="isMenuOpen = $event"
@mouseenter="handleOverlayMouseEnter"
@mouseleave="handleOverlayMouseLeave"
/>
</template>
<!-- Zoom button (top-right) - show on hover, but not when video is playing -->
<!-- Zoom button (top-right) - show on hover for all media types -->
<template v-if="showZoomOverlay" #top-right>
<IconButton size="sm" @click="actions.viewAsset(asset!.id)">
<IconButton
size="sm"
@click.stop="handleZoomClick"
@mouseenter="handleOverlayMouseEnter"
@mouseleave="handleOverlayMouseLeave"
>
<i class="icon-[lucide--zoom-in] size-4" />
</IconButton>
</template>
<!-- Duration/Format chips (bottom-left) - hide when video is playing -->
<!-- Duration/Format chips (bottom-left) - show on hover even when playing -->
<template v-if="showDurationChips" #bottom-left>
<SquareChip variant="light" :label="formattedDuration" />
<SquareChip v-if="fileFormat" variant="light" :label="fileFormat" />
<div
class="flex flex-wrap items-center gap-1"
@mouseenter="handleOverlayMouseEnter"
@mouseleave="handleOverlayMouseLeave"
>
<SquareChip variant="light" :label="formattedDuration" />
<SquareChip v-if="fileFormat" variant="light" :label="fileFormat" />
</div>
</template>
<!-- Output count (bottom-right) - hide when video is playing -->
<!-- Output count (bottom-right) - show on hover even when playing -->
<template v-if="showOutputCount" #bottom-right>
<IconTextButton
type="secondary"
size="sm"
:label="context?.outputCount?.toString() ?? '0'"
@click="actions.openMoreOutputs(asset?.id || '')"
@click.stop="actions.openMoreOutputs(asset?.id || '')"
@mouseenter="handleOverlayMouseEnter"
@mouseleave="handleOverlayMouseLeave"
>
<template #icon>
<i class="icon-[lucide--layers] size-4" />
@@ -116,6 +133,7 @@ import { formatDuration } from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import { useMediaAssetGalleryStore } from '../composables/useMediaAssetGalleryStore'
import type {
AssetContext,
AssetMeta,
@@ -159,10 +177,12 @@ const cardContainerRef = ref<HTMLElement>()
const isVideoPlaying = ref(false)
const isMenuOpen = ref(false)
const showVideoControls = ref(false)
const isOverlayHovered = ref(false)
const isHovered = useElementHover(cardContainerRef)
const actions = useMediaAssetActions()
const galleryStore = useMediaAssetGalleryStore()
provide(MediaAssetKey, {
asset: toRef(() => asset),
@@ -171,14 +191,14 @@ provide(MediaAssetKey, {
showVideoControls
})
const containerClasses = computed(() => {
return cn(
const containerClasses = computed(() =>
cn(
'gap-1',
selected
? 'border-3 border-zinc-900 dark-theme:border-white bg-zinc-200 dark-theme:bg-zinc-700'
: 'hover:bg-zinc-100 dark-theme:hover:bg-zinc-800'
)
})
)
const formattedDuration = computed(() => {
if (!asset?.duration) return ''
@@ -201,33 +221,58 @@ const durationChipClasses = computed(() => {
return ''
})
const showHoverActions = computed(() => {
return !loading && !!asset && (isHovered.value || isMenuOpen.value)
})
const isCardOrOverlayHovered = computed(
() => isHovered.value || isOverlayHovered.value || isMenuOpen.value
)
const showZoomButton = computed(() => {
return asset?.kind === 'image' || asset?.kind === '3D'
})
const showHoverActions = computed(
() => !loading && !!asset && isCardOrOverlayHovered.value
)
const showActionsOverlay = computed(() => {
return showHoverActions.value && !isVideoPlaying.value
})
const showActionsOverlay = computed(
() =>
showHoverActions.value &&
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
)
const showZoomOverlay = computed(() => {
return showHoverActions.value && showZoomButton.value && !isVideoPlaying.value
})
const showZoomOverlay = computed(
() =>
showHoverActions.value &&
asset?.kind !== '3D' &&
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
)
const showDurationChips = computed(() => {
return !loading && asset?.duration && !isVideoPlaying.value
})
const showDurationChips = computed(
() =>
!loading &&
asset?.duration &&
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
)
const showOutputCount = computed(() => {
return !loading && context?.outputCount && !isVideoPlaying.value
})
const showOutputCount = computed(
() =>
!loading &&
context?.outputCount &&
(!isVideoPlaying.value || isCardOrOverlayHovered.value)
)
const handleCardClick = () => {
if (asset) {
actions.selectAsset(asset)
}
}
const handleOverlayMouseEnter = () => {
isOverlayHovered.value = true
}
const handleOverlayMouseLeave = () => {
isOverlayHovered.value = false
}
const handleZoomClick = () => {
if (asset) {
galleryStore.openSingle(asset)
}
}
</script>

View File

@@ -1,6 +1,7 @@
<template>
<div class="flex flex-col">
<IconTextButton
v-if="asset?.kind !== '3D'"
type="transparent"
class="dark-theme:text-white"
label="Inspect asset"
@@ -93,6 +94,7 @@ import { computed, inject } from 'vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import { useMediaAssetGalleryStore } from '../composables/useMediaAssetGalleryStore'
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
import MediaAssetButtonDivider from './MediaAssetButtonDivider.vue'
@@ -102,14 +104,13 @@ const { close } = defineProps<{
const { asset, context } = inject(MediaAssetKey)!
const actions = useMediaAssetActions()
const galleryStore = useMediaAssetGalleryStore()
const showWorkflowOptions = computed(() => {
return context.value.type
})
const showWorkflowOptions = computed(() => context.value.type)
const handleInspect = () => {
if (asset.value) {
actions.viewAsset(asset.value.id)
galleryStore.openSingle(asset.value)
}
close()
}

View File

@@ -1,18 +1,19 @@
<template>
<div
class="relative h-full w-full overflow-hidden rounded bg-black"
@mouseenter="showControls = true"
@mouseleave="showControls = false"
@mouseenter="isHovered = true"
@mouseleave="isHovered = false"
>
<video
ref="videoRef"
:controls="showControls"
:controls="shouldShowControls"
preload="none"
:poster="asset.preview_url"
class="relative h-full w-full object-contain"
@click.stop
@play="onVideoPlay"
@pause="onVideoPause"
@ended="onVideoEnded"
>
<source :src="asset.src || ''" />
</video>
@@ -20,7 +21,7 @@
</template>
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import type { AssetContext, AssetMeta } from '../schemas/mediaAssetSchema'
@@ -36,22 +37,32 @@ const emit = defineEmits<{
}>()
const videoRef = ref<HTMLVideoElement>()
const showControls = ref(true)
const isHovered = ref(false)
const isPlaying = ref(false)
watch(showControls, (controlsVisible) => {
// Always show controls when not playing, hide/show based on hover when playing
const shouldShowControls = computed(() => !isPlaying.value || isHovered.value)
watch(shouldShowControls, (controlsVisible) => {
emit('videoControlsChanged', controlsVisible)
})
onMounted(() => {
emit('videoControlsChanged', showControls.value)
emit('videoControlsChanged', shouldShowControls.value)
})
const onVideoPlay = () => {
showControls.value = true
isPlaying.value = true
emit('videoPlayingStateChanged', true)
}
const onVideoPause = () => {
isPlaying.value = false
emit('videoPlayingStateChanged', false)
}
const onVideoEnded = () => {
isPlaying.value = false
emit('videoPlayingStateChanged', false)
}
</script>

View File

@@ -6,10 +6,6 @@ export function useMediaAssetActions() {
console.log('Asset selected:', asset)
}
const viewAsset = (assetId: string) => {
console.log('Viewing asset:', assetId)
}
const downloadAsset = (assetId: string) => {
console.log('Downloading asset:', assetId)
}
@@ -48,7 +44,6 @@ export function useMediaAssetActions() {
return {
selectAsset,
viewAsset,
downloadAsset,
deleteAsset,
playAsset,

View File

@@ -0,0 +1,179 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ResultItemImpl } from '@/stores/queueStore'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
import { useMediaAssetGalleryStore } from './useMediaAssetGalleryStore'
vi.mock('@/stores/queueStore', () => ({
ResultItemImpl: vi.fn().mockImplementation((data) => ({
...data,
url: ''
}))
}))
describe('useMediaAssetGalleryStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
describe('openSingle', () => {
it('should convert AssetMeta to ResultItemImpl format', () => {
const store = useMediaAssetGalleryStore()
const mockAsset: AssetMeta = {
id: 'test-1',
name: 'test-image.png',
kind: 'image',
src: 'https://example.com/image.png',
size: 1024,
tags: [],
created_at: '2025-01-01'
}
store.openSingle(mockAsset)
expect(ResultItemImpl).toHaveBeenCalledWith({
filename: 'test-image.png',
subfolder: '',
type: 'output',
nodeId: '0',
mediaType: 'images'
})
expect(store.items).toHaveLength(1)
expect(store.activeIndex).toBe(0)
})
it('should set correct mediaType for video assets', () => {
const store = useMediaAssetGalleryStore()
const mockVideoAsset: AssetMeta = {
id: 'test-2',
name: 'test-video.mp4',
kind: 'video',
src: 'https://example.com/video.mp4',
size: 2048,
tags: [],
created_at: '2025-01-01'
}
store.openSingle(mockVideoAsset)
expect(ResultItemImpl).toHaveBeenCalledWith(
expect.objectContaining({
filename: 'test-video.mp4',
mediaType: 'video'
})
)
})
it('should set correct mediaType for audio assets', () => {
const store = useMediaAssetGalleryStore()
const mockAudioAsset: AssetMeta = {
id: 'test-3',
name: 'test-audio.mp3',
kind: 'audio',
src: 'https://example.com/audio.mp3',
size: 512,
tags: [],
created_at: '2025-01-01'
}
store.openSingle(mockAudioAsset)
expect(ResultItemImpl).toHaveBeenCalledWith(
expect.objectContaining({
filename: 'test-audio.mp3',
mediaType: 'audio'
})
)
})
it('should override url getter with asset.src', () => {
const store = useMediaAssetGalleryStore()
const mockAsset: AssetMeta = {
id: 'test-4',
name: 'test.png',
kind: 'image',
src: 'https://example.com/custom-url.png',
size: 1024,
tags: [],
created_at: '2025-01-01'
}
store.openSingle(mockAsset)
const resultItem = store.items[0]
expect(resultItem.url).toBe('https://example.com/custom-url.png')
})
it('should handle assets without src gracefully', () => {
const store = useMediaAssetGalleryStore()
const mockAsset: AssetMeta = {
id: 'test-5',
name: 'no-src.png',
kind: 'image',
src: '',
size: 1024,
tags: [],
created_at: '2025-01-01'
}
store.openSingle(mockAsset)
const resultItem = store.items[0]
expect(resultItem.url).toBe('')
})
it('should update activeIndex and items when called multiple times', () => {
const store = useMediaAssetGalleryStore()
const asset1: AssetMeta = {
id: '1',
name: 'first.png',
kind: 'image',
src: 'url1',
size: 100,
tags: [],
created_at: '2025-01-01'
}
const asset2: AssetMeta = {
id: '2',
name: 'second.png',
kind: 'image',
src: 'url2',
size: 200,
tags: [],
created_at: '2025-01-01'
}
store.openSingle(asset1)
expect(store.items).toHaveLength(1)
expect(store.items[0].filename).toBe('first.png')
store.openSingle(asset2)
expect(store.items).toHaveLength(1)
expect(store.items[0].filename).toBe('second.png')
expect(store.activeIndex).toBe(0)
})
})
describe('close', () => {
it('should reset activeIndex to -1', () => {
const store = useMediaAssetGalleryStore()
const mockAsset: AssetMeta = {
id: 'test',
name: 'test.png',
kind: 'image',
src: 'test-url',
size: 1024,
tags: [],
created_at: '2025-01-01'
}
store.openSingle(mockAsset)
expect(store.activeIndex).toBe(0)
store.close()
expect(store.activeIndex).toBe(-1)
})
})
})

View File

@@ -0,0 +1,47 @@
import { defineStore } from 'pinia'
import { ref, shallowRef } from 'vue'
import { ResultItemImpl } from '@/stores/queueStore'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
export const useMediaAssetGalleryStore = defineStore(
'mediaAssetGallery',
() => {
const activeIndex = ref(-1)
const items = shallowRef<ResultItemImpl[]>([])
const close = () => {
activeIndex.value = -1
}
const openSingle = (asset: AssetMeta) => {
// Convert AssetMeta to ResultItemImpl format
const resultItem = new ResultItemImpl({
filename: asset.name,
subfolder: '',
type: 'output',
nodeId: '0',
mediaType: asset.kind === 'image' ? 'images' : asset.kind
})
// Override the url getter to use asset.src
Object.defineProperty(resultItem, 'url', {
get() {
return asset.src || ''
},
configurable: true
})
items.value = [resultItem]
activeIndex.value = 0
}
return {
activeIndex,
items,
close,
openSingle
}
}
)

View File

@@ -12,7 +12,7 @@ import { useModelToNodeStore } from '@/stores/modelToNodeStore'
const ASSETS_ENDPOINT = '/assets'
const EXPERIMENTAL_WARNING = `EXPERIMENTAL: If you are seeing this please make sure "Comfy.Assets.UseAssetAPI" is set to "false" in your ComfyUI Settings.\n`
const DEFAULT_LIMIT = 300
const DEFAULT_LIMIT = 500
export const MODELS_TAG = 'models'
export const MISSING_TAG = 'missing'

View File

@@ -1,3 +1,5 @@
import { isElectron } from '@/utils/envUtil'
/**
* Distribution types and compile-time constants for managing
* multi-distribution builds (Desktop, Localhost, Cloud)
@@ -13,6 +15,6 @@ declare global {
const DISTRIBUTION: Distribution = __DISTRIBUTION__
/** Distribution type checks */
// const isDesktop = DISTRIBUTION === 'desktop'
// const isLocalhost = DISTRIBUTION === 'localhost'
export const isDesktop = DISTRIBUTION === 'desktop' || isElectron() // TODO: replace with build var
export const isCloud = DISTRIBUTION === 'cloud'
// export const isLocalhost = !isDesktop && !isCloud

View File

@@ -2,7 +2,7 @@
<div
v-if="visible && initialized"
ref="minimapRef"
class="minimap-main-container absolute right-2 bottom-[66px] z-1000 flex md:right-11"
class="minimap-main-container absolute right-2 bottom-[66px] z-1000 flex"
>
<MiniMapPanel
v-if="showOptionsPanel"
@@ -17,11 +17,11 @@
<div
ref="containerRef"
class="litegraph-minimap relative"
class="litegraph-minimap relative bg-interface-panel-surface"
:style="containerStyles"
>
<Button
class="absolute z-10"
class="absolute top-1 left-1 z-10 hover:bg-button-hover-surface!"
size="small"
text
severity="secondary"
@@ -32,7 +32,7 @@
</template>
</Button>
<Button
class="absolute right-0 z-10"
class="absolute top-1 right-1 z-10 hover:bg-button-hover-surface!"
size="small"
text
severity="secondary"
@@ -45,7 +45,7 @@
</Button>
<hr
class="absolute top-5 h-px border-0 bg-node-component-border"
class="absolute top-7 h-px border-0 bg-node-component-border"
:style="{
width: containerStyles.width
}"

View File

@@ -1,6 +1,6 @@
<template>
<div
class="minimap-panel mr-2 flex flex-col gap-3 p-3 text-sm"
class="minimap-panel mr-2 flex flex-col gap-2 bg-interface-panel-surface p-3 text-sm"
:style="panelStyles"
>
<div class="flex items-center gap-2">

View File

@@ -25,7 +25,7 @@ export function useMinimapSettings() {
settingStore.get('Comfy.Minimap.RenderErrorState')
)
const width = 250
const width = 253
const height = 200
// Theme-aware colors
@@ -36,16 +36,14 @@ export function useMinimapSettings() {
const containerStyles = computed(() => ({
width: `${width}px`,
height: `${height}px`,
backgroundColor: isLightTheme.value ? '#FAF9F5' : '#15161C',
border: `1px solid ${isLightTheme.value ? '#ccc' : '#333'}`,
border: '1px solid var(--interface-stroke)',
borderRadius: '8px'
}))
const panelStyles = computed(() => ({
width: `210px`,
height: `${height}px`,
backgroundColor: isLightTheme.value ? '#FAF9F5' : '#15161C',
border: `1px solid ${isLightTheme.value ? '#ccc' : '#333'}`,
border: '1px solid var(--interface-stroke)',
borderRadius: '8px'
}))

View File

@@ -32,6 +32,7 @@
<!-- Main Image -->
<img
v-else
ref="currentImageEl"
:src="currentImageUrl"
:alt="imageAltText"
class="block size-full object-contain"
@@ -118,6 +119,7 @@ import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
@@ -143,6 +145,8 @@ const actualDimensions = ref<string | null>(null)
const imageError = ref(false)
const isLoading = ref(false)
const currentImageEl = ref<HTMLImageElement>()
// Computed values
const currentImageUrl = computed(() => props.imageUrls[currentIndex.value])
const hasMultipleImages = computed(() => props.imageUrls.length > 1)
@@ -182,7 +186,18 @@ const handleImageError = () => {
actualDimensions.value = null
}
// In vueNodes mode, we need to set them manually before opening the mask editor.
const setupNodeForMaskEditor = () => {
if (!props.nodeId || !currentImageEl.value) return
const node = app.rootGraph?.getNodeById(props.nodeId)
if (!node) return
node.imageIndex = currentIndex.value
node.imgs = [currentImageEl.value]
app.canvas?.select(node)
}
const handleEditMask = () => {
setupNodeForMaskEditor()
void commandStore.execute('Comfy.MaskEditor.OpenMaskEditor')
}

View File

@@ -20,6 +20,7 @@
:model-value="selectedFile?.name"
:options="[selectedFile?.name || '']"
:disabled="true"
:aria-label="`${$t('g.selectedFile')}: ${selectedFile?.name || $t('g.none')}`"
v-bind="transformCompatProps"
class="max-w-[20em] min-w-[8em] text-xs"
size="small"
@@ -89,6 +90,7 @@
:model-value="selectedFile?.name"
:options="[selectedFile?.name || '']"
:disabled="true"
:aria-label="`${$t('g.selectedFile')}: ${selectedFile?.name || $t('g.none')}`"
v-bind="transformCompatProps"
class="max-w-[20em] min-w-[8em] text-xs"
size="small"

View File

@@ -3,7 +3,9 @@ import PrimeVue from 'primevue/config'
import Textarea from 'primevue/textarea'
import { describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetMarkdown from './WidgetMarkdown.vue'
@@ -39,9 +41,19 @@ describe('WidgetMarkdown Dual Mode Display', () => {
modelValue: string,
readonly = false
) => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
...enMessages
}
}
})
return mount(WidgetMarkdown, {
global: {
plugins: [PrimeVue],
plugins: [PrimeVue, i18n],
components: { Textarea }
},
props: {

View File

@@ -15,6 +15,7 @@
v-show="isEditing"
ref="textareaRef"
v-model="localValue"
:aria-label="`${$t('g.edit')} ${widget.name || $t('g.markdown')} ${$t('g.content')}`"
class="absolute inset-0 min-h-[60px] w-full resize-none"
:pt="{
root: {

View File

@@ -59,6 +59,7 @@
min="0"
max="100"
step="0.1"
:aria-label="$t('g.audioProgress')"
class="absolute inset-0 w-full cursor-pointer opacity-0"
@input="handleSeek"
/>

View File

@@ -380,11 +380,15 @@ export class ComfyApp {
const paintedIndex = selectedIndex + 1
const combinedIndex = selectedIndex + 2
// for vueNodes mode
const images =
node.images ?? useNodeOutputStore().getNodeOutputs(node)?.images
ComfyApp.clipspace = {
widgets: widgets,
imgs: imgs,
original_imgs: orig_imgs,
images: node.images,
images: images,
selectedIndex: selectedIndex,
img_paste_mode: 'selected', // reset to default im_paste_mode state on copy action
paintedIndex: paintedIndex,
@@ -411,7 +415,8 @@ export class ComfyApp {
ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex].src
}
if (ComfyApp.clipspace.imgs && node.imgs) {
if (node.images && ComfyApp.clipspace.images) {
// Update node.images even if it's initially undefined (vueNodes mode)
if (ComfyApp.clipspace.images) {
if (ComfyApp.clipspace['img_paste_mode'] == 'selected') {
node.images = [
ComfyApp.clipspace.images[ComfyApp.clipspace['selectedIndex']]
@@ -513,6 +518,8 @@ export class ComfyApp {
}
app.graph.setDirtyCanvas(true)
useNodeOutputStore().updateNodeImages(node)
}
}

View File

@@ -331,6 +331,26 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
nodeOutputs.value = outputs
}
function updateNodeImages(node: LGraphNode) {
if (!node.images?.length) return
const nodeLocatorId = nodeIdToNodeLocatorId(node.id)
if (nodeLocatorId) {
const existingOutputs = app.nodeOutputs[nodeLocatorId]
if (existingOutputs) {
const updatedOutputs = {
...existingOutputs,
images: node.images
}
app.nodeOutputs[nodeLocatorId] = updatedOutputs
nodeOutputs.value[nodeLocatorId] = updatedOutputs
}
}
}
function resetAllOutputsAndPreviews() {
app.nodeOutputs = {}
nodeOutputs.value = {}
@@ -349,6 +369,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
setNodeOutputsByExecutionId,
setNodePreviewsByExecutionId,
setNodePreviewsByNodeId,
updateNodeImages,
// Cleanup
revokePreviewsByExecutionId,

View File

@@ -0,0 +1,18 @@
import { defineStore } from 'pinia'
import { computed } from 'vue'
import type { TopbarBadge } from '@/types/comfy'
import { useExtensionStore } from './extensionStore'
export const useTopbarBadgeStore = defineStore('topbarBadge', () => {
const extensionStore = useExtensionStore()
const badges = computed<TopbarBadge[]>(() =>
extensionStore.extensions.flatMap((e) => e.topbarBadges ?? [])
)
return {
badges
}
})

View File

@@ -33,6 +33,14 @@ type MenuCommandGroup = {
commands: string[]
}
export interface TopbarBadge {
text: string
/**
* Optional badge label (e.g., "BETA", "ALPHA", "NEW")
*/
label?: string
}
export type MissingNodeType =
| string
// Primarily used by group nodes.
@@ -74,6 +82,10 @@ export interface ComfyExtension {
* Badges to add to the about page
*/
aboutPageBadges?: AboutPageBadge[]
/**
* Badges to add to the top bar
*/
topbarBadges?: TopbarBadge[]
/**
* Allows any initialisation, e.g. loading resources. Called after the canvas is created but before nodes are added
* @param app The ComfyUI app instance

View File

@@ -76,7 +76,7 @@ describe('ZoomControlsModal', () => {
it('should execute zoom in command when zoom in button is clicked', async () => {
const wrapper = createWrapper()
const buttons = wrapper.findAll('button')
const buttons = wrapper.findAll('.cursor-pointer')
const zoomInButton = buttons.find((btn) =>
btn.text().includes('graphCanvasMenu.zoomIn')
)
@@ -90,7 +90,7 @@ describe('ZoomControlsModal', () => {
it('should execute zoom out command when zoom out button is clicked', async () => {
const wrapper = createWrapper()
const buttons = wrapper.findAll('button')
const buttons = wrapper.findAll('.cursor-pointer')
const zoomOutButton = buttons.find((btn) =>
btn.text().includes('graphCanvasMenu.zoomOut')
)
@@ -104,7 +104,7 @@ describe('ZoomControlsModal', () => {
it('should execute fit view command when fit view button is clicked', async () => {
const wrapper = createWrapper()
const buttons = wrapper.findAll('button')
const buttons = wrapper.findAll('.cursor-pointer')
const fitViewButton = buttons.find((btn) =>
btn.text().includes('zoomControls.zoomToFit')
)
@@ -115,34 +115,6 @@ describe('ZoomControlsModal', () => {
expect(mockExecute).toHaveBeenCalledWith('Comfy.Canvas.FitView')
})
it('should emit close when minimap toggle button is clicked', async () => {
const wrapper = createWrapper()
const minimapButton = wrapper.find('[data-testid="toggle-minimap-button"]')
expect(minimapButton.exists()).toBe(true)
await minimapButton.trigger('click')
expect(mockExecute).toHaveBeenCalledWith('Comfy.Canvas.ToggleMinimap')
expect(wrapper.emitted('close')).toBeTruthy()
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('should not emit close when other command buttons are clicked', async () => {
const wrapper = createWrapper()
const buttons = wrapper.findAll('button')
const fitViewButton = buttons.find((btn) =>
btn.text().includes('zoomControls.zoomToFit')
)
expect(fitViewButton).toBeDefined()
await fitViewButton!.trigger('click')
expect(mockExecute).toHaveBeenCalledWith('Comfy.Canvas.FitView')
expect(wrapper.emitted('close')).toBeFalsy()
})
it('should call setAppZoomFromPercentage with valid zoom input values', async () => {
const wrapper = createWrapper()
@@ -169,26 +141,10 @@ describe('ZoomControlsModal', () => {
expect(mockSetAppZoom).not.toHaveBeenCalled()
})
it('should display "Hide Minimap" when minimap is visible', () => {
mockSettingGet.mockReturnValue(true)
const wrapper = createWrapper()
const minimapButton = wrapper.find('[data-testid="toggle-minimap-button"]')
expect(minimapButton.text()).toContain('zoomControls.hideMinimap')
})
it('should display "Show Minimap" when minimap is hidden', () => {
mockSettingGet.mockReturnValue(false)
const wrapper = createWrapper()
const minimapButton = wrapper.find('[data-testid="toggle-minimap-button"]')
expect(minimapButton.text()).toContain('zoomControls.showMinimap')
})
it('should display keyboard shortcuts for commands', () => {
const wrapper = createWrapper()
const buttons = wrapper.findAll('button')
const buttons = wrapper.findAll('.cursor-pointer')
expect(buttons.length).toBeGreaterThan(0)
// Each command button should show the keyboard shortcut

View File

@@ -898,10 +898,9 @@ describe('useMinimap', () => {
const minimap = useMinimap()
const styles = minimap.containerStyles.value
expect(styles.width).toBe('250px')
expect(styles.width).toBe('253px')
expect(styles.height).toBe('200px')
expect(styles.backgroundColor).toBe('#15161C')
expect(styles.border).toBe('1px solid #333')
expect(styles.border).toBe('1px solid var(--interface-stroke)')
expect(styles.borderRadius).toBe('8px')
})
})

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