Compare commits

..

24 Commits

Author SHA1 Message Date
Johnpaul Chiwetelu
8ddc8739bb fix: clean up orphaned keybindings on load
Filter out stored keybindings referencing commands that are no longer
registered (e.g. from removed extensions) during registerUserKeybindings().
Persist the cleanup to remove orphans from storage.
2026-03-03 16:25:23 +01:00
Johnpaul Chiwetelu
16119dfcd2 fix: allow cursor positioning in painter opacity input (#9348) 2026-03-03 10:14:34 +01:00
Terry Jia
a6f1b1cf90 fix: sync subgraph name on double-click title rename (#9353)
## Summary
The Vue renderer's title editing path (NodeHeader →
useNodeEventHandlers) only updated node.title but not subgraph.name, so
the breadcrumb didn't reflect the new name when entering the subgraph.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9353-fix-sync-subgraph-name-on-double-click-title-rename-3186d73d365081e2bc54f19ecd421ac0)
by [Unito](https://www.unito.io)
2026-03-02 20:15:21 -08:00
Alexander Brown
c95d32249b fix: Custom Combo options display in Nodes 2.0 (#9324)
## Summary

Keep the value in the store instead of in the closure.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9324-fix-Custom-Combo-options-display-in-Nodes-2-0-3166d73d3650814db361c41ebdb1d222)
by [Unito](https://www.unito.io)
2026-03-02 19:23:01 -08:00
Dante
740df0470e feat: use cloud backend thumbnail resize for image previews (#9298)
## Summary

- In cloud mode, large generated images (4K, 8K+) cause browser freezing
when loaded at full resolution for preview display
- The cloud backend (ingest service) now supports a `res` query
parameter on `/api/view` that returns server-side resized JPEG (quality
80, max 512px) instead of redirecting to the full-size GCS original
- This PR adds `&res=512` to all image preview URLs in cloud mode,
reducing browser decode overhead from tens of MB to tens of KB
- Downloads still use the original resolution (no `res` param)
- No impact on localhost/desktop builds (`isCloud` compile-time
constant)

### without `?res`

302 -> png downloads
<img width="808" height="564" alt="스크린샷 2026-02-28 오후 6 53 03"
src="https://github.com/user-attachments/assets/7c1c62dd-0bc4-468d-9c74-7b98e892e126"
/>
<img width="323" height="137" alt="스크린샷 2026-02-28 오후 6 52 52"
src="https://github.com/user-attachments/assets/926aa0c4-856c-4057-96a0-d8fbd846762b"
/>

200 -> jpeg

### with `?res`
<img width="811" height="407" alt="스크린샷 2026-02-28 오후 6 51 55"
src="https://github.com/user-attachments/assets/d58d46ae-6749-4888-8bad-75344c4d868b"
/>


### Changes

- **New utility**: `getCloudResParam(filename?)` returns `&res=512` in
cloud mode for image files, empty string otherwise
- **Core stores**: `imagePreviewStore` appends `res` to node output
URLs; `queueStore.ResultItemImpl` gets a `previewUrl` getter (separates
preview from download URLs)
- **Applied to**: asset browser thumbnails, widget dropdown previews,
linear mode indicators, image compare node, background image upload

### Intentionally excluded

- Downloads (`getAssetUrl`) — need original resolution
- Mask editor — needs pixel-accurate data
- Audio/video/3D files — `res` only applies to raster images
- Execution-in-progress previews — use WebSocket blob URLs, not
`/api/view`

## Test plan

- [x] Unit tests for `getCloudResParam()` (5 tests: cloud/non-cloud,
image/non-image, undefined filename)
- [x] `pnpm typecheck` passes
- [x] `pnpm lint` passes
- [x] All 5332 unit tests pass
- [x] Manual verification on cloud.comfy.org: `res=512` returns 200 with
resized JPEG; without `res` returns 302 redirect to GCS PNG original

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 02:56:06 +00:00
Terry Jia
dccf68ddb7 fix: improve painter cursor performance by bypassing Vue reactivity (#9339)
## Summary
Previously painter has node performance issue. 
Use direct DOM manipulation for cursor position updates instead of
reactive refs, and add will-change-transform for GPU layer promotion.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9339-fix-improve-painter-cursor-performance-by-bypassing-Vue-reactivity-3176d73d365081d88b23d26e774cebf5)
by [Unito](https://www.unito.io)
2026-03-02 21:18:48 -05:00
Terry Jia
117448fba4 fix: stop pointer events on audio widgets to prevent node drag (#9329)
## Summary

Audio player and record widgets were missing @pointerdown.stop, causing
node drag when interacting with the timeline or controls.

## Screenshots (if applicable)
before


https://github.com/user-attachments/assets/061a9ad2-0cc2-45f8-aea0-d45e3a2912b9


after


https://github.com/user-attachments/assets/a510c50a-65b8-4944-9480-b53cbe61c7da

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9329-fix-stop-pointer-events-on-audio-widgets-to-prevent-node-drag-3176d73d36508140b236c61e83954f5c)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-02 20:43:25 -05:00
Terry Jia
da77227cf2 fix: clear combo widget value when removing image preview (#9323)
## Summary
The X button on image preview in VueNodes mode only cleared the stored
outputs but left the combo widget value intact, causing the old image to
persist across workflow runs and page refreshes.

## Screenshots (if applicable)
Before

https://github.com/user-attachments/assets/e2146ed1-5d79-41d6-946c-b30667ffac6a

After


https://github.com/user-attachments/assets/359b81fa-acc9-4711-9cee-62c230086f0c

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9323-fix-clear-combo-widget-value-when-removing-image-preview-3166d73d3650816db867eba49b8aeb6c)
by [Unito](https://www.unito.io)
2026-03-02 20:26:44 -05:00
Comfy Org PR Bot
4868e6003f 1.41.10 (#9343)
Patch version increment to 1.41.10

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9343-1-41-10-3186d73d3650814d9077c68cc06131ea)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-03-02 16:25:51 -08:00
pythongosssss
9b05d7cbb7 App mode output history UX improvements (#9285)
## Summary
- replace reka ui list with normal elements due to rekas aggressive
autoscrolling and event blocking
- rework layout to fix in progress items outside scrollable area
- extract feedback component
- avoid scroll position changing when adding new items
- add left/right keyboard navigation

## Screenshots (if applicable)
Showing fixed active items at start
<img width="1292" height="101" alt="image"
src="https://github.com/user-attachments/assets/dcd3215c-ac09-4081-b483-8631d17ca6bf"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9285-App-mode-output-history-UX-improvements-3146d73d3650819a9f97edb41db975cc)
by [Unito](https://www.unito.io)
2026-03-02 14:46:45 -08:00
Terry Jia
74626d65d3 fix: use widget.options.hidden to hide painter widgets in Vue renderer (#9337)
## Summary

The Vue node renderer checks widget.options.hidden, not widget.hidden.
This was previously masked by the backend sending hidden: true via
extra_dict, but is now needed as the backend switches to io.Color.Input.

BE change is in https://github.com/Comfy-Org/ComfyUI/pull/12294

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9337-fix-use-widget-options-hidden-to-hide-painter-widgets-in-Vue-renderer-3176d73d3650815cad4beb8f9f35f7e6)
by [Unito](https://www.unito.io)
2026-03-02 14:29:21 -08:00
pythongosssss
31a4dce5d4 Add enterAppBuilder method for skipping arrange mode (#9310)
## Summary

When already in app mode and entering builder with outputs defined, skip
the select step and go straight to arrange

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9310-Add-enterAppBuilder-method-for-skipping-arrange-mode-3156d73d36508101903ff434a2a1ac08)
by [Unito](https://www.unito.io)
2026-03-02 11:10:48 -08:00
pythongosssss
0d7dc15916 App mode output feed to only show current session results for outputs defined in the app (#9307)
## Summary

Updates app mode to only show images from:
- the workflow that generated the image
- in the current session
- for the outputs selected in the builder

## Changes

- **What**: 
- adds new mapping of jobid -> workflow path [cant use id here as it is
not guaranteed unique], capped at 4k entries
- fix bug where executing a workflow then quickly switching tabs
associated incorrect workflow
- add missing output history tests

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9307-App-mode-output-feed-to-only-show-current-session-results-for-outputs-defined-in-the-app-3156d73d36508142b4bbca3f938fc5c2)
by [Unito](https://www.unito.io)
2026-03-02 11:10:20 -08:00
AustinMroz
1dd789fa54 Support selection of app inputs and outputs from vue mode (#9259)
- The input and output indicators are now plugged directly into the
`LGraphNode.vue` template. Care was taken to make implementation to have
low cost for performance and complexity when not in app mode setup.
- Context menu event handlers are added to each widget in vue mode
instead of resolving the target widget of an event
- Swap the nodeId passed by `useGraphNodeManager` to not include the
locator id. This id was never used and was incorrect since it didn't
resolve across nested subgraphs.
- Continued bug fixes for app mode as a whole.

Known issue: There is disparity of nodeId between litegraph (which
references the widget in the root graph) and vue (which promotes the
original widget). Efforts to reconcile are ongoing.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9259-Support-selection-app-inputs-and-outputs-from-vue-mode-3136d73d365081ae8e56e35bf6322409)
by [Unito](https://www.unito.io)

---------

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-03-02 09:49:21 -08:00
Comfy Org PR Bot
84d7aa0fd9 1.41.9 (#9312)
Patch version increment to 1.41.9

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-03-01 20:19:10 -08:00
Christian Byrne
59c3215296 fix: skip CodeRabbit reviews on bot and release PRs (#9279)
## Problem

CodeRabbit is reviewing release and backport PRs created by bots (e.g.
[#9264](https://github.com/Comfy-Org/ComfyUI_frontend/pull/9264)),
leaving unnecessary review comments.

## Solution

Add ignore rules to `.coderabbit.yaml`:

- **`ignore_usernames`**: `comfy-pr-bot`, `github-actions` — skips
reviews on PRs authored by these bot accounts
- **`ignore_title_keywords`**: `[release]`, `[backport` — skips reviews
on release and backport PRs by title

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9279-fix-skip-CodeRabbit-reviews-on-bot-and-release-PRs-3146d73d3650814c9ebae0d08acbafd6)
by [Unito](https://www.unito.io)

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-28 23:29:21 -08:00
Hunter
589f58f916 feat: add ever-present upgrade button for free-tier users (#9315)
## Summary

Add persistent upgrade CTAs for free-tier users: a topbar button and
"Upgrade to add credits" replacing "Add Credits" in popovers and
settings panels.

## Changes

- **What**:
- New `TopbarSubscribeButton` component in both GraphCanvas and
LinearView topbars, visible only to free-tier users
- Profile popover (legacy + workspace): free-tier users see "Upgrade to
add credits" instead of "Add Credits", linking directly to the pricing
table
- Manage Plan settings (legacy + workspace): same replacement —
free-tier users see "Upgrade to add credits" instead of "Add Credits"
- Paid-tier users retain the original "Add Credits" behavior in all
locations
  - All upgrade buttons go directly to the pricing table (one-step flow)

## Review Focus

- The `isFreeTier` conditional gating on the buttons — ensure free-tier
users see upgrade CTAs and paid users see normal Add Credits
- Layout in Manage Plan panels uses `flex flex-col gap-3` to stack the
upgrade button below the usage history link instead of side-by-side

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9315-feat-add-ever-present-upgrade-button-for-free-tier-users-3166d73d365081228cdfe6a67fec6aec)
by [Unito](https://www.unito.io)
2026-02-28 20:07:12 -08:00
Hunter
7c8a548798 feat: add cloud frontend build dispatch workflow (#9308)
## Summary

Adds `.github/workflows/cloud-dispatch-build.yaml` — fires a
`repository_dispatch` event (`frontend-asset-build`) to
`Comfy-Org/cloud` on push to `cloud/*` branches and `main`.

The cloud repo handles the actual build, GCS upload, and secret
management (Sentry, Algolia, GCS creds). This is fire-and-forget.

## Changes

- New workflow: `cloud-dispatch-build.yaml`
- Trigger: `push` to `cloud/*` and `main` only
- Payload: `ref` (commit SHA) + `branch` (branch name), built with `jq`
to prevent injection
- SHA-pinned `peter-evans/repository-dispatch@v4.0.1`
- Hardened: `permissions: {}`, fork guard (`if: github.repository ==
'Comfy-Org/ComfyUI_frontend'`), concurrency to avoid dispatch storms
- `cloud-deploy-frontend.yaml` left unchanged (still needed during
migration)

## Setup Required

A repository secret `CLOUD_DISPATCH_TOKEN` must be configured — see PR
description comments.

## Part of

Frontend separate deploy prep (Task 1.3)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9308-feat-add-cloud-frontend-build-dispatch-workflow-3156d73d36508164a515eb968f6c5d79)
by [Unito](https://www.unito.io)
2026-02-28 17:59:19 -05:00
Alexander Brown
dd1a1f77d6 fix: stabilize nested subgraph promoted widget resolution (#9282)
## Summary

Fix multiple issues with promoted widget resolution in nested subgraphs,
ensuring correct value propagation, slot matching, and rendering for
deeply nested promoted widgets.

## Changes

- **What**: Stabilize nested subgraph promoted widget resolution chain
- Use deep source keys for promoted widget values in Vue rendering mode
- Resolve effective widget options from the source widget instead of the
promoted view
  - Stabilize slot resolution for nested promoted widgets
  - Preserve combo value rendering for promoted subgraph widgets
- Prevent subgraph definition deletion while other nodes still reference
the same type
  - Clean up unused exported resolution types

## Review Focus

- `resolveConcretePromotedWidget.ts` — new recursive resolution logic
for deeply nested promoted widgets
- `useGraphNodeManager.ts` — option extraction now uses
`effectiveWidget` for promoted widgets
- `SubgraphNode.ts` — unpack no longer force-deletes definitions
referenced by other nodes

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9282-fix-stabilize-nested-subgraph-promoted-widget-resolution-3146d73d365081208a4fe931bb7569cf)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
2026-02-28 13:45:04 -08:00
pythongosssss
0ab3fdc2c9 Add indicator circle when new unseen menu items are available (#9220)
## Summary

Adds a little indicator circle when new workflow menu items are added
that the user has not seen

## Changes

- **What**: Adds a hidden setting to track menu items flagged as new
that have been seen

## Screenshots (if applicable)

<img width="164" height="120" alt="image"
src="https://github.com/user-attachments/assets/ac36673d-fbf1-42ff-9a9e-1371eb96115b"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9220-Add-indicator-circle-when-new-unseen-menu-items-are-available-3126d73d3650819cb8cde854d6b6510b)
by [Unito](https://www.unito.io)
2026-02-28 12:53:26 -08:00
Terry Jia
ec1977131d feat: wrap CURVE widget value with typed format (#9294)
## Summary
Send CURVE values as { __type: 'CURVE', value: [...] } instead of {
__value__: [...] } to avoid ambiguity with link detection and enable
external tools to identify the data type.

change requested by @guill

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9294-feat-wrap-CURVE-widget-value-with-typed-format-3156d73d365081bf8e5de59527e2d3ce)
by [Unito](https://www.unito.io)
2026-02-28 15:00:39 -05:00
Christian Byrne
3f497081ee feat: Node Library sidebar and V2 Search dialog UI/UX updates (#9085)
## Summary

Implement 11 Figma design discrepancies for the Node Library sidebar and
V2 Node Search dialog, aligning the UI with the [Toolbox Figma
design](https://www.figma.com/design/xMFxCziXJe6Denz4dpDGTq/Toolbox?node-id=2074-21394&m=dev).

## Changes

- **What**: Sidebar: reorder tabs (All/Essentials/Blueprints), rename
Custom→Blueprints, uppercase section headers, chevron-left of folder
icon, bookmark-on-hover for node rows, filter dropdown with checkbox
items, sort labels (Categorized/A-Z) with label-left/check-right layout,
hide section headers in A-Z mode. Search dialog: expand filter chips
from 3→6, add Recents and source categories to sidebar, remove "Filter
by" label. Pull foundation V2 components from merged PR #8548.
- **Dependencies**: Depends on #8987 (V2 Node Search) and #8548
(NodeLibrarySidebarTabV2)

## Review Focus

- Filter dropdown (`filterOptions`) is UI-scaffolded but not yet wired
to filtering logic (pending V2 integration)
- "Recents" category currently returns frequency-based results as
placeholder until a usage-tracking store is implemented
- Pre-existing type errors from V2 PR dependencies not in the base
commit (SearchBoxV2, usePerTabState, TextTicker, getProviderIcon,
getLinkTypeColor, SidebarContainerKey) are expected and will resolve
when rebased onto main after parent PRs land

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9085-feat-Node-Library-sidebar-and-V2-Search-dialog-Figma-design-improvements-30f6d73d36508175bf72d716f5904476)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Yourz <crazilou@vip.qq.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-28 22:34:27 +08:00
jaeone94
a0e518aa98 refactor(node-replacement): reorganize domain components and expand comprehensive test suite (#9301)
## Summary

Resolves six open issues by reorganizing node replacement components
into a domain-driven folder structure, refactoring event handling to
follow the emit pattern, and adding comprehensive test coverage across
all affected modules.

## Changes

- **What**:
- Moved `SwapNodeGroupRow.vue` and `SwapNodesCard.vue` from
`src/components/rightSidePanel/errors/` to
`src/platform/nodeReplacement/components/` (Issues #9255)
- Moved `useMissingNodeScan.ts` from `src/composables/` to
`src/platform/nodeReplacement/missingNodeScan.ts`, renamed to reflect it
is a plain function not a Vue composable (Issues #9254)
- Refactored `SwapNodeGroupRow.vue` to emit a `'replace'` event instead
of calling `useNodeReplacement()` and `useExecutionErrorStore()`
directly; replacement logic now handled in `TabErrors.vue` (Issue #9267)
- Added unit tests for `removeMissingNodesByType`
(`executionErrorStore.test.ts`), `scanMissingNodes`
(`missingNodeScan.test.ts`), and `swapNodeGroups` computed
(`swapNodeGroups.test.ts`, `useErrorGroups.test.ts`) (Issue #9270)
- Added placeholder detection tests covering unregistered-type detection
when `has_errors` is false, and exclusion of registered types
(`useNodeReplacement.test.ts`) (Issue #9271)
- Added component tests for `MissingNodeCard` and `MissingPackGroupRow`
covering rendering, expand/collapse, events, install states, and edge
cases (Issue #9231)
- Added component tests for `SwapNodeGroupRow` and `SwapNodesCard`
(Issues #9255, #9267)

## Additional Changes (Post-Review)

- **Edge case guard in placeholder detection**
(`useNodeReplacement.ts`): When `last_serialization.type` is absent (old
serialization format), the predicate falls back to `n.type`, which the
app may have already run through `sanitizeNodeName` — stripping HTML
special characters (`& < > " ' \` =`). In that case, a `Set.has()`
lookup against the original unsanitized type name would silently miss,
causing replacement to be skipped.

Fixed by including sanitized variants of each target type in the
`targetTypes` Set at construction time. For the overwhelmingly common
case (no special characters in type names), the Set deduplicates the
entries and runtime behavior is identical to before.

A regression test was added to cover the specific scenario:
`last_serialization.type` absent + live `n.type` already sanitized.

## Review Focus

- `TabErrors.vue`: confirm the new `@replace` event handler correctly
replaces nodes and removes them from missing nodes list (mirrors the old
inline logic in `SwapNodeGroupRow`)
- `missingNodeScan.ts`: filename/export name change from
`useMissingNodeScan` — verify all call sites updated via `app.ts`
- Test mocking strategy: module-level `vi.mock()` factories use closures
over `ref`/plain objects to allow per-test overrides without global
mutable state

- Fixes #9231
- Fixes #9254
- Fixes #9255
- Fixes #9267
- Fixes #9270
- Fixes #9271
2026-02-28 06:17:30 -08:00
jaeone94
45f112e226 fix: node replacement fails after execution and modal sync (#9269)
## Summary

Fixes two bugs in the node replacement flow: placeholder detection
failing after workflow execution or pack reinstallation, and missing UI
sync in the Errors Tab when replacements are applied from the modal
dialog.

## Changes

- **Placeholder detection**: Node placeholder detection now matches
against `targetTypes` (derived from the replaceable node list built at
workflow load time) instead of relying on `has_errors` flag or
`registered_node_types` lookup. This ensures replacement works reliably
after execution (where `has_errors` gets cleared) and after pack
reinstallation (where the type becomes registered).
- **Modal → Errors Tab sync**: Added
`executionErrorStore.removeMissingNodesByType()` call in
`MissingNodesContent.vue` after replacement, so the Errors Tab reflects
changes immediately without requiring a page reload.

## Review Focus

- `collectAllNodes` predicate change in `useNodeReplacement.ts`: now
uses `targetTypes.has(originalType)` to find nodes by their original
serialized type. This is independent of runtime state like `has_errors`
or `registered_node_types`.
- `executionErrorStore.removeMissingNodesByType` call timing in
`MissingNodesContent.vue` — runs synchronously after
`replaceNodesInPlace` resolves, before auto-close logic.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9269-fix-node-replacement-fails-after-execution-and-modal-sync-3146d73d365081218398c961639b450f)
by [Unito](https://www.unito.io)
2026-02-28 04:05:58 -08:00
167 changed files with 8923 additions and 893 deletions

View File

@@ -5,3 +5,10 @@ reviews:
high_level_summary: false
auto_review:
drafts: true
ignore_title_keywords:
- '[release]'
- '[backport'
ignore_usernames:
- comfy-pr-bot
- github-actions
- github-actions[bot]

View File

@@ -0,0 +1,45 @@
---
# Dispatches a frontend-asset-build event to the cloud repo on push to
# cloud/* branches and main. The cloud repo handles the actual build,
# GCS upload, and secret management (Sentry, Algolia, GCS creds).
#
# This is fire-and-forget — it does NOT wait for the cloud workflow to
# complete. Status is visible in the cloud repo's Actions tab.
name: Cloud Frontend Build Dispatch
on:
push:
branches:
- 'cloud/*'
- 'main'
workflow_dispatch:
permissions: {}
concurrency:
group: cloud-dispatch-${{ github.ref }}
cancel-in-progress: true
jobs:
dispatch:
# Fork guard: prevent forks from dispatching to the cloud repo
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
steps:
- name: Build client payload
id: payload
run: |
payload="$(jq -nc \
--arg ref "${GITHUB_SHA}" \
--arg branch "${GITHUB_REF_NAME}" \
'{ref: $ref, branch: $branch}')"
echo "json=${payload}" >> "${GITHUB_OUTPUT}"
- name: Dispatch to cloud repo
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
with:
token: ${{ secrets.CLOUD_DISPATCH_TOKEN }}
repository: Comfy-Org/cloud
event-type: frontend-asset-build
client-payload: ${{ steps.payload.outputs.json }}

View File

@@ -0,0 +1,760 @@
{
"id": "9a37f747-e96b-4304-9212-7abcaad7bdac",
"revision": 0,
"last_node_id": 11,
"last_link_id": 18,
"nodes": [
{
"id": 2,
"type": "PreviewAny",
"pos": [1031, 434],
"size": [250, 178],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "source",
"type": "*",
"link": 5
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewAny"
},
"widgets_values": [null, null, null]
},
{
"id": 5,
"type": "1e38d8ea-45e1-48a5-aa20-966584201867",
"pos": [788, 433.5],
"size": [225, 380],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 4
}
],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"links": [5]
}
],
"properties": {
"proxyWidgets": [
["3", "string_a"],
["4", "value"],
["6", "value"],
["6", "value_1"]
]
},
"widgets_values": []
},
{
"id": 1,
"type": "PrimitiveStringMultiline",
"pos": [548, 451],
"size": [225, 142],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"links": [4]
}
],
"title": "Outer",
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Outer\n"]
}
],
"links": [
[4, 1, 0, 5, 0, "STRING"],
[5, 5, 0, 2, 0, "STRING"]
],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "1e38d8ea-45e1-48a5-aa20-966584201867",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 11,
"lastLinkId": 18,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Sub 0",
"inputNode": {
"id": -10,
"bounding": [351, 432.5, 120, 120]
},
"outputNode": {
"id": -20,
"bounding": [1352, 294.5, 120, 60]
},
"inputs": [
{
"id": "7bf3e1d4-0521-4b5c-92f5-47ca598b7eb4",
"name": "string_a",
"type": "STRING",
"linkIds": [1],
"localized_name": "string_a",
"pos": [451, 452.5]
},
{
"id": "5fb3dcf7-9bfd-4b3c-a1b9-750b4f3edf19",
"name": "value",
"type": "STRING",
"linkIds": [13],
"pos": [451, 472.5]
},
{
"id": "55d24b8a-7c82-4b02-8e3d-ff31ffb8aa13",
"name": "value_1",
"type": "STRING",
"linkIds": [16],
"pos": [451, 492.5]
},
{
"id": "c1fe7cc3-547e-4fb0-b763-61888558d4bd",
"name": "value_1_1",
"type": "STRING",
"linkIds": [18],
"pos": [451, 512.5]
}
],
"outputs": [
{
"id": "fbe975ba-d7c2-471e-a99a-a1e2c6ab466d",
"name": "STRING",
"type": "STRING",
"linkIds": [9],
"localized_name": "STRING",
"pos": [1372, 314.5]
}
],
"widgets": [],
"nodes": [
{
"id": 4,
"type": "PrimitiveStringMultiline",
"pos": [504, 437],
"size": [210, 88],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "STRING",
"widget": {
"name": "value"
},
"link": 13
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [2]
}
],
"title": "Inner 1",
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Inner 1\n"]
},
{
"id": 3,
"type": "StringConcatenate",
"pos": [743, 325],
"size": [347, 231],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 1
},
{
"localized_name": "string_b",
"name": "string_b",
"type": "STRING",
"widget": {
"name": "string_b"
},
"link": 2
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [7]
}
],
"properties": {
"Node name for S&R": "StringConcatenate"
},
"widgets_values": ["", "", ""]
},
{
"id": 6,
"type": "9be42452-056b-4c99-9f9f-7381d11c4454",
"pos": [1115, 301],
"size": [210, 196],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 7
},
{
"name": "value",
"type": "STRING",
"widget": {
"name": "value"
},
"link": 16
},
{
"name": "value_1",
"type": "STRING",
"widget": {
"name": "value_1"
},
"link": 18
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [9]
}
],
"properties": {
"proxyWidgets": [
["5", "string_a"],
["11", "value"],
["9", "value"],
["10", "string_a"]
]
},
"widgets_values": []
}
],
"groups": [],
"links": [
{
"id": 2,
"origin_id": 4,
"origin_slot": 0,
"target_id": 3,
"target_slot": 1,
"type": "STRING"
},
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 3,
"target_slot": 0,
"type": "STRING"
},
{
"id": 7,
"origin_id": 3,
"origin_slot": 0,
"target_id": 6,
"target_slot": 0,
"type": "STRING"
},
{
"id": 6,
"origin_id": 6,
"origin_slot": 0,
"target_id": -20,
"target_slot": 1,
"type": "STRING"
},
{
"id": 9,
"origin_id": 6,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
},
{
"id": 13,
"origin_id": -10,
"origin_slot": 1,
"target_id": 4,
"target_slot": 0,
"type": "STRING"
},
{
"id": 16,
"origin_id": -10,
"origin_slot": 2,
"target_id": 6,
"target_slot": 1,
"type": "STRING"
},
{
"id": 18,
"origin_id": -10,
"origin_slot": 3,
"target_id": 6,
"target_slot": 2,
"type": "STRING"
}
],
"extra": {}
},
{
"id": "9be42452-056b-4c99-9f9f-7381d11c4454",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 11,
"lastLinkId": 18,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Sub 1",
"inputNode": {
"id": -10,
"bounding": [180, 739, 120, 100]
},
"outputNode": {
"id": -20,
"bounding": [1246, 612, 120, 60]
},
"inputs": [
{
"id": "01c05c51-86b5-4bad-b32f-9c911683a13d",
"name": "string_a",
"type": "STRING",
"linkIds": [4],
"localized_name": "string_a",
"pos": [280, 759]
},
{
"id": "d50f6a62-0185-43d4-a174-a8a94bd8f6e7",
"name": "value",
"type": "STRING",
"linkIds": [14],
"pos": [280, 779]
},
{
"id": "6b78450e-5986-49cd-b743-c933e5a34a69",
"name": "value_1",
"type": "STRING",
"linkIds": [17],
"pos": [280, 799]
}
],
"outputs": [
{
"id": "a8bcf3bf-a66a-4c71-8d92-17a2a4d03686",
"name": "STRING",
"type": "STRING",
"linkIds": [12],
"localized_name": "STRING",
"pos": [1266, 632]
}
],
"widgets": [],
"nodes": [
{
"id": 11,
"type": "PrimitiveStringMultiline",
"pos": [334, 742],
"size": [210, 88],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "STRING",
"widget": {
"name": "value"
},
"link": 14
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [7]
}
],
"title": "Inner 2",
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Inner 2\n"]
},
{
"id": 10,
"type": "StringConcatenate",
"pos": [581, 637],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 4
},
{
"localized_name": "string_b",
"name": "string_b",
"type": "STRING",
"widget": {
"name": "string_b"
},
"link": 7
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [11]
}
],
"properties": {
"Node name for S&R": "StringConcatenate"
},
"widgets_values": ["", "", ""]
},
{
"id": 9,
"type": "7c2915a5-5eb8-4958-a8fd-4beb30f370ce",
"pos": [1004, 613],
"size": [210, 142],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 11
},
{
"name": "value",
"type": "STRING",
"widget": {
"name": "value"
},
"link": 17
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [12]
}
],
"properties": {
"proxyWidgets": [
["7", "string_a"],
["8", "value"]
]
},
"widgets_values": []
}
],
"groups": [],
"links": [
{
"id": 4,
"origin_id": -10,
"origin_slot": 0,
"target_id": 10,
"target_slot": 0,
"type": "STRING"
},
{
"id": 7,
"origin_id": 11,
"origin_slot": 0,
"target_id": 10,
"target_slot": 1,
"type": "STRING"
},
{
"id": 11,
"origin_id": 10,
"origin_slot": 0,
"target_id": 9,
"target_slot": 0,
"type": "STRING"
},
{
"id": 10,
"origin_id": 9,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
},
{
"id": 12,
"origin_id": 9,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
},
{
"id": 14,
"origin_id": -10,
"origin_slot": 1,
"target_id": 11,
"target_slot": 0,
"type": "STRING"
},
{
"id": 17,
"origin_id": -10,
"origin_slot": 2,
"target_id": 9,
"target_slot": 1,
"type": "STRING"
}
],
"extra": {}
},
{
"id": "7c2915a5-5eb8-4958-a8fd-4beb30f370ce",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 11,
"lastLinkId": 18,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Sub 2",
"inputNode": {
"id": -10,
"bounding": [262, 1222, 120, 80]
},
"outputNode": {
"id": -20,
"bounding": [1123.089999999999, 1125.1999999999998, 120, 60]
},
"inputs": [
{
"id": "934a8baa-d79c-428c-8ec9-814ad437d7c7",
"name": "string_a",
"type": "STRING",
"linkIds": [9],
"localized_name": "string_a",
"pos": [362, 1242]
},
{
"id": "3a545207-7202-42a9-a82f-3b62e1b0f459",
"name": "value",
"type": "STRING",
"linkIds": [15],
"pos": [362, 1262]
}
],
"outputs": [
{
"id": "4c3d243b-9ff6-4dcd-9dbf-e4ec8e1fc879",
"name": "STRING",
"type": "STRING",
"linkIds": [10],
"localized_name": "STRING",
"pos": [1143.089999999999, 1145.1999999999998]
}
],
"widgets": [],
"nodes": [
{
"id": 8,
"type": "PrimitiveStringMultiline",
"pos": [412.96000000000004, 1228.2399999999996],
"size": [210, 88],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "STRING",
"widget": {
"name": "value"
},
"link": 15
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [8]
}
],
"title": "Inner 3",
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Inner 3\n"]
},
{
"id": 7,
"type": "StringConcatenate",
"pos": [686.08, 1132.38],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 9
},
{
"localized_name": "string_b",
"name": "string_b",
"type": "STRING",
"widget": {
"name": "string_b"
},
"link": 8
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [10]
}
],
"properties": {
"Node name for S&R": "StringConcatenate"
},
"widgets_values": ["", "", ""]
}
],
"groups": [],
"links": [
{
"id": 8,
"origin_id": 8,
"origin_slot": 0,
"target_id": 7,
"target_slot": 1,
"type": "STRING"
},
{
"id": 9,
"origin_id": -10,
"origin_slot": 0,
"target_id": 7,
"target_slot": 0,
"type": "STRING"
},
{
"id": 10,
"origin_id": 7,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
},
{
"id": 15,
"origin_id": -10,
"origin_slot": 1,
"target_id": 8,
"target_slot": 0,
"type": "STRING"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [-412, 11]
},
"frontendVersion": "1.41.7"
},
"version": 0.4
}

View File

@@ -171,6 +171,7 @@ test.describe('Node Interaction', () => {
test('Can drag node', { tag: '@screenshot' }, async ({ comfyPage }) => {
await comfyPage.nodeOps.dragTextEncodeNode2()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('dragged-node1.png')
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

View File

@@ -555,6 +555,74 @@ test.describe(
})
})
test.describe('Nested Promoted Widget Disabled State', () => {
test('Externally linked promoted widget is disabled, unlinked ones are not', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
await comfyPage.nextFrame()
// Node 5 (Sub 0) has 4 promoted widgets. The first (string_a) has its
// slot connected externally from the Outer node, so it should be
// disabled. The remaining promoted textarea widgets (value, value_1)
// are unlinked and should be enabled.
const promotedNames = await getPromotedWidgetNames(comfyPage, '5')
expect(promotedNames).toContain('string_a')
expect(promotedNames).toContain('value')
const disabledState = await comfyPage.page.evaluate(() => {
const node = window.app!.canvas.graph!.getNodeById('5')
return (node?.widgets ?? []).map((w) => ({
name: w.name,
disabled: !!w.computedDisabled
}))
})
const linkedWidget = disabledState.find((w) => w.name === 'string_a')
expect(linkedWidget?.disabled).toBe(true)
const unlinkedWidgets = disabledState.filter(
(w) => w.name !== 'string_a'
)
for (const w of unlinkedWidgets) {
expect(w.disabled).toBe(false)
}
})
test('Unlinked promoted textarea widgets are editable on the subgraph exterior', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
await comfyPage.nextFrame()
// The promoted textareas that are NOT externally linked should be
// fully opaque and interactive.
const textareas = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textareas.first()).toBeVisible()
const count = await textareas.count()
for (let i = 0; i < count; i++) {
const textarea = textareas.nth(i)
const wrapper = textarea.locator('..')
const opacity = await wrapper.evaluate(
(el) => getComputedStyle(el).opacity
)
if (opacity === '1' && (await textarea.isEditable())) {
const testContent = `nested-promotion-edit-${i}`
await textarea.fill(testContent)
await expect(textarea).toHaveValue(testContent)
}
}
})
})
test.describe('Promotion Cleanup', () => {
test('Removing subgraph node clears promotion store entries', async ({
comfyPage

View File

@@ -41,7 +41,9 @@ const config: KnipConfig = {
// Used by a custom node (that should move off of this)
'src/scripts/ui/components/splitButton.ts',
// Workflow files contain license names that knip misinterprets as binaries
'.github/workflows/ci-oss-assets-validation.yaml'
'.github/workflows/ci-oss-assets-validation.yaml',
// Pending integration in stacked PR
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue'
],
compilers: {
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.41.8",
"version": "1.41.10",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -16,7 +16,7 @@
@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow}]");
/* Safelist dynamic comfy icons for essential nodes (kebab-case of node names) */
@source inline("icon-[comfy--{load-image,save-image,load-video,save-video,load-3-d,save-glb,image-batch,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,clip-text-encode,get-video-components,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,subgraph-blueprint-canny-to-video-ltx-2-0,subgraph-blueprint-pose-to-video-ltx-2-0}]");
@source inline("icon-[comfy--{load-image,save-image,load-video,save-video,load-3-d,save-glb,image-batch,batch-images-node,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,lora-loader-model-only,primitive-string-multiline,get-video-components,video-slice,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,subgraph-blueprint-canny-to-video-ltx-2-0,subgraph-blueprint-pose-to-video-ltx-2-0}]");
@custom-variant touch (@media (hover: none));
@@ -634,6 +634,18 @@
}
}
@utility bg-subscription-gradient {
background: var(--color-subscription-button-gradient);
}
@utility highlight {
background-color: color-mix(in srgb, currentColor 20%, transparent);
font-weight: 700;
border-radius: 0.25rem;
padding: 0 0.125rem;
margin: -0.125rem 0.125rem;
}
@utility scrollbar-hide {
scrollbar-width: none;
&::-webkit-scrollbar {

View File

@@ -0,0 +1,3 @@
<svg width="48" height="24" viewBox="0 0 48 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.5 9C12.7761 9 13 9.22386 13 9.5V20C13 20.2761 13.2239 20.5 13.5 20.5H28C28.2761 20.5 28.5 20.7239 28.5 21C28.5 21.2761 28.2761 21.5 28 21.5H13.5C12.6716 21.5 12 20.8284 12 20V9.5C12 9.22386 12.2239 9 12.5 9ZM14.5 7C14.7761 7 15 7.22386 15 7.5V18C15 18.2761 15.2239 18.5 15.5 18.5H30C30.2761 18.5 30.5 18.7239 30.5 19C30.5 19.2761 30.2761 19.5 30 19.5H15.5C14.6716 19.5 14 18.8284 14 18V7.5C14 7.22386 14.2239 7 14.5 7ZM16.5 5C16.7761 5 17 5.22386 17 5.5V16C17 16.2761 17.2239 16.5 17.5 16.5H32C32.2761 16.5 32.5 16.7239 32.5 17C32.5 17.2761 32.2761 17.5 32 17.5H17.5C16.6716 17.5 16 16.8284 16 16V5.5C16 5.22386 16.2239 5 16.5 5ZM33.7061 2.5C34.4126 2.5 34.9999 3.08968 35 3.7998V14.2002C34.9999 14.9103 34.4126 15.5 33.7061 15.5H19.2939C18.5874 15.5 18.0001 14.9103 18 14.2002V3.7998C18.0001 3.08968 18.5874 2.5 19.2939 2.5H33.7061ZM19.1084 12.2676V14.2002C19.1085 14.3124 19.1814 14.3856 19.293 14.3857H33.7061C33.8179 14.3857 33.8915 14.3125 33.8916 14.2002V12.6094L30.7207 10.0615L28.1055 11.873C27.9107 12.005 27.6299 11.9923 27.4473 11.8438L23.8896 8.95312L19.1084 12.2676ZM19.2939 3.61426C19.1821 3.61426 19.1085 3.68744 19.1084 3.7998V10.9092L23.5957 7.79883C23.6707 7.74519 23.7587 7.71107 23.8496 7.7002C23.9954 7.68428 24.1465 7.72944 24.2598 7.82227L27.8164 10.7178L30.4385 8.90723C30.6334 8.7753 30.9141 8.78784 31.0967 8.93652L33.8916 11.1826V3.7998C33.8915 3.68747 33.8179 3.61426 33.7061 3.61426H19.2939ZM27.7939 5.09961C28.7054 5.09987 29.4561 5.8554 29.4561 6.77148C29.456 7.68754 28.7054 8.44213 27.7939 8.44238C26.8823 8.44238 26.1309 7.6877 26.1309 6.77148C26.1309 5.85524 26.8823 5.09961 27.7939 5.09961ZM27.7939 6.21387C27.4814 6.21387 27.2393 6.45737 27.2393 6.77148C27.2393 7.08557 27.4814 7.32812 27.7939 7.32812C28.1062 7.32788 28.3476 7.08542 28.3477 6.77148C28.3477 6.45752 28.1063 6.21411 27.7939 6.21387Z" fill="#8A8A8A"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,5 @@
<svg width="48" height="24" viewBox="0 0 48 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M25.12 4.65979C26.0073 3.75304 27.4985 3.73975 28.3787 4.65979L31.3397 7.62073H31.3387C32.2523 8.49397 32.253 10.0028 31.3182 10.8785L31.3192 10.8795L23.62 18.5797L22.5096 19.6891V7.27112L22.7 7.08069L25.12 4.65979Z" stroke="#8A8A8A" stroke-width="1.3"/>
<path d="M32.3396 13.8499C33.6177 13.8499 34.65 14.8804 34.6501 16.1594V20.3401C34.6501 21.6199 33.618 22.6506 32.3396 22.6506H20.3503L21.4597 21.5403L29.1501 13.8499H32.3396Z" stroke="#8A8A8A" stroke-width="1.3"/>
<path d="M17.7604 17.2496C17.1991 17.2496 16.7498 17.6986 16.7497 18.2594C16.7497 18.8208 17.1995 19.2701 17.7604 19.2701C18.3065 19.2699 18.7702 18.8157 18.7702 18.2594C18.7701 17.6982 18.3211 17.2499 17.7604 17.2496ZM22.1706 18.2399C22.1706 20.6987 20.2192 22.6498 17.7604 22.65C15.2992 22.65 13.3493 20.677 13.3493 18.2399V3.65979C13.3494 2.38005 14.3815 1.34933 15.6598 1.34924H19.8405C21.1222 1.34934 22.1421 2.38132 22.1706 3.64514V18.2399Z" stroke="#8A8A8A" stroke-width="1.3"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

Before

Width:  |  Height:  |  Size: 652 B

After

Width:  |  Height:  |  Size: 652 B

View File

@@ -0,0 +1,3 @@
<svg width="48" height="24" viewBox="0 0 48 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M34.8593 13C35.4827 13 36.0007 13.4988 36.0009 14.0996V22.9004C36.0007 23.5012 35.4827 24 34.8593 24H22.1425C21.5191 24 21.0011 23.5012 21.0009 22.9004V14.0996C21.0011 13.4988 21.5191 13 22.1425 13H34.8593ZM21.9794 21.2646V22.9004C21.9796 22.9953 22.0439 23.0566 22.1425 23.0566H34.8593C34.9579 23.0566 35.0222 22.9953 35.0224 22.9004V21.5547L32.2255 19.3984L29.9179 20.9307C29.746 21.0424 29.498 21.032 29.3369 20.9062L26.1982 18.4609L21.9794 21.2646ZM16.5009 10.5C16.777 10.5001 17.0009 10.7239 17.0009 11V17.5C17.001 18.3283 17.6727 18.9998 18.5009 19H18.7089L18.0615 18.3535C17.8665 18.1583 17.8665 17.8417 18.0615 17.6465C18.2567 17.4512 18.5742 17.4512 18.7695 17.6465L20.1835 19.0605C20.3785 19.2557 20.3784 19.5723 20.1835 19.7676L18.7695 21.1816C18.5742 21.3769 18.2567 21.3768 18.0615 21.1816C17.8666 20.9864 17.8664 20.6697 18.0615 20.4746L18.5361 20H18.5009C17.1204 19.9998 16.001 18.8806 16.0009 17.5V11C16.001 10.724 16.2249 10.5002 16.5009 10.5ZM22.1425 13.9424C22.0439 13.9424 21.9796 14.0047 21.9794 14.0996V20.1152L25.9384 17.4834C26.0045 17.4381 26.082 17.4096 26.162 17.4004C26.2907 17.3869 26.4244 17.4244 26.5244 17.5029L29.663 19.9531L31.9755 18.4219C32.1475 18.3102 32.3954 18.3204 32.5566 18.4463L35.0224 20.3467V14.0996C35.0222 14.0047 34.9579 13.9424 34.8593 13.9424H22.1425ZM29.6425 15.2002C30.4468 15.2003 31.1093 15.839 31.1093 16.6143C31.1093 17.3895 30.4469 18.0283 29.6425 18.0283C28.8381 18.0283 28.1747 17.3895 28.1747 16.6143C28.1748 15.839 28.8381 15.2002 29.6425 15.2002ZM29.6425 16.1426C29.3668 16.1426 29.1533 16.3485 29.1533 16.6143C29.1533 16.8801 29.3667 17.0859 29.6425 17.0859C29.9182 17.0859 30.1318 16.88 30.1318 16.6143C30.1318 16.3485 29.9182 16.1426 29.6425 16.1426ZM22.0917 0C23.6924 0.000102997 25.0009 1.29808 25.0009 2.91016V7.08984C25.0009 8.70192 23.6924 9.9999 22.0917 10H14.9111C13.3103 10 12.0009 8.70198 12.0009 7.08984V2.91016C12.0009 1.29802 13.3103 0 14.9111 0H22.0917ZM14.9111 1.04199C13.8598 1.04199 13.0331 1.87561 13.0331 2.91016V7.08984C13.0331 8.12439 13.8598 8.95801 14.9111 8.95801H22.0917C23.1429 8.95791 23.9697 8.12432 23.9697 7.08984V2.91016C23.9697 1.87568 23.1429 1.04209 22.0917 1.04199H14.9111ZM17.0146 2.36523C17.1026 2.36806 17.189 2.39596 17.2636 2.44531L20.5556 4.53613C20.7284 4.64278 20.7919 4.83988 20.7919 5C20.7919 5.16007 20.7283 5.35719 20.5556 5.46387L17.2646 7.55469C17.1075 7.65858 16.9024 7.66034 16.7441 7.56055L16.7431 7.55957C16.5867 7.45933 16.4941 7.27149 16.499 7.08398V2.91016C16.4953 2.64423 16.6989 2.38047 16.9755 2.36621L17.0146 2.36523ZM17.5068 6.1416L19.3095 5L17.5068 3.85449V6.1416ZM20.4999 5.22559L20.5234 5.19434C20.5303 5.1833 20.5364 5.17121 20.5419 5.15918C20.5308 5.1833 20.5167 5.20593 20.4999 5.22559Z" fill="#8A8A8A"/>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -155,7 +155,7 @@ const unifiedWidth = computed(() =>
const { focusMode } = storeToRefs(workspaceStore)
const { isSelectMode } = useAppMode()
const { isSelectMode, isBuilderMode } = useAppMode()
const { activeSidebarTabId, activeSidebarTab } = storeToRefs(sidebarTabStore)
const { bottomPanelVisible } = storeToRefs(useBottomPanelStore())
const { isOpen: rightSidePanelVisible } = storeToRefs(rightSidePanelStore)
@@ -163,7 +163,9 @@ const showOffsideSplitter = computed(
() => rightSidePanelVisible.value || isSelectMode.value
)
const sidebarPanelVisible = computed(() => activeSidebarTab.value !== null)
const sidebarPanelVisible = computed(
() => activeSidebarTab.value !== null && !isBuilderMode.value
)
const sidebarStateKey = computed(() => {
return unifiedWidth.value

View File

@@ -9,11 +9,13 @@ import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { cn } from '@/utils/tailwindUtil'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
const { t } = useI18n()
const commandStore = useCommandStore()
const workspaceStore = useWorkspaceStore()
const { enableAppBuilder, setMode } = useAppMode()
const { enableAppBuilder } = useAppMode()
const { enterBuilder } = useAppModeStore()
const tooltipOptions = { showDelay: 300, hideDelay: 300 }
const isAssetsActive = computed(
@@ -23,10 +25,6 @@ const isWorkflowsActive = computed(
() => workspaceStore.sidebarTab.activeSidebarTab?.id === 'workflows'
)
function enterBuilderMode() {
setMode('builder:select')
}
function openAssets() {
void commandStore.execute('Workspace.ToggleSidebarTab.assets')
}
@@ -43,7 +41,7 @@ function openTemplates() {
<template>
<div class="flex flex-col gap-2 pointer-events-auto">
<WorkflowActionsDropdown source="app_mode_toolbar">
<template #button>
<template #button="{ hasUnseenItems }">
<Button
v-tooltip.right="{
value: t('sideToolbar.labels.menu'),
@@ -52,10 +50,15 @@ function openTemplates() {
variant="secondary"
size="unset"
:aria-label="t('sideToolbar.labels.menu')"
class="h-10 rounded-lg pl-3 pr-2 gap-1 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
class="relative h-10 rounded-lg pl-3 pr-2 gap-1 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
>
<i class="icon-[lucide--panels-top-left] size-4" />
<i class="icon-[lucide--chevron-down] size-4 text-muted-foreground" />
<span
v-if="hasUnseenItems"
aria-hidden="true"
class="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary-background"
/>
</Button>
</template>
</WorkflowActionsDropdown>
@@ -70,7 +73,7 @@ function openTemplates() {
size="unset"
:aria-label="t('linearMode.appModeToolbar.appBuilder')"
class="size-10 rounded-lg"
@click="enterBuilderMode"
@click="enterBuilder"
>
<i class="icon-[lucide--hammer] size-4" />
</Button>

View File

@@ -1,7 +1,7 @@
<template>
<div
data-testid="subgraph-breadcrumb"
class="subgraph-breadcrumb flex w-auto drop-shadow-(--interface-panel-drop-shadow) items-center"
class="subgraph-breadcrumb flex w-auto drop-shadow-(--interface-panel-drop-shadow) items-center -mt-4 pt-4"
:class="{
'subgraph-breadcrumb-collapse': collapseTabs,
'subgraph-breadcrumb-overflow': overflowingTabs

View File

@@ -38,20 +38,28 @@ const workflowStore = useWorkflowStore()
const { t } = useI18n()
const canvas: LGraphCanvas = canvasStore.getCanvas()
const { mode, isArrangeMode } = useAppMode()
const { isSelectMode, isArrangeMode } = useAppMode()
const hoveringSelectable = ref(false)
provide(HideLayoutFieldKey, true)
workflowStore.activeWorkflow?.changeTracker?.reset()
function resolveNode(nodeId: NodeId) {
return (
app.rootGraph.getNodeById(nodeId) ??
[...app.rootGraph.subgraphs.values()]
.flatMap((sg) => sg.nodes)
.find((n) => n.id == nodeId)
)
}
// Prune stale entries whose node/widget no longer exists, so the
// DraggableList model always matches the rendered items.
watchEffect(() => {
const valid = appModeStore.selectedInputs.filter(([nodeId, widgetName]) => {
const node = app.rootGraph.getNodeById(nodeId)
return node?.widgets?.some((w) => w.name === widgetName)
})
const valid = appModeStore.selectedInputs.filter(([nodeId, widgetName]) =>
resolveNode(nodeId)?.widgets?.some((w) => w.name === widgetName)
)
if (valid.length < appModeStore.selectedInputs.length) {
appModeStore.selectedInputs = valid
}
@@ -60,7 +68,7 @@ watchEffect(() => {
const arrangeInputs = computed(() =>
appModeStore.selectedInputs
.map(([nodeId, widgetName]) => {
const node = app.rootGraph.getNodeById(nodeId)
const node = resolveNode(nodeId)
const widget = node?.widgets?.find((w) => w.name === widgetName)
if (!node || !widget) return null
return { nodeId, widgetName, node, widget }
@@ -70,7 +78,7 @@ const arrangeInputs = computed(() =>
const inputsWithState = computed(() =>
appModeStore.selectedInputs.map(([nodeId, widgetName]) => {
const node = app.rootGraph.getNodeById(nodeId)
const node = resolveNode(nodeId)
const widget = node?.widgets?.find((w) => w.name === widgetName)
if (!node || !widget) return { nodeId, widgetName }
@@ -168,14 +176,14 @@ function handleClick(e: MouseEvent) {
if (!widget) {
if (!node.constructor.nodeData?.output_node)
return canvasInteractions.forwardEventToCanvas(e)
const index = appModeStore.selectedOutputs.findIndex((id) => id === node.id)
const index = appModeStore.selectedOutputs.findIndex((id) => id == node.id)
if (index === -1) appModeStore.selectedOutputs.push(node.id)
else appModeStore.selectedOutputs.splice(index, 1)
return
}
const index = appModeStore.selectedInputs.findIndex(
([nodeId, widgetName]) => node.id === nodeId && widget.name === widgetName
([nodeId, widgetName]) => node.id == nodeId && widget.name === widgetName
)
if (index === -1) appModeStore.selectedInputs.push([node.id, widget.name])
else appModeStore.selectedInputs.splice(index, 1)
@@ -239,6 +247,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
:disabled="!appModeStore.selectedInputs.length"
class="border-border-subtle border-b"
:tooltip="`${t('linearMode.builder.inputsDesc')}\n${t('linearMode.builder.inputsExample')}`"
:tooltip-delay="100"
>
<template #label>
<div class="flex gap-3">
@@ -274,7 +283,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
() =>
remove(
appModeStore.selectedInputs,
([id, name]) => nodeId === id && widgetName === name
([id, name]) => nodeId == id && widgetName === name
)
"
/>
@@ -286,6 +295,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
enable-empty-state
:disabled="!appModeStore.selectedOutputs.length"
:tooltip="`${t('linearMode.builder.outputsDesc')}\n${t('linearMode.builder.outputsExample')}`"
:tooltip-delay="100"
>
<template #label>
<div class="flex gap-3">
@@ -319,12 +329,15 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
"
:title
:sub-title="String(key)"
:remove="() => remove(appModeStore.selectedOutputs, (k) => k === key)"
:remove="() => remove(appModeStore.selectedOutputs, (k) => k == key)"
/>
</DraggableList>
</PropertiesAccordionItem>
<Teleport v-if="mode === 'builder:select'" to="body">
<Teleport
v-if="isSelectMode && !settingStore.get('Comfy.VueNodes.Enabled')"
to="body"
>
<div
:class="
cn(
@@ -358,13 +371,19 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
<div class="absolute top-0 right-0 size-8">
<div
v-if="isSelected"
class="absolute -top-1/2 -right-1/2 size-full p-2 bg-warning-background rounded-lg"
class="absolute -top-1/2 -right-1/2 size-full p-2 bg-warning-background rounded-lg cursor-pointer pointer-events-auto"
@click.stop="
remove(appModeStore.selectedOutputs, (k) => k == key)
"
@pointerdown.stop
>
<i class="icon-[lucide--check] bg-text-foreground size-full" />
</div>
<div
v-else
class="absolute -top-1/2 -right-1/2 size-full ring-warning-background/50 ring-4 ring-inset bg-component-node-background rounded-lg"
class="absolute -top-1/2 -right-1/2 size-full ring-warning-background/50 ring-4 ring-inset bg-component-node-background rounded-lg cursor-pointer pointer-events-auto"
@click.stop="appModeStore.selectedOutputs.push(key)"
@pointerdown.stop
/>
</div>
</div>

View File

@@ -33,8 +33,11 @@ const entries = computed(() => {
</script>
<template>
<div class="p-2 my-2 rounded-lg flex items-center-safe">
<span class="mr-auto" v-text="title" />
<span class="text-muted-foreground mr-2 text-end" v-text="subTitle" />
<span class="mr-auto truncate shrink-1" v-text="title" />
<span
class="text-muted-foreground mr-2 text-end truncate shrink-3"
v-text="subTitle"
/>
<Popover :entries>
<template #button>
<Button variant="muted-textonly">

View File

@@ -40,6 +40,7 @@ import InputText from 'primevue/inputtext'
import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
@@ -88,6 +89,7 @@ const handleFileUpload = async (event: Event) => {
type: 'input',
subfolder: 'backgrounds'
})
appendCloudResParam(params, file.name)
modelValue.value = `/api/view?${params.toString()}`
}
} catch (error) {

View File

@@ -1,7 +1,11 @@
<template>
<span
class="flex items-center gap-1 rounded border px-1.5 py-0.5 text-xxs"
:class="textColorClass"
:class="
cn(
'flex items-center gap-1 rounded border px-1.5 py-0.5 text-xxs',
textColorClass
)
"
:style="customStyle"
>
<i v-if="icon" :class="cn(icon, 'size-2.5', iconClass)" />

View File

@@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils'
import type { FlattenedItem } from 'reka-ui'
import { ref } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
@@ -9,12 +10,25 @@ import { InjectKeyContextMenuNode } from '@/types/treeExplorerTypes'
import TreeExplorerV2Node from './TreeExplorerV2Node.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
})
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn().mockReturnValue('left')
})
}))
vi.mock('@/stores/nodeBookmarkStore', () => ({
useNodeBookmarkStore: () => ({
isBookmarked: vi.fn().mockReturnValue(false),
toggleBookmark: vi.fn()
})
}))
vi.mock('@/components/node/NodePreviewCard.vue', () => ({
default: { template: '<div />' }
}))
@@ -78,6 +92,7 @@ describe('TreeExplorerV2Node', () => {
return {
wrapper: mount(TreeExplorerV2Node, {
global: {
plugins: [i18n],
stubs: {
TreeItem: treeItemStub.stub,
Teleport: { template: '<div />' }

View File

@@ -24,6 +24,25 @@
{{ item.value.label }}
</slot>
</span>
<button
:class="
cn(
'flex size-6 shrink-0 cursor-pointer items-center justify-center rounded border-none bg-transparent text-muted-foreground hover:text-foreground',
'opacity-0 group-hover/tree-node:opacity-100'
)
"
:aria-label="$t('icon.bookmark')"
@click.stop="toggleBookmark"
>
<i
:class="
cn(
isBookmarked ? 'pi pi-bookmark-fill' : 'pi pi-bookmark',
'text-xs'
)
"
/>
</button>
</div>
<!-- Folder -->
@@ -33,6 +52,15 @@
:style="rowStyle"
@click.stop="handleClick($event, handleToggle, handleSelect)"
>
<i
v-if="item.hasChildren"
:class="
cn(
'icon-[lucide--chevron-down] size-4 shrink-0 text-muted-foreground transition-transform',
!isExpanded && '-rotate-90'
)
"
/>
<i
:class="cn(item.value.icon, 'size-4 shrink-0 text-muted-foreground')"
/>
@@ -41,15 +69,6 @@
{{ item.value.label }}
</slot>
</span>
<i
v-if="item.hasChildren"
:class="
cn(
'icon-[lucide--chevron-down] mr-4 size-4 shrink-0 text-muted-foreground transition-transform',
!isExpanded && '-rotate-90'
)
"
/>
</div>
</TreeItem>
@@ -73,6 +92,7 @@ import { computed, inject } from 'vue'
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
import { useNodePreviewAndDrag } from '@/composables/node/useNodePreviewAndDrag'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { InjectKeyContextMenuNode } from '@/types/treeExplorerTypes'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
@@ -93,9 +113,21 @@ const emit = defineEmits<{
}>()
const contextMenuNode = inject(InjectKeyContextMenuNode)
const nodeBookmarkStore = useNodeBookmarkStore()
const nodeDef = computed(() => item.value.data)
const isBookmarked = computed(() => {
if (!nodeDef.value) return false
return nodeBookmarkStore.isBookmarked(nodeDef.value)
})
function toggleBookmark() {
if (nodeDef.value) {
nodeBookmarkStore.toggleBookmark(nodeDef.value)
}
}
const {
previewRef,
showPreview,

View File

@@ -9,6 +9,7 @@ import { useI18n } from 'vue-i18n'
import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
import Button from '@/components/ui/button/Button.vue'
import { useNewMenuItemIndicator } from '@/composables/useNewMenuItemIndicator'
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
@@ -27,8 +28,13 @@ const { menuItems } = useWorkflowActionsMenu(
{ isRoot: true }
)
const { hasUnseenItems, markAsSeen } = useNewMenuItemIndicator(
() => menuItems.value
)
function handleOpen(open: boolean) {
if (open) {
markAsSeen()
useTelemetry()?.trackUiButtonClicked({
button_id: source
})
@@ -39,7 +45,7 @@ function handleOpen(open: boolean) {
<template>
<DropdownMenuRoot @update:open="handleOpen">
<DropdownMenuTrigger as-child>
<slot name="button">
<slot name="button" :has-unseen-items="hasUnseenItems">
<Button
v-tooltip="{
value: t('breadcrumbsMenu.workflowActions'),
@@ -49,7 +55,7 @@ function handleOpen(open: boolean) {
variant="secondary"
size="unset"
:aria-label="t('breadcrumbsMenu.workflowActions')"
class="h-10 rounded-lg pl-3 pr-2 pointer-events-auto gap-1 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
class="relative h-10 rounded-lg pl-3 pr-2 pointer-events-auto gap-1 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
>
<i
class="size-4"
@@ -60,6 +66,11 @@ function handleOpen(open: boolean) {
"
/>
<i class="icon-[lucide--chevron-down] size-4 text-muted-foreground" />
<span
v-if="hasUnseenItems"
aria-hidden="true"
class="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary-background"
/>
</Button>
</slot>
</DropdownMenuTrigger>

View File

@@ -17,7 +17,7 @@ function createWrapper(items: WorkflowMenuItem[]) {
describe('WorkflowActionsList', () => {
it('renders action items with label and icon', () => {
const items: WorkflowMenuItem[] = [
{ label: 'Save', icon: 'pi pi-save', command: vi.fn() }
{ id: 'save', label: 'Save', icon: 'pi pi-save', command: vi.fn() }
]
const wrapper = createWrapper(items)
@@ -28,9 +28,9 @@ describe('WorkflowActionsList', () => {
it('renders separator items', () => {
const items: WorkflowMenuItem[] = [
{ label: 'Before', icon: 'pi pi-a', command: vi.fn() },
{ id: 'before', label: 'Before', icon: 'pi pi-a', command: vi.fn() },
{ separator: true },
{ label: 'After', icon: 'pi pi-b', command: vi.fn() }
{ id: 'after', label: 'After', icon: 'pi pi-b', command: vi.fn() }
]
const wrapper = createWrapper(items)
@@ -44,7 +44,7 @@ describe('WorkflowActionsList', () => {
it('dispatches command on select', async () => {
const command = vi.fn()
const items: WorkflowMenuItem[] = [
{ label: 'Action', icon: 'pi pi-play', command }
{ id: 'action', label: 'Action', icon: 'pi pi-play', command }
]
const wrapper = createWrapper(items)
@@ -57,6 +57,7 @@ describe('WorkflowActionsList', () => {
it('renders badge when present', () => {
const items: WorkflowMenuItem[] = [
{
id: 'new-feature',
label: 'New Feature',
icon: 'pi pi-star',
command: vi.fn(),
@@ -71,7 +72,7 @@ describe('WorkflowActionsList', () => {
it('does not render badge when absent', () => {
const items: WorkflowMenuAction[] = [
{ label: 'Plain', icon: 'pi pi-check', command: vi.fn() }
{ id: 'plain', label: 'Plain', icon: 'pi pi-check', command: vi.fn() }
]
const wrapper = createWrapper(items)

View File

@@ -234,6 +234,7 @@ import { isCloud } from '@/platform/distribution/types'
import type { NodeReplacement } from '@/platform/nodeReplacement/types'
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
import { useDialogStore } from '@/stores/dialogStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type { MissingNodeType } from '@/types/comfy'
import { cn } from '@/utils/tailwindUtil'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
@@ -245,6 +246,7 @@ const { missingNodeTypes } = defineProps<{
const { missingCoreNodes } = useMissingNodes()
const { replaceNodesInPlace } = useNodeReplacement()
const dialogStore = useDialogStore()
const executionErrorStore = useExecutionErrorStore()
interface ProcessedNode {
label: string
@@ -339,6 +341,14 @@ function handleReplaceSelected() {
replacedTypes.value = nextReplaced
selectedTypes.value = nextSelected
// replaceNodesInPlace() handles canvas rendering via onNodeAdded(),
// but the modal only updates its own local UI state above.
// Without this call the Errors Tab would still list the replaced nodes
// as missing because executionErrorStore is not aware of the replacement.
if (result.length > 0) {
executionErrorStore.removeMissingNodesByType(result)
}
// Auto-close when all replaceable nodes replaced and no non-replaceable remain
const allReplaced = replaceableNodes.value.every((n) =>
nextReplaced.has(n.label)

View File

@@ -18,6 +18,7 @@
>
<WorkflowTabs />
<TopbarBadges />
<TopbarSubscribeButton />
</div>
</div>
</template>
@@ -140,6 +141,7 @@ import NodePropertiesPanel from '@/components/rightSidePanel/RightSidePanel.vue'
import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
import TopbarSubscribeButton from '@/components/topbar/TopbarSubscribeButton.vue'
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'

View File

@@ -0,0 +1,116 @@
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { reactive } from 'vue'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
import type { BaseDOMWidget } from '@/scripts/domWidget'
import type { DomWidgetState } from '@/stores/domWidgetStore'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import DomWidget from './DomWidget.vue'
const mockUpdatePosition = vi.fn()
const mockUpdateClipPath = vi.fn()
const mockCanvasElement = document.createElement('canvas')
const mockCanvasStore = {
canvas: {
graph: {
getNodeById: vi.fn(() => true)
},
ds: {
offset: [0, 0],
scale: 1
},
canvas: mockCanvasElement,
selected_nodes: {}
},
getCanvas: () => ({ canvas: mockCanvasElement }),
linearMode: false
}
vi.mock('@/composables/element/useAbsolutePosition', () => ({
useAbsolutePosition: () => ({
style: reactive<Record<string, string>>({}),
updatePosition: mockUpdatePosition
})
}))
vi.mock('@/composables/element/useDomClipping', () => ({
useDomClipping: () => ({
style: reactive<Record<string, string>>({}),
updateClipPath: mockUpdateClipPath
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => mockCanvasStore
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn(() => false)
})
}))
function createWidgetState(overrideDisabled: boolean): DomWidgetState {
const domWidgetStore = useDomWidgetStore()
const node = createMockLGraphNode({
id: 1,
constructor: {
nodeData: {}
}
})
const widget = {
id: 'dom-widget-id',
name: 'test_widget',
type: 'custom',
value: '',
options: {},
node,
computedDisabled: false
} as unknown as BaseDOMWidget<object | string>
domWidgetStore.registerWidget(widget)
domWidgetStore.setPositionOverride(widget.id, {
node: createMockLGraphNode({ id: 2 }),
widget: { computedDisabled: overrideDisabled } as DomWidgetState['widget']
})
const state = domWidgetStore.widgetStates.get(widget.id)
if (!state) throw new Error('Expected registered DomWidgetState')
state.zIndex = 2
state.size = [100, 40]
return reactive(state)
}
describe('DomWidget disabled style', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
afterEach(() => {
useDomWidgetStore().clear()
vi.clearAllMocks()
})
it('uses disabled style when promoted override widget is computedDisabled', async () => {
const widgetState = createWidgetState(true)
const wrapper = mount(DomWidget, {
props: {
widgetState
}
})
widgetState.zIndex = 3
await wrapper.vm.$nextTick()
const root = wrapper.get('.dom-widget').element as HTMLElement
expect(root.style.pointerEvents).toBe('none')
expect(root.style.opacity).toBe('0.5')
})
})

View File

@@ -110,13 +110,17 @@ watch(
updateDomClipping()
}
const override = widgetState.positionOverride
const isDisabled = override
? (override.widget.computedDisabled ?? widget.computedDisabled)
: widget.computedDisabled
style.value = {
...positionStyle.value,
...(enableDomClipping.value ? clippingStyle.value : {}),
zIndex: widgetState.zIndex,
pointerEvents:
widgetState.readonly || widget.computedDisabled ? 'none' : 'auto',
opacity: widget.computedDisabled ? 0.5 : 1
pointerEvents: widgetState.readonly || isDisabled ? 'none' : 'auto',
opacity: isDisabled ? 0.5 : 1
}
},
{ deep: true }

View File

@@ -1,6 +1,6 @@
<template>
<div
class="flex w-50 flex-col overflow-hidden rounded-2xl bg-(--base-background) border border-(--border-default)"
class="flex w-50 flex-col overflow-hidden rounded-2xl bg-base-background border border-border-default"
>
<div ref="previewContainerRef" class="overflow-hidden p-3">
<div ref="previewWrapperRef" class="origin-top-left scale-50">
@@ -100,7 +100,7 @@ import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphN
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
const SCALE_FACTOR = 0.5
const PREVIEW_CONTAINER_PADDING_PX = 24 // p-3 top + bottom (12px × 2)
const PREVIEW_CONTAINER_PADDING_PX = 24
const {
nodeDef,

View File

@@ -28,8 +28,9 @@
/>
<div
v-show="cursorVisible"
class="pointer-events-none absolute left-0 top-0 rounded-full border border-black/60 shadow-[0_0_0_1px_rgba(255,255,255,0.8)]"
:style="cursorStyle"
ref="cursorEl"
class="pointer-events-none absolute left-0 top-0 rounded-full border border-black/60 shadow-[0_0_0_1px_rgba(255,255,255,0.8)] will-change-transform"
:style="cursorSizeStyle"
/>
</div>
</div>
@@ -141,7 +142,7 @@
max="100"
step="1"
class="w-7 appearance-none border-0 bg-transparent text-right text-xs text-node-text-muted outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none [-moz-appearance:textfield]"
@click.prevent
@click.stop
@change="
(e) => {
const val = Math.min(
@@ -281,6 +282,7 @@ const { nodeId } = defineProps<{
const modelValue = defineModel<string>({ default: '' })
const canvasEl = useTemplateRef<HTMLCanvasElement>('canvasEl')
const cursorEl = useTemplateRef<HTMLElement>('cursorEl')
const controlsEl = useTemplateRef<HTMLDivElement>('controlsEl')
const { width: controlsWidth } = useElementSize(controlsEl)
const compact = computed(
@@ -296,8 +298,6 @@ const {
backgroundColor,
canvasWidth,
canvasHeight,
cursorX,
cursorY,
cursorVisible,
displayBrushSize,
inputImageUrl,
@@ -309,7 +309,7 @@ const {
handlePointerLeave,
handleInputImageLoad,
handleClear
} = usePainter(nodeId, { canvasEl, modelValue })
} = usePainter(nodeId, { canvasEl, cursorEl, modelValue })
const canvasContainerStyle = computed(() => ({
aspectRatio: `${canvasWidth.value} / ${canvasHeight.value}`,
@@ -318,16 +318,10 @@ const canvasContainerStyle = computed(() => ({
: backgroundColor.value
}))
const cursorStyle = computed(() => {
const size = displayBrushSize.value
const x = cursorX.value - size / 2
const y = cursorY.value - size / 2
return {
width: `${size}px`,
height: `${size}px`,
transform: `translate(${x}px, ${y}px)`
}
})
const cursorSizeStyle = computed(() => ({
width: `${displayBrushSize.value}px`,
height: `${displayBrushSize.value}px`
}))
const brushOpacityPercent = computed({
get: () => Math.round(brushOpacity.value * 100),

View File

@@ -0,0 +1,214 @@
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import PrimeVue from 'primevue/config'
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
const mockIsCloud = { value: false }
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockIsCloud.value
}
}))
const mockApplyChanges = vi.fn()
const mockIsRestarting = ref(false)
vi.mock('@/workbench/extensions/manager/composables/useApplyChanges', () => ({
useApplyChanges: () => ({
isRestarting: mockIsRestarting,
applyChanges: mockApplyChanges
})
}))
const mockIsPackInstalled = vi.fn(() => false)
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
useComfyManagerStore: () => ({
isPackInstalled: mockIsPackInstalled
})
}))
const mockShouldShowManagerButtons = { value: false }
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
useManagerState: () => ({
shouldShowManagerButtons: mockShouldShowManagerButtons
})
}))
vi.mock('./MissingPackGroupRow.vue', () => ({
default: {
name: 'MissingPackGroupRow',
template: '<div class="pack-row" />',
props: ['group', 'showInfoButton', 'showNodeIdBadge'],
emits: ['locate-node', 'open-manager-info']
}
}))
import MissingNodeCard from './MissingNodeCard.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
rightSidePanel: {
missingNodePacks: {
ossMessage: 'Missing node packs detected. Install them.',
cloudMessage: 'Unsupported node packs detected.',
applyChanges: 'Apply Changes'
}
}
}
},
missingWarn: false,
fallbackWarn: false
})
function makePackGroups(count = 2): MissingPackGroup[] {
return Array.from({ length: count }, (_, i) => ({
packId: `pack-${i}`,
nodeTypes: [
{ type: `MissingNode${i}`, nodeId: String(i), isReplaceable: false }
],
isResolving: false
}))
}
function mountCard(
props: Partial<{
showInfoButton: boolean
showNodeIdBadge: boolean
missingPackGroups: MissingPackGroup[]
}> = {}
) {
return mount(MissingNodeCard, {
props: {
showInfoButton: false,
showNodeIdBadge: false,
missingPackGroups: makePackGroups(),
...props
},
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), PrimeVue, i18n],
stubs: {
DotSpinner: { template: '<span role="status" aria-label="loading" />' }
}
}
})
}
describe('MissingNodeCard', () => {
beforeEach(() => {
mockApplyChanges.mockClear()
mockIsPackInstalled.mockReset()
mockIsPackInstalled.mockReturnValue(false)
mockIsCloud.value = false
mockShouldShowManagerButtons.value = false
mockIsRestarting.value = false
})
describe('Rendering & Props', () => {
it('renders cloud message when isCloud is true', () => {
mockIsCloud.value = true
const wrapper = mountCard()
expect(wrapper.text()).toContain('Unsupported node packs detected')
})
it('renders OSS message when isCloud is false', () => {
const wrapper = mountCard()
expect(wrapper.text()).toContain('Missing node packs detected')
})
it('renders correct number of MissingPackGroupRow components', () => {
const wrapper = mountCard({ missingPackGroups: makePackGroups(3) })
expect(
wrapper.findAllComponents({ name: 'MissingPackGroupRow' })
).toHaveLength(3)
})
it('renders zero rows when missingPackGroups is empty', () => {
const wrapper = mountCard({ missingPackGroups: [] })
expect(
wrapper.findAllComponents({ name: 'MissingPackGroupRow' })
).toHaveLength(0)
})
it('passes props correctly to MissingPackGroupRow children', () => {
const wrapper = mountCard({
showInfoButton: true,
showNodeIdBadge: true
})
const row = wrapper.findComponent({ name: 'MissingPackGroupRow' })
expect(row.props('showInfoButton')).toBe(true)
expect(row.props('showNodeIdBadge')).toBe(true)
})
})
describe('Apply Changes Section', () => {
it('hides Apply Changes when manager is not enabled', () => {
mockShouldShowManagerButtons.value = false
const wrapper = mountCard()
expect(wrapper.text()).not.toContain('Apply Changes')
})
it('hides Apply Changes when manager enabled but no packs pending', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(false)
const wrapper = mountCard()
expect(wrapper.text()).not.toContain('Apply Changes')
})
it('shows Apply Changes when at least one pack is pending restart', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(true)
const wrapper = mountCard()
expect(wrapper.text()).toContain('Apply Changes')
})
it('displays spinner during restart', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(true)
mockIsRestarting.value = true
const wrapper = mountCard()
expect(wrapper.find('[role="status"]').exists()).toBe(true)
})
it('disables button during restart', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(true)
mockIsRestarting.value = true
const wrapper = mountCard()
const btn = wrapper.find('button')
expect(btn.attributes('disabled')).toBeDefined()
})
it('calls applyChanges when Apply Changes button is clicked', async () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(true)
const wrapper = mountCard()
const btn = wrapper.find('button')
await btn.trigger('click')
expect(mockApplyChanges).toHaveBeenCalledOnce()
})
})
describe('Event Handling', () => {
it('emits locateNode when child emits locate-node', async () => {
const wrapper = mountCard()
const row = wrapper.findComponent({ name: 'MissingPackGroupRow' })
await row.vm.$emit('locate-node', '42')
expect(wrapper.emitted('locateNode')).toBeTruthy()
expect(wrapper.emitted('locateNode')?.[0]).toEqual(['42'])
})
it('emits openManagerInfo when child emits open-manager-info', async () => {
const wrapper = mountCard()
const row = wrapper.findComponent({ name: 'MissingPackGroupRow' })
await row.vm.$emit('open-manager-info', 'pack-0')
expect(wrapper.emitted('openManagerInfo')).toBeTruthy()
expect(wrapper.emitted('openManagerInfo')?.[0]).toEqual(['pack-0'])
})
})
})

View File

@@ -0,0 +1,368 @@
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import PrimeVue from 'primevue/config'
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
const mockInstallAllPacks = vi.fn()
const mockIsInstalling = ref(false)
const mockIsPackInstalled = vi.fn(() => false)
const mockShouldShowManagerButtons = { value: false }
const mockOpenManager = vi.fn()
const mockMissingNodePacks = ref<Array<{ id: string; name: string }>>([])
const mockIsLoading = ref(false)
vi.mock(
'@/workbench/extensions/manager/composables/nodePack/useMissingNodes',
() => ({
useMissingNodes: () => ({
missingNodePacks: mockMissingNodePacks,
isLoading: mockIsLoading
})
})
)
vi.mock(
'@/workbench/extensions/manager/composables/nodePack/usePackInstall',
() => ({
usePackInstall: () => ({
isInstalling: mockIsInstalling,
installAllPacks: mockInstallAllPacks
})
})
)
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
useComfyManagerStore: () => ({
isPackInstalled: mockIsPackInstalled
})
}))
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
useManagerState: () => ({
shouldShowManagerButtons: mockShouldShowManagerButtons,
openManager: mockOpenManager
})
}))
vi.mock('@/workbench/extensions/manager/types/comfyManagerTypes', () => ({
ManagerTab: { Missing: 'missing', All: 'all' }
}))
import MissingPackGroupRow from './MissingPackGroupRow.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
loading: 'Loading'
},
rightSidePanel: {
locateNode: 'Locate node on canvas',
missingNodePacks: {
unknownPack: 'Unknown pack',
installNodePack: 'Install node pack',
installing: 'Installing...',
installed: 'Installed',
searchInManager: 'Search in Node Manager',
viewInManager: 'View in Manager',
collapse: 'Collapse',
expand: 'Expand'
}
}
}
},
missingWarn: false,
fallbackWarn: false
})
function makeGroup(
overrides: Partial<MissingPackGroup> = {}
): MissingPackGroup {
return {
packId: 'my-pack',
nodeTypes: [
{ type: 'MissingA', nodeId: '10', isReplaceable: false },
{ type: 'MissingB', nodeId: '11', isReplaceable: false }
],
isResolving: false,
...overrides
}
}
function mountRow(
props: Partial<{
group: MissingPackGroup
showInfoButton: boolean
showNodeIdBadge: boolean
}> = {}
) {
return mount(MissingPackGroupRow, {
props: {
group: makeGroup(),
showInfoButton: false,
showNodeIdBadge: false,
...props
},
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), PrimeVue, i18n],
stubs: {
TransitionCollapse: { template: '<div><slot /></div>' },
DotSpinner: {
template: '<span role="status" aria-label="loading" />'
}
}
}
})
}
describe('MissingPackGroupRow', () => {
beforeEach(() => {
mockInstallAllPacks.mockClear()
mockOpenManager.mockClear()
mockIsPackInstalled.mockReset()
mockIsPackInstalled.mockReturnValue(false)
mockShouldShowManagerButtons.value = false
mockIsInstalling.value = false
mockMissingNodePacks.value = []
mockIsLoading.value = false
})
describe('Basic Rendering', () => {
it('renders pack name from packId', () => {
const wrapper = mountRow()
expect(wrapper.text()).toContain('my-pack')
})
it('renders "Unknown pack" when packId is null', () => {
const wrapper = mountRow({ group: makeGroup({ packId: null }) })
expect(wrapper.text()).toContain('Unknown pack')
})
it('renders loading text when isResolving is true', () => {
const wrapper = mountRow({ group: makeGroup({ isResolving: true }) })
expect(wrapper.text()).toContain('Loading')
})
it('renders node count', () => {
const wrapper = mountRow()
expect(wrapper.text()).toContain('(2)')
})
it('renders count of 5 for 5 nodeTypes', () => {
const wrapper = mountRow({
group: makeGroup({
nodeTypes: Array.from({ length: 5 }, (_, i) => ({
type: `Node${i}`,
nodeId: String(i),
isReplaceable: false
}))
})
})
expect(wrapper.text()).toContain('(5)')
})
})
describe('Expand / Collapse', () => {
it('starts collapsed', () => {
const wrapper = mountRow()
expect(wrapper.text()).not.toContain('MissingA')
})
it('expands when chevron is clicked', async () => {
const wrapper = mountRow()
await wrapper.get('button[aria-label="Expand"]').trigger('click')
expect(wrapper.text()).toContain('MissingA')
expect(wrapper.text()).toContain('MissingB')
})
it('collapses when chevron is clicked again', async () => {
const wrapper = mountRow()
await wrapper.get('button[aria-label="Expand"]').trigger('click')
expect(wrapper.text()).toContain('MissingA')
await wrapper.get('button[aria-label="Collapse"]').trigger('click')
expect(wrapper.text()).not.toContain('MissingA')
})
})
describe('Node Type List', () => {
async function expand(wrapper: ReturnType<typeof mountRow>) {
await wrapper.get('button[aria-label="Expand"]').trigger('click')
}
it('renders all nodeTypes when expanded', async () => {
const wrapper = mountRow({
group: makeGroup({
nodeTypes: [
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
{ type: 'NodeB', nodeId: '2', isReplaceable: false },
{ type: 'NodeC', nodeId: '3', isReplaceable: false }
]
})
})
await expand(wrapper)
expect(wrapper.text()).toContain('NodeA')
expect(wrapper.text()).toContain('NodeB')
expect(wrapper.text()).toContain('NodeC')
})
it('shows nodeId badge when showNodeIdBadge is true', async () => {
const wrapper = mountRow({ showNodeIdBadge: true })
await expand(wrapper)
expect(wrapper.text()).toContain('#10')
})
it('hides nodeId badge when showNodeIdBadge is false', async () => {
const wrapper = mountRow({ showNodeIdBadge: false })
await expand(wrapper)
expect(wrapper.text()).not.toContain('#10')
})
it('emits locateNode when Locate button is clicked', async () => {
const wrapper = mountRow({ showNodeIdBadge: true })
await expand(wrapper)
await wrapper
.get('button[aria-label="Locate node on canvas"]')
.trigger('click')
expect(wrapper.emitted('locateNode')).toBeTruthy()
expect(wrapper.emitted('locateNode')?.[0]).toEqual(['10'])
})
it('does not show Locate for nodeType without nodeId', async () => {
const wrapper = mountRow({
group: makeGroup({
nodeTypes: [{ type: 'NoId', isReplaceable: false } as never]
})
})
await expand(wrapper)
expect(
wrapper.find('button[aria-label="Locate node on canvas"]').exists()
).toBe(false)
})
it('handles mixed nodeTypes with and without nodeId', async () => {
const wrapper = mountRow({
showNodeIdBadge: true,
group: makeGroup({
nodeTypes: [
{ type: 'WithId', nodeId: '100', isReplaceable: false },
{ type: 'WithoutId', isReplaceable: false } as never
]
})
})
await expand(wrapper)
expect(wrapper.text()).toContain('WithId')
expect(wrapper.text()).toContain('WithoutId')
expect(
wrapper.findAll('button[aria-label="Locate node on canvas"]')
).toHaveLength(1)
})
})
describe('Manager Integration', () => {
it('hides install UI when shouldShowManagerButtons is false', () => {
mockShouldShowManagerButtons.value = false
const wrapper = mountRow()
expect(wrapper.text()).not.toContain('Install node pack')
})
it('hides install UI when packId is null', () => {
mockShouldShowManagerButtons.value = true
const wrapper = mountRow({ group: makeGroup({ packId: null }) })
expect(wrapper.text()).not.toContain('Install node pack')
})
it('shows "Search in Node Manager" when packId exists but pack not in registry', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(false)
mockMissingNodePacks.value = []
const wrapper = mountRow()
expect(wrapper.text()).toContain('Search in Node Manager')
})
it('shows "Installed" state when pack is installed', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(true)
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
const wrapper = mountRow()
expect(wrapper.text()).toContain('Installed')
})
it('shows spinner when installing', () => {
mockShouldShowManagerButtons.value = true
mockIsInstalling.value = true
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
const wrapper = mountRow()
expect(wrapper.find('[role="status"]').exists()).toBe(true)
})
it('shows install button when not installed and pack found', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(false)
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
const wrapper = mountRow()
expect(wrapper.text()).toContain('Install node pack')
})
it('calls installAllPacks when Install button is clicked', async () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(false)
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
const wrapper = mountRow()
await wrapper.get('button:not([aria-label])').trigger('click')
expect(mockInstallAllPacks).toHaveBeenCalledOnce()
})
it('shows loading spinner when registry is loading', () => {
mockShouldShowManagerButtons.value = true
mockIsLoading.value = true
const wrapper = mountRow()
expect(wrapper.find('[role="status"]').exists()).toBe(true)
})
})
describe('Info Button', () => {
it('shows Info button when showInfoButton true and packId not null', () => {
const wrapper = mountRow({ showInfoButton: true })
expect(
wrapper.find('button[aria-label="View in Manager"]').exists()
).toBe(true)
})
it('hides Info button when showInfoButton is false', () => {
const wrapper = mountRow({ showInfoButton: false })
expect(
wrapper.find('button[aria-label="View in Manager"]').exists()
).toBe(false)
})
it('hides Info button when packId is null', () => {
const wrapper = mountRow({
showInfoButton: true,
group: makeGroup({ packId: null })
})
expect(
wrapper.find('button[aria-label="View in Manager"]').exists()
).toBe(false)
})
it('emits openManagerInfo when Info button is clicked', async () => {
const wrapper = mountRow({ showInfoButton: true })
await wrapper.get('button[aria-label="View in Manager"]').trigger('click')
expect(wrapper.emitted('openManagerInfo')).toBeTruthy()
expect(wrapper.emitted('openManagerInfo')?.[0]).toEqual(['my-pack'])
})
})
describe('Edge Cases', () => {
it('handles empty nodeTypes array', () => {
const wrapper = mountRow({ group: makeGroup({ nodeTypes: [] }) })
expect(wrapper.text()).toContain('(0)')
})
})
})

View File

@@ -109,6 +109,7 @@
:swap-node-groups="swapNodeGroups"
:show-node-id-badge="showNodeIdBadge"
@locate-node="handleLocateMissingNode"
@replace="handleReplaceGroup"
/>
<!-- Execution Errors -->
@@ -179,14 +180,14 @@ import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import ErrorNodeCard from './ErrorNodeCard.vue'
import MissingNodeCard from './MissingNodeCard.vue'
import SwapNodesCard from './SwapNodesCard.vue'
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { useErrorGroups } from './useErrorGroups'
import type { SwapNodeGroup } from './useErrorGroups'
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
@@ -199,8 +200,7 @@ const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
const { missingNodePacks } = useMissingNodes()
const { isInstalling: isInstallingAll, installAllPacks: installAll } =
usePackInstall(() => missingNodePacks.value)
const { replaceNodesInPlace } = useNodeReplacement()
const executionErrorStore = useExecutionErrorStore()
const { replaceGroup, replaceAllGroups } = useNodeReplacement()
const searchQuery = ref('')
@@ -264,12 +264,12 @@ function handleOpenManagerInfo(packId: string) {
}
}
function handleReplaceGroup(group: SwapNodeGroup) {
replaceGroup(group)
}
function handleReplaceAll() {
const allNodeTypes = swapNodeGroups.value.flatMap((g) => g.nodeTypes)
const replaced = replaceNodesInPlace(allNodeTypes)
if (replaced.length > 0) {
executionErrorStore.removeMissingNodesByType(replaced)
}
replaceAllGroups(swapNodeGroups.value)
}
function handleEnterSubgraph(nodeId: string) {

View File

@@ -0,0 +1,187 @@
import { createPinia, setActivePinia } from 'pinia'
import { nextTick, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { MissingNodeType } from '@/types/comfy'
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: {
serialize: vi.fn(() => ({})),
getNodeById: vi.fn()
}
}
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
getNodeByExecutionId: vi.fn(),
getExecutionIdByNode: vi.fn(),
getRootParentNode: vi.fn(() => null),
forEachNode: vi.fn(),
mapAllNodes: vi.fn(() => [])
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
vi.mock('@/i18n', () => ({
st: vi.fn((_key: string, fallback: string) => fallback)
}))
vi.mock('@/stores/comfyRegistryStore', () => ({
useComfyRegistryStore: () => ({
inferPackFromNodeName: vi.fn()
})
}))
vi.mock('@/utils/nodeTitleUtil', () => ({
resolveNodeDisplayName: vi.fn(() => '')
}))
vi.mock('@/utils/litegraphUtil', () => ({
isLGraphNode: vi.fn(() => false)
}))
vi.mock('@/utils/executableGroupNodeDto', () => ({
isGroupNode: vi.fn(() => false)
}))
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useErrorGroups } from './useErrorGroups'
function makeMissingNodeType(
type: string,
opts: {
nodeId?: string
isReplaceable?: boolean
replacement?: { new_node_id: string }
} = {}
): MissingNodeType {
return {
type,
nodeId: opts.nodeId ?? '1',
isReplaceable: opts.isReplaceable ?? false,
replacement: opts.replacement
? {
old_node_id: type,
new_node_id: opts.replacement.new_node_id,
old_widget_ids: null,
input_mapping: null,
output_mapping: null
}
: undefined
}
}
describe('swapNodeGroups computed', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
function getSwapNodeGroups(nodeTypes: MissingNodeType[]) {
const store = useExecutionErrorStore()
store.surfaceMissingNodes(nodeTypes)
const searchQuery = ref('')
const t = (key: string) => key
const { swapNodeGroups } = useErrorGroups(searchQuery, t)
return swapNodeGroups
}
it('returns empty array when no missing nodes', () => {
const swap = getSwapNodeGroups([])
expect(swap.value).toEqual([])
})
it('returns empty array when no nodes are replaceable', () => {
const swap = getSwapNodeGroups([
makeMissingNodeType('NodeA', { isReplaceable: false }),
makeMissingNodeType('NodeB', { isReplaceable: false })
])
expect(swap.value).toEqual([])
})
it('groups replaceable nodes by type', async () => {
const swap = getSwapNodeGroups([
makeMissingNodeType('OldNode', {
nodeId: '1',
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
}),
makeMissingNodeType('OldNode', {
nodeId: '2',
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
})
])
await nextTick()
expect(swap.value).toHaveLength(1)
expect(swap.value[0].type).toBe('OldNode')
expect(swap.value[0].newNodeId).toBe('NewNode')
expect(swap.value[0].nodeTypes).toHaveLength(2)
})
it('creates separate groups for different types', async () => {
const swap = getSwapNodeGroups([
makeMissingNodeType('TypeA', {
nodeId: '1',
isReplaceable: true,
replacement: { new_node_id: 'NewA' }
}),
makeMissingNodeType('TypeB', {
nodeId: '2',
isReplaceable: true,
replacement: { new_node_id: 'NewB' }
})
])
await nextTick()
expect(swap.value).toHaveLength(2)
expect(swap.value.map((g) => g.type)).toEqual(['TypeA', 'TypeB'])
})
it('sorts groups alphabetically by type', async () => {
const swap = getSwapNodeGroups([
makeMissingNodeType('Zebra', {
nodeId: '1',
isReplaceable: true,
replacement: { new_node_id: 'NewZ' }
}),
makeMissingNodeType('Alpha', {
nodeId: '2',
isReplaceable: true,
replacement: { new_node_id: 'NewA' }
})
])
await nextTick()
expect(swap.value[0].type).toBe('Alpha')
expect(swap.value[1].type).toBe('Zebra')
})
it('excludes string nodeType entries', async () => {
const swap = getSwapNodeGroups([
'StringGroupNode' as unknown as MissingNodeType,
makeMissingNodeType('OldNode', {
nodeId: '1',
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
})
])
await nextTick()
expect(swap.value).toHaveLength(1)
expect(swap.value[0].type).toBe('OldNode')
})
it('sets newNodeId to undefined when replacement is missing', async () => {
const swap = getSwapNodeGroups([
makeMissingNodeType('OldNode', {
nodeId: '1',
isReplaceable: true
// no replacement
})
])
await nextTick()
expect(swap.value).toHaveLength(1)
expect(swap.value[0].newNodeId).toBeUndefined()
})
})

View File

@@ -0,0 +1,439 @@
import { createPinia, setActivePinia } from 'pinia'
import { nextTick, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { MissingNodeType } from '@/types/comfy'
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: {
serialize: vi.fn(() => ({})),
getNodeById: vi.fn()
}
}
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
getNodeByExecutionId: vi.fn(),
getExecutionIdByNode: vi.fn(),
getRootParentNode: vi.fn(() => null),
forEachNode: vi.fn(),
mapAllNodes: vi.fn(() => [])
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
vi.mock('@/i18n', () => ({
st: vi.fn((_key: string, fallback: string) => fallback)
}))
vi.mock('@/stores/comfyRegistryStore', () => ({
useComfyRegistryStore: () => ({
inferPackFromNodeName: vi.fn()
})
}))
vi.mock('@/utils/nodeTitleUtil', () => ({
resolveNodeDisplayName: vi.fn(() => '')
}))
vi.mock('@/utils/litegraphUtil', () => ({
isLGraphNode: vi.fn(() => false)
}))
vi.mock('@/utils/executableGroupNodeDto', () => ({
isGroupNode: vi.fn(() => false)
}))
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useErrorGroups } from './useErrorGroups'
function makeMissingNodeType(
type: string,
opts: {
nodeId?: string
isReplaceable?: boolean
cnrId?: string
replacement?: { new_node_id: string }
} = {}
): MissingNodeType {
return {
type,
nodeId: opts.nodeId ?? '1',
isReplaceable: opts.isReplaceable ?? false,
cnrId: opts.cnrId,
replacement: opts.replacement
? {
old_node_id: type,
new_node_id: opts.replacement.new_node_id,
old_widget_ids: null,
input_mapping: null,
output_mapping: null
}
: undefined
}
}
function createErrorGroups() {
const store = useExecutionErrorStore()
const searchQuery = ref('')
const t = (key: string) => key
const groups = useErrorGroups(searchQuery, t)
return { store, searchQuery, groups }
}
describe('useErrorGroups', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
describe('missingPackGroups', () => {
it('returns empty array when no missing nodes', () => {
const { groups } = createErrorGroups()
expect(groups.missingPackGroups.value).toEqual([])
})
it('groups non-replaceable nodes by cnrId', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'pack-1' }),
makeMissingNodeType('NodeB', { cnrId: 'pack-1', nodeId: '2' }),
makeMissingNodeType('NodeC', { cnrId: 'pack-2', nodeId: '3' })
])
await nextTick()
expect(groups.missingPackGroups.value).toHaveLength(2)
const pack1 = groups.missingPackGroups.value.find(
(g) => g.packId === 'pack-1'
)
expect(pack1?.nodeTypes).toHaveLength(2)
const pack2 = groups.missingPackGroups.value.find(
(g) => g.packId === 'pack-2'
)
expect(pack2?.nodeTypes).toHaveLength(1)
})
it('excludes replaceable nodes from missingPackGroups', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('OldNode', {
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
}),
makeMissingNodeType('MissingNode', {
nodeId: '2',
cnrId: 'some-pack'
})
])
await nextTick()
expect(groups.missingPackGroups.value).toHaveLength(1)
expect(groups.missingPackGroups.value[0].packId).toBe('some-pack')
})
it('groups nodes without cnrId under null packId', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('UnknownNode', { nodeId: '1' }),
makeMissingNodeType('AnotherUnknown', { nodeId: '2' })
])
await nextTick()
expect(groups.missingPackGroups.value).toHaveLength(1)
expect(groups.missingPackGroups.value[0].packId).toBeNull()
expect(groups.missingPackGroups.value[0].nodeTypes).toHaveLength(2)
})
it('sorts groups alphabetically with null packId last', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'zebra-pack' }),
makeMissingNodeType('NodeB', { nodeId: '2' }),
makeMissingNodeType('NodeC', { cnrId: 'alpha-pack', nodeId: '3' })
])
await nextTick()
const packIds = groups.missingPackGroups.value.map((g) => g.packId)
expect(packIds).toEqual(['alpha-pack', 'zebra-pack', null])
})
it('sorts nodeTypes within each group alphabetically by type then nodeId', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('NodeB', { cnrId: 'pack-1', nodeId: '2' }),
makeMissingNodeType('NodeA', { cnrId: 'pack-1', nodeId: '3' }),
makeMissingNodeType('NodeA', { cnrId: 'pack-1', nodeId: '1' })
])
await nextTick()
const group = groups.missingPackGroups.value[0]
const types = group.nodeTypes.map((n) =>
typeof n === 'string' ? n : `${n.type}:${n.nodeId}`
)
expect(types).toEqual(['NodeA:1', 'NodeA:3', 'NodeB:2'])
})
it('handles string nodeType entries', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
'StringGroupNode' as unknown as MissingNodeType
])
await nextTick()
expect(groups.missingPackGroups.value).toHaveLength(1)
expect(groups.missingPackGroups.value[0].packId).toBeNull()
})
})
describe('allErrorGroups', () => {
it('returns empty array when no errors', () => {
const { groups } = createErrorGroups()
expect(groups.allErrorGroups.value).toEqual([])
})
it('includes missing_node group when missing nodes exist', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'pack-1' })
])
await nextTick()
const missingGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'missing_node'
)
expect(missingGroup).toBeDefined()
})
it('includes swap_nodes group when replaceable nodes exist', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('OldNode', {
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
})
])
await nextTick()
const swapGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'swap_nodes'
)
expect(swapGroup).toBeDefined()
})
it('includes both swap_nodes and missing_node when both exist', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('OldNode', {
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
}),
makeMissingNodeType('MissingNode', {
nodeId: '2',
cnrId: 'some-pack'
})
])
await nextTick()
const types = groups.allErrorGroups.value.map((g) => g.type)
expect(types).toContain('swap_nodes')
expect(types).toContain('missing_node')
})
it('swap_nodes has lower priority than missing_node', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('OldNode', {
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
}),
makeMissingNodeType('MissingNode', {
nodeId: '2',
cnrId: 'some-pack'
})
])
await nextTick()
const swapIdx = groups.allErrorGroups.value.findIndex(
(g) => g.type === 'swap_nodes'
)
const missingIdx = groups.allErrorGroups.value.findIndex(
(g) => g.type === 'missing_node'
)
expect(swapIdx).toBeLessThan(missingIdx)
})
it('includes execution error groups from node errors', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [
{
type: 'value_not_valid',
message: 'Value not valid',
details: 'some detail'
}
]
}
}
await nextTick()
const execGroups = groups.allErrorGroups.value.filter(
(g) => g.type === 'execution'
)
expect(execGroups.length).toBeGreaterThan(0)
})
it('includes execution error from runtime errors', async () => {
const { store, groups } = createErrorGroups()
store.lastExecutionError = {
prompt_id: 'test-prompt',
timestamp: Date.now(),
node_id: 5,
node_type: 'KSampler',
executed: [],
exception_type: 'RuntimeError',
exception_message: 'CUDA out of memory',
traceback: ['line 1', 'line 2'],
current_inputs: {},
current_outputs: {}
}
await nextTick()
const execGroups = groups.allErrorGroups.value.filter(
(g) => g.type === 'execution'
)
expect(execGroups.length).toBeGreaterThan(0)
})
it('includes prompt error when present', async () => {
const { store, groups } = createErrorGroups()
store.lastPromptError = {
type: 'prompt_no_outputs',
message: 'No outputs',
details: ''
}
await nextTick()
const promptGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'execution' && g.title === 'No outputs'
)
expect(promptGroup).toBeDefined()
})
})
describe('filteredGroups', () => {
it('returns all groups when search query is empty', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'value_error', message: 'Bad value', details: '' }]
}
}
await nextTick()
expect(groups.filteredGroups.value.length).toBeGreaterThan(0)
})
it('filters groups based on search query', async () => {
const { store, groups, searchQuery } = createErrorGroups()
store.lastNodeErrors = {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [
{
type: 'value_error',
message: 'Value error in sampler',
details: ''
}
]
},
'2': {
class_type: 'CLIPLoader',
dependent_outputs: [],
errors: [
{
type: 'file_not_found',
message: 'File not found',
details: ''
}
]
}
}
await nextTick()
searchQuery.value = 'sampler'
await nextTick()
const executionGroups = groups.filteredGroups.value.filter(
(g) => g.type === 'execution'
)
for (const group of executionGroups) {
if (group.type !== 'execution') continue
const hasMatch = group.cards.some(
(c) =>
c.title.toLowerCase().includes('sampler') ||
c.errors.some((e) => e.message.toLowerCase().includes('sampler'))
)
expect(hasMatch).toBe(true)
}
})
})
describe('groupedErrorMessages', () => {
it('returns empty array when no errors', () => {
const { groups } = createErrorGroups()
expect(groups.groupedErrorMessages.value).toEqual([])
})
it('collects unique error messages from node errors', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [
{ type: 'err_a', message: 'Error A', details: '' },
{ type: 'err_b', message: 'Error B', details: '' }
]
},
'2': {
class_type: 'CLIPLoader',
dependent_outputs: [],
errors: [{ type: 'err_a', message: 'Error A', details: '' }]
}
}
await nextTick()
const messages = groups.groupedErrorMessages.value
expect(messages).toContain('Error A')
expect(messages).toContain('Error B')
// Deduplication: Error A appears twice but should only be listed once
expect(messages.filter((m) => m === 'Error A')).toHaveLength(1)
})
it('includes missing node group title as message', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'pack-1' })
])
await nextTick()
expect(groups.groupedErrorMessages.value.length).toBeGreaterThan(0)
})
})
describe('collapseState', () => {
it('returns a reactive object', () => {
const { groups } = createErrorGroups()
expect(groups.collapseState).toBeDefined()
expect(typeof groups.collapseState).toBe('object')
})
})
})

