Compare commits

...

34 Commits

Author SHA1 Message Date
Comfy Org PR Bot
841cf55fbd [backport core/1.38] fix: prevent XSS vulnerability in context menu labels (#8922)
Backport of #8887 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8922-backport-core-1-38-fix-prevent-XSS-vulnerability-in-context-menu-labels-3096d73d3650811a9448fc9b2344a88b)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-17 02:24:10 -08:00
Comfy Org PR Bot
43b66ec5e5 1.38.14 (#8874)
Patch version increment to 1.38.14

**Base branch:** `core/1.38`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8874-1-38-14-3076d73d365081dfa3c6e6d8bd64bf73)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-02-14 06:55:44 -08:00
Comfy Org PR Bot
73e51572a0 [backport core/1.38] fix: clear draft on workflow close to prevent stale state on reopen (#8868)
Backport of #8854 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8868-backport-core-1-38-fix-clear-draft-on-workflow-close-to-prevent-stale-state-on-reopen-3076d73d36508163a919fdfb66616844)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-02-14 02:58:00 -08:00
Comfy Org PR Bot
fdd1bd3406 [backport core/1.38] Fix hit detection on vue node slots (#8798)
Backport of #8609 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8798-backport-core-1-38-Fix-hit-detection-on-vue-node-slots-3046d73d365081008dbefb2c67c3abc3)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-02-10 19:38:57 -08:00
Comfy Org PR Bot
94101d81d7 [backport core/1.38] fix: handle RIFF padding for odd-sized WEBP chunks (#8794)
Backport of #8527 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8794-backport-core-1-38-fix-handle-RIFF-padding-for-odd-sized-WEBP-chunks-3046d73d36508174afc8cdd8cd791169)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-02-10 18:51:54 -08:00
Comfy Org PR Bot
f741fb51e7 [backport core/1.38] Austin/fix move subgraph input (#8792)
Backport of #8777 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8792-backport-core-1-38-Austin-fix-move-subgraph-input-3046d73d3650816c9124c444022491de)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-02-10 18:51:08 -08:00
Comfy Org PR Bot
7dfadb5f42 [backport core/1.38] Remove comfy logo splash screen. (#8788)
Backport of #8786 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8788-backport-core-1-38-Remove-comfy-logo-splash-screen-3046d73d36508120b0cfeb49fceef38e)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-02-10 16:51:16 -08:00
Comfy Org PR Bot
8d9243e841 [backport core/1.38] fix: right-click context menu disabled when selection toolbox is off (#8783)
Backport of #8781 to `core/1.38`

Automatically created by backport workflow.

Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
2026-02-11 00:48:47 +01:00
Comfy Org PR Bot
b226b6db22 [backport core/1.38] fix(vue-nodes): hide slot labels for reroute nodes with empty names (#8727)
Backport of #8574 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8727-backport-core-1-38-fix-vue-nodes-hide-slot-labels-for-reroute-nodes-with-empty-names-3006d73d365081cc85b0fbb503fc130b)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-02-07 20:03:27 -08:00
AustinMroz
35e5f37221 [backport core/1.38] fix: localize node definition filter names and descriptions (#8564)
Backport of #8540 to core/1.38

Does not include dev-node changes since that has not been backported.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8564-backport-core-1-38-fix-localize-node-definition-filter-names-and-descriptions-2fc6d73d365081f1855ef5d1e0700329)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-02-03 11:02:59 -08:00
Comfy Org PR Bot
c8fd9a5374 1.38.13 (#8578)
Patch version increment to 1.38.13

**Base branch:** `core/1.38`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8578-1-38-13-2fc6d73d36508122b4c3dc622c48232e)
by [Unito](https://www.unito.io)

Co-authored-by: comfy-pr-bot <172744619+comfy-pr-bot@users.noreply.github.com>
2026-02-02 22:13:14 -08:00
Comfy Org PR Bot
ded366008e [backport core/1.38] fix: dedupe queueStore.update() to prevent race conditions (#8557)
Backport of #8523 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8557-backport-core-1-38-fix-dedupe-queueStore-update-to-prevent-race-conditions-2fc6d73d36508159ba03dd7ff626ecce)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-02-02 17:40:13 -08:00
Comfy Org PR Bot
d48e99db7c [backport core/1.38] fix: node header on preview has a gap on the right (not flush) (#8555)
Backport of #8487 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8555-backport-core-1-38-fix-node-header-on-preview-has-a-gap-on-the-right-not-flush-2fc6d73d365081a1b1cae2afb0675e9a)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-02-02 17:39:08 -08:00
Comfy Org PR Bot
c138670bf6 [backport core/1.38] fix: add Frame Nodes to core menu items for multi-selection context menu (#8553)
Backport of #8524 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8553-backport-core-1-38-fix-add-Frame-Nodes-to-core-menu-items-for-multi-selection-context--2fc6d73d36508188be6be38de9df2ba0)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-02-02 17:38:27 -08:00
Comfy Org PR Bot
e4f1950af5 [backport core/1.38] fix: update reactive ref after merge in imagePreviewStore (#8502)
Backport of #8479 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8502-backport-core-1-38-fix-update-reactive-ref-after-merge-in-imagePreviewStore-2f96d73d3650815f944cc401a8c2d264)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Austin Mroz <austin@comfy.org>
2026-01-31 23:40:30 -08:00
Comfy Org PR Bot
44e630d00f [backport core/1.38] Update control_after_generate schema (#8506)
Backport of #8505 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8506-backport-core-1-38-Update-control_after_generate-schema-2f96d73d36508106b677fafb3fe302fe)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-01-30 21:43:04 -08:00
Comfy Org PR Bot
d27f9faa9e [backport core/1.38] fix: prevent image/video preview reset on dynamic widget addition (#8492)
Backport of #8366 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8492-backport-core-1-38-fix-prevent-image-video-preview-reset-on-dynamic-widget-addition-2f86d73d36508123acfdfac61554da7e)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-01-30 08:37:38 -08:00
Comfy Org PR Bot
c902869b2c [backport core/1.38] fix: properties panel obscures menus in legacy layout (#8490)
Backport of #8474 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8490-backport-core-1-38-fix-properties-panel-obscures-menus-in-legacy-layout-2f86d73d36508181977df8801754eca7)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-01-30 08:23:40 -08:00
Comfy Org PR Bot
ff9823e8f0 [backport core/1.38] Fix: Hide Jobs in Assets Panel when Queue V2 is disabled. (#8485)
Backport of #8450 to `core/1.38`

Automatically created by backport workflow.

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-01-30 08:03:27 +00:00
AustinMroz
bc4e060e92 Revert matchtype slot reactivity on core/1.38 (#8481)
Fixes a bug where canvas functionality is lost if a multitype input
(like the native switch) is added to the graph in litegraph mode.

See also #8477

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8481-Revert-matchtype-slot-reactivity-on-core-1-38-2f86d73d365081ac8e0aeb5ce96fe685)
by [Unito](https://www.unito.io)
2026-01-29 23:09:57 -08:00
Comfy Org PR Bot
bc31970939 [backport core/1.38] feat: add category support for blueprints and protect global blueprints (#8465)
Backport of #8378 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8465-backport-core-1-38-feat-add-category-support-for-blueprints-and-protect-global-bluepri-2f86d73d365081b79a8be0ac55d87f0f)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: AustinMroz <austin@comfy.org>
2026-01-29 19:15:42 -08:00
Comfy Org PR Bot
6bab72feb9 [backport core/1.38] Improve template search input performance issue (#8471)
Backport of #8343 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8471-backport-core-1-38-Improve-template-search-input-performance-issue-2f86d73d365081e3afbff8779ea0ebe0)
by [Unito](https://www.unito.io)

Co-authored-by: Kelly Yang <124ykl@gmail.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-01-29 19:13:41 -08:00
Comfy Org PR Bot
390deac188 [backport core/1.38] fix: default image input for the template is displayed as empty on dropdown selection (#8455)
Backport of #8276 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8455-backport-core-1-38-fix-default-image-input-for-the-template-is-displayed-as-empty-on-d-2f86d73d3650814a895ddbb921bc4776)
by [Unito](https://www.unito.io)

Co-authored-by: Rizumu Ayaka <rizumu@ayaka.moe>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: github-actions <github-actions@github.com>
2026-01-29 16:59:19 -08:00
Comfy Org PR Bot
e4d1554b80 [backport core/1.38] Fix invalid keybind flash (#8451)
Backport of #8435 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8451-backport-core-1-38-Fix-invalid-keybind-flash-2f86d73d365081ba8443ffc6696a9d4d)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-01-29 16:34:48 -08:00
Comfy Org PR Bot
94956089f1 [backport core/1.38] Fix Help Center display in linear mode (#8448)
Backport of #8438 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8448-backport-core-1-38-Fix-Help-Center-display-in-linear-mode-2f86d73d365081ebb6a7d6de3c4555e3)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-01-29 16:21:52 -08:00
Comfy Org PR Bot
af3f96c0ca [backport core/1.38] make new queue panel disabled by default (#8445)
Backport of #8444 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8445-backport-core-1-38-make-new-queue-panel-disabled-by-default-2f76d73d36508182b643e4cf25962fef)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-01-29 16:16:16 -08:00
Comfy Org PR Bot
ada3145c2d [backport core/1.38] fix: dragging (e.g., when selecting text) in Markdown note causes node to drag (#8427)
Backport of #8413 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8427-backport-core-1-38-fix-dragging-e-g-when-selecting-text-in-Markdown-note-causes-no-2f76d73d3650813e8e28c101270bc42f)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-01-29 10:04:47 -08:00
Comfy Org PR Bot
89c76f6861 [backport core/1.38] fix: use getAuthHeader in createCustomer to support API key auth (#8425)
Backport of #8408 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8425-backport-core-1-38-fix-use-getAuthHeader-in-createCustomer-to-support-API-key-auth-2f76d73d36508136b4f1c043c384caa2)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-01-29 10:04:01 -08:00
Comfy Org PR Bot
b660638f22 [backport core/1.38] fix: add ResizeObserver to fix Preview3D initial render stretch (#8423)
Backport of #8351 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8423-backport-core-1-38-fix-add-ResizeObserver-to-fix-Preview3D-initial-render-stretch-2f76d73d365081a28ccbd2b5d9eb1aa5)
by [Unito](https://www.unito.io)

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-01-29 10:03:12 -08:00
Comfy Org PR Bot
a96938a495 [backport core/1.38] Fix flake hidream test (#8420)
Backport of #8406 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8420-backport-core-1-38-Fix-flake-hidream-test-2f76d73d36508162ad34d8bc11f000b6)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-01-29 00:11:10 -08:00
Comfy Org PR Bot
f6b571013d [backport core/1.38] [bugfix] Fix manager missing node tab with shared composable (#8410)
Backport of #8409 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8410-backport-core-1-38-bugfix-Fix-manager-missing-node-tab-with-shared-composable-2f76d73d36508137b1e8daadb13cb631)
by [Unito](https://www.unito.io)

Co-authored-by: Jin Yi <jin12cc@gmail.com>
2026-01-29 00:05:17 -08:00
Comfy Org PR Bot
54e8775acb [backport core/1.38] fix: add null check in getCanvasCenter to prevent crash on asset insert (#8403)
Backport of #8399 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8403-backport-core-1-38-fix-add-null-check-in-getCanvasCenter-to-prevent-crash-on-asset-ins-2f76d73d365081b6af21e13162afafc5)
by [Unito](https://www.unito.io)

Co-authored-by: Jin Yi <jin12cc@gmail.com>
2026-01-28 20:48:47 -08:00
Comfy Org PR Bot
13e8aa7466 [backport core/1.38] fix: increase Vue node resize handle size for better usability (#8394)
Backport of #8391 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8394-backport-core-1-38-fix-increase-Vue-node-resize-handle-size-for-better-usability-2f76d73d365081baa796c6d8c62c4352)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
2026-01-28 19:50:43 -08:00
Comfy Org PR Bot
7a224efaa0 [backport core/1.38] CI: Add formatting after generating locales. (#8361)
Backport of #8360 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8361-backport-core-1-38-CI-Add-formatting-after-generating-locales-2f66d73d365081c09af5df4c2e0a898b)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-01-27 22:41:54 -08:00
61 changed files with 1197 additions and 172 deletions

View File

@@ -41,7 +41,7 @@ jobs:
env:
PLAYWRIGHT_TEST_URL: http://localhost:5173
- name: Update translations
run: pnpm locale
run: pnpm locale && pnpm format
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Commit updated locales

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 80 KiB

View File

@@ -35,18 +35,6 @@
background-size: cover;
background-repeat: no-repeat;
}
#vue-app:has(#loading-logo) {
display: contents;
color: var(--fg-color);
& #loading-logo {
place-self: center;
font-size: clamp(2px, 1vw, 6px);
line-height: 1;
overflow: hidden;
max-width: 100vw;
border-radius: 20ch;
}
}
.visually-hidden {
position: absolute;
width: 1px;
@@ -65,36 +53,6 @@
<body class="litegraph grid">
<div id="vue-app">
<span class="visually-hidden" role="status">Loading ComfyUI...</span>
<svg
width="520"
height="520"
viewBox="0 0 520 520"
fill="none"
xmlns="http://www.w3.org/2000/svg"
id="loading-logo"
>
<mask
id="mask0_227_285"
style="mask-type: alpha"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="520"
height="520"
>
<path
d="M0 184.335C0 119.812 0 87.5502 12.5571 62.9055C23.6026 41.2274 41.2274 23.6026 62.9055 12.5571C87.5502 0 119.812 0 184.335 0H335.665C400.188 0 432.45 0 457.094 12.5571C478.773 23.6026 496.397 41.2274 507.443 62.9055C520 87.5502 520 119.812 520 184.335V335.665C520 400.188 520 432.45 507.443 457.094C496.397 478.773 478.773 496.397 457.094 507.443C432.45 520 400.188 520 335.665 520H184.335C119.812 520 87.5502 520 62.9055 507.443C41.2274 496.397 23.6026 478.773 12.5571 457.094C0 432.45 0 400.188 0 335.665V184.335Z"
fill="#EEFF30"
/>
</mask>
<g mask="url(#mask0_227_285)">
<rect y="0.751831" width="520" height="520" fill="#172DD7" />
<path
d="M176.484 428.831C168.649 428.831 162.327 425.919 158.204 420.412C153.966 414.755 152.861 406.857 155.171 398.749L164.447 366.178C165.187 363.585 164.672 360.794 163.059 358.636C161.446 356.483 158.921 355.216 156.241 355.216H129.571C121.731 355.216 115.409 352.308 111.289 346.802C107.051 341.14 105.946 333.242 108.258 325.134L140.124 213.748L143.642 201.51C148.371 184.904 165.62 171.407 182.097 171.407H214.009C217.817 171.407 221.167 168.868 222.215 165.183L232.769 128.135C237.494 111.545 254.742 98.048 271.219 98.048L339.468 97.9264L389.431 97.9221C397.268 97.9221 403.59 100.831 407.711 106.337C411.949 111.994 413.054 119.892 410.744 128L396.457 178.164C391.734 194.75 374.485 208.242 358.009 208.242L289.607 208.372H257.706C253.902 208.372 250.557 210.907 249.502 214.588L222.903 307.495C222.159 310.093 222.673 312.892 224.291 315.049C225.904 317.202 228.428 318.469 231.107 318.469C231.113 318.469 276.307 318.381 276.307 318.381H326.122C333.959 318.381 340.281 321.29 344.402 326.796C348.639 332.457 349.744 340.355 347.433 348.463L333.146 398.619C328.423 415.209 311.174 428.701 294.698 428.701L226.299 428.831H176.484Z"
fill="#F0FF41"
/>
</g>
</svg>
</div>
<script type="module" src="src/main.ts"></script>
</body>

View File

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

View File

@@ -767,7 +767,7 @@ useIntersectionObserver(loadTrigger, () => {
// Reset pagination when filters change
watch(
[
searchQuery,
filteredTemplates,
selectedNavItem,
sortBy,
selectedModels,

View File

@@ -265,18 +265,15 @@ function cancelEdit() {
}
async function saveKeybinding() {
if (currentEditingCommand.value && newBindingKeyCombo.value) {
const updated = keybindingStore.updateKeybindingOnCommand(
new KeybindingImpl({
commandId: currentEditingCommand.value.id,
combo: newBindingKeyCombo.value
})
)
if (updated) {
await keybindingService.persistUserKeybindings()
}
}
const commandId = currentEditingCommand.value?.id
const combo = newBindingKeyCombo.value
cancelEdit()
if (!combo || commandId == undefined) return
const updated = keybindingStore.updateKeybindingOnCommand(
new KeybindingImpl({ commandId, combo })
)
if (updated) await keybindingService.persistUserKeybindings()
}
async function resetKeybinding(commandData: ICommandData) {

View File

@@ -87,6 +87,7 @@
<template v-if="comfyAppReady">
<TitleEditor />
<SelectionToolbox v-if="selectionToolboxEnabled" />
<NodeContextMenu />
<!-- Render legacy DOM widgets only when Vue nodes are disabled -->
<DomWidgets v-if="!shouldRenderVueNodes" />
</template>
@@ -112,6 +113,7 @@ import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import DomWidgets from '@/components/graph/DomWidgets.vue'
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
import NodeContextMenu from '@/components/graph/NodeContextMenu.vue'
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
import TitleEditor from '@/components/graph/TitleEditor.vue'
import NodePropertiesPanel from '@/components/rightSidePanel/RightSidePanel.vue'

View File

@@ -42,7 +42,6 @@
</Panel>
</Transition>
</div>
<NodeContextMenu />
</template>
<script setup lang="ts">
@@ -69,7 +68,6 @@ import { useExtensionService } from '@/services/extensionService'
import { useCommandStore } from '@/stores/commandStore'
import type { ComfyCommandImpl } from '@/stores/commandStore'
import NodeContextMenu from './NodeContextMenu.vue'
import FrameNodes from './selectionToolbox/FrameNodes.vue'
import NodeOptionsButton from './selectionToolbox/NodeOptionsButton.vue'
import VerticalDivider from './selectionToolbox/VerticalDivider.vue'

View File

@@ -1,6 +1,6 @@
<template>
<!-- Help Center Popup positioned within canvas area -->
<Teleport to="#graph-canvas-container">
<Teleport to="body">
<div
v-if="isHelpCenterVisible"
class="help-center-popup"

View File

@@ -76,14 +76,6 @@ describe('NodePreview', () => {
expect(wrapper.find('._sb_preview_badge').text()).toBe('Preview')
})
it('applies text-ellipsis class to node header for text truncation', () => {
const wrapper = mountComponent()
const nodeHeader = wrapper.find('.node_header')
expect(nodeHeader.classes()).toContain('text-ellipsis')
expect(nodeHeader.classes()).toContain('mr-4')
})
it('sets title attribute on node header with full display name', () => {
const wrapper = mountComponent()
const nodeHeader = wrapper.find('.node_header')

View File

@@ -10,7 +10,7 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
<div v-else class="_sb_node_preview bg-component-node-background">
<div class="_sb_table">
<div
class="node_header mr-4 text-ellipsis"
class="node_header text-ellipsis"
:title="nodeDef.display_name"
:style="{
backgroundColor: litegraphColors.NODE_DEFAULT_COLOR,

View File

@@ -2,7 +2,7 @@
<div class="flex h-full flex-col">
<!-- Active Jobs Grid -->
<div
v-if="activeJobItems.length"
v-if="isQueuePanelV2Enabled && activeJobItems.length"
class="grid max-h-[50%] scrollbar-custom overflow-y-auto"
:style="gridStyle"
>
@@ -65,6 +65,7 @@ import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { isActiveJobState } from '@/utils/queueUtil'
import { cn } from '@/utils/tailwindUtil'
import { useSettingStore } from '@/platform/settings/settingStore'
const {
assets,
@@ -90,6 +91,11 @@ const emit = defineEmits<{
const { t } = useI18n()
const { jobItems } = useJobList()
const settingStore = useSettingStore()
const isQueuePanelV2Enabled = computed(() =>
settingStore.get('Comfy.Queue.QPOV2')
)
type AssetGridItem = { key: string; asset: AssetItem }

View File

@@ -1,7 +1,7 @@
<template>
<div class="flex h-full flex-col">
<div
v-if="activeJobItems.length"
v-if="isQueuePanelV2Enabled && activeJobItems.length"
class="flex max-h-[50%] scrollbar-custom flex-col gap-2 overflow-y-auto px-2"
>
<AssetsListItem
@@ -133,6 +133,7 @@ import {
} from '@/utils/formatUtil'
import { iconForJobState } from '@/utils/queueDisplay'
import { cn } from '@/utils/tailwindUtil'
import { useSettingStore } from '@/platform/settings/settingStore'
const {
assets,
@@ -154,6 +155,11 @@ const emit = defineEmits<{
const { t } = useI18n()
const { jobItems } = useJobList()
const settingStore = useSettingStore()
const isQueuePanelV2Enabled = computed(() =>
settingStore.get('Comfy.Queue.QPOV2')
)
const hoveredJobId = ref<string | null>(null)
const hoveredAssetId = ref<string | null>(null)

View File

@@ -13,10 +13,7 @@
severity="danger"
/>
</template>
<template
v-if="nodeDef.name.startsWith(useSubgraphStore().typePrefix)"
#actions
>
<template v-if="isUserBlueprint" #actions>
<Button
variant="destructive"
size="icon-sm"
@@ -128,8 +125,18 @@ const editBlueprint = async () => {
await useSubgraphStore().editBlueprint(props.node.data.name)
}
const menu = ref<InstanceType<typeof ContextMenu> | null>(null)
const subgraphStore = useSubgraphStore()
const isUserBlueprint = computed(() => {
const name = nodeDef.value.name
if (!name.startsWith(subgraphStore.typePrefix)) return false
return !subgraphStore.isGlobalBlueprint(
name.slice(subgraphStore.typePrefix.length)
)
})
const menuItems = computed<MenuItem[]>(() => {
const items: MenuItem[] = [
if (!isUserBlueprint.value) return []
return [
{
label: t('g.delete'),
icon: 'pi pi-trash',
@@ -137,15 +144,14 @@ const menuItems = computed<MenuItem[]>(() => {
command: deleteBlueprint
}
]
return items
})
function handleContextMenu(event: Event) {
if (!nodeDef.value.name.startsWith(useSubgraphStore().typePrefix)) return
if (!isUserBlueprint.value) return
menu.value?.show(event)
}
function deleteBlueprint() {
if (!props.node.data) return
void useSubgraphStore().deleteBlueprint(props.node.data.name)
void subgraphStore.deleteBlueprint(props.node.data.name)
}
const nodePreviewStyle = ref<CSSProperties>({

View File

@@ -134,6 +134,27 @@ describe('contextMenuConverter', () => {
// Node Info (section 4) should come before or with Color (section 4)
expect(getIndex('Node Info')).toBeLessThanOrEqual(getIndex('Color'))
})
it('should recognize Frame Nodes as a core menu item', () => {
const options: MenuOption[] = [
{ label: 'Rename', source: 'vue' },
{ label: 'Frame Nodes', source: 'vue' },
{ label: 'Custom Extension', source: 'vue' }
]
const result = buildStructuredMenu(options)
// Frame Nodes should appear in the core items section (before Extensions)
const frameNodesIndex = result.findIndex(
(opt) => opt.label === 'Frame Nodes'
)
const extensionsCategoryIndex = result.findIndex(
(opt) => opt.label === 'Extensions' && opt.type === 'category'
)
// Frame Nodes should come before Extensions category
expect(frameNodesIndex).toBeLessThan(extensionsCategoryIndex)
})
})
describe('convertContextMenuToOptions', () => {

View File

@@ -44,6 +44,7 @@ const CORE_MENU_ITEMS = new Set([
// Structure operations
'Convert to Subgraph',
'Frame selection',
'Frame Nodes',
'Minimize Node',
'Expand',
'Collapse',
@@ -103,7 +104,8 @@ function isDuplicateItem(label: string, existingItems: MenuOption[]): boolean {
shape: ['shape', 'shapes'],
pin: ['pin', 'unpin'],
delete: ['remove', 'delete'],
duplicate: ['clone', 'duplicate']
duplicate: ['clone', 'duplicate'],
frame: ['frame selection', 'frame nodes']
}
return existingItems.some((item) => {
@@ -226,6 +228,7 @@ const MENU_ORDER: string[] = [
// Section 3: Structure operations
'Convert to Subgraph',
'Frame selection',
'Frame Nodes',
'Minimize Node',
'Expand',
'Collapse',

View File

@@ -2,10 +2,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSelectionMenuOptions } from '@/composables/graph/useSelectionMenuOptions'
const subgraphMocks = vi.hoisted(() => ({
const mocks = vi.hoisted(() => ({
convertToSubgraph: vi.fn(),
unpackSubgraph: vi.fn(),
addSubgraphToLibrary: vi.fn(),
frameNodes: vi.fn(),
createI18nMock: vi.fn(() => ({
global: {
t: vi.fn(),
@@ -19,7 +20,7 @@ vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
}),
createI18n: subgraphMocks.createI18nMock
createI18n: mocks.createI18nMock
}))
vi.mock('@/composables/graph/useSelectionOperations', () => ({
@@ -42,18 +43,46 @@ vi.mock('@/composables/graph/useNodeArrangement', () => ({
vi.mock('@/composables/graph/useSubgraphOperations', () => ({
useSubgraphOperations: () => ({
convertToSubgraph: subgraphMocks.convertToSubgraph,
unpackSubgraph: subgraphMocks.unpackSubgraph,
addSubgraphToLibrary: subgraphMocks.addSubgraphToLibrary
convertToSubgraph: mocks.convertToSubgraph,
unpackSubgraph: mocks.unpackSubgraph,
addSubgraphToLibrary: mocks.addSubgraphToLibrary
})
}))
vi.mock('@/composables/graph/useFrameNodes', () => ({
useFrameNodes: () => ({
frameNodes: vi.fn()
frameNodes: mocks.frameNodes
})
}))
describe('useSelectionMenuOptions - multiple nodes options', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns Frame Nodes option that invokes frameNodes when called', () => {
const { getMultipleNodesOptions } = useSelectionMenuOptions()
const options = getMultipleNodesOptions()
const frameOption = options.find((opt) => opt.label === 'g.frameNodes')
expect(frameOption).toBeDefined()
expect(frameOption?.action).toBeDefined()
frameOption?.action?.()
expect(mocks.frameNodes).toHaveBeenCalledOnce()
})
it('returns Convert to Group Node option from getMultipleNodesOptions', () => {
const { getMultipleNodesOptions } = useSelectionMenuOptions()
const options = getMultipleNodesOptions()
const groupNodeOption = options.find(
(opt) => opt.label === 'contextMenu.Convert to Group Node'
)
expect(groupNodeOption).toBeDefined()
})
})
describe('useSelectionMenuOptions - subgraph options', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -68,7 +97,7 @@ describe('useSelectionMenuOptions - subgraph options', () => {
expect(options).toHaveLength(1)
expect(options[0]?.label).toBe('contextMenu.Convert to Subgraph')
expect(options[0]?.action).toBe(subgraphMocks.convertToSubgraph)
expect(options[0]?.action).toBe(mocks.convertToSubgraph)
})
it('includes convert, add to library, and unpack when subgraphs are selected', () => {
@@ -86,7 +115,7 @@ describe('useSelectionMenuOptions - subgraph options', () => {
const convertOption = options.find(
(option) => option.label === 'contextMenu.Convert to Subgraph'
)
expect(convertOption?.action).toBe(subgraphMocks.convertToSubgraph)
expect(convertOption?.action).toBe(mocks.convertToSubgraph)
})
it('hides convert option when only a single subgraph is selected', () => {

View File

@@ -87,6 +87,25 @@ describe('useSelectionState', () => {
const { hasAnySelection } = useSelectionState()
expect(hasAnySelection.value).toBe(true)
})
test('hasMultipleSelection should be true when 2+ items selected', () => {
const canvasStore = useCanvasStore()
const node1 = createMockLGraphNode({ id: 1 })
const node2 = createMockLGraphNode({ id: 2 })
canvasStore.$state.selectedItems = [node1, node2]
const { hasMultipleSelection } = useSelectionState()
expect(hasMultipleSelection.value).toBe(true)
})
test('hasMultipleSelection should be false when only 1 item selected', () => {
const canvasStore = useCanvasStore()
const node1 = createMockLGraphNode({ id: 1 })
canvasStore.$state.selectedItems = [node1]
const { hasMultipleSelection } = useSelectionState()
expect(hasMultipleSelection.value).toBe(false)
})
})
describe('Node Type Filtering', () => {

View File

@@ -1,4 +1,4 @@
import { refThrottled, watchDebounced } from '@vueuse/core'
import { refDebounced, watchDebounced } from '@vueuse/core'
import Fuse from 'fuse.js'
import type { IFuseOptions } from 'fuse.js'
import { computed, ref, watch } from 'vue'
@@ -119,7 +119,7 @@ export function useTemplateFiltering(
)
})
const debouncedSearchQuery = refThrottled(searchQuery, 50)
const debouncedSearchQuery = refDebounced(searchQuery, 150)
const filteredBySearch = computed(() => {
if (!debouncedSearchQuery.value.trim()) {

View File

@@ -1,5 +1,4 @@
import { remove } from 'es-toolkit'
import { shallowReactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type {
@@ -345,7 +344,6 @@ function applyMatchType(node: LGraphNode, inputSpec: InputSpecV2) {
requestAnimationFrame(() => {
const input = node.inputs[index]
if (!input) return
node.inputs[index] = shallowReactive(input)
node.onConnectionsChange?.(
LiteGraph.INPUT,
index,

View File

@@ -55,6 +55,7 @@ class Load3d {
private rightMouseMoved: boolean = false
private readonly dragThreshold: number = 5
private contextMenuAbortController: AbortController | null = null
private resizeObserver: ResizeObserver | null = null
constructor(container: Element | HTMLElement, options: Load3DOptions = {}) {
this.clock = new THREE.Clock()
@@ -145,6 +146,7 @@ class Load3d {
this.STATUS_MOUSE_ON_VIEWER = false
this.initContextMenu()
this.initResizeObserver(container)
this.handleResize()
this.startAnimation()
@@ -154,6 +156,14 @@ class Load3d {
}, 100)
}
private initResizeObserver(container: Element | HTMLElement): void {
this.resizeObserver = new ResizeObserver(() => {
this.handleResize()
this.forceRender()
})
this.resizeObserver.observe(container)
}
/**
* Initialize context menu on the Three.js canvas
* Detects right-click vs right-drag to show menu only on click
@@ -809,6 +819,11 @@ class Load3d {
}
public remove(): void {
if (this.resizeObserver) {
this.resizeObserver.disconnect()
this.resizeObserver = null
}
if (this.contextMenuAbortController) {
this.contextMenuAbortController.abort()
this.contextMenuAbortController = null

View File

@@ -1,3 +1,5 @@
import DOMPurify from 'dompurify'
import type {
ContextMenuDivElement,
IContextMenuOptions,
@@ -5,6 +7,38 @@ import type {
} from './interfaces'
import { LiteGraph } from './litegraph'
const ALLOWED_TAGS = ['span', 'b', 'i', 'em', 'strong']
const ALLOWED_STYLE_PROPS = new Set([
'display',
'color',
'background-color',
'padding-left',
'border-left'
])
DOMPurify.addHook('uponSanitizeAttribute', (_node, data) => {
if (data.attrName === 'style') {
const sanitizedStyle = data.attrValue
.split(';')
.map((s) => s.trim())
.filter((s) => {
const colonIdx = s.indexOf(':')
if (colonIdx === -1) return false
const prop = s.slice(0, colonIdx).trim().toLowerCase()
return ALLOWED_STYLE_PROPS.has(prop)
})
.join('; ')
data.attrValue = sanitizedStyle
}
})
function sanitizeMenuHTML(html: string): string {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS,
ALLOWED_ATTR: ['style']
})
}
// TODO: Replace this pattern with something more modern.
export interface ContextMenu<TValue = unknown> {
constructor: new (
@@ -123,7 +157,7 @@ export class ContextMenu<TValue = unknown> {
if (options.title) {
const element = document.createElement('div')
element.className = 'litemenu-title'
element.innerHTML = options.title
element.textContent = options.title
root.append(element)
}
@@ -218,11 +252,18 @@ export class ContextMenu<TValue = unknown> {
if (value === null) {
element.classList.add('separator')
} else {
const innerHtml = name === null ? '' : String(name)
const label = name === null ? '' : String(name)
if (typeof value === 'string') {
element.innerHTML = innerHtml
element.textContent = label
} else {
element.innerHTML = value?.title ?? innerHtml
// Use innerHTML for content that contains HTML tags, textContent otherwise
const hasHtmlContent =
value?.content !== undefined && /<[a-z][\s\S]*>/i.test(value.content)
if (hasHtmlContent) {
element.innerHTML = sanitizeMenuHTML(value.content!)
} else {
element.textContent = value?.title ?? label
}
if (value.disabled) {
disabled = true

View File

@@ -0,0 +1,210 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
LGraph,
LGraphCanvas,
LGraphNode,
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
layoutStore: {
querySlotAtPoint: vi.fn(),
queryRerouteAtPoint: vi.fn(),
getNodeLayoutRef: vi.fn(() => ({ value: null })),
getSlotLayout: vi.fn()
}
}))
describe('LGraphCanvas slot hit detection', () => {
let graph: LGraph
let canvas: LGraphCanvas
let node: LGraphNode
let canvasElement: HTMLCanvasElement
beforeEach(() => {
vi.clearAllMocks()
canvasElement = document.createElement('canvas')
canvasElement.width = 800
canvasElement.height = 600
const ctx = {
save: vi.fn(),
restore: vi.fn(),
translate: vi.fn(),
scale: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
fillText: vi.fn(),
measureText: vi.fn().mockReturnValue({ width: 50 }),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(),
fill: vi.fn(),
closePath: vi.fn(),
arc: vi.fn(),
rect: vi.fn(),
clip: vi.fn(),
clearRect: vi.fn(),
setTransform: vi.fn(),
roundRect: vi.fn(),
getTransform: vi
.fn()
.mockReturnValue({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }),
font: '',
fillStyle: '',
strokeStyle: '',
lineWidth: 1,
globalAlpha: 1,
textAlign: 'left' as CanvasTextAlign,
textBaseline: 'alphabetic' as CanvasTextBaseline
} as unknown as CanvasRenderingContext2D
canvasElement.getContext = vi.fn().mockReturnValue(ctx)
canvasElement.getBoundingClientRect = vi.fn().mockReturnValue({
left: 0,
top: 0,
width: 800,
height: 600
})
graph = new LGraph()
canvas = new LGraphCanvas(canvasElement, graph, {
skip_render: true
})
// Create a test node with an output slot
node = new LGraphNode('Test Node')
node.pos = [100, 100]
node.size = [150, 80]
node.addOutput('output', 'number')
graph.add(node)
// Enable Vue nodes mode for the test
LiteGraph.vueNodesMode = true
})
afterEach(() => {
LiteGraph.vueNodesMode = false
})
describe('processMouseDown slot fallback in Vue nodes mode', () => {
it('should query layoutStore.querySlotAtPoint when clicking outside node bounds', () => {
// Click position outside node bounds (node is at 100,100 with size 150x80)
// So node covers x: 100-250, y: 100-180
// Click at x=255 is outside the right edge
const clickX = 255
const clickY = 120
// Verify the click is outside the node bounds
expect(node.isPointInside(clickX, clickY)).toBe(false)
expect(graph.getNodeOnPos(clickX, clickY)).toBeNull()
// Mock the slot query to return our node's slot
vi.mocked(layoutStore.querySlotAtPoint).mockReturnValue({
nodeId: String(node.id),
index: 0,
type: 'output',
position: { x: 252, y: 120 },
bounds: { x: 246, y: 110, width: 20, height: 20 }
})
// Call processMouseDown - this should trigger the slot fallback
canvas.processMouseDown(
new MouseEvent('pointerdown', {
button: 1, // Middle button
clientX: clickX,
clientY: clickY
})
)
// The fix should query the layout store when no node is found at click position
expect(layoutStore.querySlotAtPoint).toHaveBeenCalledWith({
x: clickX,
y: clickY
})
})
it('should NOT query layoutStore when node is found directly at click position', () => {
// Initialize node's bounding rect
node.updateArea()
// Populate visible_nodes (normally done during render)
canvas.visible_nodes = [node]
// Click inside the node bounds
const clickX = 150
const clickY = 140
// Verify the click is inside the node bounds
expect(node.isPointInside(clickX, clickY)).toBe(true)
expect(graph.getNodeOnPos(clickX, clickY)).toBe(node)
// Call processMouseDown
canvas.processMouseDown(
new MouseEvent('pointerdown', {
button: 1,
clientX: clickX,
clientY: clickY
})
)
// Should NOT query the layout store since node was found directly
expect(layoutStore.querySlotAtPoint).not.toHaveBeenCalled()
})
it('should NOT query layoutStore when not in Vue nodes mode', () => {
LiteGraph.vueNodesMode = false
const clickX = 255
const clickY = 120
// Call processMouseDown
canvas.processMouseDown(
new MouseEvent('pointerdown', {
button: 1,
clientX: clickX,
clientY: clickY
})
)
// Should NOT query the layout store in non-Vue mode
expect(layoutStore.querySlotAtPoint).not.toHaveBeenCalled()
})
it('should find node via slot query for input slots extending beyond left edge', () => {
node.addInput('input', 'number')
// Click position left of node (node starts at x=100)
const clickX = 95
const clickY = 140
// Verify outside bounds
expect(node.isPointInside(clickX, clickY)).toBe(false)
vi.mocked(layoutStore.querySlotAtPoint).mockReturnValue({
nodeId: String(node.id),
index: 0,
type: 'input',
position: { x: 98, y: 140 },
bounds: { x: 88, y: 130, width: 20, height: 20 }
})
canvas.processMouseDown(
new MouseEvent('pointerdown', {
button: 1,
clientX: clickX,
clientY: clickY
})
)
expect(layoutStore.querySlotAtPoint).toHaveBeenCalledWith({
x: clickX,
y: clickY
})
})
})
})

View File

@@ -2187,9 +2187,21 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (!is_inside) return
const node =
let node =
graph.getNodeOnPos(e.canvasX, e.canvasY, this.visible_nodes) ?? undefined
// In Vue nodes mode, slots extend beyond node bounds due to CSS transforms.
// If no node was found, check if the click is on a slot and use its owning node.
if (!node && LiteGraph.vueNodesMode) {
const slotLayout = layoutStore.querySlotAtPoint({
x: e.canvasX,
y: e.canvasY
})
if (slotLayout) {
node = graph.getNodeById(slotLayout.nodeId) ?? undefined
}
}
this.mouse[0] = x
this.mouse[1] = y
this.graph_mouse[0] = e.canvasX

View File

@@ -73,6 +73,28 @@ describe('LinkConnector SubgraphInput connection validation', () => {
expect(toTargetNode.onConnectionsChange).toHaveBeenCalledTimes(1)
expect(fromTargetNode.onConnectionsChange).toHaveBeenCalledTimes(1)
})
it('should allow reconnection to same target', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'number_input', type: 'number' }]
})
const node = new LGraphNode('TargetNode')
node.addInput('number_in', 'number')
subgraph.add(node)
const link = subgraph.inputNode.slots[0].connect(node.inputs[0], node)
const renderLink = new ToInputFromIoNodeLink(
subgraph,
subgraph.inputNode,
subgraph.inputNode.slots[0],
undefined,
LinkDirection.CENTER,
link
)
renderLink.connectToInput(node, node.inputs[0], connector.events)
expect(node.inputs[0].link).not.toBeNull()
})
})
describe('MovingOutputLink validation', () => {

View File

@@ -58,6 +58,12 @@ export class ToInputFromIoNodeLink implements RenderLink {
events: CustomEventTarget<LinkConnectorEventMap>
) {
const { fromSlot, fromReroute, existingLink } = this
if (
existingLink &&
node.id === existingLink.target_id &&
node.inputs[existingLink.target_slot] === input
)
return
const newLink = fromSlot.connect(input, node, fromReroute?.id)

View File

@@ -993,7 +993,8 @@
"showAll": "Show all",
"hidden": "Hidden / nested parameters",
"hideAll": "Hide all",
"showRecommended": "Show recommended widgets"
"showRecommended": "Show recommended widgets",
"cannotDeleteGlobal": "Cannot delete installed blueprints"
},
"electronFileDownload": {
"inProgress": "In Progress",
@@ -2824,5 +2825,15 @@
"label": "Preview Version",
"tooltip": "You are using a nightly version of ComfyUI. Please use the feedback button to share your thoughts about these features."
}
},
"nodeFilters": {
"hideDeprecated": "Hide Deprecated Nodes",
"hideDeprecatedDescription": "Hides nodes marked as deprecated unless explicitly enabled",
"hideExperimental": "Hide Experimental Nodes",
"hideExperimentalDescription": "Hides nodes marked as experimental unless explicitly enabled",
"hideDevOnly": "Hide Dev-Only Nodes",
"hideDevOnlyDescription": "Hides nodes marked as dev-only unless dev mode is enabled",
"hideSubgraph": "Hide Subgraph Nodes",
"hideSubgraphDescription": "Temporarily hides subgraph nodes from node library and search"
}
}

View File

@@ -1178,7 +1178,7 @@ export const CORE_SETTINGS: SettingParams[] = [
type: 'boolean',
tooltip:
'Replaces the floating job queue panel with an equivalent job queue embedded in the Assets side panel. You can disable this to return to the floating panel layout.',
defaultValue: true,
defaultValue: false,
experimental: true
},
{

View File

@@ -215,6 +215,8 @@ export const useWorkflowService = () => {
}
}
workflowDraftStore.removeDraft(workflow.path)
// If this is the last workflow, create a new default temporary workflow
if (workflowStore.openWorkflows.length === 1) {
await loadDefaultWorkflow()

View File

@@ -11,6 +11,7 @@ import {
useWorkflowBookmarkStore,
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowDraftStore } from '@/platform/workflow/persistence/stores/workflowDraftStore'
import { api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { defaultGraph, defaultGraphJSON } from '@/scripts/defaultGraph'
@@ -910,4 +911,41 @@ describe('useWorkflowStore', () => {
expect(mostRecent).toBeNull()
})
})
describe('closeWorkflow draft cleanup', () => {
it('should remove draft for persisted workflows on close', async () => {
const draftStore = useWorkflowDraftStore()
await syncRemoteWorkflows(['a.json'])
const workflow = store.getWorkflowByPath('workflows/a.json')!
draftStore.saveDraft('workflows/a.json', {
data: '{"dirty":true}',
updatedAt: Date.now(),
name: 'a.json',
isTemporary: false
})
expect(draftStore.getDraft('workflows/a.json')).toBeDefined()
await store.closeWorkflow(workflow)
expect(draftStore.getDraft('workflows/a.json')).toBeUndefined()
})
it('should remove draft for temporary workflows on close', async () => {
const draftStore = useWorkflowDraftStore()
const workflow = store.createTemporary('temp.json')
draftStore.saveDraft(workflow.path, {
data: '{"dirty":true}',
updatedAt: Date.now(),
name: 'temp.json',
isTemporary: true
})
expect(draftStore.getDraft(workflow.path)).toBeDefined()
await store.closeWorkflow(workflow)
expect(draftStore.getDraft(workflow.path)).toBeUndefined()
})
})
})

View File

@@ -463,11 +463,9 @@ export const useWorkflowStore = defineStore('workflow', () => {
openWorkflowPaths.value = openWorkflowPaths.value.filter(
(path) => path !== workflow.path
)
useWorkflowDraftStore().removeDraft(workflow.path)
if (workflow.isTemporary) {
// Clear thumbnail when temporary workflow is closed
clearThumbnail(workflow.key)
// Clear draft when unsaved workflow tab is closed
useWorkflowDraftStore().removeDraft(workflow.path)
delete workflowLookup.value[workflow.path]
} else {
workflow.unload()

View File

@@ -394,6 +394,7 @@ interface SubgraphDefinitionBase<
id: string
revision: number
name: string
category?: string
inputNode: T extends ComfyWorkflow1BaseInput
? z.input<typeof zExportedSubgraphIONode>
@@ -425,6 +426,7 @@ const zSubgraphDefinition = zComfyWorkflow1
id: z.string().uuid(),
revision: z.number(),
name: z.string(),
category: z.string().optional(),
inputNode: zExportedSubgraphIONode,
outputNode: zExportedSubgraphIONode,

View File

@@ -158,7 +158,15 @@ const hasMultipleVideos = computed(() => props.imageUrls.length > 1)
// Watch for URL changes and reset state
watch(
() => props.imageUrls,
(newUrls) => {
(newUrls, oldUrls) => {
// Only reset state if URLs actually changed (not just array reference)
const urlsChanged =
!oldUrls ||
newUrls.length !== oldUrls.length ||
newUrls.some((url, i) => url !== oldUrls[i])
if (!urlsChanged) return
// Reset current index if it's out of bounds
if (currentIndex.value >= newUrls.length) {
currentIndex.value = 0
@@ -169,7 +177,7 @@ watch(
videoError.value = false
showLoader.value = newUrls.length > 0
},
{ deep: true, immediate: true }
{ immediate: true }
)
// Event handlers

View File

@@ -308,4 +308,80 @@ describe('ImagePreview', () => {
expect(imgElement.exists()).toBe(true)
expect(imgElement.attributes('alt')).toBe('Node output 2')
})
describe('URL change detection', () => {
it('should NOT reset loading state when imageUrls prop is reassigned with identical URLs', async () => {
vi.useFakeTimers()
try {
const urls = ['/api/view?filename=test.png&type=output']
const wrapper = mountImagePreview({ imageUrls: urls })
// Simulate image load completing
const img = wrapper.find('img')
await img.trigger('load')
await nextTick()
// Verify loader is hidden after load
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false)
// Reassign with new array reference but same content
await wrapper.setProps({ imageUrls: [...urls] })
await nextTick()
// Advance past the 250ms delayed loader timeout
await vi.advanceTimersByTimeAsync(300)
await nextTick()
// Loading state should NOT have been reset - aria-busy should still be false
// because the URLs are identical (just a new array reference)
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false)
} finally {
vi.useRealTimers()
}
})
it('should reset loading state when imageUrls prop changes to different URLs', async () => {
const urls = ['/api/view?filename=test.png&type=output']
const wrapper = mountImagePreview({ imageUrls: urls })
// Simulate image load completing
const img = wrapper.find('img')
await img.trigger('load')
await nextTick()
// Verify loader is hidden
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false)
// Change to different URL
await wrapper.setProps({
imageUrls: ['/api/view?filename=different.png&type=output']
})
await nextTick()
// After 250ms timeout, loading state should be reset (aria-busy="true")
// We can check the internal state via the Skeleton appearing
// or wait for the timeout
await new Promise((resolve) => setTimeout(resolve, 300))
await nextTick()
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(true)
})
it('should handle empty to non-empty URL transitions correctly', async () => {
const wrapper = mountImagePreview({ imageUrls: [] })
// No preview initially
expect(wrapper.find('.image-preview').exists()).toBe(false)
// Add URLs
await wrapper.setProps({
imageUrls: ['/api/view?filename=test.png&type=output']
})
await nextTick()
// Preview should appear
expect(wrapper.find('.image-preview').exists()).toBe(true)
expect(wrapper.find('img').exists()).toBe(true)
})
})
})

View File

@@ -176,7 +176,15 @@ const imageAltText = computed(() => `Node output ${currentIndex.value + 1}`)
// Watch for URL changes and reset state
watch(
() => props.imageUrls,
(newUrls) => {
(newUrls, oldUrls) => {
// Only reset state if URLs actually changed (not just array reference)
const urlsChanged =
!oldUrls ||
newUrls.length !== oldUrls.length ||
newUrls.some((url, i) => url !== oldUrls[i])
if (!urlsChanged) return
// Reset current index if it's out of bounds
if (currentIndex.value >= newUrls.length) {
currentIndex.value = 0
@@ -188,7 +196,7 @@ watch(
imageError.value = false
if (newUrls.length > 0) startDelayedLoader()
},
{ deep: true, immediate: true }
{ immediate: true }
)
// Event handlers

View File

@@ -7,7 +7,7 @@
cn(
'lg-slot lg-slot--input flex items-center group rounded-r-lg m-0',
'cursor-crosshair',
props.dotOnly ? 'lg-slot--dot-only' : 'pr-6',
dotOnly ? 'lg-slot--dot-only' : 'pr-6',
{
'lg-slot--connected': props.connected,
'lg-slot--compatible': props.compatible,
@@ -36,7 +36,7 @@
<!-- Slot Name -->
<div class="h-full flex items-center min-w-0">
<span
v-if="!dotOnly"
v-if="!props.dotOnly && !hasNoLabel"
:class="
cn(
'truncate text-node-component-slot-text',
@@ -47,8 +47,7 @@
{{
slotData.label ||
slotData.localized_name ||
slotData.name ||
`Input ${index}`
(slotData.name ?? `Input ${index}`)
}}
</span>
</div>
@@ -84,6 +83,14 @@ interface InputSlotProps {
const props = defineProps<InputSlotProps>()
const hasNoLabel = computed(
() =>
!props.slotData.label &&
!props.slotData.localized_name &&
props.slotData.name === ''
)
const dotOnly = computed(() => props.dotOnly || hasNoLabel.value)
const executionStore = useExecutionStore()
const hasSlotError = computed(() => {

View File

@@ -150,7 +150,9 @@
v-if="!isCollapsed && nodeData.resizable !== false"
role="button"
:aria-label="t('g.resizeFromBottomRight')"
:class="cn(baseResizeHandleClasses, 'right-0 bottom-0 cursor-se-resize')"
:class="
cn(baseResizeHandleClasses, '-right-1 -bottom-1 cursor-se-resize')
"
@pointerdown.stop="handleResizePointerDown"
/>
</div>
@@ -344,7 +346,7 @@ function initSizeStyles() {
}
const baseResizeHandleClasses =
'absolute h-3 w-3 opacity-0 pointer-events-auto focus-visible:outline focus-visible:outline-2 focus-visible:outline-white/40'
'absolute h-5 w-5 opacity-0 pointer-events-auto focus-visible:outline focus-visible:outline-2 focus-visible:outline-white/40'
const MIN_NODE_WIDTH = 225
@@ -549,6 +551,12 @@ const showAdvancedState = customRef((track, trigger) => {
}
})
const hasVideoInput = computed(() => {
return (
lgraphNode.value?.inputs?.some((input) => input.type === 'VIDEO') ?? false
)
})
const nodeMedia = computed(() => {
const newOutputs = nodeOutputs.nodeOutputs[nodeOutputLocatorId.value]
const node = lgraphNode.value
@@ -558,13 +566,9 @@ const nodeMedia = computed(() => {
const urls = nodeOutputs.getNodeImageUrls(node)
if (!urls?.length) return undefined
// Determine media type from previewMediaType or fallback to input slot types
// Note: Despite the field name "images", videos are also included in outputs
// TODO: fix the backend to return videos using the videos key instead of the images key
const hasVideoInput = node.inputs?.some((input) => input.type === 'VIDEO')
const type =
node.previewMediaType === 'video' ||
(!node.previewMediaType && hasVideoInput)
(!node.previewMediaType && hasVideoInput.value)
? 'video'
: 'image'

View File

@@ -3,8 +3,11 @@
<div v-else v-tooltip.right="tooltipConfig" :class="slotWrapperClass">
<div class="relative h-full flex items-center min-w-0">
<!-- Slot Name -->
<span v-if="!dotOnly" class="truncate text-node-component-slot-text">
{{ slotData.localized_name || slotData.name || `Output ${index}` }}
<span
v-if="!props.dotOnly && !hasNoLabel"
class="truncate text-node-component-slot-text"
>
{{ slotData.localized_name || (slotData.name ?? `Output ${index}`) }}
</span>
</div>
<!-- Connection Dot -->
@@ -44,6 +47,11 @@ interface OutputSlotProps {
const props = defineProps<OutputSlotProps>()
const hasNoLabel = computed(
() => !props.slotData.localized_name && props.slotData.name === ''
)
const dotOnly = computed(() => props.dotOnly || hasNoLabel.value)
// Error boundary implementation
const renderError = ref<string | null>(null)
@@ -79,7 +87,7 @@ const slotWrapperClass = computed(() =>
cn(
'lg-slot lg-slot--output flex items-center justify-end group rounded-l-lg h-6',
'cursor-crosshair',
props.dotOnly ? 'lg-slot--dot-only justify-center' : 'pl-6',
dotOnly.value ? 'lg-slot--dot-only justify-center' : 'pl-6',
{
'lg-slot--connected': props.connected,
'lg-slot--compatible': props.compatible,

View File

@@ -206,6 +206,60 @@ describe('WidgetMarkdown Dual Mode Display', () => {
expect(clickSpy).not.toHaveBeenCalled()
expect(keydownSpy).not.toHaveBeenCalled()
})
describe('Pointer Event Propagation', () => {
it('stops pointerdown propagation to prevent node drag during text selection', async () => {
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test')
await clickToEdit(wrapper)
const textarea = wrapper.find('textarea')
expect(textarea.exists()).toBe(true)
const parentPointerdownHandler = vi.fn()
const wrapperEl = wrapper.element as HTMLElement
wrapperEl.addEventListener('pointerdown', parentPointerdownHandler)
await textarea.trigger('pointerdown')
expect(parentPointerdownHandler).not.toHaveBeenCalled()
})
it('stops pointermove propagation during text selection', async () => {
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test')
await clickToEdit(wrapper)
const textarea = wrapper.find('textarea')
const parentPointermoveHandler = vi.fn()
const wrapperEl = wrapper.element as HTMLElement
wrapperEl.addEventListener('pointermove', parentPointermoveHandler)
await textarea.trigger('pointermove')
expect(parentPointermoveHandler).not.toHaveBeenCalled()
})
it('stops pointerup propagation after text selection', async () => {
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test')
await clickToEdit(wrapper)
const textarea = wrapper.find('textarea')
const parentPointerupHandler = vi.fn()
const wrapperEl = wrapper.element as HTMLElement
wrapperEl.addEventListener('pointerup', parentPointerupHandler)
await textarea.trigger('pointerup')
expect(parentPointerupHandler).not.toHaveBeenCalled()
})
})
})
describe('Value Updates', () => {

View File

@@ -21,6 +21,9 @@
}
}"
data-capture-wheel="true"
@pointerdown.capture.stop
@pointermove.capture.stop
@pointerup.capture.stop
@click.stop
@keydown.stop
/>

View File

@@ -152,6 +152,50 @@ describe('WidgetSelectDropdown custom label mapping', () => {
expect(consoleErrorSpy).toHaveBeenCalled()
consoleErrorSpy.mockRestore()
})
it('falls back to original value when label mapping returns empty string', () => {
const getOptionLabel = vi.fn((value: string | null) => {
if (value === 'photo_abc.jpg') {
return ''
}
return `Labeled: ${value}`
})
const widget = createMockWidget('img_001.png', {
getOptionLabel
})
const wrapper = mountComponent(widget, 'img_001.png')
const inputItems = wrapper.vm.inputItems
expect(inputItems[0].name).toBe('img_001.png')
expect(inputItems[0].label).toBe('Labeled: img_001.png')
expect(inputItems[1].name).toBe('photo_abc.jpg')
expect(inputItems[1].label).toBe('photo_abc.jpg')
expect(inputItems[2].name).toBe('hash789.png')
expect(inputItems[2].label).toBe('Labeled: hash789.png')
})
it('falls back to original value when label mapping returns undefined', () => {
const getOptionLabel = vi.fn((value: string | null) => {
if (value === 'hash789.png') {
return undefined as unknown as string
}
return `Labeled: ${value}`
})
const widget = createMockWidget('img_001.png', {
getOptionLabel
})
const wrapper = mountComponent(widget, 'img_001.png')
const inputItems = wrapper.vm.inputItems
expect(inputItems[0].name).toBe('img_001.png')
expect(inputItems[0].label).toBe('Labeled: img_001.png')
expect(inputItems[1].name).toBe('photo_abc.jpg')
expect(inputItems[1].label).toBe('Labeled: photo_abc.jpg')
expect(inputItems[2].name).toBe('hash789.png')
expect(inputItems[2].label).toBe('hash789.png')
})
})
describe('output items with custom label mapping', () => {
@@ -171,4 +215,102 @@ describe('WidgetSelectDropdown custom label mapping', () => {
expect(Array.isArray(outputItems)).toBe(true)
})
})
describe('missing value handling for template-loaded nodes', () => {
it('creates a fallback item in "all" filter when modelValue is not in available items', () => {
const widget = createMockWidget('template_image.png', {
values: ['img_001.png', 'photo_abc.jpg']
})
const wrapper = mountComponent(widget, 'template_image.png')
const inputItems = wrapper.vm.inputItems
expect(inputItems).toHaveLength(2)
expect(
inputItems.some((item) => item.name === 'template_image.png')
).toBe(false)
// The missing value should be accessible via dropdownItems when filter is 'all' (default)
const dropdownItems = (
wrapper.vm as unknown as { dropdownItems: DropdownItem[] }
).dropdownItems
expect(
dropdownItems.some((item) => item.name === 'template_image.png')
).toBe(true)
expect(dropdownItems[0].name).toBe('template_image.png')
expect(dropdownItems[0].id).toBe('missing-template_image.png')
})
it('does not include fallback item when filter is "inputs"', async () => {
const widget = createMockWidget('template_image.png', {
values: ['img_001.png', 'photo_abc.jpg']
})
const wrapper = mountComponent(widget, 'template_image.png')
const vmWithFilter = wrapper.vm as unknown as {
filterSelected: string
dropdownItems: DropdownItem[]
}
vmWithFilter.filterSelected = 'inputs'
await wrapper.vm.$nextTick()
const dropdownItems = vmWithFilter.dropdownItems
expect(dropdownItems).toHaveLength(2)
expect(
dropdownItems.every((item) => !String(item.id).startsWith('missing-'))
).toBe(true)
})
it('does not include fallback item when filter is "outputs"', async () => {
const widget = createMockWidget('template_image.png', {
values: ['img_001.png', 'photo_abc.jpg']
})
const wrapper = mountComponent(widget, 'template_image.png')
const vmWithFilter = wrapper.vm as unknown as {
filterSelected: string
dropdownItems: DropdownItem[]
outputItems: DropdownItem[]
}
vmWithFilter.filterSelected = 'outputs'
await wrapper.vm.$nextTick()
const dropdownItems = vmWithFilter.dropdownItems
expect(dropdownItems).toHaveLength(wrapper.vm.outputItems.length)
expect(
dropdownItems.every((item) => !String(item.id).startsWith('missing-'))
).toBe(true)
})
it('does not create a fallback item when modelValue exists in available items', () => {
const widget = createMockWidget('img_001.png', {
values: ['img_001.png', 'photo_abc.jpg']
})
const wrapper = mountComponent(widget, 'img_001.png')
const dropdownItems = (
wrapper.vm as unknown as { dropdownItems: DropdownItem[] }
).dropdownItems
expect(dropdownItems).toHaveLength(2)
expect(
dropdownItems.every((item) => !String(item.id).startsWith('missing-'))
).toBe(true)
})
it('does not create a fallback item when modelValue is undefined', () => {
const widget = createMockWidget(undefined as unknown as string, {
values: ['img_001.png', 'photo_abc.jpg']
})
const wrapper = mountComponent(widget, undefined)
const dropdownItems = (
wrapper.vm as unknown as { dropdownItems: DropdownItem[] }
).dropdownItems
expect(dropdownItems).toHaveLength(2)
expect(
dropdownItems.every((item) => !String(item.id).startsWith('missing-'))
).toBe(true)
})
})
})

View File

@@ -85,14 +85,15 @@ const selectedSet = ref<Set<SelectedKey>>(new Set())
/**
* Transforms a value using getOptionLabel if available.
* Falls back to the original value if getOptionLabel is not provided or throws an error.
* Falls back to the original value if getOptionLabel is not provided,
* returns undefined/null, or throws an error.
*/
function getDisplayLabel(value: string): string {
const getOptionLabel = props.widget.options?.getOptionLabel
if (!getOptionLabel) return value
try {
return getOptionLabel(value)
return getOptionLabel(value) || value
} catch (e) {
console.error('Failed to map value:', e)
return value
@@ -146,11 +147,69 @@ const outputItems = computed<DropdownItem[]>(() => {
}))
})
/**
* Creates a fallback item for the current modelValue when it doesn't exist
* in the available items list. This handles cases like template-loaded nodes
* where the saved value may not exist in the current server environment.
* Works for both local mode (inputItems/outputItems) and cloud mode (assetData).
*/
const missingValueItem = computed<DropdownItem | undefined>(() => {
const currentValue = modelValue.value
if (!currentValue) return undefined
// Check in cloud mode assets
if (props.isAssetMode && assetData) {
const existsInAssets = assetData.dropdownItems.value.some(
(item) => item.name === currentValue
)
if (existsInAssets) return undefined
return {
id: `missing-${currentValue}`,
mediaSrc: '',
name: currentValue,
label: getDisplayLabel(currentValue),
metadata: ''
}
}
// Check in local mode inputs/outputs
const existsInInputs = inputItems.value.some(
(item) => item.name === currentValue
)
const existsInOutputs = outputItems.value.some(
(item) => item.name === currentValue
)
if (existsInInputs || existsInOutputs) return undefined
const isOutput = currentValue.endsWith(' [output]')
const strippedValue = isOutput
? currentValue.replace(' [output]', '')
: currentValue
return {
id: `missing-${currentValue}`,
mediaSrc: getMediaUrl(strippedValue, isOutput ? 'output' : 'input'),
name: currentValue,
label: getDisplayLabel(currentValue),
metadata: ''
}
})
const allItems = computed<DropdownItem[]>(() => {
if (props.isAssetMode && assetData) {
return assetData.dropdownItems.value
const items = assetData.dropdownItems.value
if (missingValueItem.value) {
return [missingValueItem.value, ...items]
}
return items
}
return [...inputItems.value, ...outputItems.value]
return [
...(missingValueItem.value ? [missingValueItem.value] : []),
...inputItems.value,
...outputItems.value
]
})
const dropdownItems = computed<DropdownItem[]>(() => {
@@ -165,7 +224,7 @@ const dropdownItems = computed<DropdownItem[]>(() => {
return outputItems.value
case 'all':
default:
return [...inputItems.value, ...outputItems.value]
return allItems.value
}
})

View File

@@ -74,10 +74,14 @@ const addMultiSelectWidget = (
// TODO: Add remote support to multi-select widget
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/3003
if (inputSpec.control_after_generate) {
const defaultType =
typeof inputSpec.control_after_generate === 'string'
? inputSpec.control_after_generate
: 'fixed'
widget.linkedWidgets = addValueControlWidgets(
node,
widget,
'fixed',
defaultType,
undefined,
transformInputSpecV2ToV1(inputSpec)
)
@@ -209,10 +213,14 @@ const createInputMappingWidget = (
if (!isComboWidget(widget)) {
throw new Error(`Expected combo widget but received ${widget.type}`)
}
const defaultType =
typeof inputSpec.control_after_generate === 'string'
? inputSpec.control_after_generate
: 'randomize'
widget.linkedWidgets = addValueControlWidgets(
node,
widget,
undefined,
defaultType,
undefined,
transformInputSpecV2ToV1(inputSpec)
)
@@ -284,10 +292,14 @@ const addComboWidget = (
throw new Error(`Expected combo widget but received ${widget.type}`)
}
const defaultType =
typeof inputSpec.control_after_generate === 'string'
? inputSpec.control_after_generate
: 'randomize'
widget.linkedWidgets = addValueControlWidgets(
node,
widget,
undefined,
defaultType,
undefined,
transformInputSpecV2ToV1(inputSpec)
)

View File

@@ -72,10 +72,14 @@ export const useIntWidget = () => {
['seed', 'noise_seed'].includes(inputSpec.name)
if (controlAfterGenerate) {
const defaultType =
typeof inputSpec.control_after_generate === 'string'
? inputSpec.control_after_generate
: 'randomize'
const controlWidget = addValueControlWidget(
node,
widget,
'randomize',
defaultType,
undefined,
undefined,
transformInputSpecV2ToV1(inputSpec)

View File

@@ -2,6 +2,7 @@ import { z } from 'zod'
import { fromZodError } from 'zod-validation-error'
import { resultItemType } from '@/schemas/apiSchema'
import { CONTROL_OPTIONS } from '@/types/simplifiedWidget'
const zComboOption = z.union([z.string(), z.number()])
const zRemoteWidgetConfig = z.object({
@@ -50,7 +51,9 @@ export const zIntInputOptions = zNumericInputOptions.extend({
* If true, a linked widget will be added to the node to select the mode
* of `control_after_generate`.
*/
control_after_generate: z.boolean().optional()
control_after_generate: z
.union([z.boolean(), z.enum(CONTROL_OPTIONS)])
.optional()
})
export const zFloatInputOptions = zNumericInputOptions.extend({
@@ -74,7 +77,9 @@ export const zStringInputOptions = zBaseInputOptions.extend({
})
export const zComboInputOptions = zBaseInputOptions.extend({
control_after_generate: z.boolean().optional(),
control_after_generate: z
.union([z.boolean(), z.enum(CONTROL_OPTIONS)])
.optional(),
image_upload: z.boolean().optional(),
image_folder: resultItemType.optional(),
allow_batch: z.boolean().optional(),

View File

@@ -231,7 +231,10 @@ type ComplexApiEvents = keyof NeverNever<ApiEventTypes>
export type GlobalSubgraphData = {
name: string
info: { node_pack: string }
info: {
node_pack: string
category?: string
}
data: string | Promise<string>
}

View File

@@ -124,7 +124,9 @@ export function getWebpMetadata(file: File) {
break
}
offset += 8 + chunk_length
// RIFF spec requires odd-sized chunks to be padded with a single byte
// https://developers.google.com/speed/webp/docs/riff_container#riff_file_format
offset += 8 + chunk_length + (chunk_length % 2)
}
r(txt_chunks)

View File

@@ -876,7 +876,11 @@ export const useLitegraphService = () => {
function getCanvasCenter(): Point {
const dpi = Math.max(window.devicePixelRatio ?? 1, 1)
const [x, y, w, h] = app.canvas.ds.visible_area
const visibleArea = app.canvas?.ds?.visible_area
if (!visibleArea) {
return [0, 0]
}
const [x, y, w, h] = visibleArea
return [x + w / dpi / 2, y + h / dpi / 2]
}

View File

@@ -278,7 +278,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
}
const createCustomer = async (): Promise<CreateCustomerResponse> => {
const authHeader = await getFirebaseAuthHeader()
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}

View File

@@ -1,3 +1,4 @@
import { createTestingPinia } from '@pinia/testing'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -13,7 +14,9 @@ vi.mock('@/utils/litegraphUtil', () => ({
vi.mock('@/scripts/app', () => ({
app: {
getPreviewFormatParam: vi.fn(() => '&format=test_webp')
getPreviewFormatParam: vi.fn(() => '&format=test_webp'),
nodeOutputs: {} as Record<string, unknown>,
nodePreviewImages: {} as Record<string, string[]>
}
}))
@@ -28,6 +31,62 @@ const createMockOutputs = (
images?: ExecutedWsMessage['output']['images']
): ExecutedWsMessage['output'] => ({ images })
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: vi.fn(() => ({
executionIdToNodeLocatorId: vi.fn((id: string) => id)
}))
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: vi.fn(() => ({
nodeIdToNodeLocatorId: vi.fn((id: string | number) => String(id)),
nodeToNodeLocatorId: vi.fn((node: { id: number }) => String(node.id))
}))
}))
describe('imagePreviewStore setNodeOutputsByExecutionId with merge', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
app.nodeOutputs = {}
app.nodePreviewImages = {}
})
it('should update reactive nodeOutputs.value when merging outputs', () => {
const store = useNodeOutputStore()
const executionId = '1'
const initialOutput = createMockOutputs([{ filename: 'a.png' }])
store.setNodeOutputsByExecutionId(executionId, initialOutput)
expect(app.nodeOutputs[executionId]?.images).toHaveLength(1)
expect(store.nodeOutputs[executionId]?.images).toHaveLength(1)
const newOutput = createMockOutputs([{ filename: 'b.png' }])
store.setNodeOutputsByExecutionId(executionId, newOutput, { merge: true })
expect(app.nodeOutputs[executionId]?.images).toHaveLength(2)
expect(store.nodeOutputs[executionId]?.images).toHaveLength(2)
})
it('should assign to reactive ref after merge for Vue reactivity', () => {
const store = useNodeOutputStore()
const executionId = '1'
const initialOutput = createMockOutputs([{ filename: 'a.png' }])
store.setNodeOutputsByExecutionId(executionId, initialOutput)
const newOutput = createMockOutputs([{ filename: 'b.png' }])
store.setNodeOutputsByExecutionId(executionId, newOutput, { merge: true })
expect(store.nodeOutputs[executionId]).toStrictEqual(
app.nodeOutputs[executionId]
)
expect(store.nodeOutputs[executionId]?.images).toHaveLength(2)
})
})
describe('imagePreviewStore getPreviewParam', () => {
beforeEach(() => {
setActivePinia(createPinia())

View File

@@ -148,6 +148,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
existingOutput[k] = newValue
}
}
nodeOutputs.value[nodeLocatorId] = existingOutput
return
}
}

View File

@@ -3,6 +3,7 @@ import _ from 'es-toolkit/compat'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { t } from '@/i18n'
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { transformNodeDefV1ToV2 } from '@/schemas/nodeDef/migration'
@@ -408,17 +409,16 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
// Deprecated nodes filter
registerNodeDefFilter({
id: 'core.deprecated',
name: 'Hide Deprecated Nodes',
description: 'Hides nodes marked as deprecated unless explicitly enabled',
name: t('nodeFilters.hideDeprecated'),
description: t('nodeFilters.hideDeprecatedDescription'),
predicate: (nodeDef) => showDeprecated.value || !nodeDef.deprecated
})
// Experimental nodes filter
registerNodeDefFilter({
id: 'core.experimental',
name: 'Hide Experimental Nodes',
description:
'Hides nodes marked as experimental unless explicitly enabled',
name: t('nodeFilters.hideExperimental'),
description: t('nodeFilters.hideExperimentalDescription'),
predicate: (nodeDef) => showExperimental.value || !nodeDef.experimental
})
@@ -426,9 +426,8 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
// Filter out litegraph typed subgraphs, saved blueprints are added in separately
registerNodeDefFilter({
id: 'core.subgraph',
name: 'Hide Subgraph Nodes',
description:
'Temporarily hides subgraph nodes from node library and search',
name: t('nodeFilters.hideSubgraph'),
description: t('nodeFilters.hideSubgraphDescription'),
predicate: (nodeDef) => {
// Hide subgraph nodes (identified by category='subgraph' and python_module='nodes')
return !(

View File

@@ -44,6 +44,9 @@ const createTaskOutput = (
}
})
type QueueResponse = { Running: JobListItem[]; Pending: JobListItem[] }
type QueueResolver = (value: QueueResponse) => void
// Mock API
vi.mock('@/scripts/api', () => ({
api: {
@@ -797,4 +800,106 @@ describe('useQueueStore', () => {
expect(mockGetHistory).toHaveBeenCalled()
})
})
describe('update deduplication', () => {
it('should discard stale responses when newer request completes first', async () => {
let resolveFirst: QueueResolver
let resolveSecond: QueueResolver
const firstQueuePromise = new Promise<QueueResponse>((resolve) => {
resolveFirst = resolve
})
const secondQueuePromise = new Promise<QueueResponse>((resolve) => {
resolveSecond = resolve
})
mockGetHistory.mockResolvedValue([])
mockGetQueue
.mockReturnValueOnce(firstQueuePromise)
.mockReturnValueOnce(secondQueuePromise)
const firstUpdate = store.update()
const secondUpdate = store.update()
resolveSecond!({ Running: [], Pending: [createPendingJob(2, 'new-job')] })
await secondUpdate
expect(store.pendingTasks).toHaveLength(1)
expect(store.pendingTasks[0].promptId).toBe('new-job')
resolveFirst!({
Running: [],
Pending: [createPendingJob(1, 'stale-job')]
})
await firstUpdate
expect(store.pendingTasks).toHaveLength(1)
expect(store.pendingTasks[0].promptId).toBe('new-job')
})
it('should set isLoading to false only for the latest request', async () => {
let resolveFirst: QueueResolver
let resolveSecond: QueueResolver
const firstQueuePromise = new Promise<QueueResponse>((resolve) => {
resolveFirst = resolve
})
const secondQueuePromise = new Promise<QueueResponse>((resolve) => {
resolveSecond = resolve
})
mockGetHistory.mockResolvedValue([])
mockGetQueue
.mockReturnValueOnce(firstQueuePromise)
.mockReturnValueOnce(secondQueuePromise)
const firstUpdate = store.update()
expect(store.isLoading).toBe(true)
const secondUpdate = store.update()
expect(store.isLoading).toBe(true)
resolveSecond!({ Running: [], Pending: [] })
await secondUpdate
expect(store.isLoading).toBe(false)
resolveFirst!({ Running: [], Pending: [] })
await firstUpdate
expect(store.isLoading).toBe(false)
})
it('should handle stale request failure without affecting latest state', async () => {
let resolveSecond: QueueResolver
const secondQueuePromise = new Promise<QueueResponse>((resolve) => {
resolveSecond = resolve
})
mockGetHistory.mockResolvedValue([])
mockGetQueue
.mockRejectedValueOnce(new Error('stale network error'))
.mockReturnValueOnce(secondQueuePromise)
const firstUpdate = store.update()
const secondUpdate = store.update()
resolveSecond!({ Running: [], Pending: [createPendingJob(2, 'new-job')] })
await secondUpdate
expect(store.pendingTasks).toHaveLength(1)
expect(store.pendingTasks[0].promptId).toBe('new-job')
expect(store.isLoading).toBe(false)
await expect(firstUpdate).rejects.toThrow('stale network error')
expect(store.pendingTasks).toHaveLength(1)
expect(store.pendingTasks[0].promptId).toBe('new-job')
expect(store.isLoading).toBe(false)
})
})
})

View File

@@ -475,6 +475,9 @@ export const useQueueStore = defineStore('queue', () => {
const maxHistoryItems = ref(64)
const isLoading = ref(false)
// Scoped per-store instance; incremented to dedupe concurrent update() calls
let updateRequestId = 0
const tasks = computed<TaskItemImpl[]>(
() =>
[
@@ -498,6 +501,7 @@ export const useQueueStore = defineStore('queue', () => {
)
const update = async () => {
const requestId = ++updateRequestId
isLoading.value = true
try {
const [queue, history] = await Promise.all([
@@ -505,6 +509,8 @@ export const useQueueStore = defineStore('queue', () => {
api.getHistory(maxHistoryItems.value)
])
if (requestId !== updateRequestId) return
// API returns pre-sorted data (sort_by=create_time&order=desc)
runningTasks.value = queue.Running.map((job) => new TaskItemImpl(job))
pendingTasks.value = queue.Pending.map((job) => new TaskItemImpl(job))
@@ -545,7 +551,12 @@ export const useQueueStore = defineStore('queue', () => {
return existing
})
} finally {
isLoading.value = false
// Only clear loading if this is the latest request.
// A stale request completing (success or error) should not touch loading state
// since a newer request is responsible for it.
if (requestId === updateRequestId) {
isLoading.value = false
}
}
}

View File

@@ -2,6 +2,7 @@ import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyNodeDef as ComfyNodeDefV1 } from '@/schemas/nodeDefSchema'
import type { GlobalSubgraphData } from '@/scripts/api'
import { api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { useLitegraphService } from '@/services/litegraphService'
@@ -24,6 +25,7 @@ vi.mock('@/scripts/api', () => ({
getUserData: vi.fn(),
storeUserData: vi.fn(),
listUserDataFullInfo: vi.fn(),
getGlobalSubgraphs: vi.fn(),
apiURL: vi.fn(),
addEventListener: vi.fn()
}
@@ -59,7 +61,10 @@ const mockGraph = {
describe('useSubgraphStore', () => {
let store: ReturnType<typeof useSubgraphStore>
const mockFetch = async (filenames: Record<string, unknown>) => {
async function mockFetch(
filenames: Record<string, unknown>,
globalSubgraphs: Record<string, GlobalSubgraphData> = {}
) {
vi.mocked(api.listUserDataFullInfo).mockResolvedValue(
Object.keys(filenames).map((filename) => ({
path: filename,
@@ -67,13 +72,13 @@ describe('useSubgraphStore', () => {
size: 1 // size !== -1 for remote workflows
}))
)
vi.mocked(api).getUserData = vi.fn(
(f) =>
({
status: 200,
text: () => JSON.stringify(filenames[f.slice(10)])
}) as any
vi.mocked(api).getUserData = vi.fn((f) =>
Promise.resolve({
status: 200,
text: () => Promise.resolve(JSON.stringify(filenames[f.slice(10)]))
} as Response)
)
vi.mocked(api.getGlobalSubgraphs).mockResolvedValue(globalSubgraphs)
return await store.fetchSubgraphs()
}
@@ -113,7 +118,7 @@ describe('useSubgraphStore', () => {
await mockFetch({ 'test.json': mockGraph })
expect(
useNodeDefStore().nodeDefs.filter(
(d) => d.category == 'Subgraph Blueprints'
(d) => d.category === 'Subgraph Blueprints/User'
)
).toHaveLength(1)
})
@@ -131,4 +136,25 @@ describe('useSubgraphStore', () => {
} as ComfyNodeDefV1)
expect(res).toBeTruthy()
})
it('should identify user blueprints as non-global', async () => {
await mockFetch({ 'test.json': mockGraph })
expect(store.isGlobalBlueprint('test')).toBe(false)
})
it('should identify global blueprints loaded from getGlobalSubgraphs', async () => {
await mockFetch(
{},
{
global_test: {
name: 'Global Test Blueprint',
info: { node_pack: 'comfy_essentials' },
data: JSON.stringify(mockGraph)
}
}
)
expect(store.isGlobalBlueprint('global_test')).toBe(true)
})
it('should return false for non-existent blueprints', async () => {
await mockFetch({ 'test.json': mockGraph })
expect(store.isGlobalBlueprint('nonexistent')).toBe(false)
})
})

View File

@@ -96,7 +96,9 @@ export const useSubgraphStore = defineStore('subgraph', () => {
this.hasPromptedSave = true
}
const ret = await super.save()
useSubgraphStore().updateDef(await this.load())
registerNodeDef(await this.load(), {
category: 'Subgraph Blueprints/User'
})
return ret
}
@@ -104,7 +106,9 @@ export const useSubgraphStore = defineStore('subgraph', () => {
this.validateSubgraph()
this.hasPromptedSave = true
const ret = await super.saveAs(path)
useSubgraphStore().updateDef(await this.load())
registerNodeDef(await this.load(), {
category: 'Subgraph Blueprints/User'
})
return ret
}
override async load({ force = false }: { force?: boolean } = {}): Promise<
@@ -151,7 +155,7 @@ export const useSubgraphStore = defineStore('subgraph', () => {
options.path = SubgraphBlueprint.basePath + options.path
const bp = await new SubgraphBlueprint(options, true).load()
useWorkflowStore().attachWorkflow(bp)
registerNodeDef(bp)
registerNodeDef(bp, { category: 'Subgraph Blueprints/User' })
}
async function loadInstalledBlueprints() {
async function loadGlobalBlueprint([k, v]: [string, GlobalSubgraphData]) {
@@ -165,11 +169,15 @@ export const useSubgraphStore = defineStore('subgraph', () => {
blueprint.filename = v.name
useWorkflowStore().attachWorkflow(blueprint)
const loaded = await blueprint.load()
const category = v.info.category
? `Subgraph Blueprints/${v.info.category}`
: 'Subgraph Blueprints'
registerNodeDef(
loaded,
{
python_module: v.info.node_pack,
display_name: v.name
display_name: v.name,
category
},
k
)
@@ -284,7 +292,6 @@ export const useSubgraphStore = defineStore('subgraph', () => {
await workflow.save()
//add to files list?
useWorkflowStore().attachWorkflow(loadedWorkflow)
registerNodeDef(loadedWorkflow)
useToastStore().add({
severity: 'success',
summary: t('subgraphStore.publishSuccess'),
@@ -292,9 +299,6 @@ export const useSubgraphStore = defineStore('subgraph', () => {
life: 4000
})
}
function updateDef(blueprint: LoadedComfyWorkflow) {
registerNodeDef(blueprint)
}
async function editBlueprint(nodeType: string) {
const name = nodeType.slice(typePrefix.length)
if (!(name in subgraphCache))
@@ -315,9 +319,17 @@ export const useSubgraphStore = defineStore('subgraph', () => {
}
async function deleteBlueprint(nodeType: string) {
const name = nodeType.slice(typePrefix.length)
if (!(name in subgraphCache))
//As loading is blocked on in startup, this can likely be changed to invalid type
throw new Error('not yet loaded')
if (!(name in subgraphCache)) throw new Error('not yet loaded')
if (isGlobalBlueprint(name)) {
useToastStore().add({
severity: 'warn',
summary: t('subgraphStore.cannotDeleteGlobal'),
life: 4000
})
return
}
if (
!(await useDialogService().confirm({
title: t('subgraphStore.confirmDeleteTitle'),
@@ -338,15 +350,20 @@ export const useSubgraphStore = defineStore('subgraph', () => {
return workflow instanceof SubgraphBlueprint
}
function isGlobalBlueprint(name: string): boolean {
const nodeDef = subgraphDefCache.value.get(name)
return nodeDef !== undefined && nodeDef.python_module !== 'blueprint'
}
return {
deleteBlueprint,
editBlueprint,
fetchSubgraphs,
getBlueprint,
isGlobalBlueprint,
isSubgraphBlueprint,
publishSubgraph,
subgraphBlueprints,
typePrefix,
updateDef
typePrefix
}
})

View File

@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -19,8 +19,13 @@ type RightSidePanelSection = 'advanced-inputs' | string
export const useRightSidePanelStore = defineStore('rightSidePanel', () => {
const settingStore = useSettingStore()
const isLegacyMenu = computed(
() => settingStore.get('Comfy.UseNewMenu') === 'Disabled'
)
const isOpen = computed({
get: () => settingStore.get('Comfy.RightSidePanel.IsOpen'),
get: () =>
!isLegacyMenu.value && settingStore.get('Comfy.RightSidePanel.IsOpen'),
set: (value: boolean) =>
settingStore.set('Comfy.RightSidePanel.IsOpen', value)
})
@@ -29,7 +34,15 @@ export const useRightSidePanelStore = defineStore('rightSidePanel', () => {
const focusedSection = ref<RightSidePanelSection | null>(null)
const searchQuery = ref('')
// Auto-close panel when switching to legacy menu mode
watch(isLegacyMenu, (legacy) => {
if (legacy) {
void settingStore.set('Comfy.RightSidePanel.IsOpen', false)
}
})
function openPanel(tab?: RightSidePanelTab) {
if (isLegacyMenu.value) return
isOpen.value = true
if (tab) {
activeTab.value = tab

View File

@@ -15,7 +15,7 @@ export type WidgetValue =
| void
| File[]
const CONTROL_OPTIONS = [
export const CONTROL_OPTIONS = [
'fixed',
'increment',
'decrement',

View File

@@ -1,3 +1,4 @@
import { createSharedComposable } from '@vueuse/core'
import { computed, onUnmounted, ref } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
@@ -9,7 +10,6 @@ import { useSystemStatsStore } from '@/stores/systemStatsStore'
import type { components } from '@/types/comfyRegistryTypes'
import { mapAllNodes } from '@/utils/graphTraversalUtil'
import { useNodePacks } from '@/workbench/extensions/manager/composables/nodePack/useNodePacks'
import type { UseNodePacksOptions } from '@/workbench/extensions/manager/types/comfyManagerTypes'
type WorkflowPack = {
id:
@@ -22,9 +22,10 @@ const CORE_NODES_PACK_NAME = 'comfy-core'
/**
* Handles parsing node pack metadata from nodes on the graph and fetching the
* associated node packs from the registry
* associated node packs from the registry.
* This is a shared singleton composable - all components use the same instance.
*/
export const useWorkflowPacks = (options: UseNodePacksOptions = {}) => {
const _useWorkflowPacks = () => {
const nodeDefStore = useNodeDefStore()
const systemStatsStore = useSystemStatsStore()
const { inferPackFromNodeName } = useComfyRegistryStore()
@@ -129,7 +130,7 @@ export const useWorkflowPacks = (options: UseNodePacksOptions = {}) => {
)
const { startFetch, cleanup, error, isLoading, nodePacks, isReady } =
useNodePacks(workflowPacksIds, options)
useNodePacks(workflowPacksIds)
const isIdInWorkflow = (packId: string) =>
workflowPacksIds.value.includes(packId)
@@ -153,3 +154,5 @@ export const useWorkflowPacks = (options: UseNodePacksOptions = {}) => {
filterWorkflowPack
}
}
export const useWorkflowPacks = createSharedComposable(_useWorkflowPacks)