View File

@@ -11,6 +11,7 @@ const {
enableEmptyState,
tooltip,
size = 'default',
tooltipDelay = 1000,
class: className
} = defineProps<{
disabled?: boolean
@@ -18,6 +19,7 @@ const {
enableEmptyState?: boolean
tooltip?: string
size?: 'default' | 'lg'
tooltipDelay?: number
class?: string
}>()
@@ -27,7 +29,7 @@ const isExpanded = computed(() => !isCollapse.value && !disabled)
const tooltipConfig = computed(() => {
if (!tooltip) return undefined
return { value: tooltip, showDelay: 1000 }
return { value: tooltip, showDelay: tooltipDelay }
})
</script>

View File

@@ -50,7 +50,8 @@ describe('NodeSearchCategorySidebar', () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'EssentialNode',
essentials_category: 'basic'
essentials_category: 'basic',
python_module: 'comfy_essentials'
})
])
await nextTick()
@@ -58,9 +59,13 @@ describe('NodeSearchCategorySidebar', () => {
const wrapper = await createWrapper()
expect(wrapper.text()).toContain('Most relevant')
expect(wrapper.text()).toContain('Recents')
expect(wrapper.text()).toContain('Favorites')
expect(wrapper.text()).toContain('Essentials')
expect(wrapper.text()).toContain('Custom')
expect(wrapper.text()).toContain('Blueprints')
expect(wrapper.text()).toContain('Partner')
expect(wrapper.text()).toContain('Comfy')
expect(wrapper.text()).toContain('Extensions')
})
it('should mark the selected preset category as selected', async () => {

View File

@@ -53,7 +53,6 @@ import NodeSearchCategoryTreeNode, {
CATEGORY_UNSELECTED_CLASS
} from '@/components/searchbox/v2/NodeSearchCategoryTreeNode.vue'
import type { CategoryNode } from '@/components/searchbox/v2/NodeSearchCategoryTreeNode.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { nodeOrganizationService } from '@/services/nodeOrganizationService'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
@@ -65,11 +64,11 @@ const selectedCategory = defineModel<string>('selectedCategory', {
})
const { t } = useI18n()
const { flags } = useFeatureFlags()
const nodeDefStore = useNodeDefStore()
const topCategories = computed(() => [
{ id: 'most-relevant', label: t('g.mostRelevant') },
{ id: 'recents', label: t('g.recents') },
{ id: 'favorites', label: t('g.favorites') }
])
@@ -81,10 +80,18 @@ const hasEssentialNodes = computed(() =>
const sourceCategories = computed(() => {
const categories = []
if (flags.nodeLibraryEssentialsEnabled && hasEssentialNodes.value) {
if (hasEssentialNodes.value) {
categories.push({ id: 'essentials', label: t('g.essentials') })
}
categories.push({ id: 'custom', label: t('g.custom') })
categories.push(
{
id: 'blueprints',
label: t('sideToolbar.nodeLibraryTab.filterOptions.blueprints')
},
{ id: 'partner', label: t('g.partner') },
{ id: 'comfy', label: t('g.comfy') },
{ id: 'extensions', label: t('g.extensions') }
)
return categories
})

View File

@@ -132,7 +132,7 @@ describe('NodeSearchContent', () => {
expect(wrapper.text()).toContain('No results')
})
it('should show only non-Core nodes when Custom is selected', async () => {
it('should show only CustomNodes when Extensions is selected', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'CoreNode',
@@ -155,7 +155,7 @@ describe('NodeSearchContent', () => {
).toBe(NodeSourceType.CustomNodes)
const wrapper = await createWrapper()
await wrapper.find('[data-testid="category-custom"]').trigger('click')
await wrapper.find('[data-testid="category-extensions"]').trigger('click')
await nextTick()
const items = getNodeItems(wrapper)

View File

@@ -19,9 +19,6 @@
<!-- Filter header row -->
<div class="flex items-center">
<div class="shrink-0 px-3 py-2 text-sm text-muted-foreground">
{{ $t('g.filterBy') }}
</div>
<NodeSearchFilterBar
class="flex-1"
:active-chip-key="activeFilter?.key"
@@ -128,7 +125,6 @@ const searchQuery = ref('')
const selectedCategory = ref('most-relevant')
const selectedIndex = ref(0)
// Filter selection mode
const activeFilter = ref<FilterChip | null>(null)
const filterQuery = ref('')
@@ -171,7 +167,6 @@ function cancelFilter() {
nextTick(() => searchInputRef.value?.focus())
}
// Node search
const searchResults = computed(() => {
if (!searchQuery.value && filters.length === 0) {
return nodeFrequencyStore.topNodeDefs
@@ -212,11 +207,24 @@ const displayedResults = computed<ComfyNodeDefImpl[]>(() => {
(n) => n.nodeSource.type === NodeSourceType.Essentials
)
break
case 'custom':
case 'recents':
return searchResults.value
case 'blueprints':
results = allNodes.filter(
(n) =>
n.nodeSource.type !== NodeSourceType.Core &&
n.nodeSource.type !== NodeSourceType.Essentials
(n) => n.nodeSource.type === NodeSourceType.Blueprint
)
break
case 'partner':
results = allNodes.filter((n) => n.api_node)
break
case 'comfy':
results = allNodes.filter(
(n) => n.nodeSource.type === NodeSourceType.Core
)
break
case 'extensions':
results = allNodes.filter(
(n) => n.nodeSource.type === NodeSourceType.CustomNodes
)
break
default:
@@ -247,7 +255,6 @@ watch([selectedCategory, searchQuery, () => filters], () => {
selectedIndex.value = 0
})
// Keyboard navigation
function onKeyDown() {
if (activeFilter.value) {
filterPanelRef.value?.navigate(1)

View File

@@ -39,14 +39,17 @@ describe(NodeSearchFilterBar, () => {
return wrapper
}
it('should render Input, Output, and Source filter chips', async () => {
it('should render all filter chips', async () => {
const wrapper = await createWrapper()
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(3)
expect(buttons[0].text()).toBe('Input')
expect(buttons[1].text()).toBe('Output')
expect(buttons[2].text()).toBe('Source')
expect(buttons).toHaveLength(6)
expect(buttons[0].text()).toBe('Blueprints')
expect(buttons[1].text()).toBe('Partner Nodes')
expect(buttons[2].text()).toBe('Essentials')
expect(buttons[3].text()).toBe('Extensions')
expect(buttons[4].text()).toBe('Input')
expect(buttons[5].text()).toBe('Output')
})
it('should mark active chip as pressed when activeChipKey matches', async () => {

View File

@@ -52,6 +52,26 @@ const nodeDefStore = useNodeDefStore()
const chips = computed<FilterChip[]>(() => {
const searchService = nodeDefStore.nodeSearchService
return [
{
key: 'blueprints',
label: t('sideToolbar.nodeLibraryTab.filterOptions.blueprints'),
filter: searchService.nodeSourceFilter
},
{
key: 'partnerNodes',
label: t('sideToolbar.nodeLibraryTab.filterOptions.partnerNodes'),
filter: searchService.nodeSourceFilter
},
{
key: 'essentials',
label: t('g.essentials'),
filter: searchService.nodeSourceFilter
},
{
key: 'extensions',
label: t('g.extensions'),
filter: searchService.nodeSourceFilter
},
{
key: 'input',
label: t('g.input'),
@@ -61,11 +81,6 @@ const chips = computed<FilterChip[]>(() => {
key: 'output',
label: t('g.output'),
filter: searchService.outputTypeFilter
},
{
key: 'source',
label: t('g.source'),
filter: searchService.nodeSourceFilter
}
]
})

View File

@@ -123,13 +123,3 @@ const nodeFrequency = computed(() =>
const nodeBookmarkStore = useNodeBookmarkStore()
const isBookmarked = computed(() => nodeBookmarkStore.isBookmarked(nodeDef))
</script>
<style scoped>
:deep(.highlight) {
background-color: color-mix(in srgb, currentColor 20%, transparent);
font-weight: 700;
border-radius: 0.25rem;
padding: 0 0.125rem;
margin: -0.125rem 0.125rem;
}
</style>

View File

@@ -37,15 +37,27 @@ export const testI18n = createI18n({
addNode: 'Add a node...',
filterBy: 'Filter by:',
mostRelevant: 'Most relevant',
recents: 'Recents',
favorites: 'Favorites',
essentials: 'Essentials',
custom: 'Custom',
comfy: 'Comfy',
partner: 'Partner',
extensions: 'Extensions',
noResults: 'No results',
filterByType: 'Filter by {type}...',
input: 'Input',
output: 'Output',
source: 'Source',
search: 'Search'
},
sideToolbar: {
nodeLibraryTab: {
filterOptions: {
blueprints: 'Blueprints',
partnerNodes: 'Partner Nodes'
}
}
}
}
}

View File

@@ -46,10 +46,10 @@ vi.mock('./nodeLibrary/AllNodesPanel.vue', () => ({
}
}))
vi.mock('./nodeLibrary/CustomNodesPanel.vue', () => ({
vi.mock('./nodeLibrary/BlueprintsPanel.vue', () => ({
default: {
name: 'CustomNodesPanel',
template: '<div data-testid="custom-panel"><slot /></div>',
name: 'BlueprintsPanel',
template: '<div data-testid="blueprints-panel"><slot /></div>',
props: ['sections', 'expandedKeys']
}
}))
@@ -58,7 +58,7 @@ vi.mock('./nodeLibrary/EssentialNodesPanel.vue', () => ({
default: {
name: 'EssentialNodesPanel',
template: '<div data-testid="essential-panel"><slot /></div>',
props: ['root', 'expandedKeys']
props: ['root', 'expandedKeys', 'flatNodes']
}
}))
@@ -127,6 +127,8 @@ describe('NodeLibrarySidebarTabV2', () => {
expect(wrapper.find('[data-testid="essential-panel"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="all-panel"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="custom-panel"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="blueprints-panel"]').exists()).toBe(
false
)
})
})

View File

@@ -29,17 +29,79 @@
v-for="option in sortingOptions"
:key="option.id"
:value="option.id"
class="flex cursor-pointer items-center justify-end gap-2 rounded-md px-2 py-1.5 text-sm outline-none hover:bg-comfy-input"
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none hover:bg-comfy-input"
>
<span class="flex-1">{{ $t(option.label) }}</span>
<DropdownMenuItemIndicator class="w-4">
<i class="icon-[lucide--check] size-4" />
</DropdownMenuItemIndicator>
<span>{{ $t(option.label) }}</span>
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
<DropdownMenuRoot v-if="selectedTab === 'all'">
<DropdownMenuTrigger as-child>
<button
:aria-label="$t('sideToolbar.nodeLibraryTab.filter')"
class="flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-comfy-input hover:bg-comfy-input-hover border-none"
>
<i class="icon-[lucide--list-filter] size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
class="z-[9999] min-w-32 rounded-lg border border-border-default bg-comfy-menu-bg p-1 shadow-lg"
align="end"
:side-offset="4"
>
<DropdownMenuCheckboxItem
v-model="filterOptions.blueprints"
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none hover:bg-comfy-input"
>
<span class="flex-1">{{
$t('sideToolbar.nodeLibraryTab.filterOptions.blueprints')
}}</span>
<DropdownMenuItemIndicator class="w-4">
<i class="icon-[lucide--check] size-4" />
</DropdownMenuItemIndicator>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
v-model="filterOptions.partnerNodes"
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none hover:bg-comfy-input"
>
<span class="flex-1">{{
$t('sideToolbar.nodeLibraryTab.filterOptions.partnerNodes')
}}</span>
<DropdownMenuItemIndicator class="w-4">
<i class="icon-[lucide--check] size-4" />
</DropdownMenuItemIndicator>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
v-model="filterOptions.comfyNodes"
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none hover:bg-comfy-input"
>
<span class="flex-1">{{
$t('sideToolbar.nodeLibraryTab.filterOptions.comfyNodes')
}}</span>
<DropdownMenuItemIndicator class="w-4">
<i class="icon-[lucide--check] size-4" />
</DropdownMenuItemIndicator>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
v-model="filterOptions.extensions"
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none hover:bg-comfy-input"
>
<span class="flex-1">{{
$t('sideToolbar.nodeLibraryTab.filterOptions.extensions')
}}</span>
<DropdownMenuItemIndicator class="w-4">
<i class="icon-[lucide--check] size-4" />
</DropdownMenuItemIndicator>
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</div>
<Separator decorative class="border border-dashed border-comfy-input" />
<!-- Tab list in header (fixed) -->
@@ -52,7 +114,7 @@
:value="tab.value"
:class="
cn(
'flex-1 text-center select-none border-none outline-none px-3 py-2 rounded-lg cursor-pointer',
'select-none border-none outline-none px-3 py-2 rounded-lg cursor-pointer',
'text-sm text-foreground transition-colors',
selectedTab === tab.value
? 'bg-comfy-input font-bold'
@@ -75,6 +137,7 @@
"
v-model:expanded-keys="expandedKeys"
:root="renderedEssentialRoot"
:flat-nodes="essentialFlatNodes"
@node-click="handleNodeClick"
/>
<AllNodesPanel
@@ -82,12 +145,13 @@
v-model:expanded-keys="expandedKeys"
:sections="renderedSections"
:fill-node-info="fillNodeInfo"
:sort-order="sortOrder"
@node-click="handleNodeClick"
/>
<CustomNodesPanel
v-if="selectedTab === 'custom'"
<BlueprintsPanel
v-if="selectedTab === 'blueprints'"
v-model:expanded-keys="expandedKeys"
:sections="renderedCustomSections"
:sections="renderedBlueprintsSections"
@node-click="handleNodeClick"
/>
</TabsRoot>
@@ -99,6 +163,7 @@
import { cn } from '@/utils/tailwindUtil'
import { useLocalStorage } from '@vueuse/core'
import {
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItemIndicator,
DropdownMenuPortal,
@@ -125,17 +190,23 @@ import {
nodeOrganizationService
} from '@/services/nodeOrganizationService'
import { getProviderIcon } from '@/utils/categoryUtil'
import { sortedTree } from '@/utils/treeUtil'
import { flattenTree, sortedTree, unwrapTreeRoot } from '@/utils/treeUtil'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import type { SortingStrategyId, TabId } from '@/types/nodeOrganizationTypes'
import { buildNodeDefTree, useNodeDefStore } from '@/stores/nodeDefStore'
import type {
NodeCategoryId,
NodeSection,
SortingStrategyId,
TabId
} from '@/types/nodeOrganizationTypes'
import type {
NodeLibrarySection,
RenderedTreeExplorerNode,
TreeNode
} from '@/types/treeExplorerTypes'
import AllNodesPanel from './nodeLibrary/AllNodesPanel.vue'
import CustomNodesPanel from './nodeLibrary/CustomNodesPanel.vue'
import BlueprintsPanel from './nodeLibrary/BlueprintsPanel.vue'
import EssentialNodesPanel from './nodeLibrary/EssentialNodesPanel.vue'
import NodeDragPreview from './nodeLibrary/NodeDragPreview.vue'
import SidebarTabTemplate from './SidebarTabTemplate.vue'
@@ -161,7 +232,7 @@ const sortOrderByTab = useLocalStorage<Record<TabId, SortingStrategyId>>(
{
essentials: DEFAULT_SORTING_ID,
all: DEFAULT_SORTING_ID,
custom: 'alphabetical'
blueprints: 'alphabetical'
}
)
const sortOrder = usePerTabState(selectedTab, sortOrderByTab)
@@ -173,14 +244,21 @@ const sortingOptions = computed(() =>
}))
)
const filterOptions = ref<Record<NodeCategoryId, boolean>>({
blueprints: true,
partnerNodes: true,
comfyNodes: true,
extensions: true
})
const { t } = useI18n()
const searchBoxRef = ref()
const searchBoxRef = ref<InstanceType<typeof SearchBox> | null>(null)
const searchQuery = ref('')
const expandedKeysByTab = ref<Record<TabId, string[]>>({
essentials: [],
all: [],
custom: []
blueprints: []
})
const expandedKeys = usePerTabState(selectedTab, expandedKeysByTab)
@@ -213,8 +291,8 @@ const sections = computed(() => {
function getFolderIcon(node: TreeNode): string {
const firstLeaf = findFirstLeaf(node)
if (
firstLeaf?.key?.startsWith('root/api node') &&
firstLeaf.key.replace(`${node.key}/`, '') === firstLeaf.label
firstLeaf?.data?.api_node &&
firstLeaf.key?.replace(`${node.key}/`, '') === firstLeaf.label
) {
return getProviderIcon(node.label ?? '')
}
@@ -264,12 +342,33 @@ function applySorting(tree: TreeNode): TreeNode {
return tree
}
const renderedSections = computed(() => {
return sections.value.map((section) => ({
function renderSections(
nodeSections: NodeSection[],
filter?: (section: NodeSection) => boolean
): NodeLibrarySection<ComfyNodeDefImpl>[] {
const filtered = filter ? nodeSections.filter(filter) : nodeSections
if (sortOrder.value === 'alphabetical') {
const allNodes = filtered.flatMap((section) =>
flattenTree<ComfyNodeDefImpl>(section.tree)
)
const mergedTree = unwrapTreeRoot(buildNodeDefTree(allNodes))
return [{ root: fillNodeInfo(applySorting(mergedTree)) }]
}
return filtered.map((section) => ({
category: section.category,
title: section.title,
root: fillNodeInfo(applySorting(section.tree))
}))
})
}
const renderedSections = computed(() =>
renderSections(
sections.value,
(section) => !section.category || filterOptions.value[section.category]
)
)
const essentialSections = computed(() => {
if (selectedTab.value !== 'essentials') return []
@@ -286,18 +385,32 @@ const renderedEssentialRoot = computed(() => {
: fillNodeInfo({ key: 'root', label: '', children: [] })
})
const customSections = computed(() => {
if (selectedTab.value !== 'custom') return []
return nodeOrganizationService.organizeNodesByTab(activeNodes.value, 'custom')
function flattenRenderedLeaves(
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>
): RenderedTreeExplorerNode<ComfyNodeDefImpl>[] {
if (node.type === 'node') return [node]
return node.children?.flatMap(flattenRenderedLeaves) ?? []
}
const essentialFlatNodes = computed(() => {
if (sortOrder.value !== 'alphabetical') return []
return flattenRenderedLeaves(renderedEssentialRoot.value).sort((a, b) =>
(a.label ?? '').localeCompare(b.label ?? '')
)
})
const renderedCustomSections = computed(() => {
return customSections.value.map((section) => ({
title: section.title,
root: fillNodeInfo(applySorting(section.tree))
}))
const blueprintsSections = computed(() => {
if (selectedTab.value !== 'blueprints') return []
return nodeOrganizationService.organizeNodesByTab(
activeNodes.value,
'blueprints'
)
})
const renderedBlueprintsSections = computed(() =>
renderSections(blueprintsSections.value)
)
function collectFolderKeys(node: TreeNode): string[] {
if (node.leaf) return []
const keys = [node.key]
@@ -334,8 +447,8 @@ async function handleSearch() {
for (const section of essentialSections.value) {
allKeys.push(...collectFolderKeys(section.tree))
}
} else if (selectedTab.value === 'custom') {
for (const section of customSections.value) {
} else if (selectedTab.value === 'blueprints') {
for (const section of blueprintsSections.value) {
allKeys.push(...collectFolderKeys(section.tree))
}
} else {
@@ -347,19 +460,18 @@ async function handleSearch() {
}
const tabs = computed(() => {
const baseTabs: Array<{ value: TabId; label: string }> = [
const allTabs: Array<{ value: TabId; label: string }> = [
{ value: 'all', label: t('sideToolbar.nodeLibraryTab.allNodes') },
{ value: 'custom', label: t('sideToolbar.nodeLibraryTab.custom') }
{
value: 'essentials' as TabId,
label: t('sideToolbar.nodeLibraryTab.essentials')
},
{
value: 'blueprints',
label: t('sideToolbar.nodeLibraryTab.blueprints')
}
]
return flags.nodeLibraryEssentialsEnabled
? [
{
value: 'essentials' as TabId,
label: t('sideToolbar.nodeLibraryTab.essentials')
},
...baseTabs
]
: baseTabs
return flags.nodeLibraryEssentialsEnabled ? allTabs : [allTabs[0], allTabs[2]]
})
onMounted(() => {

View File

@@ -1,6 +1,5 @@
<template>
<div
ref="containerRef"
:class="
cn(
'comfy-vue-side-bar-container group/sidebar-tab flex h-full flex-col w-full',
@@ -37,17 +36,9 @@
</div>
</template>
<script lang="ts">
import type { InjectionKey, Ref } from 'vue'
export const SidebarContainerKey: InjectionKey<Ref<HTMLElement | null>> =
Symbol('SidebarContainer')
</script>
<script setup lang="ts">
import ScrollPanel from 'primevue/scrollpanel'
import Toolbar from 'primevue/toolbar'
import { provide, ref } from 'vue'
import { cn } from '@/utils/tailwindUtil'
@@ -58,7 +49,4 @@ const props = defineProps<{
const sidebarPt = {
start: 'min-w-0 flex-1 overflow-hidden'
}
const containerRef = ref<HTMLElement | null>(null)
provide(SidebarContainerKey, containerRef)
</script>

View File

@@ -1,28 +1,30 @@
<template>
<TabsContent value="all" class="flex-1 overflow-y-auto h-full">
<!-- Favorites section -->
<template v-if="hasFavorites">
<h3
class="px-4 py-2 text-xs font-medium uppercase tracking-wide text-muted-foreground mb-0"
>
{{ $t('sideToolbar.nodeLibraryTab.sections.favorites') }}
</h3>
<TreeExplorerV2
v-model:expanded-keys="expandedKeys"
:root="favoritesRoot"
show-context-menu
@node-click="(node) => emit('nodeClick', node)"
@add-to-favorites="handleAddToFavorites"
/>
</template>
<h3
class="px-4 py-2 text-xs font-medium uppercase tracking-wide text-muted-foreground mb-0"
>
{{ $t('sideToolbar.nodeLibraryTab.sections.bookmarked') }}
</h3>
<TreeExplorerV2
v-if="hasFavorites"
v-model:expanded-keys="expandedKeys"
:root="favoritesRoot"
show-context-menu
@node-click="(node) => emit('nodeClick', node)"
@add-to-favorites="handleAddToFavorites"
/>
<div v-else class="px-6 py-2 text-xs text-muted-background">
{{ $t('sideToolbar.nodeLibraryTab.noBookmarkedNodes') }}
</div>
<!-- Node sections -->
<div v-for="(section, index) in sections" :key="section.title ?? index">
<div v-for="(section, index) in sections" :key="section.category ?? index">
<h3
v-if="section.title"
class="px-4 py-2 text-xs font-medium tracking-wide text-muted-foreground mb-0"
v-if="section.category && sortOrder !== 'alphabetical'"
class="px-4 py-2 text-xs font-medium uppercase tracking-wide text-muted-foreground mb-0"
>
{{ section.title }}
{{ $t(NODE_CATEGORY_LABELS[section.category]) }}
</h3>
<TreeExplorerV2
v-model:expanded-keys="expandedKeys"
@@ -42,15 +44,17 @@ import { computed } from 'vue'
import TreeExplorerV2 from '@/components/common/TreeExplorerV2.vue'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { NODE_CATEGORY_LABELS } from '@/types/nodeOrganizationTypes'
import type {
NodeLibrarySection,
RenderedTreeExplorerNode,
TreeNode
} from '@/types/treeExplorerTypes'
const { fillNodeInfo } = defineProps<{
sections: NodeLibrarySection[]
const { fillNodeInfo, sortOrder = 'original' } = defineProps<{
sections: NodeLibrarySection<ComfyNodeDefImpl>[]
fillNodeInfo: (node: TreeNode) => RenderedTreeExplorerNode<ComfyNodeDefImpl>
sortOrder?: string
}>()
const expandedKeys = defineModel<string[]>('expandedKeys', { required: true })

View File

@@ -0,0 +1,39 @@
<template>
<TabsContent value="blueprints" class="flex-1 overflow-y-auto h-full">
<div v-for="(section, index) in sections" :key="section.title ?? index">
<h3
v-if="section.title"
class="px-4 py-2 text-xs font-medium uppercase tracking-wide text-muted-foreground mb-0"
>
{{ $t(section.title) }}
</h3>
<TreeExplorerV2
v-model:expanded-keys="expandedKeys"
:root="section.root"
:show-context-menu="false"
@node-click="(node) => emit('nodeClick', node)"
/>
</div>
</TabsContent>
</template>
<script setup lang="ts">
import { TabsContent } from 'reka-ui'
import TreeExplorerV2 from '@/components/common/TreeExplorerV2.vue'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type {
NodeLibrarySection,
RenderedTreeExplorerNode
} from '@/types/treeExplorerTypes'
defineProps<{
sections: NodeLibrarySection<ComfyNodeDefImpl>[]
}>()
const expandedKeys = defineModel<string[]>('expandedKeys', { required: true })
const emit = defineEmits<{
nodeClick: [node: RenderedTreeExplorerNode<ComfyNodeDefImpl>]
}>()
</script>

View File

@@ -8,7 +8,7 @@
<!-- Section header -->
<h3
v-if="section.title"
class="px-4 py-2 text-xs font-medium tracking-wide text-muted-foreground mb-0"
class="px-4 py-2 text-xs font-medium uppercase tracking-wide text-muted-foreground mb-0"
>
{{ section.title }}
</h3>
@@ -46,7 +46,7 @@ import type {
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
defineProps<{
sections: NodeLibrarySection[]
sections: NodeLibrarySection<ComfyNodeDefImpl>[]
}>()
const expandedKeys = defineModel<string[]>('expandedKeys', { required: true })

View File

@@ -35,11 +35,11 @@
<script setup lang="ts">
import { kebabCase } from 'es-toolkit/string'
import type { Ref } from 'vue'
import { computed, inject } from 'vue'
import TextTickerMultiLine from '@/components/common/TextTickerMultiLine.vue'
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
import { SidebarContainerKey } from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import { useNodePreviewAndDrag } from '@/composables/node/useNodePreviewAndDrag'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
@@ -49,11 +49,15 @@ const { node } = defineProps<{
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>
}>()
const panelRef = inject<Ref<HTMLElement | null>>(
'essentialsPanelRef',
undefined!
)
const emit = defineEmits<{
click: [node: RenderedTreeExplorerNode<ComfyNodeDefImpl>]
}>()
const panelRef = inject(SidebarContainerKey, undefined)
const nodeDef = computed(() => node.data)
const {
@@ -64,7 +68,7 @@ const {
handleMouseLeave,
handleDragStart,
handleDragEnd
} = useNodePreviewAndDrag(nodeDef, { panelRef })
} = useNodePreviewAndDrag(nodeDef, panelRef)
const nodeIcon = computed(() => {
const nodeName = node.data?.name

View File

@@ -82,14 +82,15 @@ describe('EssentialNodesPanel', () => {
function mountComponent(
root = createMockRoot(),
expandedKeys: string[] = []
expandedKeys: string[] = [],
flatNodes: RenderedTreeExplorerNode<ComfyNodeDefImpl>[] = []
) {
const WrapperComponent = {
template: `<EssentialNodesPanel :root="root" v-model:expandedKeys="keys" />`,
template: `<EssentialNodesPanel :root="root" :flat-nodes="flatNodes" v-model:expandedKeys="keys" />`,
components: { EssentialNodesPanel },
setup() {
const keys = ref(expandedKeys)
return { root, keys }
return { root, flatNodes, keys }
}
}
return mount(WrapperComponent, {
@@ -204,4 +205,20 @@ describe('EssentialNodesPanel', () => {
expect(cards.length).toBeGreaterThanOrEqual(2)
})
})
describe('flat nodes mode', () => {
it('should render flat grid without collapsible folders when flatNodes is provided', () => {
const flatNodes = [
createMockNode('LoadAudio'),
createMockNode('LoadImage'),
createMockNode('SaveImage')
]
const wrapper = mountComponent(createMockRoot(), [], flatNodes)
expect(wrapper.findAll('.collapsible-root')).toHaveLength(0)
const cards = wrapper.findAllComponents({ name: 'EssentialNodeCard' })
expect(cards).toHaveLength(3)
})
})
})

View File

@@ -1,41 +1,61 @@
<template>
<TabsContent value="essentials" class="flex-1 overflow-y-auto px-3 h-full">
<TabsContent
ref="panelEl"
value="essentials"
class="flex-1 overflow-y-auto px-3 h-full"
>
<div class="flex flex-col gap-2 pb-6">
<CollapsibleRoot
v-for="folder in folders"
:key="folder.key"
class="rounded-lg"
:open="expandedKeys.includes(folder.key)"
@update:open="toggleFolder(folder.key, $event)"
<!-- Flat sorted grid when alphabetical -->
<div
v-if="flatNodes.length > 0"
class="grid grid-cols-[repeat(auto-fill,minmax(5rem,1fr))] gap-3 pt-3"
>
<CollapsibleTrigger
class="group flex w-full cursor-pointer items-center justify-between border-0 bg-transparent py-3 px-1 text-xs font-medium tracking-wide text-muted-foreground h-8 box-content"
<EssentialNodeCard
v-for="node in flatNodes"
:key="node.key"
:node="node"
@click="emit('nodeClick', $event)"
/>
</div>
<!-- Grouped collapsible folders when original -->
<template v-else>
<CollapsibleRoot
v-for="folder in folders"
:key="folder.key"
class="rounded-lg"
:open="expandedKeys.includes(folder.key)"
@update:open="toggleFolder(folder.key, $event)"
>
<span class="uppercase">{{ folder.label }}</span>
<i
:class="
cn(
'icon-[lucide--chevron-up] size-4 transition-transform duration-200',
!expandedKeys.includes(folder.key) && '-rotate-180'
)
"
/>
</CollapsibleTrigger>
<CollapsibleContent
class="overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down"
>
<div
class="grid grid-cols-[repeat(auto-fill,minmax(5rem,1fr))] gap-3"
<CollapsibleTrigger
class="group flex w-full cursor-pointer items-center justify-between border-0 bg-transparent py-3 px-1 text-xs font-medium tracking-wide text-muted-foreground h-8 box-content"
>
<EssentialNodeCard
v-for="node in folder.children"
:key="node.key"
:node="node"
@click="emit('nodeClick', $event)"
<span class="uppercase">{{ folder.label }}</span>
<i
:class="
cn(
'icon-[lucide--chevron-up] size-4 transition-transform duration-200',
!expandedKeys.includes(folder.key) && '-rotate-180'
)
"
/>
</div>
</CollapsibleContent>
</CollapsibleRoot>
</CollapsibleTrigger>
<CollapsibleContent
class="overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down"
>
<div
class="grid grid-cols-[repeat(auto-fill,minmax(5rem,1fr))] gap-3"
>
<EssentialNodeCard
v-for="node in folder.children"
:key="node.key"
:node="node"
@click="emit('nodeClick', $event)"
/>
</div>
</CollapsibleContent>
</CollapsibleRoot>
</template>
</div>
</TabsContent>
</template>
@@ -47,16 +67,22 @@ import {
CollapsibleTrigger,
TabsContent
} from 'reka-ui'
import { computed, ref, watch } from 'vue'
import type { ComponentPublicInstance } from 'vue'
import { computed, provide, ref, watch } from 'vue'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
import { cn } from '@/utils/tailwindUtil'
const panelEl = ref<ComponentPublicInstance | null>(null)
const panelRef = computed(() => panelEl.value?.$el as HTMLElement | null)
provide('essentialsPanelRef', panelRef)
import EssentialNodeCard from './EssentialNodeCard.vue'
const props = defineProps<{
const { root, flatNodes = [] } = defineProps<{
root: RenderedTreeExplorerNode<ComfyNodeDefImpl>
flatNodes?: RenderedTreeExplorerNode<ComfyNodeDefImpl>[]
}>()
const expandedKeys = defineModel<string[]>('expandedKeys', { required: true })
@@ -74,7 +100,7 @@ function flattenLeaves(
const folders = computed(() => {
const topFolders =
(props.root.children?.filter(
(root.children?.filter(
(child) => child.type === 'folder'
) as RenderedTreeExplorerNode<ComfyNodeDefImpl>[]) ?? []

View File

@@ -46,6 +46,16 @@
class="icon-[lucide--circle-help] cursor-help text-base text-muted-foreground mr-auto"
/>
<Button
v-if="isFreeTier"
variant="gradient"
size="sm"
data-testid="upgrade-to-add-credits-button"
@click="handleUpgradeToAddCredits"
>
{{ $t('subscription.upgradeToAddCredits') }}
</Button>
<Button
v-else
variant="secondary"
size="sm"
class="text-base-foreground"
@@ -61,7 +71,7 @@
:fluid="false"
:label="$t('subscription.subscribeToComfyCloud')"
size="sm"
variant="gradient"
button-variant="gradient"
@subscribed="handleSubscribed"
/>
</div>
@@ -170,6 +180,7 @@ const settingsDialog = useSettingsDialog()
const dialogService = useDialogService()
const {
isActiveSubscription,
isFreeTier,
subscriptionTierName,
subscriptionTier,
fetchStatus
@@ -237,6 +248,11 @@ const handleOpenPartnerNodesInfo = () => {
emit('close')
}
const handleUpgradeToAddCredits = () => {
subscriptionDialog.showPricingTable()
emit('close')
}
const handleLogout = async () => {
await handleSignOut()
emit('close')

View File

@@ -0,0 +1,25 @@
<template>
<Button
v-if="isFreeTier"
class="mr-2 shrink-0 whitespace-nowrap"
variant="gradient"
size="sm"
data-testid="topbar-subscribe-button"
@click="handleClick"
>
{{ $t('subscription.subscribeForMore') }}
</Button>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
const { isFreeTier } = useBillingContext()
const subscriptionDialog = useSubscriptionDialog()
function handleClick() {
subscriptionDialog.showPricingTable()
}
</script>

View File

@@ -198,11 +198,13 @@ const contextMenuItems = computed<WorkflowMenuItem[]>(() => [
...baseMenuItems.value,
{ separator: true },
{
id: 'close-tab',
label: t('tabMenu.closeTab'),
icon: 'pi pi-times',
command: () => onCloseWorkflow(props.workflowOption)
},
{
id: 'close-tabs-to-left',
label: t('tabMenu.closeTabsToLeft'),
overlayIcon: {
mainIcon: 'pi pi-times',
@@ -215,6 +217,7 @@ const contextMenuItems = computed<WorkflowMenuItem[]>(() => [
disabled: props.isFirst
},
{
id: 'close-tabs-to-right',
label: t('tabMenu.closeTabsToRight'),
overlayIcon: {
mainIcon: 'pi pi-times',
@@ -227,6 +230,7 @@ const contextMenuItems = computed<WorkflowMenuItem[]>(() => [
disabled: props.isLast
},
{
id: 'close-other-tabs',
label: t('tabMenu.closeOtherTabs'),
overlayIcon: {
mainIcon: 'pi pi-times',

View File

@@ -5,8 +5,9 @@ import { useTemplateRef } from 'vue'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
defineProps<{
const { active = true } = defineProps<{
dataTfWidget: string
active?: boolean
}>()
const feedbackRef = useTemplateRef('feedbackRef')
@@ -40,6 +41,6 @@ whenever(feedbackRef, () => {
<i class="icon-[lucide--circle-help] size-4" />
</Button>
</template>
<div ref="feedbackRef" data-tf-auto-resize :data-tf-widget />
<div v-if="active" ref="feedbackRef" data-tf-auto-resize :data-tf-widget />
</Popover>
</template>

View File

@@ -19,7 +19,9 @@ export const buttonVariants = cva({
'text-muted-foreground bg-transparent hover:bg-secondary-background-hover',
'destructive-textonly':
'text-destructive-background bg-transparent hover:bg-destructive-background/10',
'overlay-white': 'bg-white text-gray-600 hover:bg-white/90'
'overlay-white': 'bg-white text-gray-600 hover:bg-white/90',
gradient:
'bg-subscription-gradient text-white border-transparent hover:opacity-90'
},
size: {
sm: 'h-6 rounded-sm px-2 py-1 text-xs',
@@ -47,7 +49,8 @@ const variants = [
'textonly',
'muted-textonly',
'destructive-textonly',
'overlay-white'
'overlay-white',
'gradient'
] as const satisfies Array<ButtonVariants['variant']>
const sizes = ['sm', 'md', 'lg', 'icon', 'icon-sm'] as const satisfies Array<
ButtonVariants['size']

View File

@@ -272,3 +272,47 @@ describe('Subgraph Promoted Pseudo Widgets', () => {
expect(promotedWidget?.options?.canvasOnly).toBe(true)
})
})
describe('Nested promoted widget mapping', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('maps store identity to deepest concrete widget for two-layer promotions', () => {
const subgraphA = createTestSubgraph({
inputs: [{ name: 'a_input', type: '*' }]
})
const innerNode = new LGraphNode('InnerComboNode')
const innerInput = innerNode.addInput('picker_input', '*')
innerNode.addWidget('combo', 'picker', 'a', () => undefined, {
values: ['a', 'b']
})
innerInput.widget = { name: 'picker' }
subgraphA.add(innerNode)
subgraphA.inputNode.slots[0].connect(innerInput, innerNode)
const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 11 })
const subgraphB = createTestSubgraph({
inputs: [{ name: 'b_input', type: '*' }]
})
subgraphB.add(subgraphNodeA)
subgraphNodeA._internalConfigureAfterSlots()
subgraphB.inputNode.slots[0].connect(subgraphNodeA.inputs[0], subgraphNodeA)
const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 22 })
const graph = subgraphNodeB.graph as LGraph
graph.add(subgraphNodeB)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(subgraphNodeB.id))
const mappedWidget = nodeData?.widgets?.[0]
expect(mappedWidget).toBeDefined()
expect(mappedWidget?.type).toBe('combo')
expect(mappedWidget?.storeName).toBe('picker')
expect(mappedWidget?.storeNodeId).toBe(
`${subgraphNodeB.subgraph.id}:${innerNode.id}`
)
})
})

View File

@@ -7,7 +7,9 @@ import { reactive, shallowReactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import type {
INodeInputSlot,
INodeOutputSlot
@@ -46,7 +48,9 @@ export interface WidgetSlotMetadata {
*/
export interface SafeWidgetData {
nodeId?: NodeId
storeNodeId?: NodeId
name: string
storeName?: string
type: string
/** Callback to invoke when widget value changes (wraps LiteGraph callback + triggerDraw) */
callback?: ((value: unknown) => void) | undefined
@@ -161,7 +165,7 @@ function getSharedWidgetEnhancements(
/**
* Validates that a value is a valid WidgetValue type
*/
const normalizeWidgetValue = (value: unknown): WidgetValue => {
function normalizeWidgetValue(value: unknown): WidgetValue {
if (value === null || value === undefined || value === void 0) {
return undefined
}
@@ -193,11 +197,69 @@ function safeWidgetMapper(
node: LGraphNode,
slotMetadata: Map<string, WidgetSlotMetadata>
): (widget: IBaseWidget) => SafeWidgetData {
function extractWidgetDisplayOptions(
widget: IBaseWidget
): SafeWidgetData['options'] {
if (!widget.options) return undefined
return {
canvasOnly: widget.options.canvasOnly,
advanced: widget.advanced,
hidden: widget.options.hidden,
read_only: widget.options.read_only
}
}
function resolvePromotedSourceByInputName(inputName: string): {
sourceNodeId: string
sourceWidgetName: string
} | null {
const resolvedTarget = resolveSubgraphInputTarget(node, inputName)
if (!resolvedTarget) return null
return {
sourceNodeId: resolvedTarget.nodeId,
sourceWidgetName: resolvedTarget.widgetName
}
}
function resolvePromotedWidgetIdentity(widget: IBaseWidget): {
displayName: string
promotedSource: { sourceNodeId: string; sourceWidgetName: string } | null
} {
if (!isPromotedWidgetView(widget)) {
return {
displayName: widget.name,
promotedSource: null
}
}
const promotedInputName = node.inputs?.find((input) => {
if (input.name === widget.name) return true
if (input._widget === widget) return true
return false
})?.name
const displayName = promotedInputName ?? widget.name
const promotedSource = resolvePromotedSourceByInputName(displayName) ?? {
sourceNodeId: widget.sourceNodeId,
sourceWidgetName: widget.sourceWidgetName
}
return {
displayName,
promotedSource
}
}
return function (widget) {
try {
const { displayName, promotedSource } =
resolvePromotedWidgetIdentity(widget)
// Get shared enhancements (controlWidget, spec, nodeType)
const sharedEnhancements = getSharedWidgetEnhancements(node, widget)
const slotInfo = slotMetadata.get(widget.name)
const slotInfo =
slotMetadata.get(displayName) ?? slotMetadata.get(widget.name)
// Wrapper callback specific to Nodes 2.0 rendering
const callback = (v: unknown) => {
@@ -215,36 +277,52 @@ function safeWidgetMapper(
isPromotedWidgetView(widget) && widget.sourceWidgetName.startsWith('$$')
// Extract only render-critical options (canvasOnly, advanced, read_only)
const options = widget.options
? {
canvasOnly: widget.options.canvasOnly,
advanced: widget.advanced,
hidden: widget.options.hidden,
read_only: widget.options.read_only
}
: undefined
const options = extractWidgetDisplayOptions(widget)
const subgraphId = node.isSubgraphNode() && node.subgraph.id
const resolvedSourceResult =
isPromotedWidgetView(widget) && promotedSource
? resolveConcretePromotedWidget(
node,
promotedSource.sourceNodeId,
promotedSource.sourceWidgetName
)
: null
const resolvedSource =
resolvedSourceResult?.status === 'resolved'
? resolvedSourceResult.resolved
: undefined
const sourceWidget = resolvedSource?.widget
const sourceNode = resolvedSource?.node
const effectiveWidget = sourceWidget ?? widget
const localId = isPromotedWidgetView(widget)
? widget.sourceNodeId
? String(sourceNode?.id ?? promotedSource?.sourceNodeId)
: undefined
const nodeId =
subgraphId && localId ? `${subgraphId}:${localId}` : undefined
const name = isPromotedWidgetView(widget)
? widget.sourceWidgetName
: widget.name
const storeName = isPromotedWidgetView(widget)
? (sourceWidget?.name ?? promotedSource?.sourceWidgetName)
: undefined
const name = storeName ?? displayName
return {
nodeId,
storeNodeId: nodeId,
name,
type: widget.type,
storeName,
type: effectiveWidget.type,
...sharedEnhancements,
callback,
hasLayoutSize: typeof widget.computeLayoutSize === 'function',
hasLayoutSize: typeof effectiveWidget.computeLayoutSize === 'function',
isDOMWidget: isDOMWidget(widget) || isPromotedDOMWidget(widget),
options: isPromotedPseudoWidget
? { ...options, canvasOnly: true }
: options,
? {
...(extractWidgetDisplayOptions(effectiveWidget) ?? options),
canvasOnly: true
}
: (extractWidgetDisplayOptions(effectiveWidget) ?? options),
slotMetadata: slotInfo,
slotName: name !== widget.name ? widget.name : undefined
}
@@ -312,14 +390,18 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
})
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
const widgetsSnapshot = node.widgets ?? []
slotMetadata.clear()
node.inputs?.forEach((input, index) => {
if (!input?.widget?.name) return
slotMetadata.set(input.widget.name, {
const slotInfo = {
index,
linked: input.link != null
})
}
if (input.name) slotMetadata.set(input.name, slotInfo)
if (input.widget?.name) slotMetadata.set(input.widget.name, slotInfo)
})
return node.widgets?.map(safeWidgetMapper(node, slotMetadata)) ?? []
return widgetsSnapshot.map(safeWidgetMapper(node, slotMetadata))
})
const nodeType =
@@ -375,11 +457,12 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
const slotMetadata = new Map<string, WidgetSlotMetadata>()
nodeRef.inputs?.forEach((input, index) => {
if (!input?.widget?.name) return
slotMetadata.set(input.widget.name, {
const slotInfo = {
index,
linked: input.link != null
})
}
if (input.name) slotMetadata.set(input.name, slotInfo)
if (input.widget?.name) slotMetadata.set(input.widget.name, slotInfo)
})
// Update only widgets with new slot metadata, keeping other widget data intact

View File

@@ -1,7 +1,12 @@
import { computed, ref } from 'vue'
import type { Ref } from 'vue'
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type {
LGraphGroup,
LGraphNode,
NodeId
} from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { getExtraOptionsForWidget } from '@/services/litegraphService'
import { isLGraphGroup } from '@/utils/litegraphUtil'
@@ -46,7 +51,7 @@ export enum BadgeVariant {
// Global singleton for NodeOptions component reference
let nodeOptionsInstance: null | NodeOptionsInstance = null
const hoveredWidgetName = ref<string>()
const hoveredWidget = ref<[string, NodeId | undefined]>()
/**
* Toggle the node options popover
@@ -63,14 +68,12 @@ export function toggleNodeOptions(event: Event) {
* Use this for contextmenu events where we always want to show at the new position
* @param event - The trigger event (must be MouseEvent for position)
*/
export function showNodeOptions(event: MouseEvent) {
hoveredWidgetName.value = undefined
const target = event.target
if (target instanceof HTMLElement) {
const widgetEl = target.closest('.lg-node-widget')
if (widgetEl instanceof HTMLElement)
hoveredWidgetName.value = widgetEl.dataset.widgetName
}
export function showNodeOptions(
event: MouseEvent,
widgetName?: string,
nodeId?: NodeId
) {
hoveredWidget.value = widgetName ? [widgetName, nodeId] : undefined
if (nodeOptionsInstance?.show) {
nodeOptionsInstance.show(event)
}
@@ -259,8 +262,16 @@ export function useMoreOptionsMenu() {
options.push(...getImageMenuOptions(selectedNodes.value[0]))
options.push({ type: 'divider' })
}
const rawName = hoveredWidgetName.value
const widget = node?.widgets?.find((w) => w.name === rawName)
const [widgetName, nodeId] = hoveredWidget.value ?? []
const widget =
nodeId !== undefined
? node?.widgets?.find(
(w) =>
isPromotedWidgetView(w) &&
w.sourceWidgetName === widgetName &&
w.sourceNodeId === nodeId
)
: node?.widgets?.find((w) => w.name === widgetName)
if (widget) {
const widgetOptions = convertContextMenuToOptions(
getExtraOptionsForWidget(node, widget)

View File

@@ -10,7 +10,7 @@ const PREVIEW_MARGIN = 16
export function useNodePreviewAndDrag(
nodeDef: Ref<ComfyNodeDefImpl | undefined>,
options?: { panelRef?: Ref<HTMLElement | null> }
panelRef?: Ref<HTMLElement | null>
) {
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
const settingStore = useSettingStore()
@@ -56,8 +56,7 @@ export function useNodePreviewAndDrag(
const target = e.currentTarget as HTMLElement
const rect = target.getBoundingClientRect()
const horizontalRect =
options?.panelRef?.value?.getBoundingClientRect() ?? rect
const horizontalRect = panelRef?.value?.getBoundingClientRect() ?? rect
const { left, viewportHeight } = calculatePreviewPosition(horizontalRect)
let top = rect.top

View File

@@ -27,11 +27,12 @@ export const PAINTER_TOOLS: Record<string, PainterTool> = {
interface UsePainterOptions {
canvasEl: Ref<HTMLCanvasElement | null>
cursorEl: Ref<HTMLElement | null>
modelValue: Ref<string>
}
export function usePainter(nodeId: string, options: UsePainterOptions) {
const { canvasEl, modelValue } = options
const { canvasEl, cursorEl, modelValue } = options
const { t } = useI18n()
const nodeOutputStore = useNodeOutputStore()
const toastStore = useToastStore()
@@ -41,8 +42,6 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
const canvasWidth = ref(512)
const canvasHeight = ref(512)
const cursorX = ref(0)
const cursorY = ref(0)
const cursorVisible = ref(false)
const inputImageUrl = ref<string | null>(null)
@@ -518,8 +517,10 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
}
function updateCursorPos(e: PointerEvent) {
cursorX.value = e.offsetX
cursorY.value = e.offsetY
const el = cursorEl.value
if (!el) return
const size = displayBrushSize.value
el.style.transform = `translate(${e.offsetX - size / 2}px, ${e.offsetY - size / 2}px)`
}
function handlePointerDown(e: PointerEvent) {
@@ -760,8 +761,6 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
canvasWidth,
canvasHeight,
cursorX,
cursorY,
cursorVisible,
displayBrushSize,

View File

@@ -0,0 +1,111 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useNewMenuItemIndicator } from '@/composables/useNewMenuItemIndicator'
import type { WorkflowMenuItem } from '@/types/workflowMenuItem'
const mockSettingStore = vi.hoisted(() => ({
get: vi.fn((): string[] => []),
set: vi.fn()
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => mockSettingStore)
}))
function createItems(...ids: string[]): WorkflowMenuItem[] {
return ids.map((id) => ({
id,
label: `Label for ${id}`,
icon: 'pi pi-test',
command: vi.fn(),
isNew: true,
badge: 'BETA'
}))
}
describe('useNewMenuItemIndicator', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockSettingStore.get.mockReturnValue([])
})
it('reports unseen items when no items have been seen', () => {
const items = createItems('feature-a')
const { hasUnseenItems } = useNewMenuItemIndicator(() => items)
expect(hasUnseenItems.value).toBe(true)
})
it('reports no unseen items when all new items are already seen', () => {
mockSettingStore.get.mockReturnValue(['feature-a'])
const items = createItems('feature-a')
const { hasUnseenItems } = useNewMenuItemIndicator(() => items)
expect(hasUnseenItems.value).toBe(false)
})
it('reports unseen when some new items are not yet seen', () => {
mockSettingStore.get.mockReturnValue(['feature-a'])
const items = createItems('feature-a', 'feature-b')
const { hasUnseenItems } = useNewMenuItemIndicator(() => items)
expect(hasUnseenItems.value).toBe(true)
})
it('reports no unseen items when menu has no isNew items', () => {
const items: WorkflowMenuItem[] = [
{ id: 'regular', label: 'Regular', icon: 'pi pi-test', command: vi.fn() }
]
const { hasUnseenItems } = useNewMenuItemIndicator(() => items)
expect(hasUnseenItems.value).toBe(false)
})
it('ignores separators', () => {
const items: WorkflowMenuItem[] = [
{ separator: true },
...createItems('feature-a')
]
const { hasUnseenItems } = useNewMenuItemIndicator(() => items)
expect(hasUnseenItems.value).toBe(true)
})
it('markAsSeen persists current new item ids', () => {
const items = createItems('feature-a', 'feature-b')
const { markAsSeen } = useNewMenuItemIndicator(() => items)
markAsSeen()
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.WorkflowActions.SeenItems',
['feature-a', 'feature-b']
)
})
it('markAsSeen replaces stale entries with current new items', () => {
mockSettingStore.get.mockReturnValue(['old-feature', 'feature-a'])
const items = createItems('feature-a')
const { markAsSeen } = useNewMenuItemIndicator(() => items)
markAsSeen()
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.WorkflowActions.SeenItems',
['feature-a']
)
})
it('markAsSeen does nothing when there are no new items', () => {
const items: WorkflowMenuItem[] = [
{ id: 'regular', label: 'Regular', icon: 'pi pi-test', command: vi.fn() }
]
const { markAsSeen } = useNewMenuItemIndicator(() => items)
markAsSeen()
expect(mockSettingStore.set).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,41 @@
import type { MaybeRefOrGetter } from 'vue'
import { computed, toValue } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import type {
WorkflowMenuAction,
WorkflowMenuItem
} from '@/types/workflowMenuItem'
function getNewItemIds(items: WorkflowMenuItem[]): string[] {
return items
.filter((i): i is WorkflowMenuAction => !('separator' in i && i.separator))
.filter((i) => i.isNew)
.map((i) => i.id)
}
export function useNewMenuItemIndicator(
menuItems: MaybeRefOrGetter<WorkflowMenuItem[]>
) {
const settingStore = useSettingStore()
const newItemIds = computed(() => getNewItemIds(toValue(menuItems)))
const seenItems = computed<string[]>(
() => settingStore.get('Comfy.WorkflowActions.SeenItems') ?? []
)
const hasUnseenItems = computed(() => {
const seen = new Set(seenItems.value)
return newItemIds.value.some((id) => !seen.has(id))
})
function markAsSeen() {
if (!newItemIds.value.length) return
void settingStore.set('Comfy.WorkflowActions.SeenItems', [
...newItemIds.value
])
}
return { hasUnseenItems, markAsSeen }
}

View File

@@ -28,6 +28,7 @@ interface WorkflowActionsMenuOptions {
}
interface AddItemOptions {
id: string
label: string
icon: string
command: () => void
@@ -71,6 +72,7 @@ export function useWorkflowActionsMenu(
const items: WorkflowMenuItem[] = []
const addItem = ({
id,
label,
icon,
command,
@@ -81,9 +83,10 @@ export function useWorkflowActionsMenu(
}: AddItemOptions) => {
if (!visible) return
if (prependSeparator) items.push({ separator: true })
const item: WorkflowMenuAction = { label, icon, command, disabled }
const item: WorkflowMenuAction = { id, label, icon, command, disabled }
if (isNew) {
item.badge = t('contextMenu.new')
item.badge = t('g.experimental')
item.isNew = true
}
items.push(item)
}
@@ -94,6 +97,7 @@ export function useWorkflowActionsMenu(
const isBookmarked = bookmarkStore.isBookmarked(workflow?.path ?? '')
addItem({
id: 'rename',
label: t('g.rename'),
icon: 'pi pi-pencil',
command: async () => {
@@ -104,6 +108,7 @@ export function useWorkflowActionsMenu(
})
addItem({
id: 'duplicate',
label: t('breadcrumbsMenu.duplicate'),
icon: 'pi pi-copy',
command: async () => {
@@ -115,6 +120,7 @@ export function useWorkflowActionsMenu(
})
addItem({
id: 'toggle-bookmark',
label: isBookmarked
? t('tabMenu.removeFromBookmarks')
: t('tabMenu.addToBookmarks'),
@@ -129,6 +135,7 @@ export function useWorkflowActionsMenu(
})
addItem({
id: 'save',
label: t('menuLabels.Save'),
icon: 'pi pi-save',
command: async () => {
@@ -140,6 +147,7 @@ export function useWorkflowActionsMenu(
})
addItem({
id: 'save-as',
label: t('menuLabels.Save As'),
icon: 'pi pi-save',
command: async () => {
@@ -150,6 +158,7 @@ export function useWorkflowActionsMenu(
})
addItem({
id: 'export',
label: t('menuLabels.Export'),
icon: 'pi pi-download',
command: async () => {
@@ -161,6 +170,7 @@ export function useWorkflowActionsMenu(
})
addItem({
id: 'export-api',
label: t('menuLabels.Export (API)'),
icon: 'pi pi-download',
command: async () => {
@@ -171,6 +181,7 @@ export function useWorkflowActionsMenu(
})
addItem({
id: 'toggle-app-mode',
label: isLinearMode
? t('breadcrumbsMenu.exitAppMode')
: t('breadcrumbsMenu.enterAppMode'),
@@ -188,6 +199,7 @@ export function useWorkflowActionsMenu(
})
addItem({
id: 'clear-workflow',
label: t('breadcrumbsMenu.clearWorkflow'),
icon: 'pi pi-trash',
command: async () => {
@@ -198,6 +210,7 @@ export function useWorkflowActionsMenu(
})
addItem({
id: 'publish',
label: t('subgraphStore.publish'),
icon: 'pi pi-upload',
command: async () => {
@@ -210,6 +223,7 @@ export function useWorkflowActionsMenu(
})
addItem({
id: 'delete',
label: isBlueprint
? t('breadcrumbsMenu.deleteBlueprint')
: t('breadcrumbsMenu.deleteWorkflow'),

View File

@@ -1,5 +1,11 @@
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
export type ResolvedPromotedWidget = {
node: LGraphNode
widget: IBaseWidget
}
export interface PromotedWidgetView extends IBaseWidget {
readonly node: SubgraphNode

View File

@@ -17,6 +17,7 @@ import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { createPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import { usePromotionStore } from '@/stores/promotionStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
@@ -121,11 +122,19 @@ describe(createPromotedWidgetView, () => {
expect(view.serialize).toBe(false)
})
test('computedDisabled is false and setter is a no-op', () => {
test('computedDisabled defaults to false and accepts boolean values', () => {
const [subgraphNode] = setupSubgraph()
const view = createPromotedWidgetView(subgraphNode, '1', 'myWidget')
expect(view.computedDisabled).toBe(false)
view.computedDisabled = true
expect(view.computedDisabled).toBe(true)
})
test('computedDisabled treats undefined as false', () => {
const [subgraphNode] = setupSubgraph()
const view = createPromotedWidgetView(subgraphNode, '1', 'myWidget')
view.computedDisabled = true
view.computedDisabled = undefined
expect(view.computedDisabled).toBe(false)
})
@@ -382,11 +391,173 @@ describe('SubgraphNode.widgets getter', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
test('defers promotions while subgraph node id is -1 and flushes on add', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'picker_input', type: '*' }]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: -1 })
const innerNode = new LGraphNode('InnerNode')
const innerInput = innerNode.addInput('picker_input', '*')
innerNode.addWidget('combo', 'picker', 'a', () => {}, {
values: ['a', 'b']
})
innerInput.widget = { name: 'picker' }
subgraph.add(innerNode)
subgraph.inputNode.slots[0].connect(innerInput, innerNode)
subgraphNode._internalConfigureAfterSlots()
const store = usePromotionStore()
expect(store.getPromotions(subgraphNode.rootGraph.id, -1)).toStrictEqual([])
subgraphNode.graph?.add(subgraphNode)
expect(subgraphNode.id).not.toBe(-1)
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toStrictEqual([
{
interiorNodeId: String(innerNode.id),
widgetName: 'picker'
}
])
})
test('rebinds one input to latest source without stale disconnected views', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'picker_input', type: '*' }]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: 41 })
subgraphNode.graph?.add(subgraphNode)
const firstNode = new LGraphNode('FirstNode')
const firstInput = firstNode.addInput('picker_input', '*')
firstNode.addWidget('combo', 'picker', 'a', () => {}, {
values: ['a', 'b']
})
firstInput.widget = { name: 'picker' }
subgraph.add(firstNode)
const subgraphInputSlot = subgraph.inputNode.slots[0]
subgraphInputSlot.connect(firstInput, firstNode)
// Mirror user-driven rebind behavior: move the slot connection from first
// source to second source, rather than keeping both links connected.
subgraphInputSlot.disconnect()
const secondNode = new LGraphNode('SecondNode')
const secondInput = secondNode.addInput('picker_input', '*')
secondNode.addWidget('combo', 'picker', 'b', () => {}, {
values: ['a', 'b']
})
secondInput.widget = { name: 'picker' }
subgraph.add(secondNode)
subgraphInputSlot.connect(secondInput, secondNode)
const promotions = usePromotionStore().getPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id
)
expect(promotions).toHaveLength(1)
expect(promotions[0]).toStrictEqual({
interiorNodeId: String(secondNode.id),
widgetName: 'picker'
})
expect(subgraphNode.widgets).toHaveLength(1)
expect(subgraphNode.widgets[0].value).toBe('b')
})
test('preserves distinct promoted display names when two inputs share one concrete widget name', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'strength_model', type: '*' },
{ name: 'strength_model_1', type: '*' }
]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: 90 })
subgraphNode.graph?.add(subgraphNode)
const innerNode = new LGraphNode('InnerNumberNode')
const firstInput = innerNode.addInput('strength_model', '*')
const secondInput = innerNode.addInput('strength_model_1', '*')
innerNode.addWidget('number', 'strength_model', 1, () => {})
firstInput.widget = { name: 'strength_model' }
secondInput.widget = { name: 'strength_model' }
subgraph.add(innerNode)
subgraph.inputNode.slots[0].connect(firstInput, innerNode)
subgraph.inputNode.slots[1].connect(secondInput, innerNode)
expect(subgraphNode.widgets).toHaveLength(2)
expect(subgraphNode.widgets.map((widget) => widget.name)).toStrictEqual([
'strength_model',
'strength_model_1'
])
})
test('returns empty array when no proxyWidgets', () => {
const [subgraphNode] = setupSubgraph()
expect(subgraphNode.widgets).toEqual([])
})
test('widgets getter prefers live linked entries over stale store entries', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'widgetA', type: '*' }]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: 91 })
subgraphNode.graph?.add(subgraphNode)
const liveNode = new LGraphNode('LiveNode')
const liveInput = liveNode.addInput('widgetA', '*')
liveNode.addWidget('text', 'widgetA', 'a', () => {})
liveInput.widget = { name: 'widgetA' }
subgraph.add(liveNode)
subgraph.inputNode.slots[0].connect(liveInput, liveNode)
setPromotions(subgraphNode, [
[String(liveNode.id), 'widgetA'],
['9999', 'missingWidget']
])
expect(subgraphNode.widgets).toHaveLength(1)
expect(subgraphNode.widgets[0].name).toBe('widgetA')
})
test('partial linked coverage does not destructively prune unresolved store promotions', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'widgetA', type: '*' },
{ name: 'widgetB', type: '*' }
]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: 92 })
subgraphNode.graph?.add(subgraphNode)
const liveNode = new LGraphNode('LiveNode')
const liveInput = liveNode.addInput('widgetA', '*')
liveNode.addWidget('text', 'widgetA', 'a', () => {})
liveInput.widget = { name: 'widgetA' }
subgraph.add(liveNode)
subgraph.inputNode.slots[0].connect(liveInput, liveNode)
setPromotions(subgraphNode, [
[String(liveNode.id), 'widgetA'],
['9999', 'widgetB']
])
// Trigger widgets getter reconciliation in partial-linked state.
void subgraphNode.widgets
const promotions = usePromotionStore().getPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id
)
expect(promotions).toStrictEqual([
{ interiorNodeId: String(liveNode.id), widgetName: 'widgetA' },
{ interiorNodeId: '9999', widgetName: 'widgetB' }
])
})
test('caches view objects across getter calls (stable references)', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
@@ -741,7 +912,7 @@ describe('disconnected state', () => {
expect(subgraphNode.widgets[0].type).toBe('number')
})
test('view falls back to button type when interior node is removed', () => {
test('keeps promoted entry as disconnected when interior node is removed', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'myWidget', 'val', () => {})
setPromotions(subgraphNode, [['1', 'myWidget']])
@@ -750,6 +921,7 @@ describe('disconnected state', () => {
// Remove the interior node from the subgraph
subgraphNode.subgraph.remove(innerNodes[0])
expect(subgraphNode.widgets).toHaveLength(1)
expect(subgraphNode.widgets[0].type).toBe('button')
})
@@ -767,16 +939,11 @@ describe('disconnected state', () => {
expect(subgraphNode.widgets[0].type).toBe('text')
})
test('options returns empty object when disconnected', () => {
test('keeps missing source-node promotions as disconnected views', () => {
const [subgraphNode] = setupSubgraph()
setPromotions(subgraphNode, [['999', 'ghost']])
expect(subgraphNode.widgets[0].options).toEqual({})
})
test('tooltip returns undefined when disconnected', () => {
const [subgraphNode] = setupSubgraph()
setPromotions(subgraphNode, [['999', 'ghost']])
expect(subgraphNode.widgets[0].tooltip).toBeUndefined()
expect(subgraphNode.widgets).toHaveLength(1)
expect(subgraphNode.widgets[0].type).toBe('button')
})
})
@@ -786,6 +953,381 @@ function createFakeCanvasContext() {
})
}
function createInspectableCanvasContext(fillText = vi.fn()) {
const fallback = vi.fn()
return new Proxy(
{
fillText,
beginPath: vi.fn(),
roundRect: vi.fn(),
rect: vi.fn(),
fill: vi.fn(),
stroke: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
arc: vi.fn(),
measureText: (text: string) => ({ width: text.length * 8 }),
fillStyle: '#fff',
strokeStyle: '#fff',
textAlign: 'left',
globalAlpha: 1,
lineWidth: 1
} as Record<string, unknown>,
{
get(target, key) {
if (typeof key === 'string' && key in target)
return target[key as keyof typeof target]
return fallback
}
}
) as unknown as CanvasRenderingContext2D
}
function createTwoLevelNestedSubgraph() {
const subgraphA = createTestSubgraph({
inputs: [{ name: 'a_input', type: '*' }]
})
const innerNode = new LGraphNode('InnerComboNode')
const innerInput = innerNode.addInput('picker_input', '*')
const comboWidget = innerNode.addWidget('combo', 'picker', 'a', () => {}, {
values: ['a', 'b']
})
innerInput.widget = { name: 'picker' }
subgraphA.add(innerNode)
subgraphA.inputNode.slots[0].connect(innerInput, innerNode)
const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 11 })
const subgraphB = createTestSubgraph({
inputs: [{ name: 'b_input', type: '*' }]
})
subgraphB.add(subgraphNodeA)
subgraphNodeA._internalConfigureAfterSlots()
subgraphB.inputNode.slots[0].connect(subgraphNodeA.inputs[0], subgraphNodeA)
const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 22 })
return { innerNode, comboWidget, subgraphNodeB }
}
describe('promoted combo rendering', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
test('draw shows value even when interior combo is computedDisabled', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const innerNode = firstInnerNode(innerNodes)
const comboWidget = innerNode.addWidget('combo', 'picker', 'a', () => {}, {
values: ['a', 'b']
})
// Simulates source widgets connected to subgraph inputs.
comboWidget.computedDisabled = true
setPromotions(subgraphNode, [[String(innerNode.id), 'picker']])
const fillText = vi.fn()
const ctx = createInspectableCanvasContext(fillText)
subgraphNode.widgets[0].draw?.(
ctx,
subgraphNode,
260,
0,
LiteGraph.NODE_WIDGET_HEIGHT,
false
)
const renderedText = fillText.mock.calls.map((call) => call[0])
expect(renderedText).toContain('a')
})
test('draw shows value through two input-based promotion layers', () => {
const { comboWidget, subgraphNodeB } = createTwoLevelNestedSubgraph()
comboWidget.computedDisabled = true
const fillText = vi.fn()
const ctx = createInspectableCanvasContext(fillText)
subgraphNodeB.widgets[0].draw?.(
ctx,
subgraphNodeB,
260,
0,
LiteGraph.NODE_WIDGET_HEIGHT,
false
)
const renderedText = fillText.mock.calls.map((call) => call[0])
expect(renderedText).toContain('a')
})
test('value updates propagate through two promoted input layers', () => {
const { comboWidget, subgraphNodeB } = createTwoLevelNestedSubgraph()
comboWidget.computedDisabled = true
const promotedWidget = subgraphNodeB.widgets[0]
expect(promotedWidget.value).toBe('a')
promotedWidget.value = 'b'
expect(comboWidget.value).toBe('b')
const fillText = vi.fn()
const ctx = createInspectableCanvasContext(fillText)
promotedWidget.draw?.(
ctx,
subgraphNodeB,
260,
0,
LiteGraph.NODE_WIDGET_HEIGHT,
false
)
const renderedText = fillText.mock.calls.map((call) => call[0])
expect(renderedText).toContain('b')
})
test('draw projection recovers after transient button fallback in nested promotion', () => {
const { innerNode, subgraphNodeB } = createTwoLevelNestedSubgraph()
const promotedWidget = subgraphNodeB.widgets[0]
// Force a transient disconnect to project a fallback widget once.
innerNode.widgets = []
promotedWidget.draw?.(
createInspectableCanvasContext(),
subgraphNodeB,
260,
0,
LiteGraph.NODE_WIDGET_HEIGHT,
false
)
// Restore the concrete widget and verify draw reflects recovery.
innerNode.addWidget('combo', 'picker', 'a', () => {}, {
values: ['a', 'b']
})
const fillText = vi.fn()
promotedWidget.draw?.(
createInspectableCanvasContext(fillText),
subgraphNodeB,
260,
0,
LiteGraph.NODE_WIDGET_HEIGHT,
false
)
const renderedText = fillText.mock.calls.map((call) => call[0])
expect(renderedText).toContain('a')
})
test('state lookup behavior resolves to deepest promoted widget source', () => {
const { comboWidget, subgraphNodeB } = createTwoLevelNestedSubgraph()
const promotedWidget = subgraphNodeB.widgets[0]
expect(promotedWidget.value).toBe('a')
comboWidget.value = 'b'
expect(promotedWidget.value).toBe('b')
})
test('state lookup does not use promotion store fallback when intermediate view is unavailable', () => {
const subgraphA = createTestSubgraph({
inputs: [{ name: 'strength_model', type: '*' }]
})
const innerNode = new LGraphNode('InnerNumberNode')
const innerInput = innerNode.addInput('strength_model', '*')
innerNode.addWidget('number', 'strength_model', 1, () => {})
innerInput.widget = { name: 'strength_model' }
subgraphA.add(innerNode)
subgraphA.inputNode.slots[0].connect(innerInput, innerNode)
const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 47 })
const subgraphB = createTestSubgraph({
inputs: [{ name: 'strength_model', type: '*' }]
})
subgraphB.add(subgraphNodeA)
subgraphNodeA._internalConfigureAfterSlots()
subgraphB.inputNode.slots[0].connect(subgraphNodeA.inputs[0], subgraphNodeA)
const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 46 })
// Simulate transient stale intermediate view state by forcing host 47
// to report no promoted widgets while promotionStore still has entries.
Object.defineProperty(subgraphNodeA, 'widgets', {
get: () => [],
configurable: true
})
expect(subgraphNodeB.widgets[0].type).toBe('button')
})
test('state lookup does not use input-widget fallback when intermediate promotions are absent', () => {
const subgraphA = createTestSubgraph({
inputs: [{ name: 'strength_model', type: '*' }]
})
const innerNode = new LGraphNode('InnerNumberNode')
const innerInput = innerNode.addInput('strength_model', '*')
innerNode.addWidget('number', 'strength_model', 1, () => {})
innerInput.widget = { name: 'strength_model' }
subgraphA.add(innerNode)
subgraphA.inputNode.slots[0].connect(innerInput, innerNode)
const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 47 })
const subgraphB = createTestSubgraph({
inputs: [{ name: 'strength_model', type: '*' }]
})
subgraphB.add(subgraphNodeA)
subgraphNodeA._internalConfigureAfterSlots()
subgraphB.inputNode.slots[0].connect(subgraphNodeA.inputs[0], subgraphNodeA)
const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 46 })
// Simulate a transient where intermediate promotions are unavailable but
// input _widget binding is already updated.
usePromotionStore().setPromotions(
subgraphNodeA.rootGraph.id,
subgraphNodeA.id,
[]
)
Object.defineProperty(subgraphNodeA, 'widgets', {
get: () => [],
configurable: true
})
expect(subgraphNodeB.widgets[0].type).toBe('button')
})
test('state lookup does not use subgraph-link fallback when intermediate bindings are unavailable', () => {
const subgraphA = createTestSubgraph({
inputs: [{ name: 'strength_model', type: '*' }]
})
const innerNode = new LGraphNode('InnerNumberNode')
const innerInput = innerNode.addInput('strength_model', '*')
innerNode.addWidget('number', 'strength_model', 1, () => {})
innerInput.widget = { name: 'strength_model' }
subgraphA.add(innerNode)
subgraphA.inputNode.slots[0].connect(innerInput, innerNode)
const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 47 })
const subgraphB = createTestSubgraph({
inputs: [{ name: 'strength_model', type: '*' }]
})
subgraphB.add(subgraphNodeA)
subgraphNodeA._internalConfigureAfterSlots()
subgraphB.inputNode.slots[0].connect(subgraphNodeA.inputs[0], subgraphNodeA)
const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 46 })
usePromotionStore().setPromotions(
subgraphNodeA.rootGraph.id,
subgraphNodeA.id,
[]
)
Object.defineProperty(subgraphNodeA, 'widgets', {
get: () => [],
configurable: true
})
subgraphNodeA.inputs[0]._widget = undefined
expect(subgraphNodeB.widgets[0].type).toBe('button')
})
test('nested promotion keeps concrete widget types at top level', () => {
const subgraphA = createTestSubgraph({
inputs: [
{ name: 'lora_name', type: '*' },
{ name: 'strength_model', type: '*' }
]
})
const innerNode = new LGraphNode('InnerLoraNode')
const comboInput = innerNode.addInput('lora_name', '*')
const numberInput = innerNode.addInput('strength_model', '*')
innerNode.addWidget('combo', 'lora_name', 'a', () => {}, {
values: ['a', 'b']
})
innerNode.addWidget('number', 'strength_model', 1, () => {})
comboInput.widget = { name: 'lora_name' }
numberInput.widget = { name: 'strength_model' }
subgraphA.add(innerNode)
subgraphA.inputNode.slots[0].connect(comboInput, innerNode)
subgraphA.inputNode.slots[1].connect(numberInput, innerNode)
const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 60 })
const subgraphB = createTestSubgraph({
inputs: [
{ name: 'lora_name', type: '*' },
{ name: 'strength_model', type: '*' }
]
})
subgraphB.add(subgraphNodeA)
subgraphNodeA._internalConfigureAfterSlots()
subgraphB.inputNode.slots[0].connect(subgraphNodeA.inputs[0], subgraphNodeA)
subgraphB.inputNode.slots[1].connect(subgraphNodeA.inputs[1], subgraphNodeA)
const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 61 })
expect(subgraphNodeB.widgets[0].type).toBe('combo')
expect(subgraphNodeB.widgets[1].type).toBe('number')
})
test('input promotion from promoted view stores immediate source node id', () => {
const subgraphA = createTestSubgraph({
inputs: [{ name: 'lora_name', type: '*' }]
})
const innerNode = new LGraphNode('InnerNode')
const innerInput = innerNode.addInput('lora_name', '*')
innerNode.addWidget('combo', 'lora_name', 'a', () => {}, {
values: ['a', 'b']
})
innerInput.widget = { name: 'lora_name' }
subgraphA.add(innerNode)
subgraphA.inputNode.slots[0].connect(innerInput, innerNode)
const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 70 })
const subgraphB = createTestSubgraph({
inputs: [{ name: 'lora_name', type: '*' }]
})
subgraphB.add(subgraphNodeA)
subgraphNodeA._internalConfigureAfterSlots()
subgraphB.inputNode.slots[0].connect(subgraphNodeA.inputs[0], subgraphNodeA)
const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 71 })
const promotions = usePromotionStore().getPromotions(
subgraphNodeB.rootGraph.id,
subgraphNodeB.id
)
expect(promotions).toContainEqual({
interiorNodeId: String(subgraphNodeA.id),
widgetName: 'lora_name'
})
expect(promotions).not.toContainEqual({
interiorNodeId: String(innerNode.id),
widgetName: 'lora_name'
})
})
test('resolvePromotedWidgetSource is safe for detached subgraph hosts', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph, { id: 101 })
const promotedView = createPromotedWidgetView(
subgraphNode,
'999',
'missingWidget'
)
subgraphNode.graph = null
expect(() =>
resolvePromotedWidgetSource(subgraphNode, promotedView)
).not.toThrow()
expect(
resolvePromotedWidgetSource(subgraphNode, promotedView)
).toBeUndefined()
})
})
describe('DOM widget promotion', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))

View File

@@ -1,4 +1,4 @@
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
import type { Point } from '@/lib/litegraph/src/interfaces'
@@ -13,23 +13,16 @@ import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import {
resolveConcretePromotedWidget,
resolvePromotedWidgetAtHost
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidgetTypes'
export type { PromotedWidgetView } from './promotedWidgetTypes'
export { isPromotedWidgetView } from './promotedWidgetTypes'
function resolve(
subgraphNode: SubgraphNode,
nodeId: string,
widgetName: string
): { node: LGraphNode; widget: IBaseWidget } | undefined {
const node = subgraphNode.subgraph.getNodeById(nodeId)
if (!node) return undefined
const widget = node.widgets?.find((w: IBaseWidget) => w.name === widgetName)
return widget ? { node, widget } : undefined
}
function isWidgetValue(value: unknown): value is IBaseWidget['value'] {
if (value === undefined) return true
if (typeof value === 'string') return true
@@ -46,6 +39,8 @@ function hasLegacyMouse(widget: IBaseWidget): widget is LegacyMouseWidget {
return 'mouse' in widget && typeof widget.mouse === 'function'
}
const designTokenCache = new Map<string, string>()
export function createPromotedWidgetView(
subgraphNode: SubgraphNode,
nodeId: string,
@@ -67,12 +62,15 @@ class PromotedWidgetView implements IPromotedWidgetView {
computedHeight?: number
private readonly graphId: string
private readonly bareNodeId: NodeId
private yValue = 0
private _computedDisabled = false
private projectedSourceNode?: LGraphNode
private projectedSourceWidget?: IBaseWidget
private projectedSourceWidgetType?: IBaseWidget['type']
private projectedWidget?: BaseWidget
private cachedDeepestByFrame?: { node: LGraphNode; widget: IBaseWidget }
private cachedDeepestFrame = -1
constructor(
private readonly subgraphNode: SubgraphNode,
@@ -83,7 +81,6 @@ class PromotedWidgetView implements IPromotedWidgetView {
this.sourceNodeId = nodeId
this.sourceWidgetName = widgetName
this.graphId = subgraphNode.rootGraph.id
this.bareNodeId = stripGraphPrefix(nodeId)
}
get node(): SubgraphNode {
@@ -103,32 +100,34 @@ class PromotedWidgetView implements IPromotedWidgetView {
this.syncDomOverride()
}
get computedDisabled(): false {
return false
get computedDisabled(): boolean {
return this._computedDisabled
}
set computedDisabled(_value: boolean | undefined) {}
set computedDisabled(value: boolean | undefined) {
this._computedDisabled = value ?? false
}
get type(): IBaseWidget['type'] {
return this.resolve()?.widget.type ?? 'button'
return this.resolveDeepest()?.widget.type ?? 'button'
}
get options(): IBaseWidget['options'] {
return this.resolve()?.widget.options ?? {}
return this.resolveDeepest()?.widget.options ?? {}
}
get tooltip(): string | undefined {
return this.resolve()?.widget.tooltip
return this.resolveDeepest()?.widget.tooltip
}
get linkedWidgets(): IBaseWidget[] | undefined {
return this.resolve()?.widget.linkedWidgets
return this.resolveDeepest()?.widget.linkedWidgets
}
get value(): IBaseWidget['value'] {
const state = this.getWidgetState()
if (state && isWidgetValue(state.value)) return state.value
return this.resolve()?.widget.value
return this.resolveAtHost()?.widget.value
}
set value(value: IBaseWidget['value']) {
@@ -138,7 +137,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
return
}
const resolved = this.resolve()
const resolved = this.resolveAtHost()
if (resolved && isWidgetValue(value)) {
resolved.widget.value = value
}
@@ -155,18 +154,18 @@ class PromotedWidgetView implements IPromotedWidgetView {
}
get hidden(): boolean {
return this.resolve()?.widget.hidden ?? false
return this.resolveDeepest()?.widget.hidden ?? false
}
get computeLayoutSize(): IBaseWidget['computeLayoutSize'] {
const resolved = this.resolve()
const resolved = this.resolveDeepest()
const computeLayoutSize = resolved?.widget.computeLayoutSize
if (!computeLayoutSize) return undefined
return (node: LGraphNode) => computeLayoutSize.call(resolved.widget, node)
}
get computeSize(): IBaseWidget['computeSize'] {
const resolved = this.resolve()
const resolved = this.resolveDeepest()
const computeSize = resolved?.widget.computeSize
if (!computeSize) return undefined
return (width?: number) => computeSize.call(resolved.widget, width)
@@ -180,7 +179,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
H: number,
lowQuality?: boolean
): void {
const resolved = this.resolve()
const resolved = this.resolveDeepest()
if (!resolved) {
drawDisconnectedPlaceholder(ctx, widgetWidth, y, H)
return
@@ -193,9 +192,11 @@ class PromotedWidgetView implements IPromotedWidgetView {
const originalY = projected.y
const originalComputedHeight = projected.computedHeight
const originalComputedDisabled = projected.computedDisabled
projected.y = this.y
projected.computedHeight = this.computedHeight
projected.computedDisabled = this.computedDisabled
projected.value = this.value
projected.drawWidget(ctx, {
@@ -207,6 +208,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
projected.y = originalY
projected.computedHeight = originalComputedHeight
projected.computedDisabled = originalComputedDisabled
}
onPointerDown(
@@ -214,7 +216,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
_node: LGraphNode,
canvas: LGraphCanvas
): boolean {
const resolved = this.resolve()
const resolved = this.resolveAtHost()
if (!resolved) return false
const interior = resolved.widget
@@ -240,18 +242,48 @@ class PromotedWidgetView implements IPromotedWidgetView {
pos?: Point,
e?: CanvasPointerEvent
) {
this.resolve()?.widget.callback?.(value, canvas, node, pos, e)
this.resolveAtHost()?.widget.callback?.(value, canvas, node, pos, e)
}
private resolve(): { node: LGraphNode; widget: IBaseWidget } | undefined {
return resolve(this.subgraphNode, this.sourceNodeId, this.sourceWidgetName)
private resolveAtHost():
| { node: LGraphNode; widget: IBaseWidget }
| undefined {
return resolvePromotedWidgetAtHost(
this.subgraphNode,
this.sourceNodeId,
this.sourceWidgetName
)
}
private resolveDeepest():
| { node: LGraphNode; widget: IBaseWidget }
| undefined {
const frame = this.subgraphNode.rootGraph.primaryCanvas?.frame
if (frame !== undefined && this.cachedDeepestFrame === frame)
return this.cachedDeepestByFrame
const result = resolveConcretePromotedWidget(
this.subgraphNode,
this.sourceNodeId,
this.sourceWidgetName
)
const resolved = result.status === 'resolved' ? result.resolved : undefined
if (frame !== undefined) {
this.cachedDeepestFrame = frame
this.cachedDeepestByFrame = resolved
}
return resolved
}
private getWidgetState() {
const resolved = this.resolveDeepest()
if (!resolved) return undefined
return useWidgetValueStore().getWidget(
this.graphId,
this.bareNodeId,
this.sourceWidgetName
stripGraphPrefix(String(resolved.node.id)),
resolved.widget.name
)
}
@@ -262,7 +294,8 @@ class PromotedWidgetView implements IPromotedWidgetView {
const shouldRebuild =
!this.projectedWidget ||
this.projectedSourceNode !== resolved.node ||
this.projectedSourceWidget !== resolved.widget
this.projectedSourceWidget !== resolved.widget ||
this.projectedSourceWidgetType !== resolved.widget.type
if (!shouldRebuild) return this.projectedWidget
@@ -271,12 +304,14 @@ class PromotedWidgetView implements IPromotedWidgetView {
this.projectedWidget = undefined
this.projectedSourceNode = undefined
this.projectedSourceWidget = undefined
this.projectedSourceWidgetType = undefined
return undefined
}
this.projectedWidget = concrete.createCopyForNode(this.subgraphNode)
this.projectedSourceNode = resolved.node
this.projectedSourceWidget = resolved.widget
this.projectedSourceWidgetType = resolved.widget.type
return this.projectedWidget
}
@@ -333,7 +368,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
private syncDomOverride(
resolved:
| { node: LGraphNode; widget: IBaseWidget }
| undefined = this.resolve()
| undefined = this.resolveAtHost()
) {
if (!resolved || !isBaseDOMWidget(resolved.widget)) return
useDomWidgetStore().setPositionOverride(resolved.widget.id, {
@@ -356,13 +391,35 @@ function drawDisconnectedPlaceholder(
y: number,
H: number
) {
const backgroundColor = readDesignToken(
'--color-secondary-background',
'#333'
)
const textColor = readDesignToken('--color-text-secondary', '#999')
const fontSize = readDesignToken('--text-xxs', '11px')
const fontFamily = readDesignToken('--font-inter', 'sans-serif')
ctx.save()
ctx.fillStyle = '#333'
ctx.fillStyle = backgroundColor
ctx.fillRect(15, y, width - 30, H)
ctx.fillStyle = '#999'
ctx.font = '11px monospace'
ctx.fillStyle = textColor
ctx.font = `${fontSize} ${fontFamily}`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(t('subgraphStore.disconnected'), width / 2, y + H / 2)
ctx.restore()
}
function readDesignToken(token: string, fallback: string): string {
if (typeof document === 'undefined') return fallback
const cachedValue = designTokenCache.get(token)
if (cachedValue) return cachedValue
const value = getComputedStyle(document.documentElement)
.getPropertyValue(token)
.trim()
const resolvedValue = value || fallback
designTokenCache.set(token, resolvedValue)
return resolvedValue
}

View File

@@ -0,0 +1,257 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import {
resolveConcretePromotedWidget,
resolvePromotedWidgetAtHost
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
}))
vi.mock('@/stores/domWidgetStore', () => ({
useDomWidgetStore: () => ({ widgetStates: new Map() })
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
type PromotedWidgetStub = Pick<
IBaseWidget,
'name' | 'type' | 'options' | 'value' | 'y'
> & {
sourceNodeId: string
sourceWidgetName: string
node?: SubgraphNode
}
function createHostNode(id: number): SubgraphNode {
return createTestSubgraphNode(createTestSubgraph(), { id })
}
function addNodeToHost(host: SubgraphNode, title: string): LGraphNode {
const node = new LGraphNode(title)
host.subgraph.add(node)
return node
}
function addConcreteWidget(node: LGraphNode, name: string): IBaseWidget {
return node.addWidget('text', name, `${name}-value`, () => undefined)
}
function createPromotedWidget(
name: string,
sourceNodeId: string,
sourceWidgetName: string,
node?: SubgraphNode
): IBaseWidget {
const promotedWidget: PromotedWidgetStub = {
name,
type: 'button',
options: {},
y: 0,
value: undefined,
sourceNodeId,
sourceWidgetName,
node
}
return promotedWidget as IBaseWidget
}
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
describe('resolvePromotedWidgetAtHost', () => {
test('resolves a direct concrete widget on the host subgraph node', () => {
const host = createHostNode(100)
const concreteNode = addNodeToHost(host, 'leaf')
addConcreteWidget(concreteNode, 'seed')
const resolved = resolvePromotedWidgetAtHost(
host,
String(concreteNode.id),
'seed'
)
expect(resolved).toBeDefined()
expect(resolved?.node.id).toBe(concreteNode.id)
expect(resolved?.widget.name).toBe('seed')
})
test('returns undefined when host does not contain the target node', () => {
const host = createHostNode(100)
const resolved = resolvePromotedWidgetAtHost(host, 'missing', 'seed')
expect(resolved).toBeUndefined()
})
})
describe('resolveConcretePromotedWidget', () => {
test('resolves a direct concrete source widget', () => {
const host = createHostNode(100)
const concreteNode = addNodeToHost(host, 'leaf')
addConcreteWidget(concreteNode, 'seed')
const result = resolveConcretePromotedWidget(
host,
String(concreteNode.id),
'seed'
)
expect(result.status).toBe('resolved')
if (result.status !== 'resolved') return
expect(result.resolved.node.id).toBe(concreteNode.id)
expect(result.resolved.widget.name).toBe('seed')
})
test('descends through nested promoted widgets to resolve concrete source', () => {
const rootHost = createHostNode(100)
const nestedHost = createHostNode(101)
const leafNode = addNodeToHost(nestedHost, 'leaf')
addConcreteWidget(leafNode, 'seed')
const sourceNode = addNodeToHost(rootHost, 'source')
sourceNode.widgets = [
createPromotedWidget('outer', String(leafNode.id), 'seed', nestedHost)
]
const result = resolveConcretePromotedWidget(
rootHost,
String(sourceNode.id),
'outer'
)
expect(result.status).toBe('resolved')
if (result.status !== 'resolved') return
expect(result.resolved.node.id).toBe(leafNode.id)
expect(result.resolved.widget.name).toBe('seed')
})
test('returns cycle failure when promoted widgets form a loop', () => {
const hostA = createHostNode(200)
const hostB = createHostNode(201)
const relayA = addNodeToHost(hostA, 'relayA')
const relayB = addNodeToHost(hostB, 'relayB')
relayA.widgets = [
createPromotedWidget('wA', String(relayB.id), 'wB', hostB)
]
relayB.widgets = [
createPromotedWidget('wB', String(relayA.id), 'wA', hostA)
]
const result = resolveConcretePromotedWidget(hostA, String(relayA.id), 'wA')
expect(result).toEqual({
status: 'failure',
failure: 'cycle'
})
})
test('does not report a cycle when different host objects share an id', () => {
const rootHost = createHostNode(41)
const nestedHost = createHostNode(41)
const leafNode = addNodeToHost(nestedHost, 'leaf')
addConcreteWidget(leafNode, 'w')
const sourceNode = addNodeToHost(rootHost, 'source')
sourceNode.widgets = [
createPromotedWidget('w', String(leafNode.id), 'w', nestedHost)
]
const result = resolveConcretePromotedWidget(
rootHost,
String(sourceNode.id),
'w'
)
expect(result.status).toBe('resolved')
if (result.status !== 'resolved') return
expect(result.resolved.node.id).toBe(leafNode.id)
expect(result.resolved.widget.name).toBe('w')
})
test('returns max-depth-exceeded for very deep non-cyclic promoted chains', () => {
const hosts = Array.from({ length: 102 }, (_, index) =>
createHostNode(index + 1)
)
const relayNodes = hosts.map((host, index) =>
addNodeToHost(host, `relay-${index}`)
)
for (let index = 0; index < relayNodes.length - 1; index += 1) {
relayNodes[index].widgets = [
createPromotedWidget(
`w-${index}`,
String(relayNodes[index + 1].id),
`w-${index + 1}`,
hosts[index + 1]
)
]
}
addConcreteWidget(
relayNodes[relayNodes.length - 1],
`w-${relayNodes.length - 1}`
)
const result = resolveConcretePromotedWidget(
hosts[0],
String(relayNodes[0].id),
'w-0'
)
expect(result).toEqual({
status: 'failure',
failure: 'max-depth-exceeded'
})
})
test('returns invalid-host for non-subgraph host node', () => {
const host = new LGraphNode('plain-host')
const result = resolveConcretePromotedWidget(host, 'x', 'y')
expect(result).toEqual({
status: 'failure',
failure: 'invalid-host'
})
})
test('returns missing-node when source node does not exist in host subgraph', () => {
const host = createHostNode(100)
const result = resolveConcretePromotedWidget(host, 'missing-node', 'seed')
expect(result).toEqual({
status: 'failure',
failure: 'missing-node'
})
})
test('returns missing-widget when source node exists but widget cannot be resolved', () => {
const host = createHostNode(100)
const sourceNode = addNodeToHost(host, 'source')
sourceNode.widgets = []
const result = resolveConcretePromotedWidget(
host,
String(sourceNode.id),
'missing-widget'
)
expect(result).toEqual({
status: 'failure',
failure: 'missing-widget'
})
})
})

View File

@@ -0,0 +1,102 @@
import type { ResolvedPromotedWidget } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
type PromotedWidgetResolutionFailure =
| 'invalid-host'
| 'cycle'
| 'missing-node'
| 'missing-widget'
| 'max-depth-exceeded'
type PromotedWidgetResolutionResult =
| { status: 'resolved'; resolved: ResolvedPromotedWidget }
| { status: 'failure'; failure: PromotedWidgetResolutionFailure }
const MAX_PROMOTED_WIDGET_CHAIN_DEPTH = 100
function traversePromotedWidgetChain(
hostNode: SubgraphNode,
nodeId: string,
widgetName: string
): PromotedWidgetResolutionResult {
const visited = new Set<string>()
const hostUidByObject = new WeakMap<SubgraphNode, number>()
let nextHostUid = 0
let currentHost = hostNode
let currentNodeId = nodeId
let currentWidgetName = widgetName
for (let depth = 0; depth < MAX_PROMOTED_WIDGET_CHAIN_DEPTH; depth++) {
let hostUid = hostUidByObject.get(currentHost)
if (hostUid === undefined) {
hostUid = nextHostUid
nextHostUid += 1
hostUidByObject.set(currentHost, hostUid)
}
const key = `${hostUid}:${currentNodeId}:${currentWidgetName}`
if (visited.has(key)) {
return { status: 'failure', failure: 'cycle' }
}
visited.add(key)
const sourceNode = currentHost.subgraph.getNodeById(currentNodeId)
if (!sourceNode) {
return { status: 'failure', failure: 'missing-node' }
}
const sourceWidget = sourceNode.widgets?.find(
(entry) => entry.name === currentWidgetName
)
if (!sourceWidget) {
return { status: 'failure', failure: 'missing-widget' }
}
if (!isPromotedWidgetView(sourceWidget)) {
return {
status: 'resolved',
resolved: { node: sourceNode, widget: sourceWidget }
}
}
if (!sourceWidget.node?.isSubgraphNode()) {
return { status: 'failure', failure: 'missing-node' }
}
currentHost = sourceWidget.node
currentNodeId = sourceWidget.sourceNodeId
currentWidgetName = sourceWidget.sourceWidgetName
}
return { status: 'failure', failure: 'max-depth-exceeded' }
}
export function resolvePromotedWidgetAtHost(
hostNode: SubgraphNode,
nodeId: string,
widgetName: string
): ResolvedPromotedWidget | undefined {
const node = hostNode.subgraph.getNodeById(nodeId)
if (!node) return undefined
const widget = node.widgets?.find(
(entry: IBaseWidget) => entry.name === widgetName
)
if (!widget) return undefined
return { node, widget }
}
export function resolveConcretePromotedWidget(
hostNode: LGraphNode,
nodeId: string,
widgetName: string
): PromotedWidgetResolutionResult {
if (!hostNode.isSubgraphNode()) {
return { status: 'failure', failure: 'invalid-host' }
}
return traversePromotedWidgetChain(hostNode, nodeId, widgetName)
}

View File

@@ -1,29 +1,22 @@
import type { ResolvedPromotedWidget } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
interface ResolvedPromotedWidgetSource {
node: LGraphNode
widget: IBaseWidget
}
export function resolvePromotedWidgetSource(
hostNode: LGraphNode,
widget: IBaseWidget
): ResolvedPromotedWidgetSource | undefined {
): ResolvedPromotedWidget | undefined {
if (!isPromotedWidgetView(widget)) return undefined
if (!hostNode.isSubgraphNode()) return undefined
const sourceNode = hostNode.subgraph.getNodeById(widget.sourceNodeId)
if (!sourceNode) return undefined
const sourceWidget = sourceNode.widgets?.find(
(entry) => entry.name === widget.sourceWidgetName
const result = resolveConcretePromotedWidget(
hostNode,
widget.sourceNodeId,
widget.sourceWidgetName
)
if (!sourceWidget) return undefined
if (result.status === 'resolved') return result.resolved
return {
node: sourceNode,
widget: sourceWidget
}
return undefined
}

View File

@@ -0,0 +1,147 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { resolveSubgraphInputLink } from '@/core/graph/subgraph/resolveSubgraphInputLink'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
}))
vi.mock('@/stores/domWidgetStore', () => ({
useDomWidgetStore: () => ({ widgetStates: new Map() })
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
function createSubgraphSetup(inputName: string): {
subgraph: Subgraph
subgraphNode: SubgraphNode
} {
const subgraph = createTestSubgraph({
inputs: [{ name: inputName, type: '*' }]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: 1 })
return { subgraph, subgraphNode }
}
function addLinkedInteriorInput(
subgraph: Subgraph,
inputName: string,
linkedInputName: string,
widgetName: string
): {
node: LGraphNode
linkId: number
} {
const inputSlot = subgraph.inputNode.slots.find(
(slot) => slot.name === inputName
)
if (!inputSlot) throw new Error(`Missing subgraph input slot: ${inputName}`)
const node = new LGraphNode(`Interior-${linkedInputName}`)
const input = node.addInput(linkedInputName, '*')
node.addWidget('text', widgetName, '', () => undefined)
input.widget = { name: widgetName }
subgraph.add(node)
inputSlot.connect(input, node)
if (input.link == null)
throw new Error(`Expected link to be created for input ${linkedInputName}`)
return { node, linkId: input.link }
}
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
})
describe('resolveSubgraphInputLink', () => {
test('returns undefined for non-subgraph nodes', () => {
const node = new LGraphNode('plain-node')
const result = resolveSubgraphInputLink(node, 'missing', () => 'resolved')
expect(result).toBeUndefined()
})
test('returns undefined when input slot is missing', () => {
const { subgraphNode } = createSubgraphSetup('existing')
const result = resolveSubgraphInputLink(
subgraphNode,
'missing',
() => 'resolved'
)
expect(result).toBeUndefined()
})
test('skips stale links where inputNode.inputs is unavailable', () => {
const { subgraph, subgraphNode } = createSubgraphSetup('prompt')
addLinkedInteriorInput(subgraph, 'prompt', 'seed_input', 'seed')
const stale = addLinkedInteriorInput(
subgraph,
'prompt',
'stale_input',
'stale'
)
const originalGetLink = subgraph.getLink.bind(subgraph)
vi.spyOn(subgraph, 'getLink').mockImplementation((linkId) => {
if (typeof linkId !== 'number') return originalGetLink(linkId)
if (linkId === stale.linkId) {
return {
resolve: () => ({
inputNode: {
inputs: undefined,
getWidgetFromSlot: () => ({ name: 'ignored' })
}
})
} as unknown as ReturnType<typeof subgraph.getLink>
}
return originalGetLink(linkId)
})
const result = resolveSubgraphInputLink(
subgraphNode,
'prompt',
({ targetInput }) => targetInput.name
)
expect(result).toBe('seed_input')
})
test('caches getTargetWidget result within the same callback evaluation', () => {
const { subgraph, subgraphNode } = createSubgraphSetup('model')
const linked = addLinkedInteriorInput(
subgraph,
'model',
'model_input',
'modelWidget'
)
const getWidgetFromSlot = vi.spyOn(linked.node, 'getWidgetFromSlot')
const result = resolveSubgraphInputLink(
subgraphNode,
'model',
({ getTargetWidget }) => {
expect(getTargetWidget()?.name).toBe('modelWidget')
expect(getTargetWidget()?.name).toBe('modelWidget')
return 'ok'
}
)
expect(result).toBe('ok')
expect(getWidgetFromSlot).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,55 @@
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
type SubgraphInputLinkContext = {
inputNode: LGraphNode
targetInput: INodeInputSlot
getTargetWidget: () => ReturnType<LGraphNode['getWidgetFromSlot']>
}
export function resolveSubgraphInputLink<TResult>(
node: LGraphNode,
inputName: string,
resolve: (context: SubgraphInputLinkContext) => TResult | undefined
): TResult | undefined {
if (!node.isSubgraphNode()) return undefined
const inputSlot = node.subgraph.inputNode.slots.find(
(slot) => slot.name === inputName
)
if (!inputSlot) return undefined
// Iterate from newest to oldest so the latest connection wins.
for (let index = inputSlot.linkIds.length - 1; index >= 0; index -= 1) {
const linkId = inputSlot.linkIds[index]
const link = node.subgraph.getLink(linkId)
if (!link) continue
const { inputNode } = link.resolve(node.subgraph)
if (!inputNode) continue
if (!Array.isArray(inputNode.inputs)) continue
const targetInput = inputNode.inputs.find((entry) => entry.link === linkId)
if (!targetInput) continue
let cachedTargetWidget:
| ReturnType<LGraphNode['getWidgetFromSlot']>
| undefined
let hasCachedTargetWidget = false
const resolved = resolve({
inputNode,
targetInput,
getTargetWidget: () => {
if (!hasCachedTargetWidget) {
cachedTargetWidget = inputNode.getWidgetFromSlot(targetInput)
hasCachedTargetWidget = true
}
return cachedTargetWidget
}
})
if (resolved !== undefined) return resolved
}
return undefined
}

View File

@@ -0,0 +1,34 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { resolveSubgraphInputLink } from './resolveSubgraphInputLink'
type ResolvedSubgraphInputTarget = {
nodeId: string
widgetName: string
}
export function resolveSubgraphInputTarget(
node: LGraphNode,
inputName: string
): ResolvedSubgraphInputTarget | undefined {
return resolveSubgraphInputLink(
node,
inputName,
({ inputNode, targetInput, getTargetWidget }) => {
if (inputNode.isSubgraphNode()) {
return {
nodeId: String(inputNode.id),
widgetName: targetInput.name
}
}
const targetWidget = getTargetWidget()
if (!targetWidget) return undefined
return {
nodeId: String(inputNode.id),
widgetName: targetWidget.name
}
}
)
}

View File

@@ -6,6 +6,7 @@ import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LLink } from '@/lib/litegraph/src/litegraph'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { app } from '@/scripts/app'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
function applyToGraph(this: LGraphNode, extraLinks: LLink[] = []) {
if (!this.outputs[0].links?.length || !this.graph) return
@@ -74,17 +75,25 @@ function onCustomComboCreated(this: LGraphNode) {
function addOption(node: LGraphNode) {
if (!node.widgets) return
const newCount = node.widgets.length - 1
node.addWidget('string', `option${newCount}`, '', () => {})
const widget = node.widgets.at(-1)
const widgetName = `option${newCount}`
const widget = node.addWidget('string', widgetName, '', () => {})
if (!widget) return
let value = ''
Object.defineProperty(widget, 'value', {
get() {
return value
return useWidgetValueStore().getWidget(
app.rootGraph.id,
node.id,
widgetName
)?.value
},
set(v) {
value = v
set(v: string) {
const state = useWidgetValueStore().getWidget(
app.rootGraph.id,
node.id,
widgetName
)
if (state) state.value = v
updateCombo()
if (!node.widgets) return
const lastWidget = node.widgets.at(-1)

View File

@@ -1,5 +1,6 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { NodeOutputWith } from '@/schemas/apiSchema'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useExtensionService } from '@/services/extensionService'
@@ -26,8 +27,11 @@ useExtensionService().registerExtension({
const { a_images: aImages, b_images: bImages } = output
const rand = app.getRandParam()
const toUrl = (params: Record<string, string>) =>
api.apiURL(`/view?${new URLSearchParams(params)}${rand}`)
const toUrl = (record: Record<string, string>) => {
const params = new URLSearchParams(record)
appendCloudResParam(params)
return api.apiURL(`/view?${params}${rand}`)
}
const beforeImages =
aImages && aImages.length > 0 ? aImages.map(toUrl) : []

View File

@@ -15,7 +15,7 @@ useExtensionService().registerExtension({
for (const widget of node.widgets ?? []) {
if (HIDDEN_WIDGETS.has(widget.name)) {
widget.hidden = true
widget.options.hidden = true
}
}
}

View File

@@ -634,4 +634,25 @@ describe('Subgraph Unpacking', () => {
expect(unpackedTarget.inputs[0].link).not.toBeNull()
expect(unpackedTarget.inputs[1].link).toBeNull()
})
it('keeps subgraph definition when unpacking one instance while another remains', () => {
const rootGraph = new LGraph()
const subgraph = createSubgraphOnGraph(rootGraph)
const firstInstance = createTestSubgraphNode(subgraph, { pos: [100, 100] })
const secondInstance = createTestSubgraphNode(subgraph, { pos: [300, 100] })
secondInstance.id = 2
rootGraph.add(firstInstance)
rootGraph.add(secondInstance)
rootGraph.unpackSubgraph(firstInstance)
expect(rootGraph.subgraphs.has(subgraph.id)).toBe(true)
const serialized = rootGraph.serialize()
const definitionIds =
serialized.definitions?.subgraphs?.map((definition) => definition.id) ??
[]
expect(definitionIds).toContain(subgraph.id)
})
})

View File

@@ -1071,13 +1071,23 @@ export class LGraph
}
if (node.isSubgraphNode()) {
forEachNode(node.subgraph, (innerNode) => {
innerNode.onRemoved?.()
innerNode.graph?.onNodeRemoved?.(innerNode)
if (innerNode.isSubgraphNode())
this.rootGraph.subgraphs.delete(innerNode.subgraph.id)
})
this.rootGraph.subgraphs.delete(node.subgraph.id)
const allGraphs = [this.rootGraph, ...this.rootGraph.subgraphs.values()]
const hasRemainingReferences = allGraphs.some((graph) =>
graph.nodes.some(
(candidate) =>
candidate !== node &&
candidate.isSubgraphNode() &&
candidate.type === node.subgraph.id
)
)
if (!hasRemainingReferences) {
forEachNode(node.subgraph, (innerNode) => {
innerNode.onRemoved?.()
innerNode.graph?.onNodeRemoved?.(innerNode)
})
this.rootGraph.subgraphs.delete(node.subgraph.id)
}
}
// callback
@@ -1869,6 +1879,7 @@ export class LGraph
})
)
)
return { subgraph, node: subgraphNode as SubgraphNode }
}
@@ -2055,7 +2066,6 @@ export class LGraph
})
}
this.remove(subgraphNode)
this.subgraphs.delete(subgraphNode.subgraph.id)
// Deduplicate links by (oid, oslot, tid, tslot) to prevent repeated
// disconnect/reconnect cycles on widget inputs that can shift slot indices.
@@ -2342,7 +2352,6 @@ export class LGraph
const usedSubgraphs = [...this._subgraphs.values()]
.filter((subgraph) => usedSubgraphIds.has(subgraph.id))
.map((x) => x.asSerialisable())
if (usedSubgraphs.length > 0) {
data.definitions = { subgraphs: usedSubgraphs }
}

View File

@@ -1,11 +1,18 @@
import { describe, expect, test } from 'vitest'
import { PromotedWidgetViewManager } from '@/lib/litegraph/src/subgraph/PromotedWidgetViewManager'
import type { SubgraphPromotionEntry } from '@/services/subgraphPseudoWidgetCache'
function makeView(entry: SubgraphPromotionEntry) {
type TestPromotionEntry = {
interiorNodeId: string
widgetName: string
viewKey?: string
}
function makeView(entry: TestPromotionEntry) {
const baseKey = `${entry.interiorNodeId}:${entry.widgetName}`
return {
key: `${entry.interiorNodeId}:${entry.widgetName}`
key: entry.viewKey ? `${baseKey}:${entry.viewKey}` : baseKey
}
}
@@ -76,4 +83,46 @@ describe('PromotedWidgetViewManager', () => {
expect(restored[0]).toBe(first[1])
expect(restored[1]).not.toBe(first[0])
})
test('keeps distinct views for same source widget when viewKeys differ', () => {
const manager = new PromotedWidgetViewManager<{ key: string }>()
const views = manager.reconcile(
[
{ interiorNodeId: '1', widgetName: 'widgetA', viewKey: 'slotA' },
{ interiorNodeId: '1', widgetName: 'widgetA', viewKey: 'slotB' }
],
makeView
)
expect(views).toHaveLength(2)
expect(views[0]).not.toBe(views[1])
expect(views[0].key).toBe('1:widgetA:slotA')
expect(views[1].key).toBe('1:widgetA:slotB')
})
test('removeByViewKey removes only the targeted keyed view', () => {
const manager = new PromotedWidgetViewManager<{ key: string }>()
const firstPass = manager.reconcile(
[
{ interiorNodeId: '1', widgetName: 'widgetA', viewKey: 'slotA' },
{ interiorNodeId: '1', widgetName: 'widgetA', viewKey: 'slotB' }
],
makeView
)
manager.removeByViewKey('1', 'widgetA', 'slotA')
const secondPass = manager.reconcile(
[
{ interiorNodeId: '1', widgetName: 'widgetA', viewKey: 'slotA' },
{ interiorNodeId: '1', widgetName: 'widgetA', viewKey: 'slotB' }
],
makeView
)
expect(secondPass[0]).not.toBe(firstPass[0])
expect(secondPass[1]).toBe(firstPass[1])
})
})

View File

@@ -1,6 +1,7 @@
type PromotionEntry = {
interiorNodeId: string
widgetName: string
viewKey?: string
}
type CreateView<TView> = (entry: PromotionEntry) => TView
@@ -14,20 +15,28 @@ type CreateView<TView> = (entry: PromotionEntry) => TView
export class PromotedWidgetViewManager<TView> {
private viewCache = new Map<string, TView>()
private cachedViews: TView[] | null = null
private cachedEntriesRef: readonly PromotionEntry[] | null = null
private cachedEntryKeys: string[] | null = null
reconcile(
entries: readonly PromotionEntry[],
createView: CreateView<TView>
): TView[] {
if (this.cachedViews && entries === this.cachedEntriesRef)
const entryKeys = entries.map((entry) =>
this.makeKey(entry.interiorNodeId, entry.widgetName, entry.viewKey)
)
if (this.cachedViews && this.areEntryKeysEqual(entryKeys))
return this.cachedViews
const views: TView[] = []
const seenKeys = new Set<string>()
for (const entry of entries) {
const key = this.makeKey(entry.interiorNodeId, entry.widgetName)
const key = this.makeKey(
entry.interiorNodeId,
entry.widgetName,
entry.viewKey
)
if (seenKeys.has(key)) continue
seenKeys.add(key)
@@ -47,16 +56,17 @@ export class PromotedWidgetViewManager<TView> {
}
this.cachedViews = views
this.cachedEntriesRef = entries
this.cachedEntryKeys = entryKeys
return views
}
getOrCreate(
interiorNodeId: string,
widgetName: string,
createView: () => TView
createView: () => TView,
viewKey?: string
): TView {
const key = this.makeKey(interiorNodeId, widgetName)
const key = this.makeKey(interiorNodeId, widgetName, viewKey)
const cached = this.viewCache.get(key)
if (cached) return cached
@@ -70,6 +80,15 @@ export class PromotedWidgetViewManager<TView> {
this.invalidateMemoizedList()
}
removeByViewKey(
interiorNodeId: string,
widgetName: string,
viewKey: string
): void {
this.viewCache.delete(this.makeKey(interiorNodeId, widgetName, viewKey))
this.invalidateMemoizedList()
}
clear(): void {
this.viewCache.clear()
this.invalidateMemoizedList()
@@ -77,10 +96,25 @@ export class PromotedWidgetViewManager<TView> {
invalidateMemoizedList(): void {
this.cachedViews = null
this.cachedEntriesRef = null
this.cachedEntryKeys = null
}
private makeKey(interiorNodeId: string, widgetName: string): string {
return `${interiorNodeId}:${widgetName}`
private areEntryKeysEqual(entryKeys: string[]): boolean {
if (!this.cachedEntryKeys) return false
if (this.cachedEntryKeys.length !== entryKeys.length) return false
for (let index = 0; index < entryKeys.length; index += 1) {
if (this.cachedEntryKeys[index] !== entryKeys[index]) return false
}
return true
}
private makeKey(
interiorNodeId: string,
widgetName: string,
viewKey?: string
): string {
const baseKey = `${interiorNodeId}:${widgetName}`
return viewKey ? `${baseKey}:${viewKey}` : baseKey
}
}

View File

@@ -87,7 +87,9 @@ export class SubgraphInput extends SubgraphSlot {
return
}
this._widget ??= inputWidget
// Keep the widget reference in sync with the active upstream widget.
// Stale references can appear across nested promotion rebinds.
this._widget = inputWidget
this.events.dispatch('input-connected', {
input: slot,
widget: inputWidget,
@@ -208,6 +210,8 @@ export class SubgraphInput extends SubgraphSlot {
override disconnect(): void {
super.disconnect()
this._widget = undefined
this.events.dispatch('input-disconnected', { input: this })
}

View File

@@ -34,6 +34,7 @@ import {
isPromotedWidgetView
} from '@/core/graph/subgraph/promotedWidgetView'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { usePromotionStore } from '@/stores/promotionStore'
@@ -48,6 +49,11 @@ const workflowSvg = new Image()
workflowSvg.src =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' width='16' height='16'%3E%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='white' stroke-linecap='round' stroke-width='1.3' d='M9.18613 3.09999H6.81377M9.18613 12.9H7.55288c-3.08678 0-5.35171-2.99581-4.60305-6.08843l.3054-1.26158M14.7486 2.1721l-.5931 2.45c-.132.54533-.6065.92789-1.1508.92789h-2.2993c-.77173 0-1.33797-.74895-1.1508-1.5221l.5931-2.45c.132-.54533.6065-.9279 1.1508-.9279h2.2993c.7717 0 1.3379.74896 1.1508 1.52211Zm-8.3033 0-.59309 2.45c-.13201.54533-.60646.92789-1.15076.92789H2.4021c-.7717 0-1.33793-.74895-1.15077-1.5221l.59309-2.45c.13201-.54533.60647-.9279 1.15077-.9279h2.29935c.77169 0 1.33792.74896 1.15076 1.52211Zm8.3033 9.8-.5931 2.45c-.132.5453-.6065.9279-1.1508.9279h-2.2993c-.77173 0-1.33797-.749-1.1508-1.5221l.5931-2.45c.132-.5453.6065-.9279 1.1508-.9279h2.2993c.7717 0 1.3379.7489 1.1508 1.5221Z'/%3E%3C/svg%3E %3C/svg%3E"
type LinkedPromotionEntry = {
inputName: string
interiorNodeId: string
widgetName: string
}
// Pre-rasterize the SVG to a bitmap canvas to avoid Firefox re-processing
// the SVG's internal stylesheet on every ctx.drawImage() call per frame.
const workflowBitmapCache = createBitmapCache(workflowSvg, 32)
@@ -78,21 +84,244 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
private _promotedViewManager =
new PromotedWidgetViewManager<PromotedWidgetView>()
/**
* Promotions buffered before this node is attached to a graph (`id === -1`).
* They are flushed in `_flushPendingPromotions()` from `_setWidget()` and
* `onAdded()`, so construction-time promotions require normal add-to-graph
* lifecycle to persist.
*/
private _pendingPromotions: Array<{
interiorNodeId: string
widgetName: string
}> = []
// Declared as accessor via Object.defineProperty in constructor.
// TypeScript doesn't allow overriding a property with get/set syntax,
// so we use declare + defineProperty instead.
declare widgets: IBaseWidget[]
private _resolveLinkedPromotionByInputName(
inputName: string
): { interiorNodeId: string; widgetName: string } | undefined {
const resolvedTarget = resolveSubgraphInputTarget(this, inputName)
if (!resolvedTarget) return undefined
return {
interiorNodeId: resolvedTarget.nodeId,
widgetName: resolvedTarget.widgetName
}
}
private _getLinkedPromotionEntries(): LinkedPromotionEntry[] {
const linkedEntries: LinkedPromotionEntry[] = []
// TODO(pr9282): Optimization target. This path runs on widgets getter reads
// and resolves each input link chain eagerly.
for (const input of this.inputs) {
const resolved = this._resolveLinkedPromotionByInputName(input.name)
if (!resolved) continue
linkedEntries.push({ inputName: input.name, ...resolved })
}
const seenEntryKeys = new Set<string>()
const deduplicatedEntries = linkedEntries.filter((entry) => {
const entryKey = this._makePromotionViewKey(
entry.inputName,
entry.interiorNodeId,
entry.widgetName
)
if (seenEntryKeys.has(entryKey)) return false
seenEntryKeys.add(entryKey)
return true
})
return deduplicatedEntries
}
private _getPromotedViews(): PromotedWidgetView[] {
const store = usePromotionStore()
const entries = store.getPromotionsRef(this.rootGraph.id, this.id)
const linkedEntries = this._getLinkedPromotionEntries()
const { displayNameByViewKey, reconcileEntries } =
this._buildPromotionReconcileState(entries, linkedEntries)
return this._promotedViewManager.reconcile(entries, (entry) =>
createPromotedWidgetView(this, entry.interiorNodeId, entry.widgetName)
return this._promotedViewManager.reconcile(reconcileEntries, (entry) =>
createPromotedWidgetView(
this,
entry.interiorNodeId,
entry.widgetName,
entry.viewKey ? displayNameByViewKey.get(entry.viewKey) : undefined
)
)
}
private _syncPromotions(): void {
if (this.id === -1) return
const store = usePromotionStore()
const entries = store.getPromotionsRef(this.rootGraph.id, this.id)
const linkedEntries = this._getLinkedPromotionEntries()
const { mergedEntries, shouldPersistLinkedOnly } =
this._buildPromotionPersistenceState(entries, linkedEntries)
if (!shouldPersistLinkedOnly) return
const hasChanged =
mergedEntries.length !== entries.length ||
mergedEntries.some(
(entry, index) =>
entry.interiorNodeId !== entries[index]?.interiorNodeId ||
entry.widgetName !== entries[index]?.widgetName
)
if (!hasChanged) return
store.setPromotions(this.rootGraph.id, this.id, mergedEntries)
}
private _buildPromotionReconcileState(
entries: Array<{ interiorNodeId: string; widgetName: string }>,
linkedEntries: LinkedPromotionEntry[]
): {
displayNameByViewKey: Map<string, string>
reconcileEntries: Array<{
interiorNodeId: string
widgetName: string
viewKey?: string
}>
} {
const { fallbackStoredEntries } = this._collectLinkedAndFallbackEntries(
entries,
linkedEntries
)
const linkedReconcileEntries =
this._buildLinkedReconcileEntries(linkedEntries)
const shouldPersistLinkedOnly = this._shouldPersistLinkedOnly(linkedEntries)
return {
displayNameByViewKey: this._buildDisplayNameByViewKey(linkedEntries),
reconcileEntries: shouldPersistLinkedOnly
? linkedReconcileEntries
: [...linkedReconcileEntries, ...fallbackStoredEntries]
}
}
private _buildPromotionPersistenceState(
entries: Array<{ interiorNodeId: string; widgetName: string }>,
linkedEntries: LinkedPromotionEntry[]
): {
mergedEntries: Array<{ interiorNodeId: string; widgetName: string }>
shouldPersistLinkedOnly: boolean
} {
const { linkedPromotionEntries, fallbackStoredEntries } =
this._collectLinkedAndFallbackEntries(entries, linkedEntries)
const shouldPersistLinkedOnly = this._shouldPersistLinkedOnly(linkedEntries)
return {
mergedEntries: shouldPersistLinkedOnly
? linkedPromotionEntries
: [...linkedPromotionEntries, ...fallbackStoredEntries],
shouldPersistLinkedOnly
}
}
private _collectLinkedAndFallbackEntries(
entries: Array<{ interiorNodeId: string; widgetName: string }>,
linkedEntries: LinkedPromotionEntry[]
): {
linkedPromotionEntries: Array<{
interiorNodeId: string
widgetName: string
}>
fallbackStoredEntries: Array<{ interiorNodeId: string; widgetName: string }>
} {
const linkedPromotionEntries = this._toPromotionEntries(linkedEntries)
const fallbackStoredEntries = this._getFallbackStoredEntries(
entries,
linkedPromotionEntries
)
return {
linkedPromotionEntries,
fallbackStoredEntries
}
}
private _shouldPersistLinkedOnly(
linkedEntries: LinkedPromotionEntry[]
): boolean {
return this.inputs.length > 0 && linkedEntries.length === this.inputs.length
}
private _toPromotionEntries(
linkedEntries: LinkedPromotionEntry[]
): Array<{ interiorNodeId: string; widgetName: string }> {
return linkedEntries.map(({ interiorNodeId, widgetName }) => ({
interiorNodeId,
widgetName
}))
}
private _getFallbackStoredEntries(
entries: Array<{ interiorNodeId: string; widgetName: string }>,
linkedPromotionEntries: Array<{
interiorNodeId: string
widgetName: string
}>
): Array<{ interiorNodeId: string; widgetName: string }> {
const linkedKeys = new Set(
linkedPromotionEntries.map((entry) =>
this._makePromotionEntryKey(entry.interiorNodeId, entry.widgetName)
)
)
return entries.filter(
(entry) =>
!linkedKeys.has(
this._makePromotionEntryKey(entry.interiorNodeId, entry.widgetName)
)
)
}
private _buildLinkedReconcileEntries(
linkedEntries: LinkedPromotionEntry[]
): Array<{ interiorNodeId: string; widgetName: string; viewKey: string }> {
return linkedEntries.map(({ inputName, interiorNodeId, widgetName }) => ({
interiorNodeId,
widgetName,
viewKey: this._makePromotionViewKey(inputName, interiorNodeId, widgetName)
}))
}
private _buildDisplayNameByViewKey(
linkedEntries: LinkedPromotionEntry[]
): Map<string, string> {
return new Map(
linkedEntries.map((entry) => [
this._makePromotionViewKey(
entry.inputName,
entry.interiorNodeId,
entry.widgetName
),
entry.inputName
])
)
}
private _makePromotionEntryKey(
interiorNodeId: string,
widgetName: string
): string {
return `${interiorNodeId}:${widgetName}`
}
private _makePromotionViewKey(
inputName: string,
interiorNodeId: string,
widgetName: string
): string {
return `${inputName}:${interiorNodeId}:${widgetName}`
}
private _resolveLegacyEntry(
widgetName: string
): [string, string] | undefined {
@@ -107,23 +336,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
// Fallback: find via subgraph input slot connection
const subgraphInput = this.subgraph.inputNode.slots.find(
(slot) => slot.name === widgetName
)
if (!subgraphInput) return undefined
const resolvedTarget = resolveSubgraphInputTarget(this, widgetName)
if (!resolvedTarget) return undefined
for (const linkId of subgraphInput.linkIds) {
const link = this.subgraph.getLink(linkId)
if (!link) continue
const { inputNode } = link.resolve(this.subgraph)
if (!inputNode) continue
const targetInput = inputNode.inputs.find((inp) => inp.link === linkId)
if (!targetInput) continue
const w = inputNode.getWidgetFromSlot(targetInput)
if (w) return [String(inputNode.id), w.name]
}
return undefined
return [resolvedTarget.nodeId, resolvedTarget.widgetName]
}
/** Manages lifecycle of all subgraph event listeners */
@@ -190,6 +406,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
if (widget) this.ensureWidgetRemoved(widget)
this.removeInput(e.detail.index)
this._syncPromotions()
this.setDirtyCanvas(true, true)
},
{ signal }
@@ -309,6 +526,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
widgetLocator,
e.detail.node
)
this._syncPromotions()
},
{ signal }
)
@@ -325,6 +543,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
delete input.pos
delete input.widget
input._widget = undefined
this._syncPromotions()
},
{ signal }
)
@@ -469,24 +688,68 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
break
}
}
this._syncPromotions()
}
private _setWidget(
subgraphInput: Readonly<SubgraphInput>,
input: INodeInputSlot,
_widget: Readonly<IBaseWidget>,
interiorWidget: Readonly<IBaseWidget>,
inputWidget: IWidgetLocator | undefined,
interiorNode: LGraphNode
) {
const nodeId = String(interiorNode.id)
const widgetName = _widget.name
this._flushPendingPromotions()
// Add to promotion store
usePromotionStore().promote(this.rootGraph.id, this.id, nodeId, widgetName)
const nodeId = String(interiorNode.id)
const widgetName = interiorWidget.name
const previousView = input._widget
if (
previousView &&
isPromotedWidgetView(previousView) &&
(previousView.sourceNodeId !== nodeId ||
previousView.sourceWidgetName !== widgetName)
) {
usePromotionStore().demote(
this.rootGraph.id,
this.id,
previousView.sourceNodeId,
previousView.sourceWidgetName
)
this._removePromotedView(previousView)
}
if (this.id === -1) {
if (
!this._pendingPromotions.some(
(entry) =>
entry.interiorNodeId === nodeId && entry.widgetName === widgetName
)
) {
this._pendingPromotions.push({
interiorNodeId: nodeId,
widgetName
})
}
} else {
// Add to promotion store
usePromotionStore().promote(
this.rootGraph.id,
this.id,
nodeId,
widgetName
)
}
// Create/retrieve the view from cache
const view = this._promotedViewManager.getOrCreate(nodeId, widgetName, () =>
createPromotedWidgetView(this, nodeId, widgetName, subgraphInput.name)
const view = this._promotedViewManager.getOrCreate(
nodeId,
widgetName,
() =>
createPromotedWidgetView(this, nodeId, widgetName, subgraphInput.name),
this._makePromotionViewKey(subgraphInput.name, nodeId, widgetName)
)
// NOTE: This code creates linked chains of prototypes for passing across
@@ -505,6 +768,26 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
})
}
private _flushPendingPromotions() {
if (this.id === -1 || this._pendingPromotions.length === 0) return
for (const entry of this._pendingPromotions) {
usePromotionStore().promote(
this.rootGraph.id,
this.id,
entry.interiorNodeId,
entry.widgetName
)
}
this._pendingPromotions = []
}
override onAdded(_graph: LGraph): void {
this._flushPendingPromotions()
this._syncPromotions()
}
/**
* Ensures the subgraph slot is in the params before adding the input as normal.
* @param name The name of the input slot.
@@ -650,6 +933,21 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
}
private _removePromotedView(view: PromotedWidgetView): void {
this._promotedViewManager.remove(view.sourceNodeId, view.sourceWidgetName)
// Reconciled views can also be keyed by inputName-scoped view keys.
// Remove both key shapes to avoid stale cache entries across promote/rebind flows.
this._promotedViewManager.removeByViewKey(
view.sourceNodeId,
view.sourceWidgetName,
this._makePromotionViewKey(
view.name,
view.sourceNodeId,
view.sourceWidgetName
)
)
}
override removeWidget(widget: IBaseWidget): void {
this.ensureWidgetRemoved(widget)
}
@@ -668,10 +966,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
widget.sourceNodeId,
widget.sourceWidgetName
)
this._promotedViewManager.remove(
widget.sourceNodeId,
widget.sourceWidgetName
)
this._removePromotedView(widget)
}
for (const input of this.inputs) {
if (input._widget === widget) {
@@ -683,6 +978,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
widget,
subgraphNode: this
})
this._syncPromotions()
}
override onRemoved(): void {

View File

@@ -1031,6 +1031,7 @@
"or": "أو",
"output": "إخراج",
"overwrite": "الكتابة فوق",
"partner": "شريك",
"playPause": "تشغيل/إيقاف مؤقت",
"playRecording": "تشغيل التسجيل",
"playbackSpeed": "سرعة التشغيل",
@@ -1041,6 +1042,7 @@
"progressCountOf": "من",
"queued": "في قائمة الانتظار",
"ready": "جاهز",
"recents": "الأخيرة",
"reconnected": "تم الاتصال من جديد",
"reconnecting": "إعادة الاتصال",
"refresh": "تحديث",
@@ -2629,8 +2631,16 @@
"nodeLibrary": "مكتبة العقد",
"nodeLibraryTab": {
"allNodes": "جميع العقد",
"blueprints": "المخططات",
"custom": "مخصص",
"essentials": "الأساسيات",
"filter": "تصفية",
"filterOptions": {
"blueprints": "المخططات",
"comfyNodes": "عُقد Comfy",
"extensions": "الإضافات",
"partnerNodes": "عُقد الشركاء"
},
"groupBy": "التجميع حسب",
"groupStrategies": {
"category": "الفئة",
@@ -2640,10 +2650,18 @@
"source": "المصدر",
"sourceDesc": "التجميع حسب نوع المصدر (أساسي، مخصص، API)"
},
"noBookmarkedNodes": "لا توجد مفضلات بعد",
"resetView": "إعادة تعيين العرض إلى الافتراضي",
"sections": {
"bookmarked": "المفضلة",
"comfyBlueprints": "مخططات Comfy",
"comfyNodes": "عُقد Comfy",
"extensions": "الإضافات",
"favoriteNode": "العقدة المفضلة",
"favorites": "المفضلة",
"myBlueprints": "مخططاتي",
"partnerNodes": "عُقد الشركاء",
"subgraphBlueprints": "مخططات الرسم الفرعي",
"unfavoriteNode": "إزالة العقدة من المفضلة"
},
"sortBy": {
@@ -2887,6 +2905,7 @@
"resubscribe": "إعادة الاشتراك",
"resubscribeSuccess": "تمت إعادة تفعيل الاشتراك بنجاح",
"resubscribeTo": "إعادة الاشتراك في {plan}",
"subscribeForMore": "ترقية",
"subscribeNow": "اشترك الآن",
"subscribeTo": "اشترك في {plan}",
"subscribeToComfyCloud": "الاشتراك في Comfy Cloud",
@@ -2917,6 +2936,7 @@
"upgrade": "ترقية",
"upgradePlan": "ترقية الخطة",
"upgradeTo": "الترقية إلى {plan}",
"upgradeToAddCredits": "قم بالترقية لإضافة أرصدة",
"usdPerMonth": "دولار أمريكي / شهريًا",
"usdPerMonthPerMember": "دولار أمريكي / شهر / عضو",
"videoEstimateExplanation": "هذه التقديرات مبنية على قالب Wan 2.2 لتحويل الصورة إلى فيديو باستخدام الإعدادات الافتراضية (5 ثوانٍ، 640x640، 16 إطار/ثانية، 4 خطوات أخذ عينات).",

View File

@@ -3778,6 +3778,9 @@
"outputs": {
"0": {
"tooltip": null
},
"1": {
"tooltip": null
}
}
},
@@ -18154,6 +18157,68 @@
}
}
},
"WanSCAILToVideo": {
"display_name": "WanSCAILToVideo",
"inputs": {
"batch_size": {
"name": "حجم الدفعة"
},
"clip_vision_output": {
"name": "clip_vision_output"
},
"height": {
"name": "الارتفاع"
},
"length": {
"name": "الطول"
},
"negative": {
"name": "سلبي"
},
"pose_end": {
"name": "نهاية الوضعية",
"tooltip": "خطوة النهاية لاستخدام تكييف الوضعية."
},
"pose_start": {
"name": "بدء الوضعية",
"tooltip": "خطوة البدء لاستخدام تكييف الوضعية."
},
"pose_strength": {
"name": "قوة الوضعية",
"tooltip": "قوة الفضاء الكامن للوضعية."
},
"pose_video": {
"name": "فيديو الوضعية",
"tooltip": "الفيديو المستخدم لتكييف الوضعية. سيتم تقليل دقته إلى نصف دقة الفيديو الرئيسي."
},
"positive": {
"name": "إيجابي"
},
"reference_image": {
"name": "صورة مرجعية"
},
"vae": {
"name": "vae"
},
"width": {
"name": "العرض"
}
},
"outputs": {
"0": {
"name": "إيجابي",
"tooltip": null
},
"1": {
"name": "سلبي",
"tooltip": null
},
"2": {
"name": "كامن",
"tooltip": "فضاء كامن فارغ بحجم التوليد."
}
}
},
"WanSoundImageToVideo": {
"display_name": "WanSoundImageToVideo",
"inputs": {

View File

@@ -303,7 +303,9 @@
"beta": "BETA",
"nightly": "NIGHTLY",
"profile": "Profile",
"noItems": "No items"
"noItems": "No items",
"recents": "Recents",
"partner": "Partner"
},
"manager": {
"title": "Nodes Manager",
@@ -790,8 +792,16 @@
"newBlankWorkflow": "Create a new blank workflow",
"nodeLibraryTab": {
"essentials": "Essentials",
"allNodes": "All nodes",
"allNodes": "All",
"blueprints": "Blueprints",
"custom": "Custom",
"filter": "Filter",
"filterOptions": {
"blueprints": "Blueprints",
"partnerNodes": "Partner Nodes",
"comfyNodes": "Comfy Nodes",
"extensions": "Extensions"
},
"groupBy": "Group By",
"sortMode": "Sort Mode",
"resetView": "Reset View to Default",
@@ -804,16 +814,24 @@
"sourceDesc": "Group by source type (Core, Custom, API)"
},
"sortBy": {
"original": "Original",
"original": "Categorized",
"originalDesc": "Keep original order",
"alphabetical": "Alphabetical",
"alphabetical": "A-Z",
"alphabeticalDesc": "Sort alphabetically within groups"
},
"sections": {
"favorites": "Favorites",
"favoriteNode": "Favorite Node",
"unfavoriteNode": "Unfavorite Node"
}
"unfavoriteNode": "Unfavorite Node",
"bookmarked": "Bookmarked",
"subgraphBlueprints": "Subgraph Blueprints",
"myBlueprints": "My Blueprints",
"comfyBlueprints": "Comfy Blueprints",
"partnerNodes": "Partner Nodes",
"comfyNodes": "Comfy Nodes",
"extensions": "Extensions"
},
"noBookmarkedNodes": "No favorites yet"
},
"modelLibrary": "Model Library",
"downloads": "Downloads",
@@ -2274,6 +2292,8 @@
},
"subscribeToRun": "Subscribe",
"subscribeToRunFull": "Subscribe to Run",
"subscribeForMore": "Upgrade",
"upgradeToAddCredits": "Upgrade to add credits",
"subscribeNow": "Subscribe Now",
"subscribeToComfyCloud": "Subscribe to Comfy Cloud",
"workspaceNotSubscribed": "This workspace is not on a subscription",
@@ -3036,11 +3056,11 @@
"promptAddInputs": "Click on node parameters to add them here as inputs",
"noInputs": "No inputs added yet",
"inputsDesc": "Users will interact and adjust these to generate their outputs.",
"inputsExample": "Examples: “Load image”, “Text prompt”, “Steps”",
"inputsExample": "Examples: “Load image”, “Text prompt”, “Steps”",
"promptAddOutputs": "Click on output nodes to add them here. These will be the generated results.",
"noOutputs": "No output nodes added yet",
"outputsDesc": "Connect at least one output node so users can see results after running.",
"outputsExample": "Examples: “Save Image” or “Save Video”"
"outputsExample": "Examples: “Save Image” or “Save Video”"
},
"queue": {
"clickToClear": "Click to clear queue",

View File

@@ -3678,6 +3678,9 @@
"outputs": {
"0": {
"tooltip": null
},
"1": {
"tooltip": null
}
}
},
@@ -8030,7 +8033,7 @@
}
},
"Mahiro": {
"display_name": "Mahiro CFG",
"display_name": "Positive-Biased Guidance",
"description": "Modify the guidance to scale more on the 'direction' of the positive prompt rather than the difference between the negative prompt.",
"inputs": {
"model": {
@@ -18193,6 +18196,68 @@
}
}
},
"WanSCAILToVideo": {
"display_name": "WanSCAILToVideo",
"inputs": {
"positive": {
"name": "positive"
},
"negative": {
"name": "negative"
},
"vae": {
"name": "vae"
},
"width": {
"name": "width"
},
"height": {
"name": "height"
},
"length": {
"name": "length"
},
"batch_size": {
"name": "batch_size"
},
"pose_strength": {
"name": "pose_strength",
"tooltip": "Strength of the pose latent."
},
"pose_start": {
"name": "pose_start",
"tooltip": "Start step to use pose conditioning."
},
"pose_end": {
"name": "pose_end",
"tooltip": "End step to use pose conditioning."
},
"clip_vision_output": {
"name": "clip_vision_output"
},
"reference_image": {
"name": "reference_image"
},
"pose_video": {
"name": "pose_video",
"tooltip": "Video used for pose conditioning. Will be downscaled to half the resolution of the main video."
}
},
"outputs": {
"0": {
"name": "positive",
"tooltip": null
},
"1": {
"name": "negative",
"tooltip": null
},
"2": {
"name": "latent",
"tooltip": "Empty latent of the generation size."
}
}
},
"WanSoundImageToVideo": {
"display_name": "WanSoundImageToVideo",
"inputs": {

View File

@@ -1031,6 +1031,7 @@
"or": "o",
"output": "Salida",
"overwrite": "Sobrescribir",
"partner": "Socio",
"playPause": "Reproducir/Pausar",
"playRecording": "Reproducir grabación",
"playbackSpeed": "Velocidad de reproducción",
@@ -1041,6 +1042,7 @@
"progressCountOf": "de",
"queued": "En cola",
"ready": "Listo",
"recents": "Recientes",
"reconnected": "Reconectado",
"reconnecting": "Reconectando",
"refresh": "Actualizar",
@@ -2629,8 +2631,16 @@
"nodeLibrary": "Biblioteca de nodos",
"nodeLibraryTab": {
"allNodes": "Todos los nodos",
"blueprints": "Planos",
"custom": "Personalizado",
"essentials": "Esenciales",
"filter": "Filtrar",
"filterOptions": {
"blueprints": "Planos",
"comfyNodes": "Nodos Comfy",
"extensions": "Extensiones",
"partnerNodes": "Nodos de socios"
},
"groupBy": "Agrupar por",
"groupStrategies": {
"category": "Categoría",
@@ -2640,10 +2650,18 @@
"source": "Fuente",
"sourceDesc": "Agrupar por tipo de fuente (Core, Custom, API)"
},
"noBookmarkedNodes": "Aún no hay favoritos",
"resetView": "Restablecer vista a la predeterminada",
"sections": {
"bookmarked": "Favoritos",
"comfyBlueprints": "Planos Comfy",
"comfyNodes": "Nodos Comfy",
"extensions": "Extensiones",
"favoriteNode": "Nodo favorito",
"favorites": "Favoritos",
"myBlueprints": "Mis planos",
"partnerNodes": "Nodos de socios",
"subgraphBlueprints": "Planos de subgráficos",
"unfavoriteNode": "Quitar de favoritos"
},
"sortBy": {
@@ -2887,6 +2905,7 @@
"resubscribe": "Volver a suscribirse",
"resubscribeSuccess": "¡Suscripción reactivada correctamente!",
"resubscribeTo": "Volver a suscribirse a {plan}",
"subscribeForMore": "Mejorar",
"subscribeNow": "Suscribirse Ahora",
"subscribeTo": "Suscribirse a {plan}",
"subscribeToComfyCloud": "Suscribirse a Comfy Cloud",
@@ -2917,6 +2936,7 @@
"upgrade": "MEJORAR",
"upgradePlan": "Mejorar plan",
"upgradeTo": "Mejorar a {plan}",
"upgradeToAddCredits": "Mejorar para añadir créditos",
"usdPerMonth": "USD / mes",
"usdPerMonthPerMember": "USD / mes / miembro",
"videoEstimateExplanation": "Estas estimaciones se basan en la plantilla Wan 2.2 Imagen a Video usando la configuración predeterminada (5 segundos, 640x640, 16fps, muestreo de 4 pasos).",

View File

@@ -3778,6 +3778,9 @@
"outputs": {
"0": {
"tooltip": null
},
"1": {
"tooltip": null
}
}
},
@@ -18154,6 +18157,68 @@
}
}
},
"WanSCAILToVideo": {
"display_name": "WanSCAILToVideo",
"inputs": {
"batch_size": {
"name": "tamaño_lote"
},
"clip_vision_output": {
"name": "clip_vision_output"
},
"height": {
"name": "alto"
},
"length": {
"name": "longitud"
},
"negative": {
"name": "negativo"
},
"pose_end": {
"name": "fin_pose",
"tooltip": "Paso final para usar el condicionamiento de pose."
},
"pose_start": {
"name": "inicio_pose",
"tooltip": "Paso inicial para usar el condicionamiento de pose."
},
"pose_strength": {
"name": "fuerza_pose",
"tooltip": "Fuerza del espacio latente de la pose."
},
"pose_video": {
"name": "video_pose",
"tooltip": "Video usado para el condicionamiento de pose. Se reducirá a la mitad de la resolución del video principal."
},
"positive": {
"name": "positivo"
},
"reference_image": {
"name": "imagen_referencia"
},
"vae": {
"name": "vae"
},
"width": {
"name": "ancho"
}
},
"outputs": {
"0": {
"name": "positivo",
"tooltip": null
},
"1": {
"name": "negativo",
"tooltip": null
},
"2": {
"name": "latente",
"tooltip": "Espacio latente vacío del tamaño de la generación."
}
}
},
"WanSoundImageToVideo": {
"display_name": "WanSoundImageToVideo",
"inputs": {

View File

@@ -1031,6 +1031,7 @@
"or": "یا",
"output": "خروجی",
"overwrite": "جایگزینی",
"partner": "همکار",
"playPause": "پخش/توقف",
"playRecording": "پخش ضبط",
"playbackSpeed": "سرعت پخش",
@@ -1041,6 +1042,7 @@
"progressCountOf": "از",
"queued": "در صف",
"ready": "آماده",
"recents": "اخیراً",
"reconnected": "اتصال مجدد برقرار شد",
"reconnecting": "در حال اتصال مجدد",
"refresh": "بازنشانی",
@@ -2641,8 +2643,16 @@
"nodeLibrary": "کتابخانه Node",
"nodeLibraryTab": {
"allNodes": "همه nodeها",
"blueprints": "بلوپرینت‌ها",
"custom": "سفارشی",
"essentials": "ضروریات",
"filter": "فیلتر",
"filterOptions": {
"blueprints": "بلوپرینت‌ها",
"comfyNodes": "گره‌های Comfy",
"extensions": "افزونه‌ها",
"partnerNodes": "گره‌های همکار"
},
"groupBy": "گروه‌بندی بر اساس",
"groupStrategies": {
"category": "دسته‌بندی",
@@ -2652,10 +2662,18 @@
"source": "منبع",
"sourceDesc": "گروه‌بندی بر اساس نوع منبع (Core، Custom، API)"
},
"noBookmarkedNodes": "هنوز مورد علاقه‌ای وجود ندارد",
"resetView": "بازنشانی نمای پیش‌فرض",
"sections": {
"bookmarked": "نشان‌شده‌ها",
"comfyBlueprints": "بلوپرینت‌های Comfy",
"comfyNodes": "گره‌های Comfy",
"extensions": "افزونه‌ها",
"favoriteNode": "گره مورد علاقه",
"favorites": "علاقه‌مندی‌ها",
"myBlueprints": "بلوپرینت‌های من",
"partnerNodes": "گره‌های همکار",
"subgraphBlueprints": "بلوپرینت‌های زیرگراف",
"unfavoriteNode": "حذف از علاقه‌مندی‌ها"
},
"sortBy": {
@@ -2899,6 +2917,7 @@
"resubscribe": "تمدید اشتراک",
"resubscribeSuccess": "اشتراک با موفقیت فعال شد",
"resubscribeTo": "تمدید اشتراک {plan}",
"subscribeForMore": "ارتقاء",
"subscribeNow": "هم‌اکنون اشتراک بگیرید",
"subscribeTo": "اشتراک در {plan}",
"subscribeToComfyCloud": "اشتراک در Comfy Cloud",
@@ -2929,6 +2948,7 @@
"upgrade": "ارتقا",
"upgradePlan": "ارتقا طرح",
"upgradeTo": "ارتقا به {plan}",
"upgradeToAddCredits": "برای افزودن اعتبار ارتقاء دهید",
"usdPerMonth": "دلار آمریکا / ماه",
"usdPerMonthPerMember": "دلار آمریکا / ماه / هر عضو",
"videoEstimateExplanation": "این تخمین‌ها بر اساس قالب Wan 2.2 Image-to-Video با تنظیمات پیش‌فرض (۵ ثانیه، ۶۴۰×۶۴۰، ۱۶ فریم بر ثانیه، ۴ مرحله نمونه‌گیری) است.",

View File

@@ -3778,6 +3778,9 @@
"outputs": {
"0": {
"tooltip": null
},
"1": {
"tooltip": null
}
}
},
@@ -18154,6 +18157,68 @@
}
}
},
"WanSCAILToVideo": {
"display_name": "WanSCAILToVideo",
"inputs": {
"batch_size": {
"name": "اندازه دسته"
},
"clip_vision_output": {
"name": "خروجی clip vision"
},
"height": {
"name": "ارتفاع"
},
"length": {
"name": "طول"
},
"negative": {
"name": "منفی"
},
"pose_end": {
"name": "پایان pose",
"tooltip": "گام پایان برای استفاده از pose conditioning."
},
"pose_start": {
"name": "شروع pose",
"tooltip": "گام شروع برای استفاده از pose conditioning."
},
"pose_strength": {
"name": "قدرت pose",
"tooltip": "قدرت pose در latent."
},
"pose_video": {
"name": "ویدئوی pose",
"tooltip": "ویدئو برای pose conditioning استفاده می‌شود. به نصف وضوح ویدئوی اصلی کاهش داده می‌شود."
},
"positive": {
"name": "مثبت"
},
"reference_image": {
"name": "تصویر مرجع"
},
"vae": {
"name": "vae"
},
"width": {
"name": "عرض"
}
},
"outputs": {
"0": {
"name": "مثبت",
"tooltip": null
},
"1": {
"name": "منفی",
"tooltip": null
},
"2": {
"name": "latent",
"tooltip": "latent خالی با اندازه تولید."
}
}
},
"WanSoundImageToVideo": {
"display_name": "وان‌ساند ایمیج تو ویدیو",
"inputs": {

View File

@@ -1031,6 +1031,7 @@
"or": "ou",
"output": "Sortie",
"overwrite": "Écraser",
"partner": "Partenaire",
"playPause": "Lecture/Pause",
"playRecording": "Lire l'enregistrement",
"playbackSpeed": "Vitesse de lecture",
@@ -1041,6 +1042,7 @@
"progressCountOf": "sur",
"queued": "En file dattente",
"ready": "Prêt",
"recents": "Récents",
"reconnected": "Reconnecté",
"reconnecting": "Reconnexion",
"refresh": "Rafraîchir",
@@ -2629,8 +2631,16 @@
"nodeLibrary": "Bibliothèque de nœuds",
"nodeLibraryTab": {
"allNodes": "Tous les nœuds",
"blueprints": "Plans",
"custom": "Personnalisé",
"essentials": "Essentiels",
"filter": "Filtrer",
"filterOptions": {
"blueprints": "Plans",
"comfyNodes": "Nœuds Comfy",
"extensions": "Extensions",
"partnerNodes": "Nœuds partenaires"
},
"groupBy": "Grouper par",
"groupStrategies": {
"category": "Catégorie",
@@ -2640,10 +2650,18 @@
"source": "Source",
"sourceDesc": "Grouper par type de source (Core, Custom, API)"
},
"noBookmarkedNodes": "Aucun favori pour le moment",
"resetView": "Réinitialiser la vue par défaut",
"sections": {
"bookmarked": "Favoris",
"comfyBlueprints": "Plans Comfy",
"comfyNodes": "Nœuds Comfy",
"extensions": "Extensions",
"favoriteNode": "Nœud favori",
"favorites": "Favoris",
"myBlueprints": "Mes plans",
"partnerNodes": "Nœuds partenaires",
"subgraphBlueprints": "Plans de sous-graphe",
"unfavoriteNode": "Retirer des favoris"
},
"sortBy": {
@@ -2887,6 +2905,7 @@
"resubscribe": "Se réabonner",
"resubscribeSuccess": "Abonnement réactivé avec succès",
"resubscribeTo": "Se réabonner à {plan}",
"subscribeForMore": "Mettre à niveau",
"subscribeNow": "S'abonner maintenant",
"subscribeTo": "S'abonner à {plan}",
"subscribeToComfyCloud": "S'abonner à Comfy Cloud",
@@ -2917,6 +2936,7 @@
"upgrade": "AMÉLIORER",
"upgradePlan": "Améliorer le forfait",
"upgradeTo": "Passer à {plan}",
"upgradeToAddCredits": "Mettre à niveau pour ajouter des crédits",
"usdPerMonth": "USD / mois",
"usdPerMonthPerMember": "USD / mois / membre",
"videoEstimateExplanation": "Ces estimations sont basées sur le modèle Wan 2.2 Image-to-Video avec les paramètres par défaut (5 secondes, 640x640, 16fps, échantillonnage en 4 étapes).",

View File

@@ -3778,6 +3778,9 @@
"outputs": {
"0": {
"tooltip": null
},
"1": {
"tooltip": null
}
}
},
@@ -18154,6 +18157,68 @@
}
}
},
"WanSCAILToVideo": {
"display_name": "WanSCAILToVideo",
"inputs": {
"batch_size": {
"name": "taille_du_lot"
},
"clip_vision_output": {
"name": "clip_vision_output"
},
"height": {
"name": "hauteur"
},
"length": {
"name": "longueur"
},
"negative": {
"name": "négatif"
},
"pose_end": {
"name": "fin_de_pose",
"tooltip": "Étape de fin pour utiliser le conditionnement de pose."
},
"pose_start": {
"name": "début_de_pose",
"tooltip": "Étape de début pour utiliser le conditionnement de pose."
},
"pose_strength": {
"name": "force_de_pose",
"tooltip": "Force du latent de pose."
},
"pose_video": {
"name": "vidéo_de_pose",
"tooltip": "Vidéo utilisée pour le conditionnement de pose. Sera réduite à la moitié de la résolution de la vidéo principale."
},
"positive": {
"name": "positif"
},
"reference_image": {
"name": "image_de_référence"
},
"vae": {
"name": "vae"
},
"width": {
"name": "largeur"
}
},
"outputs": {
"0": {
"name": "positif",
"tooltip": null
},
"1": {
"name": "négatif",
"tooltip": null
},
"2": {
"name": "latent",
"tooltip": "Latent vide de la taille de génération."
}
}
},
"WanSoundImageToVideo": {
"display_name": "WanSoundImageToVideo",
"inputs": {

View File

@@ -1031,6 +1031,7 @@
"or": "または",
"output": "出力",
"overwrite": "上書き",
"partner": "パートナー",
"playPause": "再生/一時停止",
"playRecording": "録音を再生",
"playbackSpeed": "再生速度",
@@ -1041,6 +1042,7 @@
"progressCountOf": "の",
"queued": "キュー中",
"ready": "準備完了",
"recents": "最近使用",
"reconnected": "再接続されました",
"reconnecting": "再接続中",
"refresh": "更新",
@@ -2629,8 +2631,16 @@
"nodeLibrary": "ノードライブラリ",
"nodeLibraryTab": {
"allNodes": "すべてのノード",
"blueprints": "ブループリント",
"custom": "カスタム",
"essentials": "必須",
"filter": "フィルター",
"filterOptions": {
"blueprints": "ブループリント",
"comfyNodes": "Comfyード",
"extensions": "拡張機能",
"partnerNodes": "パートナーノード"
},
"groupBy": "グループ化",
"groupStrategies": {
"category": "カテゴリ",
@@ -2640,10 +2650,18 @@
"source": "ソース",
"sourceDesc": "ソースタイプCore、Custom、APIでグループ化"
},
"noBookmarkedNodes": "お気に入りはまだありません",
"resetView": "ビューをデフォルトにリセット",
"sections": {
"bookmarked": "ブックマーク済み",
"comfyBlueprints": "Comfyブループリント",
"comfyNodes": "Comfyード",
"extensions": "拡張機能",
"favoriteNode": "お気に入りノード",
"favorites": "お気に入り",
"myBlueprints": "マイブループリント",
"partnerNodes": "パートナーノード",
"subgraphBlueprints": "サブグラフブループリント",
"unfavoriteNode": "お気に入りノードから削除"
},
"sortBy": {
@@ -2887,6 +2905,7 @@
"resubscribe": "再購読する",
"resubscribeSuccess": "サブスクリプションが再開されました",
"resubscribeTo": "{plan}を再購読する",
"subscribeForMore": "アップグレード",
"subscribeNow": "今すぐ購読",
"subscribeTo": "{plan}に登録",
"subscribeToComfyCloud": "Comfy Cloudを購読",
@@ -2917,6 +2936,7 @@
"upgrade": "アップグレード",
"upgradePlan": "プランをアップグレード",
"upgradeTo": "{plan}にアップグレード",
"upgradeToAddCredits": "クレジット追加のためアップグレード",
"usdPerMonth": "USD / 月",
"usdPerMonthPerMember": "USD / 月 / メンバー",
"videoEstimateExplanation": "これらの見積もりは、Wan 2.2 画像から動画テンプレートのデフォルト設定5秒、640x640、16fps、4ステップサンプリングに基づいています。",

View File

@@ -3778,6 +3778,9 @@
"outputs": {
"0": {
"tooltip": null
},
"1": {
"tooltip": null
}
}
},
@@ -18154,6 +18157,68 @@
}
}
},
"WanSCAILToVideo": {
"display_name": "WanSCAILToVideo",
"inputs": {
"batch_size": {
"name": "バッチサイズ"
},
"clip_vision_output": {
"name": "clip_vision_output"
},
"height": {
"name": "高さ"
},
"length": {
"name": "長さ"
},
"negative": {
"name": "negative"
},
"pose_end": {
"name": "ポーズ終了ステップ",
"tooltip": "ポーズ条件付けを使用する終了ステップ。"
},
"pose_start": {
"name": "ポーズ開始ステップ",
"tooltip": "ポーズ条件付けを使用する開始ステップ。"
},
"pose_strength": {
"name": "ポーズ強度",
"tooltip": "ポーズのlatentの強さ。"
},
"pose_video": {
"name": "ポーズビデオ",
"tooltip": "ポーズ条件付けに使用するビデオ。メインビデオの解像度の半分にダウンサンプリングされます。"
},
"positive": {
"name": "positive"
},
"reference_image": {
"name": "参照画像"
},
"vae": {
"name": "vae"
},
"width": {
"name": "幅"
}
},
"outputs": {
"0": {
"name": "positive",
"tooltip": null
},
"1": {
"name": "negative",
"tooltip": null
},
"2": {
"name": "latent",
"tooltip": "生成サイズの空のlatent。"
}
}
},
"WanSoundImageToVideo": {
"display_name": "WanSoundImageToVideo",
"inputs": {

View File

@@ -1031,6 +1031,7 @@
"or": "또는",
"output": "출력",
"overwrite": "덮어쓰기",
"partner": "파트너",
"playPause": "재생/일시정지",
"playRecording": "녹음 재생",
"playbackSpeed": "재생 속도",
@@ -1041,6 +1042,7 @@
"progressCountOf": "중",
"queued": "대기 중",
"ready": "준비됨",
"recents": "최근 항목",
"reconnected": "재연결됨",
"reconnecting": "재연결 중",
"refresh": "새로 고침",
@@ -2629,8 +2631,16 @@
"nodeLibrary": "노드 라이브러리",
"nodeLibraryTab": {
"allNodes": "모든 노드",
"blueprints": "블루프린트",
"custom": "사용자 정의",
"essentials": "필수",
"filter": "필터",
"filterOptions": {
"blueprints": "블루프린트",
"comfyNodes": "Comfy 노드",
"extensions": "확장 프로그램",
"partnerNodes": "파트너 노드"
},
"groupBy": "그룹 기준",
"groupStrategies": {
"category": "카테고리",
@@ -2640,10 +2650,18 @@
"source": "소스",
"sourceDesc": "소스 유형(Core, Custom, API)별로 그룹화"
},
"noBookmarkedNodes": "아직 즐겨찾기가 없습니다",
"resetView": "기본 보기로 재설정",
"sections": {
"bookmarked": "즐겨찾기",
"comfyBlueprints": "Comfy 블루프린트",
"comfyNodes": "Comfy 노드",
"extensions": "확장 프로그램",
"favoriteNode": "즐겨찾는 노드",
"favorites": "즐겨찾기",
"myBlueprints": "내 블루프린트",
"partnerNodes": "파트너 노드",
"subgraphBlueprints": "서브그래프 블루프린트",
"unfavoriteNode": "즐겨찾기 해제 노드"
},
"sortBy": {
@@ -2887,6 +2905,7 @@
"resubscribe": "다시 구독하기",
"resubscribeSuccess": "구독이 성공적으로 재활성화되었습니다",
"resubscribeTo": "{plan} 다시 구독하기",
"subscribeForMore": "업그레이드",
"subscribeNow": "지금 구독하기",
"subscribeTo": "{plan} 구독하기",
"subscribeToComfyCloud": "Comfy Cloud 구독",
@@ -2917,6 +2936,7 @@
"upgrade": "업그레이드",
"upgradePlan": "플랜 업그레이드",
"upgradeTo": "{plan}로 업그레이드",
"upgradeToAddCredits": "크레딧 추가를 위해 업그레이드하세요",
"usdPerMonth": "USD / 월",
"usdPerMonthPerMember": "USD / 월 / 멤버",
"videoEstimateExplanation": "이 추정치는 기본 설정(5초, 640x640, 16fps, 4단계 샘플링)을 사용한 Wan 2.2 이미지-투-비디오 템플릿을 기준으로 합니다.",

View File

@@ -3778,6 +3778,9 @@
"outputs": {
"0": {
"tooltip": null
},
"1": {
"tooltip": null
}
}
},
@@ -18154,6 +18157,68 @@
}
}
},
"WanSCAILToVideo": {
"display_name": "WanSCAILToVideo",
"inputs": {
"batch_size": {
"name": "배치 크기"
},
"clip_vision_output": {
"name": "clip_vision_output"
},
"height": {
"name": "높이"
},
"length": {
"name": "길이"
},
"negative": {
"name": "negative"
},
"pose_end": {
"name": "포즈 종료",
"tooltip": "포즈 컨디셔닝을 적용할 마지막 스텝입니다."
},
"pose_start": {
"name": "포즈 시작",
"tooltip": "포즈 컨디셔닝을 적용할 시작 스텝입니다."
},
"pose_strength": {
"name": "포즈 강도",
"tooltip": "포즈 latent의 강도입니다."
},
"pose_video": {
"name": "포즈 비디오",
"tooltip": "포즈 컨디셔닝에 사용되는 비디오입니다. 메인 비디오 해상도의 절반으로 다운스케일됩니다."
},
"positive": {
"name": "positive"
},
"reference_image": {
"name": "참조 이미지"
},
"vae": {
"name": "vae"
},
"width": {
"name": "너비"
}
},
"outputs": {
"0": {
"name": "positive",
"tooltip": null
},
"1": {
"name": "negative",
"tooltip": null
},
"2": {
"name": "latent",
"tooltip": "생성 크기의 빈 latent입니다."
}
}
},
"WanSoundImageToVideo": {
"display_name": "WanSoundImageToVideo",
"inputs": {

View File

@@ -1031,6 +1031,7 @@
"or": "ou",
"output": "Saída",
"overwrite": "Sobrescrever",
"partner": "Parceiro",
"playPause": "Reproduzir/Pausar",
"playRecording": "Reproduzir gravação",
"playbackSpeed": "Velocidade de reprodução",
@@ -1041,6 +1042,7 @@
"progressCountOf": "de",
"queued": "Na fila",
"ready": "Pronto",
"recents": "Recentes",
"reconnected": "Reconectado",
"reconnecting": "Reconectando",
"refresh": "Atualizar",
@@ -2641,8 +2643,16 @@
"nodeLibrary": "Biblioteca de nós",
"nodeLibraryTab": {
"allNodes": "Todos os nós",
"blueprints": "Blueprints",
"custom": "Personalizado",
"essentials": "Essenciais",
"filter": "Filtrar",
"filterOptions": {
"blueprints": "Blueprints",
"comfyNodes": "Nós Comfy",
"extensions": "Extensões",
"partnerNodes": "Nós de Parceiros"
},
"groupBy": "Agrupar por",
"groupStrategies": {
"category": "Categoria",
@@ -2652,10 +2662,18 @@
"source": "Fonte",
"sourceDesc": "Agrupar por tipo de fonte (Core, Custom, API)"
},
"noBookmarkedNodes": "Nenhum favorito ainda",
"resetView": "Restaurar visualização padrão",
"sections": {
"bookmarked": "Favoritos",
"comfyBlueprints": "Blueprints Comfy",
"comfyNodes": "Nós Comfy",
"extensions": "Extensões",
"favoriteNode": "Favoritar nó",
"favorites": "Favoritos",
"myBlueprints": "Meus Blueprints",
"partnerNodes": "Nós de Parceiros",
"subgraphBlueprints": "Blueprints de Subgrafo",
"unfavoriteNode": "Desfavoritar nó"
},
"sortBy": {
@@ -2899,6 +2917,7 @@
"resubscribe": "Reassinar",
"resubscribeSuccess": "Assinatura reativada com sucesso",
"resubscribeTo": "Reassinar {plan}",
"subscribeForMore": "Fazer upgrade",
"subscribeNow": "Assine Agora",
"subscribeTo": "Assinar {plan}",
"subscribeToComfyCloud": "Assine o Comfy Cloud",
@@ -2929,6 +2948,7 @@
"upgrade": "ATUALIZAR",
"upgradePlan": "Atualizar Plano",
"upgradeTo": "Atualizar para {plan}",
"upgradeToAddCredits": "Faça upgrade para adicionar créditos",
"usdPerMonth": "USD / mês",
"usdPerMonthPerMember": "USD / mês / membro",
"videoEstimateExplanation": "Essas estimativas são baseadas no template Wan 2.2 Image-to-Video usando as configurações padrão (5 segundos, 640x640, 16fps, amostragem de 4 etapas).",

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