Compare commits

...

12 Commits

Author SHA1 Message Date
Christian Byrne
77d119888c Revert "feat: add showScrollbar prop to VirtualGrid (#7227)"
This reverts commit f385ee8ca2.
2025-12-15 11:03:24 -07:00
Benjamin Lu
abf966ab83 Topbar: add Custom Nodes Manager button (#7400)
Adds a desktop-only "Custom Nodes Manager" topbar button (Lucide puzzle)
in its own bordered island left of the actionbar. Button opens the
manager via useManagerState().openManager().

- New i18n key: menu.customNodesManager

<img width="318" height="147" alt="image"
src="https://github.com/user-attachments/assets/e36c5c7f-80d1-454c-87de-e0daa822fad1"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7400-Topbar-add-Custom-Nodes-Manager-button-2c76d73d3650810b9122da61a3c4be39)
by [Unito](https://www.unito.io)
2025-12-15 09:35:38 -08:00
Christian Byrne
a89fa5a784 fix: "convert to subgraph" not shown in context menu if subgraph inside the selection context (#7470)
Fixes https://github.com/Comfy-Org/ComfyUI_frontend/issues/7453.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7470-fix-convert-to-subgraph-not-shown-in-context-menu-if-subgraph-inside-the-selection-con-2c96d73d36508146a475e8d39c64183c)
by [Unito](https://www.unito.io)
2025-12-15 16:39:18 +01:00
Johnpaul Chiwetelu
c414635ead [feat] Add context menu converter infrastructure (#7113)
## Summary
- Add `contextMenuConverter.ts` with utilities for converting LiteGraph
context menu items to Vue menu format
- Improve `contextMenuCompat.ts` with set-based diffing for more
reliable legacy extension detection
- Extend `MenuOption`/`SubMenuOption` types with `source`, `disabled`,
`isColorPicker`, and `category` type fields
- Add unit tests for converter functions

## Context
This is foundational work for migrating the node context menu from a
custom Popover-based component to PrimeVue ContextMenu.

The converter provides:
- Menu ordering and section grouping (core items first, then extensions)
- Deduplication with preference for Vue-native items over LiteGraph
items
- Extension categorization with labeled section
- Support for disabled states and color picker submenus

## Test plan
- [x] Unit tests pass for `buildStructuredMenu` (9 tests)
- [x] Unit tests pass for `convertContextMenuToOptions` (7 tests)
- [x] Typecheck passes
- [x] Lint passes
- [x] Knip passes (no unused exports)

## Related
This is PR 1 of 2 for the node context menu migration. PR 2 will wire up
the UI component.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7113-feat-Add-context-menu-converter-infrastructure-2be6d73d3650816ca6c9d2cf50f10159)
by [Unito](https://www.unito.io)
2025-12-14 21:01:12 -07:00
Terry Jia
e96593fe4c fix: prevent unrelated groups from moving when dragging nodes in vueNodes mode (#7473)
## Summary

Previously, when dragging a node that was not part of the selection, any
selected groups would still move along with it. This fix ensures groups
only move when the dragged node is actually part of the selection.

## Screenshots (if applicable)
before


https://github.com/user-attachments/assets/ff9a18c2-59b2-4bbd-81b4-7a6ecb35e659


after


https://github.com/user-attachments/assets/019a6cc6-b1e2-41d1-bfec-d6af7ae84091

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7473-fix-prevent-unrelated-groups-from-moving-when-dragging-nodes-in-vueNodes-mode-2c96d73d365081a194a6fef57f9c1108)
by [Unito](https://www.unito.io)
2025-12-14 22:13:00 -05:00
Benjamin Lu
93178c80ba feat(server-config): add legacy manager UI toggle (#7478)
Adds a Desktop (Electron) Server-Config setting for
`--enable-manager-legacy-ui` so users can opt into ComfyUI-Manager’s
legacy UI.

- Adds `enable-manager-legacy-ui` to `SERVER_CONFIG_ITEMS`
- Adds EN i18n label + tooltip

Note: this PR only adds the setting/flag wiring; it does not change
restart behavior in Desktop.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7478-feat-server-config-add-legacy-manager-UI-toggle-2ca6d73d365081a79bb2c376506f5346)
by [Unito](https://www.unito.io)


> [!NOTE]
> This is a stacked PR. (main <=
https://github.com/Comfy-Org/ComfyUI_frontend/pull/7478 <=
https://github.com/Comfy-Org/ComfyUI_frontend/pull/7479)
2025-12-14 18:40:25 -08:00
Christian Byrne
585d46d4fb fix: inner groups being moved double when moving outer group (in vue mode) (#7447)
## Summary

Fixes issue when dragging a group that had inner groups when in vue
mode.

When dragging the outer group in Vue mode:

1. getAllNestedItems(selected) returns ALL items: outer group + inner
groups + nodes
2. moveChildNodesInGroupVueMode loops through all items
3. For outer group G1: calls G1.move(delta, true) then
moveGroupChildren(G1, ...)
4. moveGroupChildren calls G2.move(delta) (no skipChildren) - this moves
G2 AND G2's children!
5. Then the loop reaches G2: calls G2.move(delta, true) - moves G2 again
6. Plus moveGroupChildren(G2, ...) processes G2's children again

This PR fixes it by adding `skipChildren=true` to the `move` call.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7447-fix-inner-groups-being-moved-double-when-moving-outer-group-in-vue-mode-2c86d73d365081ce97abec682f2a8518)
by [Unito](https://www.unito.io)
2025-12-14 18:37:29 -08:00
Comfy Org PR Bot
d70039103c 1.36.1 (#7477)
Patch version increment to 1.36.1

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7477-1-36-1-2ca6d73d3650812d84e6d6b0b079ec7d)
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>
2025-12-14 18:35:26 -08:00
AustinMroz
3a091277d0 Nesting support for autogrow (#7275)
- Modifies autogrow inputs to be named by key
- Allows autogrow inputs to be added after initialization.
  - Such as when added by another dynamic combo
- Groups dynamic input information under a single comfyDynamic property
which is opaque to Litegraph

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7275-Nesting-support-for-autogrow-2c46d73d36508171893ec43275f5b644)
by [Unito](https://www.unito.io)
2025-12-14 02:29:34 -08:00
Christian Byrne
209903e1f1 remove contentype badge from media assets card (#7440)
## Summary

Match Figma by removing contenttype assets badge, which doesn't really
serve a good purpose.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7440-remove-contentype-badge-from-media-assets-card-2c86d73d3650818e8d14ed4573d28725)
by [Unito](https://www.unito.io)
2025-12-14 02:26:19 -08:00
Christian Byrne
9ca58ce525 docs: add ADR on importmap removal decision (#7466)
## Summary

Adds an ADR detailing the context and rationale behind removing
importmap and explains how extension developers may go about resolving
any issues caused thereafter. See
https://github.com/Comfy-Org/ComfyUI_frontend/issues/7267#issuecomment-3650045669
for more details.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7466-docs-add-ADR-on-importmap-removal-decision-2c96d73d3650817599a7e8baad539a94)
by [Unito](https://www.unito.io)
2025-12-13 21:15:43 -07:00
Comfy Org PR Bot
c0d3fb312f 1.36.0 (#7467)
Minor version increment to 1.36.0

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7467-1-36-0-2c96d73d365081babc9fc0e3eeab858b)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-12-13 20:37:04 -07:00
25 changed files with 1791 additions and 340 deletions

View File

@@ -0,0 +1,92 @@
{
"id": "2ba0b800-2f13-4f21-b8d6-c6cdb0152cae",
"revision": 0,
"last_node_id": 17,
"last_link_id": 9,
"nodes": [
{
"id": 17,
"type": "VAEDecode",
"pos": [
318.8446183157076,
355.3961392345528
],
"size": [
225,
102
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": null
},
{
"name": "vae",
"type": "VAE",
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
}
],
"properties": {
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
}
],
"links": [],
"groups": [
{
"id": 4,
"title": "Outer Group",
"bounding": [
-46.25245366331014,
-150.82497138023245,
1034.4034361963616,
1007.338460439933
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 3,
"title": "Inner Group",
"bounding": [
80.96059074101554,
28.123757436778178,
718.286373661183,
691.2397164539732
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
}
],
"config": {},
"extra": {
"ds": {
"scale": 0.7121393732101533,
"offset": [
289.18242848011835,
367.0747755524199
]
},
"frontendVersion": "1.35.5",
"VHS_latentpreview": false,
"VHS_latentpreviewrate": 0,
"VHS_MetadataImage": true,
"VHS_KeepIntermediate": true,
"workflowRendererVersion": "Vue"
},
"version": 0.4
}

View File

@@ -1653,6 +1653,55 @@ export class ComfyPage {
}, focusMode)
await this.nextFrame()
}
/**
* Get the position of a group by title.
* @param title The title of the group to find
* @returns The group's canvas position
* @throws Error if group not found
*/
async getGroupPosition(title: string): Promise<Position> {
const pos = await this.page.evaluate((title) => {
const groups = window['app'].graph.groups
const group = groups.find((g: { title: string }) => g.title === title)
if (!group) return null
return { x: group.pos[0], y: group.pos[1] }
}, title)
if (!pos) throw new Error(`Group "${title}" not found`)
return pos
}
/**
* Drag a group by its title.
* @param options.name The title of the group to drag
* @param options.deltaX Horizontal drag distance in screen pixels
* @param options.deltaY Vertical drag distance in screen pixels
*/
async dragGroup(options: {
name: string
deltaX: number
deltaY: number
}): Promise<void> {
const { name, deltaX, deltaY } = options
const screenPos = await this.page.evaluate((title) => {
const app = window['app']
const groups = app.graph.groups
const group = groups.find((g: { title: string }) => g.title === title)
if (!group) return null
// Position in the title area of the group
const clientPos = app.canvasPosToClientPos([
group.pos[0] + 50,
group.pos[1] + 15
])
return { x: clientPos[0], y: clientPos[1] }
}, name)
if (!screenPos) throw new Error(`Group "${name}" not found`)
await this.dragAndDrop(screenPos, {
x: screenPos.x + deltaX,
y: screenPos.y + deltaY
})
}
}
export const testComfySnapToGridGridSize = 50

View File

@@ -32,4 +32,42 @@ test.describe('Vue Node Groups', () => {
'vue-groups-fit-to-contents.png'
)
})
test('should move nested groups together when dragging outer group', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('groups/nested-groups-1-inner-node')
// Get initial positions with null guards
const outerInitial = await comfyPage.getGroupPosition('Outer Group')
const innerInitial = await comfyPage.getGroupPosition('Inner Group')
const initialOffsetX = innerInitial.x - outerInitial.x
const initialOffsetY = innerInitial.y - outerInitial.y
// Drag the outer group
const dragDelta = { x: 100, y: 80 }
await comfyPage.dragGroup({
name: 'Outer Group',
deltaX: dragDelta.x,
deltaY: dragDelta.y
})
// Use retrying assertion to wait for positions to update
await expect(async () => {
const outerFinal = await comfyPage.getGroupPosition('Outer Group')
const innerFinal = await comfyPage.getGroupPosition('Inner Group')
const finalOffsetX = innerFinal.x - outerFinal.x
const finalOffsetY = innerFinal.y - outerFinal.y
// Both groups should have moved
expect(outerFinal.x).not.toBe(outerInitial.x)
expect(innerFinal.x).not.toBe(innerInitial.x)
// The relative offset should be maintained (inner group moved with outer)
expect(finalOffsetX).toBeCloseTo(initialOffsetX, 0)
expect(finalOffsetY).toBeCloseTo(initialOffsetY, 0)
}).toPass({ timeout: 5000 })
})
})

View File

@@ -0,0 +1,97 @@
# 5. Remove Import Map for Vue Extensions
Date: 2025-12-13
## Status
Accepted
## Context
ComfyUI frontend previously used a Vite plugin (`generateImportMapPlugin`) to inject an HTML import map exposing shared modules to extensions. This allowed Vue-based extensions to mark dependencies as external in their Vite configs:
```typescript
// Extension vite.config.ts (old pattern)
rollupOptions: {
external: ['vue', 'vue-i18n', 'pinia', /^primevue\/?.*/, ...]
}
```
The import map resolved bare specifiers like `import { ref } from 'vue'` at runtime by mapping them to pre-built ESM files served from `/assets/lib/`.
**Modules exposed via import map:**
- `vue` (vue.esm-browser.prod.js)
- `vue-i18n` (vue-i18n.esm-browser.prod.js)
- `primevue/*` (all PrimeVue components)
- `@primevue/themes/*`
- `@primevue/forms/*`
**Problems with import map approach:**
1. **Blocked tree shaking**: Vue and PrimeVue loaded as remote modules at runtime, preventing bundler optimizations. The entire Vue runtime was loaded even if only a few APIs were used.
2. **Poor code splitting**: PrimeVue's component library split into hundreds of small chunks, each requiring a separate network request on mount. This significantly impacted initial page load.
3. **Cold start performance**: Each externalized module required a separate HTTP request and browser module resolution step. This compounded on lower-end systems and slower networks.
4. **Version alignment complexity**: Extensions relied on the frontend's Vue version at runtime. Subtle version mismatches between build-time types and runtime code caused debugging difficulties.
5. **Incompatible with Cloud distribution**: The Cloud deployment model requires fully bundled, optimized assets. Import maps added a layer of indirection incompatible with our CDN and caching strategy.
## Decision
Remove the `generateImportMapPlugin` and require Vue-based extensions to bundle their own Vue instance.
**Implementation (PR #6899):**
- Deleted `build/plugins/generateImportMapPlugin.ts`
- Removed plugin configuration from `vite.config.mts`
- Removed `fast-glob` dependency used by the plugin
**Extension migration path:**
1. Remove `external: ['vue', ...]` from Vite rollup options
2. Vue and related dependencies will be bundled into the extension output
3. No code changes required in extension source files
The import map was already disabled for Cloud builds (PR #6559) before complete removal. Removal aligns all distribution channels on the same bundling strategy.
## Consequences
### Positive
- **Improved page load**: Full tree shaking and optimal code splitting now apply to Vue and PrimeVue
- **Faster development**: No import map generation step; simplified build pipeline
- **Better debugging**: Extension's bundled Vue matches build-time expectations exactly
- **Cloud compatibility**: All assets fully bundled and CDN-optimizable
- **Consistent behavior**: Same bundling strategy across desktop, localhost, and cloud distributions
- **Reduced network requests**: Fewer module fetches on initial page load
### Negative
- **Breaking change for existing extensions**: Extensions using `external: ['vue']` pattern fail with "Failed to resolve module specifier 'vue'" error
- **Larger extension bundles**: Each extension now includes its own Vue instance (~30KB gzipped)
- **Potential version fragmentation**: Different extensions may bundle different Vue versions (mitigated by Vue's stable API)
### Migration Impact
Extensions affected must update their build configuration. The migration is straightforward:
```diff
// vite.config.ts
rollupOptions: {
- external: ['vue', 'vue-i18n', 'primevue', ...]
}
```
Affected versions:
- **v1.32.x - v1.33.8**: Import map present, external pattern works
- **v1.33.9+**: Import map removed, bundling required
## Notes
- [ComfyUI_frontend_vue_basic](https://github.com/jtydhr88/ComfyUI_frontend_vue_basic) has been updated to demonstrate the new bundled pattern
- Issue #7267 documents the user-facing impact and migration discussion
- Future Extension API v2 (Issue #4668) may provide alternative mechanisms for shared dependencies

View File

@@ -14,6 +14,7 @@ An Architecture Decision Record captures an important architectural decision mad
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
| [0005](0005-remove-importmap-for-vue-extensions.md) | Remove Import Map for Vue Extensions | Accepted | 2025-12-13 |
## Creating a New ADR

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.35.7",
"version": "1.36.1",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",

View File

@@ -10,48 +10,66 @@
</div>
<div class="mx-1 flex flex-col items-end gap-1">
<div
class="actionbar-container pointer-events-auto flex h-12 items-center rounded-lg border border-interface-stroke px-2 shadow-interface"
>
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div class="flex items-center gap-2">
<div
ref="legacyCommandsContainerRef"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
<ComfyActionbar />
<IconButton
v-tooltip.bottom="queueHistoryTooltipConfig"
type="transparent"
size="sm"
class="relative mr-2 text-base-foreground transition-colors duration-200 ease-in-out bg-secondary-background hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
:aria-pressed="isQueueOverlayExpanded"
:aria-label="
t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
"
@click="toggleQueueOverlay"
v-if="managerState.shouldShowManagerButtons.value && isDesktop"
class="pointer-events-auto flex h-12 shrink-0 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
>
<i class="icon-[lucide--history] size-4" />
<span
v-if="queuedCount > 0"
class="absolute -top-1 -right-1 min-w-[16px] rounded-full bg-primary-background py-0.25 text-[10px] font-medium leading-[14px] text-white"
<IconButton
v-tooltip.bottom="customNodesManagerTooltipConfig"
type="transparent"
size="sm"
class="text-base-foreground transition-colors duration-200 ease-in-out bg-secondary-background hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
:aria-label="t('menu.customNodesManager')"
@click="openCustomNodeManager"
>
{{ queuedCount }}
</span>
</IconButton>
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
<LoginButton v-else-if="isDesktop" />
<IconButton
v-if="!isRightSidePanelOpen"
v-tooltip.bottom="rightSidePanelTooltipConfig"
type="transparent"
size="sm"
class="mr-2 text-base-foreground transition-colors duration-200 ease-in-out bg-secondary-background hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
:aria-label="t('rightSidePanel.togglePanel')"
@click="rightSidePanelStore.togglePanel"
<i class="icon-[lucide--puzzle] size-4" />
</IconButton>
</div>
<div
class="actionbar-container pointer-events-auto flex h-12 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
>
<i class="icon-[lucide--panel-right] size-4" />
</IconButton>
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
ref="legacyCommandsContainerRef"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
<ComfyActionbar />
<IconButton
v-tooltip.bottom="queueHistoryTooltipConfig"
type="transparent"
size="sm"
class="relative mr-2 text-base-foreground transition-colors duration-200 ease-in-out bg-secondary-background hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
:aria-pressed="isQueueOverlayExpanded"
:aria-label="
t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
"
@click="toggleQueueOverlay"
>
<i class="icon-[lucide--history] size-4" />
<span
v-if="queuedCount > 0"
class="absolute -top-1 -right-1 min-w-[16px] rounded-full bg-primary-background py-0.25 text-[10px] font-medium leading-[14px] text-white"
>
{{ queuedCount }}
</span>
</IconButton>
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
<LoginButton v-else-if="isDesktop" />
<IconButton
v-if="!isRightSidePanelOpen"
v-tooltip.bottom="rightSidePanelTooltipConfig"
type="transparent"
size="sm"
class="mr-2 text-base-foreground transition-colors duration-200 ease-in-out bg-secondary-background hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
:aria-label="t('rightSidePanel.togglePanel')"
@click="rightSidePanelStore.togglePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</IconButton>
</div>
</div>
<QueueProgressOverlay
v-model:expanded="isQueueOverlayExpanded"
@@ -74,18 +92,23 @@ import ActionBarButtons from '@/components/topbar/ActionBarButtons.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import LoginButton from '@/components/topbar/LoginButton.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { app } from '@/scripts/app'
import { useQueueStore } from '@/stores/queueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isElectron } from '@/utils/envUtil'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
const workspaceStore = useWorkspaceStore()
const rightSidePanelStore = useRightSidePanelStore()
const managerState = useManagerState()
const { isLoggedIn } = useCurrentUser()
const isDesktop = isElectron()
const { t } = useI18n()
const { toastErrorHandler } = useErrorHandling()
const isQueueOverlayExpanded = ref(false)
const queueStore = useQueueStore()
const isTopMenuHovered = ref(false)
@@ -93,6 +116,9 @@ const queuedCount = computed(() => queueStore.pendingTasks.length)
const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
)
const customNodesManagerTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.customNodesManager'))
)
// Right side panel toggle
const { isOpen: isRightSidePanelOpen } = storeToRefs(rightSidePanelStore)
@@ -112,10 +138,20 @@ onMounted(() => {
const toggleQueueOverlay = () => {
isQueueOverlayExpanded.value = !isQueueOverlayExpanded.value
}
</script>
<style scoped>
.actionbar-container {
background-color: var(--comfy-menu-bg);
const openCustomNodeManager = async () => {
try {
await managerState.openManager({
initialTab: ManagerTab.All,
showToastOnLegacyError: false
})
} catch (error) {
try {
toastErrorHandler(error)
} catch (toastError) {
console.error(error)
console.error(toastError)
}
}
}
</style>
</script>

View File

@@ -117,7 +117,16 @@ onBeforeUnmount(() => {
.scroll-container {
height: 100%;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--dialog-surface) transparent;
/* Firefox */
scrollbar-width: none;
&::-webkit-scrollbar {
width: 1px;
}
&::-webkit-scrollbar-thumb {
background-color: transparent;
}
}
</style>

View File

@@ -0,0 +1,190 @@
import { describe, it, expect } from 'vitest'
import type { MenuOption } from './useMoreOptionsMenu'
import {
buildStructuredMenu,
convertContextMenuToOptions
} from './contextMenuConverter'
describe('contextMenuConverter', () => {
describe('buildStructuredMenu', () => {
it('should order core items before extension items', () => {
const options: MenuOption[] = [
{ label: 'Custom Extension Item', source: 'litegraph' },
{ label: 'Copy', source: 'vue' },
{ label: 'Rename', source: 'vue' }
]
const result = buildStructuredMenu(options)
// Core items (Rename, Copy) should come before extension items
const renameIndex = result.findIndex((opt) => opt.label === 'Rename')
const copyIndex = result.findIndex((opt) => opt.label === 'Copy')
const extensionIndex = result.findIndex(
(opt) => opt.label === 'Custom Extension Item'
)
expect(renameIndex).toBeLessThan(extensionIndex)
expect(copyIndex).toBeLessThan(extensionIndex)
})
it('should add Extensions category label before extension items', () => {
const options: MenuOption[] = [
{ label: 'Copy', source: 'vue' },
{ label: 'My Custom Extension', source: 'litegraph' }
]
const result = buildStructuredMenu(options)
const extensionsLabel = result.find(
(opt) => opt.label === 'Extensions' && opt.type === 'category'
)
expect(extensionsLabel).toBeDefined()
expect(extensionsLabel?.disabled).toBe(true)
})
it('should place Delete at the very end', () => {
const options: MenuOption[] = [
{ label: 'Delete', action: () => {}, source: 'vue' },
{ label: 'Copy', source: 'vue' },
{ label: 'Rename', source: 'vue' }
]
const result = buildStructuredMenu(options)
const lastNonDivider = [...result]
.reverse()
.find((opt) => opt.type !== 'divider')
expect(lastNonDivider?.label).toBe('Delete')
})
it('should deduplicate items with same label, preferring vue source', () => {
const options: MenuOption[] = [
{ label: 'Copy', action: () => {}, source: 'litegraph' },
{ label: 'Copy', action: () => {}, source: 'vue' }
]
const result = buildStructuredMenu(options)
const copyItems = result.filter((opt) => opt.label === 'Copy')
expect(copyItems).toHaveLength(1)
expect(copyItems[0].source).toBe('vue')
})
it('should preserve dividers between sections', () => {
const options: MenuOption[] = [
{ label: 'Rename', source: 'vue' },
{ label: 'Copy', source: 'vue' },
{ label: 'Pin', source: 'vue' }
]
const result = buildStructuredMenu(options)
const dividers = result.filter((opt) => opt.type === 'divider')
expect(dividers.length).toBeGreaterThan(0)
})
it('should handle empty input', () => {
const result = buildStructuredMenu([])
expect(result).toEqual([])
})
it('should handle only dividers', () => {
const options: MenuOption[] = [{ type: 'divider' }, { type: 'divider' }]
const result = buildStructuredMenu(options)
// Should be empty since dividers are filtered initially
expect(result).toEqual([])
})
it('should recognize Remove as equivalent to Delete', () => {
const options: MenuOption[] = [
{ label: 'Remove', action: () => {}, source: 'vue' },
{ label: 'Copy', source: 'vue' }
]
const result = buildStructuredMenu(options)
// Remove should be placed at the end like Delete
const lastNonDivider = [...result]
.reverse()
.find((opt) => opt.type !== 'divider')
expect(lastNonDivider?.label).toBe('Remove')
})
it('should group core items in correct section order', () => {
const options: MenuOption[] = [
{ label: 'Color', source: 'vue' },
{ label: 'Node Info', source: 'vue' },
{ label: 'Pin', source: 'vue' },
{ label: 'Rename', source: 'vue' }
]
const result = buildStructuredMenu(options)
// Get indices of items (excluding dividers and categories)
const getIndex = (label: string) =>
result.findIndex((opt) => opt.label === label)
// Rename (section 1) should come before Pin (section 2)
expect(getIndex('Rename')).toBeLessThan(getIndex('Pin'))
// Pin (section 2) should come before Node Info (section 4)
expect(getIndex('Pin')).toBeLessThan(getIndex('Node Info'))
// Node Info (section 4) should come before or with Color (section 4)
expect(getIndex('Node Info')).toBeLessThanOrEqual(getIndex('Color'))
})
})
describe('convertContextMenuToOptions', () => {
it('should convert empty array to empty result', () => {
const result = convertContextMenuToOptions([])
expect(result).toEqual([])
})
it('should convert null items to dividers', () => {
const result = convertContextMenuToOptions([null], undefined, false)
expect(result).toHaveLength(1)
expect(result[0].type).toBe('divider')
})
it('should skip blacklisted items like Properties', () => {
const items = [{ content: 'Properties', callback: () => {} }]
const result = convertContextMenuToOptions(items, undefined, false)
expect(result.find((opt) => opt.label === 'Properties')).toBeUndefined()
})
it('should convert basic menu items with content', () => {
const items = [{ content: 'Test Item', callback: () => {} }]
const result = convertContextMenuToOptions(items, undefined, false)
expect(result).toHaveLength(1)
expect(result[0].label).toBe('Test Item')
})
it('should mark items as litegraph source', () => {
const items = [{ content: 'Test Item', callback: () => {} }]
const result = convertContextMenuToOptions(items, undefined, false)
expect(result[0].source).toBe('litegraph')
})
it('should pass through disabled state', () => {
const items = [{ content: 'Disabled Item', disabled: true }]
const result = convertContextMenuToOptions(items, undefined, false)
expect(result[0].disabled).toBe(true)
})
it('should apply structuring by default', () => {
const items = [
{ content: 'Copy', callback: () => {} },
{ content: 'Custom Extension', callback: () => {} }
]
const result = convertContextMenuToOptions(items)
// With structuring, there should be Extensions category
const hasExtensionsCategory = result.some(
(opt) => opt.label === 'Extensions' && opt.type === 'category'
)
expect(hasExtensionsCategory).toBe(true)
})
})
})

View File

@@ -0,0 +1,620 @@
import { default as DOMPurify } from 'dompurify'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type {
IContextMenuValue,
LGraphNode,
IContextMenuOptions,
ContextMenu
} from '@/lib/litegraph/src/litegraph'
import type { MenuOption, SubMenuOption } from './useMoreOptionsMenu'
import type { ContextMenuDivElement } from '@/lib/litegraph/src/interfaces'
/**
* Hard blacklist - items that should NEVER be included
*/
const HARD_BLACKLIST = new Set([
'Properties', // Never include Properties submenu
'Colors', // Use singular "Color" instead
'Shapes', // Use singular "Shape" instead
'Title',
'Mode',
'Properties Panel',
'Copy (Clipspace)'
])
/**
* Core menu items - items that should appear in the main menu, not under Extensions
* Includes both LiteGraph base menu items and ComfyUI built-in functionality
*/
const CORE_MENU_ITEMS = new Set([
// Basic operations
'Rename',
'Copy',
'Duplicate',
'Clone',
// Node state operations
'Run Branch',
'Pin',
'Unpin',
'Bypass',
'Remove Bypass',
'Mute',
// Structure operations
'Convert to Subgraph',
'Frame selection',
'Minimize Node',
'Expand',
'Collapse',
// Info and adjustments
'Node Info',
'Resize',
'Title',
'Properties Panel',
'Adjust Size',
// Visual
'Color',
'Colors',
'Shape',
'Shapes',
'Mode',
// Built-in node operations (node-specific)
'Open Image',
'Copy Image',
'Save Image',
'Open in Mask Editor',
'Edit Subgraph Widgets',
'Unpack Subgraph',
'Copy (Clipspace)',
'Paste (Clipspace)',
// Selection and alignment
'Align Selected To',
'Distribute Nodes',
// Deletion
'Delete',
'Remove',
// LiteGraph base items
'Show Advanced',
'Hide Advanced'
])
/**
* Normalize menu item label for duplicate detection
* Handles variations like Colors/Color, Shapes/Shape, Pin/Unpin, Remove/Delete
*/
function normalizeLabel(label: string): string {
return label
.toLowerCase()
.replace(/^un/, '') // Remove 'un' prefix (Unpin -> Pin)
.trim()
}
/**
* Check if a similar menu item already exists in the results
* Returns true if an item with the same normalized label exists
*/
function isDuplicateItem(label: string, existingItems: MenuOption[]): boolean {
const normalizedLabel = normalizeLabel(label)
// Map of equivalent items
const equivalents: Record<string, string[]> = {
color: ['color', 'colors'],
shape: ['shape', 'shapes'],
pin: ['pin', 'unpin'],
delete: ['remove', 'delete'],
duplicate: ['clone', 'duplicate']
}
return existingItems.some((item) => {
if (!item.label) return false
const existingNormalized = normalizeLabel(item.label)
// Check direct match
if (existingNormalized === normalizedLabel) return true
// Check if they're in the same equivalence group
for (const values of Object.values(equivalents)) {
if (
values.includes(normalizedLabel) &&
values.includes(existingNormalized)
) {
return true
}
}
return false
})
}
/**
* Check if a menu item is a core menu item (not an extension)
* Core items include LiteGraph base items and ComfyUI built-in functionality
*/
function isCoreMenuItem(label: string): boolean {
return CORE_MENU_ITEMS.has(label)
}
/**
* Filter out duplicate menu items based on label
* Gives precedence to Vue hardcoded options over LiteGraph options
*/
function removeDuplicateMenuOptions(options: MenuOption[]): MenuOption[] {
// Group items by label
const itemsByLabel = new Map<string, MenuOption[]>()
const itemsWithoutLabel: MenuOption[] = []
for (const opt of options) {
// Always keep dividers and category items
if (opt.type === 'divider' || opt.type === 'category') {
itemsWithoutLabel.push(opt)
continue
}
// Items without labels are kept as-is
if (!opt.label) {
itemsWithoutLabel.push(opt)
continue
}
// Group by label
if (!itemsByLabel.has(opt.label)) {
itemsByLabel.set(opt.label, [])
}
itemsByLabel.get(opt.label)!.push(opt)
}
// Select best item for each label (prefer vue over litegraph)
const result: MenuOption[] = []
const seenLabels = new Set<string>()
for (const opt of options) {
// Add non-labeled items in original order
if (opt.type === 'divider' || opt.type === 'category' || !opt.label) {
if (itemsWithoutLabel.includes(opt)) {
result.push(opt)
const idx = itemsWithoutLabel.indexOf(opt)
itemsWithoutLabel.splice(idx, 1)
}
continue
}
// Skip if we already processed this label
if (seenLabels.has(opt.label)) {
continue
}
seenLabels.add(opt.label)
// Get all items with this label
const duplicates = itemsByLabel.get(opt.label)!
// If only one item, add it
if (duplicates.length === 1) {
result.push(duplicates[0])
continue
}
// Multiple items: prefer vue source over litegraph
const vueItem = duplicates.find((item) => item.source === 'vue')
if (vueItem) {
result.push(vueItem)
} else {
// No vue item, just take the first one
result.push(duplicates[0])
}
}
return result
}
/**
* Order groups for menu items - defines the display order of sections
*/
const MENU_ORDER: string[] = [
// Section 1: Basic operations
'Rename',
'Copy',
'Duplicate',
// Section 2: Node actions
'Run Branch',
'Pin',
'Unpin',
'Bypass',
'Remove Bypass',
'Mute',
// Section 3: Structure operations
'Convert to Subgraph',
'Frame selection',
'Minimize Node',
'Expand',
'Collapse',
'Resize',
'Clone',
// Section 4: Node properties
'Node Info',
'Color',
// Section 5: Node-specific operations
'Open in Mask Editor',
'Open Image',
'Copy Image',
'Save Image',
'Copy (Clipspace)',
'Paste (Clipspace)',
// Fallback for other core items
'Convert to Group Node (Deprecated)'
]
/**
* Get the order index for a menu item (lower = earlier in menu)
*/
function getMenuItemOrder(label: string): number {
const index = MENU_ORDER.indexOf(label)
return index === -1 ? 999 : index
}
/**
* Build structured menu with core items first, then extensions under a labeled section
* Ensures Delete always appears at the bottom
*/
export function buildStructuredMenu(options: MenuOption[]): MenuOption[] {
// First, remove duplicates (giving precedence to Vue hardcoded options)
const deduplicated = removeDuplicateMenuOptions(options)
const coreItemsMap = new Map<string, MenuOption>()
const extensionItems: MenuOption[] = []
let deleteItem: MenuOption | undefined
// Separate items into core and extension categories
for (const option of deduplicated) {
// Skip dividers for now - we'll add them between sections later
if (option.type === 'divider') {
continue
}
// Skip category labels (they'll be added separately)
if (option.type === 'category') {
continue
}
// Check if this is the Delete/Remove item - save it for the end
const isDeleteItem = option.label === 'Delete' || option.label === 'Remove'
if (isDeleteItem && !option.hasSubmenu) {
deleteItem = option
continue
}
// Categorize based on label
if (option.label && isCoreMenuItem(option.label)) {
coreItemsMap.set(option.label, option)
} else {
extensionItems.push(option)
}
}
// Build ordered core items based on MENU_ORDER
const orderedCoreItems: MenuOption[] = []
const coreLabels = Array.from(coreItemsMap.keys())
coreLabels.sort((a, b) => getMenuItemOrder(a) - getMenuItemOrder(b))
// Section boundaries based on MENU_ORDER indices
// Section 1: 0-2 (Rename, Copy, Duplicate)
// Section 2: 3-8 (Run Branch, Pin, Unpin, Bypass, Remove Bypass, Mute)
// Section 3: 9-15 (Convert to Subgraph, Frame selection, Minimize Node, Expand, Collapse, Resize, Clone)
// Section 4: 16-17 (Node Info, Color)
// Section 5: 18+ (Image operations and fallback items)
const getSectionNumber = (index: number): number => {
if (index <= 2) return 1
if (index <= 8) return 2
if (index <= 15) return 3
if (index <= 17) return 4
return 5
}
let lastSection = 0
for (const label of coreLabels) {
const item = coreItemsMap.get(label)!
const itemIndex = getMenuItemOrder(label)
const currentSection = getSectionNumber(itemIndex)
// Add divider when moving to a new section
if (lastSection > 0 && currentSection !== lastSection) {
orderedCoreItems.push({ type: 'divider' })
}
orderedCoreItems.push(item)
lastSection = currentSection
}
// Build the final menu structure
const result: MenuOption[] = []
// Add ordered core items with their dividers
result.push(...orderedCoreItems)
// Add extensions section if there are extension items
if (extensionItems.length > 0) {
// Add divider before Extensions section
result.push({ type: 'divider' })
// Add non-clickable Extensions label
result.push({
label: 'Extensions',
type: 'category',
disabled: true
})
// Add extension items
result.push(...extensionItems)
}
// Add Delete at the bottom if it exists
if (deleteItem) {
result.push({ type: 'divider' })
result.push(deleteItem)
}
return result
}
/**
* Convert LiteGraph IContextMenuValue items to Vue MenuOption format
* Used to bridge LiteGraph context menus into Vue node menus
* @param items - The LiteGraph menu items to convert
* @param node - The node context (optional)
* @param applyStructuring - Whether to apply menu structuring (core/extensions separation). Defaults to true.
*/
export function convertContextMenuToOptions(
items: (IContextMenuValue | null)[],
node?: LGraphNode,
applyStructuring: boolean = true
): MenuOption[] {
const result: MenuOption[] = []
for (const item of items) {
// Null items are separators in LiteGraph
if (item === null) {
result.push({ type: 'divider' })
continue
}
// Skip items without content (shouldn't happen, but be safe)
if (!item.content) {
continue
}
// Skip hard blacklisted items
if (HARD_BLACKLIST.has(item.content)) {
continue
}
// Skip if a similar item already exists in results
if (isDuplicateItem(item.content, result)) {
continue
}
const option: MenuOption = {
label: item.content,
source: 'litegraph'
}
// Pass through disabled state
if (item.disabled) {
option.disabled = true
}
// Handle submenus
if (item.has_submenu) {
// Static submenu with pre-defined options
if (item.submenu?.options) {
option.hasSubmenu = true
option.submenu = convertSubmenuToOptions(item.submenu.options)
}
// Dynamic submenu - callback creates it on-demand
else if (item.callback && !item.disabled) {
option.hasSubmenu = true
// Intercept the callback to capture dynamic submenu items
const capturedSubmenu = captureDynamicSubmenu(item, node)
if (capturedSubmenu) {
option.submenu = capturedSubmenu
} else {
console.warn(
'[ContextMenuConverter] Failed to capture submenu for:',
item.content
)
}
}
}
// Handle callback (only if not disabled and not a submenu)
else if (item.callback && !item.disabled) {
// Wrap the callback to match the () => void signature
option.action = () => {
try {
void item.callback?.call(
item as unknown as ContextMenuDivElement,
item.value,
{},
undefined,
undefined,
item
)
} catch (error) {
console.error('Error executing context menu callback:', error)
}
}
}
result.push(option)
}
// Apply structured menu with core items and extensions section (if requested)
if (applyStructuring) {
return buildStructuredMenu(result)
}
return result
}
/**
* Capture submenu items from a dynamic submenu callback
* Intercepts ContextMenu constructor to extract items without creating HTML menu
*/
function captureDynamicSubmenu(
item: IContextMenuValue,
node?: LGraphNode
): SubMenuOption[] | undefined {
let capturedItems: readonly (IContextMenuValue | string | null)[] | undefined
let capturedOptions: IContextMenuOptions | undefined
// Store original ContextMenu constructor
const OriginalContextMenu = LiteGraph.ContextMenu
try {
// Mock ContextMenu constructor to capture submenu items and options
LiteGraph.ContextMenu = function (
items: readonly (IContextMenuValue | string | null)[],
options?: IContextMenuOptions
) {
// Capture both items and options
capturedItems = items
capturedOptions = options
// Return a minimal mock object to prevent errors
return {
close: () => {},
root: document.createElement('div')
} as unknown as ContextMenu
} as unknown as typeof ContextMenu
// Execute the callback to trigger submenu creation
try {
// Create a mock MouseEvent for the callback
const mockEvent = new MouseEvent('click', {
bubbles: true,
cancelable: true,
clientX: 0,
clientY: 0
})
// Create a mock parent menu
const mockMenu = {
close: () => {},
root: document.createElement('div')
} as unknown as ContextMenu
// Call the callback which should trigger ContextMenu constructor
// Callback signature varies, but typically: (value, options, event, menu, node)
void item.callback?.call(
item as unknown as ContextMenuDivElement,
item.value,
{},
mockEvent,
mockMenu,
node // Pass the node context for callbacks that need it
)
} catch (error) {
console.warn(
'[ContextMenuConverter] Error executing callback for:',
item.content,
error
)
}
} finally {
// Always restore original constructor
LiteGraph.ContextMenu = OriginalContextMenu
}
// Convert captured items to Vue submenu format
if (capturedItems) {
const converted = convertSubmenuToOptions(capturedItems, capturedOptions)
return converted
}
console.warn('[ContextMenuConverter] No items captured for:', item.content)
return undefined
}
/**
* Convert LiteGraph submenu items to Vue SubMenuOption format
*/
function convertSubmenuToOptions(
items: readonly (IContextMenuValue | string | null)[],
options?: IContextMenuOptions
): SubMenuOption[] {
const result: SubMenuOption[] = []
for (const item of items) {
// Skip null separators
if (item === null) {
continue
}
// Handle string items (simple labels like in Mode/Shapes menus)
if (typeof item === 'string') {
const subOption: SubMenuOption = {
label: item,
action: () => {
try {
// Call the options callback with the string value
if (options?.callback) {
void options.callback.call(
null,
item,
options,
undefined,
undefined,
options.extra
)
}
} catch (error) {
console.error('Error executing string item callback:', error)
}
}
}
result.push(subOption)
continue
}
// Handle object items
if (!item.content) {
continue
}
// Extract text content from HTML if present
const content = stripHtmlTags(item.content)
const subOption: SubMenuOption = {
label: content,
action: () => {
try {
void item.callback?.call(
item as unknown as ContextMenuDivElement,
item.value,
{},
undefined,
undefined,
item
)
} catch (error) {
console.error('Error executing submenu callback:', error)
}
}
}
// Pass through disabled state
if (item.disabled) {
subOption.disabled = true
}
result.push(subOption)
}
return result
}
/**
* Strip HTML tags from content string safely
* LiteGraph menu items often include HTML for styling
*/
function stripHtmlTags(html: string): string {
// Use DOMPurify to sanitize and strip all HTML tags
const sanitized = DOMPurify.sanitize(html, { ALLOWED_TAGS: [] })
const result = sanitized.trim()
return result || html.replace(/<[^>]*>/g, '').trim() || html
}

View File

@@ -15,10 +15,13 @@ export interface MenuOption {
icon?: string
shortcut?: string
hasSubmenu?: boolean
type?: 'divider'
type?: 'divider' | 'category'
action?: () => void
submenu?: SubMenuOption[]
badge?: BadgeVariant
disabled?: boolean
source?: 'litegraph' | 'vue'
isColorPicker?: boolean
}
export interface SubMenuOption {
@@ -26,6 +29,7 @@ export interface SubMenuOption {
icon?: string
action: () => void
color?: string
disabled?: boolean
}
export enum BadgeVariant {
@@ -173,7 +177,12 @@ export function useMoreOptionsMenu() {
}
// Section 5: Subgraph operations
options.push(...getSubgraphOptions(hasSubgraphsSelected))
options.push(
...getSubgraphOptions({
hasSubgraphs: hasSubgraphsSelected,
hasMultipleSelection: hasMultipleNodes.value
})
)
// Section 6: Multiple nodes operations
if (hasMultipleNodes.value) {

View File

@@ -0,0 +1,106 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSelectionMenuOptions } from '@/composables/graph/useSelectionMenuOptions'
const subgraphMocks = vi.hoisted(() => ({
convertToSubgraph: vi.fn(),
unpackSubgraph: vi.fn(),
addSubgraphToLibrary: vi.fn(),
createI18nMock: vi.fn(() => ({
global: {
t: vi.fn(),
te: vi.fn(),
d: vi.fn()
}
}))
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
}),
createI18n: subgraphMocks.createI18nMock
}))
vi.mock('@/composables/graph/useSelectionOperations', () => ({
useSelectionOperations: () => ({
copySelection: vi.fn(),
duplicateSelection: vi.fn(),
deleteSelection: vi.fn(),
renameSelection: vi.fn()
})
}))
vi.mock('@/composables/graph/useNodeArrangement', () => ({
useNodeArrangement: () => ({
alignOptions: [{ localizedName: 'align-left', icon: 'align-left' }],
distributeOptions: [{ localizedName: 'distribute', icon: 'distribute' }],
applyAlign: vi.fn(),
applyDistribute: vi.fn()
})
}))
vi.mock('@/composables/graph/useSubgraphOperations', () => ({
useSubgraphOperations: () => ({
convertToSubgraph: subgraphMocks.convertToSubgraph,
unpackSubgraph: subgraphMocks.unpackSubgraph,
addSubgraphToLibrary: subgraphMocks.addSubgraphToLibrary
})
}))
vi.mock('@/composables/graph/useFrameNodes', () => ({
useFrameNodes: () => ({
frameNodes: vi.fn()
})
}))
describe('useSelectionMenuOptions - subgraph options', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns only convert option when no subgraphs are selected', () => {
const { getSubgraphOptions } = useSelectionMenuOptions()
const options = getSubgraphOptions({
hasSubgraphs: false,
hasMultipleSelection: true
})
expect(options).toHaveLength(1)
expect(options[0]?.label).toBe('contextMenu.Convert to Subgraph')
expect(options[0]?.action).toBe(subgraphMocks.convertToSubgraph)
})
it('includes convert, add to library, and unpack when subgraphs are selected', () => {
const { getSubgraphOptions } = useSelectionMenuOptions()
const options = getSubgraphOptions({
hasSubgraphs: true,
hasMultipleSelection: true
})
const labels = options.map((option) => option.label)
expect(labels).toContain('contextMenu.Convert to Subgraph')
expect(labels).toContain('contextMenu.Add Subgraph to Library')
expect(labels).toContain('contextMenu.Unpack Subgraph')
const convertOption = options.find(
(option) => option.label === 'contextMenu.Convert to Subgraph'
)
expect(convertOption?.action).toBe(subgraphMocks.convertToSubgraph)
})
it('hides convert option when only a single subgraph is selected', () => {
const { getSubgraphOptions } = useSelectionMenuOptions()
const options = getSubgraphOptions({
hasSubgraphs: true,
hasMultipleSelection: false
})
const labels = options.map((option) => option.label)
expect(labels).not.toContain('contextMenu.Convert to Subgraph')
expect(labels).toEqual([
'contextMenu.Add Subgraph to Library',
'contextMenu.Unpack Subgraph'
])
})
})

View File

@@ -63,9 +63,29 @@ export function useSelectionMenuOptions() {
}
]
const getSubgraphOptions = (hasSubgraphs: boolean): MenuOption[] => {
const getSubgraphOptions = ({
hasSubgraphs,
hasMultipleSelection
}: {
hasSubgraphs: boolean
hasMultipleSelection: boolean
}): MenuOption[] => {
const convertOption: MenuOption = {
label: t('contextMenu.Convert to Subgraph'),
icon: 'icon-[lucide--shrink]',
action: convertToSubgraph,
badge: BadgeVariant.NEW
}
const options: MenuOption[] = []
const showConvertOption = !hasSubgraphs || hasMultipleSelection
if (showConvertOption) {
options.push(convertOption)
}
if (hasSubgraphs) {
return [
options.push(
{
label: t('contextMenu.Add Subgraph to Library'),
icon: 'icon-[lucide--folder-plus]',
@@ -76,17 +96,10 @@ export function useSelectionMenuOptions() {
icon: 'icon-[lucide--expand]',
action: unpackSubgraph
}
]
} else {
return [
{
label: t('contextMenu.Convert to Subgraph'),
icon: 'icon-[lucide--shrink]',
action: convertToSubgraph,
badge: BadgeVariant.NEW
}
]
)
}
return options
}
const getMultipleNodesOptions = (): MenuOption[] => {

View File

@@ -22,7 +22,10 @@ export const useContextMenuTranslation = () => {
this: LGraphCanvas,
...args: Parameters<typeof getCanvasMenuOptions>
) {
const res: IContextMenuValue[] = getCanvasMenuOptions.apply(this, args)
const res: (IContextMenuValue | null)[] = getCanvasMenuOptions.apply(
this,
args
)
// Add items from new extension API
const newApiItems = app.collectCanvasMenuItems(this)
@@ -58,13 +61,16 @@ export const useContextMenuTranslation = () => {
LGraphCanvas.prototype
)
// Install compatibility layer for getNodeMenuOptions
legacyMenuCompat.install(LGraphCanvas.prototype, 'getNodeMenuOptions')
// Wrap getNodeMenuOptions to add new API items
const nodeMenuFn = LGraphCanvas.prototype.getNodeMenuOptions
const getNodeMenuOptionsWithExtensions = function (
this: LGraphCanvas,
...args: Parameters<typeof nodeMenuFn>
) {
const res = nodeMenuFn.apply(this, args)
const res = nodeMenuFn.apply(this, args) as (IContextMenuValue | null)[]
// Add items from new extension API
const node = args[0]
@@ -73,11 +79,28 @@ export const useContextMenuTranslation = () => {
res.push(item)
}
// Add legacy monkey-patched items
const legacyItems = legacyMenuCompat.extractLegacyItems(
'getNodeMenuOptions',
this,
...args
)
for (const item of legacyItems) {
res.push(item)
}
return res
}
LGraphCanvas.prototype.getNodeMenuOptions = getNodeMenuOptionsWithExtensions
legacyMenuCompat.registerWrapper(
'getNodeMenuOptions',
getNodeMenuOptionsWithExtensions,
nodeMenuFn,
LGraphCanvas.prototype
)
function translateMenus(
values: readonly (IContextMenuValue | string | null)[] | undefined,
options: IContextMenuOptions

View File

@@ -389,6 +389,13 @@ export const SERVER_CONFIG_ITEMS: ServerConfig<any>[] = [
type: 'boolean',
defaultValue: false
},
{
id: 'enable-manager-legacy-ui',
name: 'Use legacy Manager UI',
tooltip: 'Uses the legacy ComfyUI-Manager UI instead of the new UI.',
type: 'boolean',
defaultValue: false
},
{
id: 'disable-all-custom-nodes',
name: 'Disable loading all custom nodes.',

View File

@@ -1,5 +1,6 @@
import { remove } from 'es-toolkit'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import type {
ISlotType,
INodeInputSlot,
@@ -23,22 +24,41 @@ import type { ComfyApp } from '@/scripts/app'
const INLINE_INPUTS = false
type MatchTypeNode = LGraphNode &
Pick<Required<LGraphNode>, 'comfyMatchType' | 'onConnectionsChange'>
Pick<Required<LGraphNode>, 'onConnectionsChange'> & {
comfyDynamic: { matchType: Record<string, Record<string, string>> }
}
type AutogrowNode = LGraphNode &
Pick<Required<LGraphNode>, 'onConnectionsChange' | 'widgets'> & {
comfyDynamic: {
autogrow: Record<
string,
{
min: number
max: number
inputSpecs: InputSpecV2[]
prefix?: string
names?: string[]
}
>
}
}
function ensureWidgetForInput(node: LGraphNode, input: INodeInputSlot) {
if (input.widget?.name) return
node.widgets ??= []
const { widget } = input
if (widget && node.widgets.some((w) => w.name === widget.name)) return
node.widgets.push({
name: input.name,
y: 0,
type: 'shim',
options: {},
draw(ctx, _n, _w, y) {
ctx.save()
ctx.fillStyle = LiteGraph.NODE_TEXT_COLOR
ctx.fillText(input.label ?? input.name, 20, y + 15)
ctx.restore()
}
},
name: input.name,
options: {},
serialize: false,
type: 'shim',
y: 0
})
input.alwaysVisible = true
input.widget = { name: input.name }
@@ -66,72 +86,47 @@ function dynamicComboWidget(
appArg,
widgetName
)
let currentDynamicNames: string[] = []
function isInGroup(e: { name: string }): boolean {
return e.name.startsWith(inputName + '.')
}
const updateWidgets = (value?: string) => {
if (!node.widgets) throw new Error('Not Reachable')
const newSpec = value ? options[value] : undefined
const inputsToRemove: Record<string, INodeInputSlot> = {}
for (const name of currentDynamicNames) {
const input = node.inputs.find((input) => input.name === name)
if (input) inputsToRemove[input.name] = input
const widgetIndex = node.widgets.findIndex(
(widget) => widget.name === name
)
if (widgetIndex === -1) continue
node.widgets[widgetIndex].value = undefined
node.widgets.splice(widgetIndex, 1)
}
currentDynamicNames = []
if (!newSpec) {
for (const input of Object.values(inputsToRemove)) {
const inputIndex = node.inputs.findIndex((inp) => inp === input)
if (inputIndex === -1) continue
node.removeInput(inputIndex)
}
return
}
const removedInputs = remove(node.inputs, isInGroup)
remove(node.widgets, isInGroup)
if (!newSpec) return
const insertionPoint = node.widgets.findIndex((w) => w === widget) + 1
const startingLength = node.widgets.length
const initialInputIndex =
node.inputs.findIndex((i) => i.name === widget.name) + 1
let startingInputLength = node.inputs.length
const startingInputLength = node.inputs.length
if (insertionPoint === 0)
throw new Error("Dynamic widget doesn't exist on node")
const inputTypes: [Record<string, InputSpec> | undefined, boolean][] = [
[newSpec.required, false],
[newSpec.optional, true]
const inputTypes: (Record<string, InputSpec> | undefined)[] = [
newSpec.required,
newSpec.optional
]
for (const [inputType, isOptional] of inputTypes)
inputTypes.forEach((inputType, idx) => {
for (const key in inputType ?? {}) {
const name = `${widget.name}.${key}`
const specToAdd = transformInputSpecV1ToV2(inputType![key], {
name,
isOptional
isOptional: idx !== 0
})
specToAdd.display_name = key
addNodeInput(node, specToAdd)
currentDynamicNames.push(name)
if (INLINE_INPUTS) ensureWidgetForInput(node, node.inputs.at(-1)!)
if (
!inputsToRemove[name] ||
Array.isArray(inputType![key][0]) ||
!LiteGraph.isValidConnection(
inputsToRemove[name].type,
inputType![key][0]
)
)
continue
node.inputs.at(-1)!.link = inputsToRemove[name].link
inputsToRemove[name].link = null
const newInputs = node.inputs
.slice(startingInputLength)
.filter((inp) => inp.name.startsWith(name))
for (const newInput of newInputs) {
if (INLINE_INPUTS && !newInput.widget)
ensureWidgetForInput(node, newInput)
}
}
})
for (const input of Object.values(inputsToRemove)) {
const inputIndex = node.inputs.findIndex((inp) => inp === input)
if (inputIndex === -1) continue
if (inputIndex < initialInputIndex) startingInputLength--
node.removeInput(inputIndex)
}
const inputInsertionPoint =
node.inputs.findIndex((i) => i.name === widget.name) + 1
const addedWidgets = node.widgets.splice(startingLength)
@@ -157,6 +152,28 @@ function dynamicComboWidget(
)
//assume existing inputs are in correct order
spliceInputs(node, inputInsertionPoint, 0, ...addedInputs)
for (const input of removedInputs) {
const inputIndex = node.inputs.findIndex((inp) => inp.name === input.name)
if (inputIndex === -1) {
node.inputs.push(input)
node.removeInput(node.inputs.length - 1)
} else {
node.inputs[inputIndex].link = input.link
if (!input.link) continue
const link = node.graph?.links?.[input.link]
if (!link) continue
link.target_slot = inputIndex
node.onConnectionsChange?.(
LiteGraph.INPUT,
inputIndex,
true,
link,
node.inputs[inputIndex]
)
}
}
node.size[1] = node.computeSize([...node.size])[1]
if (!node.graph) return
node._setConcreteSlots()
@@ -243,8 +260,9 @@ function changeOutputType(
}
function withComfyMatchType(node: LGraphNode): asserts node is MatchTypeNode {
if (node.comfyMatchType) return
node.comfyMatchType = {}
if (node.comfyDynamic?.matchType) return
node.comfyDynamic ??= {}
node.comfyDynamic.matchType = {}
const outputGroups = node.constructor.nodeData?.output_matchtypes
node.onConnectionsChange = useChainCallback(
@@ -258,9 +276,9 @@ function withComfyMatchType(node: LGraphNode): asserts node is MatchTypeNode {
) {
const input = this.inputs[slot]
if (contype !== LiteGraph.INPUT || !this.graph || !input) return
const [matchKey, matchGroup] = Object.entries(this.comfyMatchType).find(
([, group]) => input.name in group
) ?? ['', undefined]
const [matchKey, matchGroup] = Object.entries(
this.comfyDynamic.matchType
).find(([, group]) => input.name in group) ?? ['', undefined]
if (!matchGroup) return
if (iscon && linf) {
const { output, subgraphInput } = linf.resolve(this.graph)
@@ -317,8 +335,8 @@ function applyMatchType(node: LGraphNode, inputSpec: InputSpecV2) {
const typedSpec = { ...inputSpec, type: allowed_types }
addNodeInput(node, typedSpec)
withComfyMatchType(node)
node.comfyMatchType[template_id] ??= {}
node.comfyMatchType[template_id][name] = allowed_types
node.comfyDynamic.matchType[template_id] ??= {}
node.comfyDynamic.matchType[template_id][name] = allowed_types
//TODO: instead apply on output add?
//ensure outputs get updated
@@ -329,160 +347,215 @@ function applyMatchType(node: LGraphNode, inputSpec: InputSpecV2) {
)
}
function applyAutogrow(node: LGraphNode, untypedInputSpec: InputSpecV2) {
function autogrowOrdinalToName(
ordinal: number,
key: string,
groupName: string,
node: AutogrowNode
) {
const {
names,
prefix = '',
inputSpecs
} = node.comfyDynamic.autogrow[groupName]
const baseName = names
? names[ordinal]
: (inputSpecs.length == 1 ? prefix : key) + ordinal
return { name: `${groupName}.${baseName}`, display_name: baseName }
}
function addAutogrowGroup(
ordinal: number,
groupName: string,
node: AutogrowNode
) {
const { addNodeInput } = useLitegraphService()
const { max, min, inputSpecs } = node.comfyDynamic.autogrow[groupName]
if (ordinal >= max) return
const parseResult = zAutogrowOptions.safeParse(untypedInputSpec)
if (!parseResult.success) throw new Error('invalid Autogrow spec')
const inputSpec = parseResult.data
const namedSpecs = inputSpecs.map((input) => ({
...input,
isOptional: ordinal >= (min ?? 0) || input.isOptional,
...autogrowOrdinalToName(ordinal, input.name, groupName, node)
}))
const { input, min, names, prefix, max } = inputSpec.template
const inputTypes: [Record<string, InputSpec> | undefined, boolean][] = [
[input.required, false],
[input.optional, true]
]
const inputsV2 = inputTypes.flatMap(([inputType, isOptional]) =>
Object.entries(inputType ?? {}).map(([name, v]) =>
transformInputSpecV1ToV2(v, { name, isOptional })
const newInputs = namedSpecs
.filter(
(namedSpec) => !node.inputs.some((inp) => inp.name === namedSpec.name)
)
.map((namedSpec) => {
addNodeInput(node, namedSpec)
const input = spliceInputs(node, node.inputs.length - 1, 1)[0]
if (inputSpecs.length !== 1 || (INLINE_INPUTS && !input.widget))
ensureWidgetForInput(node, input)
return input
})
const lastIndex = node.inputs.findLastIndex((inp) =>
inp.name.startsWith(groupName)
)
const insertionIndex = lastIndex === -1 ? node.inputs.length : lastIndex + 1
spliceInputs(node, insertionIndex, 0, ...newInputs)
app.canvas?.setDirty(true, true)
}
function nameToInputIndex(name: string) {
const index = node.inputs.findIndex((input) => input.name === name)
if (index === -1) throw new Error('Failed to find input')
return index
}
function nameToInput(name: string) {
return node.inputs[nameToInputIndex(name)]
const ORDINAL_REGEX = /\d+$/
function resolveAutogrowOrdinal(
inputName: string,
groupName: string,
node: AutogrowNode
): number | undefined {
//TODO preslice groupname?
const name = inputName.slice(groupName.length + 1)
const { names } = node.comfyDynamic.autogrow[groupName]
if (names) {
const ordinal = names.findIndex((s) => s === name)
return ordinal === -1 ? undefined : ordinal
}
const match = name.match(ORDINAL_REGEX)
if (!match) return undefined
const ordinal = parseInt(match[0])
return ordinal !== ordinal ? undefined : ordinal
}
function autogrowInputConnected(index: number, node: AutogrowNode) {
const input = node.inputs[index]
const groupName = input.name.slice(0, input.name.lastIndexOf('.'))
const lastInput = node.inputs.findLast((inp) =>
inp.name.startsWith(groupName)
)
const ordinal = resolveAutogrowOrdinal(input.name, groupName, node)
if (
!lastInput ||
ordinal == undefined ||
ordinal !== resolveAutogrowOrdinal(lastInput.name, groupName, node)
)
return
addAutogrowGroup(ordinal + 1, groupName, node)
}
function autogrowInputDisconnected(index: number, node: AutogrowNode) {
const input = node.inputs[index]
if (!input) return
const groupName = input.name.slice(0, input.name.lastIndexOf('.'))
const { min = 1, inputSpecs } = node.comfyDynamic.autogrow[groupName]
const ordinal = resolveAutogrowOrdinal(input.name, groupName, node)
if (ordinal == undefined || ordinal + 1 < min) return
//In the distance, someone shouting YAGNI
const trackedInputs: string[][] = []
function addInputGroup(insertionIndex: number) {
const ordinal = trackedInputs.length
const inputGroup = inputsV2.map((input) => ({
...input,
name: names
? names[ordinal]
: ((inputsV2.length == 1 ? prefix : input.name) ?? '') + ordinal,
isOptional: ordinal >= (min ?? 0) || input.isOptional
}))
const newInputs = inputGroup
.filter(
(namedSpec) => !node.inputs.some((inp) => inp.name === namedSpec.name)
)
.map((namedSpec) => {
addNodeInput(node, namedSpec)
const input = spliceInputs(node, node.inputs.length - 1, 1)[0]
if (inputsV2.length !== 1) ensureWidgetForInput(node, input)
return input
})
spliceInputs(node, insertionIndex, 0, ...newInputs)
trackedInputs.push(inputGroup.map((inp) => inp.name))
app.canvas?.setDirty(true, true)
//resolve all inputs in group
const groupInputs = node.inputs.filter(
(inp) =>
inp.name.startsWith(groupName + '.') &&
inp.name.lastIndexOf('.') === groupName.length
)
const stride = inputSpecs.length
if (groupInputs.length % stride !== 0) {
console.error('Failed to group multi-input autogrow inputs')
return
}
for (let i = 0; i < (min || 1); i++) addInputGroup(node.inputs.length)
function removeInputGroup(inputName: string) {
const groupIndex = trackedInputs.findIndex((ig) =>
ig.some((inpName) => inpName === inputName)
)
if (groupIndex == -1) throw new Error('Failed to find group')
const group = trackedInputs[groupIndex]
for (const nameToRemove of group) {
const inputIndex = nameToInputIndex(nameToRemove)
const input = spliceInputs(node, inputIndex, 1)[0]
if (!input.widget?.name) continue
const widget = node.widgets?.find((w) => w.name === input.widget!.name)
if (!widget) return
widget.value = undefined
node.removeWidget(widget)
}
trackedInputs.splice(groupIndex, 1)
node.size[1] = node.computeSize([...node.size])[1]
app.canvas?.setDirty(true, true)
}
function inputConnected(index: number) {
const input = node.inputs[index]
const groupIndex = trackedInputs.findIndex((ig) =>
ig.some((inputName) => inputName === input.name)
)
if (groupIndex == -1) throw new Error('Failed to find group')
if (
groupIndex + 1 === trackedInputs.length &&
trackedInputs.length < (max ?? names?.length ?? 100)
app.canvas?.setDirty(true, true)
//groupBy would be nice here, but may not be supported
for (let column = 0; column < stride; column++) {
for (
let bubbleOrdinal = ordinal * stride + column;
bubbleOrdinal + stride < groupInputs.length;
bubbleOrdinal += stride
) {
const lastInput = trackedInputs[groupIndex].at(-1)
if (!lastInput) return
const insertionIndex = nameToInputIndex(lastInput) + 1
if (insertionIndex === 0) throw new Error('Failed to find Input')
addInputGroup(insertionIndex)
const curInput = groupInputs[bubbleOrdinal]
curInput.link = groupInputs[bubbleOrdinal + stride].link
if (!curInput.link) continue
const link = node.graph?.links[curInput.link]
if (!link) continue
const curIndex = node.inputs.findIndex((inp) => inp === curInput)
if (curIndex === -1) throw new Error('missing input')
link.target_slot = curIndex
}
const lastInput = groupInputs.at(column - stride)
if (!lastInput) continue
lastInput.link = null
}
function inputDisconnected(index: number) {
const input = node.inputs[index]
if (trackedInputs.length === 1) return
const groupIndex = trackedInputs.findIndex((ig) =>
ig.some((inputName) => inputName === input.name)
)
if (groupIndex == -1) throw new Error('Failed to find group')
if (
trackedInputs[groupIndex].some(
(inputName) => nameToInput(inputName).link != null
)
)
return
if (groupIndex + 1 < (min ?? 0)) return
//For each group from here to last group, bubble swap links
for (let column = 0; column < trackedInputs[0].length; column++) {
let prevInput = nameToInputIndex(trackedInputs[groupIndex][column])
for (let i = groupIndex + 1; i < trackedInputs.length; i++) {
const curInput = nameToInputIndex(trackedInputs[i][column])
const linkId = node.inputs[curInput].link
node.inputs[prevInput].link = linkId
const link = linkId && node.graph?.links?.[linkId]
if (link) link.target_slot = prevInput
prevInput = curInput
}
node.inputs[prevInput].link = null
}
if (
trackedInputs.at(-2) &&
!trackedInputs.at(-2)?.some((name) => !!nameToInput(name).link)
)
removeInputGroup(trackedInputs.at(-1)![0])
const removalChecks = groupInputs.slice((min - 1) * stride)
let i
for (i = removalChecks.length - stride; i >= 0; i -= stride) {
if (removalChecks.slice(i, i + stride).some((inp) => inp.link)) break
}
const toRemove = removalChecks.slice(i + stride * 2)
remove(node.inputs, (inp) => toRemove.includes(inp))
for (const input of toRemove) {
const widgetName = input?.widget?.name
if (!widgetName) continue
remove(node.widgets, (w) => w.name === widgetName)
}
node.size[1] = node.computeSize([...node.size])[1]
}
function withComfyAutogrow(node: LGraphNode): asserts node is AutogrowNode {
if (node.comfyDynamic?.autogrow) return
node.comfyDynamic ??= {}
node.comfyDynamic.autogrow = {}
let pendingConnection: number | undefined
let swappingConnection = false
const originalOnConnectInput = node.onConnectInput
node.onConnectInput = function (slot: number, ...args) {
pendingConnection = slot
requestAnimationFrame(() => (pendingConnection = undefined))
return originalOnConnectInput?.apply(this, [slot, ...args]) ?? true
}
node.onConnectionsChange = useChainCallback(
node.onConnectionsChange,
(
type: ISlotType,
index: number,
function (
this: AutogrowNode,
contype: ISlotType,
slot: number,
iscon: boolean,
linf: LLink | null | undefined
) => {
if (type !== NodeSlotType.INPUT) return
const inputName = node.inputs[index].name
if (!trackedInputs.flat().some((name) => name === inputName)) return
if (iscon) {
) {
const input = this.inputs[slot]
if (contype !== LiteGraph.INPUT || !input) return
//Return if input isn't known autogrow
const key = input.name.slice(0, input.name.lastIndexOf('.'))
const autogrowGroup = this.comfyDynamic.autogrow[key]
if (!autogrowGroup) return
if (app.configuringGraph && input.widget)
ensureWidgetForInput(node, input)
if (iscon && linf) {
if (swappingConnection || !linf) return
inputConnected(index)
autogrowInputConnected(slot, this)
} else {
if (pendingConnection === index) {
if (pendingConnection === slot) {
swappingConnection = true
requestAnimationFrame(() => (swappingConnection = false))
return
}
requestAnimationFrame(() => inputDisconnected(index))
requestAnimationFrame(() => autogrowInputDisconnected(slot, this))
}
}
)
}
function applyAutogrow(node: LGraphNode, inputSpecV2: InputSpecV2) {
withComfyAutogrow(node)
const parseResult = zAutogrowOptions.safeParse(inputSpecV2)
if (!parseResult.success) throw new Error('invalid Autogrow spec')
const inputSpec = parseResult.data
const { input, min = 1, names, prefix, max = 100 } = inputSpec.template
const inputTypes: (Record<string, InputSpec> | undefined)[] = [
input.required,
input.optional
]
const inputsV2 = inputTypes.flatMap((inputType, index) =>
Object.entries(inputType ?? {}).map(([name, v]) =>
transformInputSpecV1ToV2(v, { name, isOptional: index === 1 })
)
)
node.comfyDynamic.autogrow[inputSpecV2.name] = {
names,
min,
max: names?.length ?? max,
prefix,
inputSpecs: inputsV2
}
for (let i = 0; i < min; i++) addAutogrowGroup(i, inputSpecV2.name, node)
}

View File

@@ -710,8 +710,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
getMenuOptions?(): IContextMenuValue<string>[]
getExtraMenuOptions?(
canvas: LGraphCanvas,
options: IContextMenuValue<string>[]
): IContextMenuValue<string>[]
options: (IContextMenuValue<string> | null)[]
): (IContextMenuValue<string> | null)[]
static active_node: LGraphNode
/** called before modifying the graph */
onBeforeChange?(graph: LGraph): void
@@ -8019,8 +8019,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
}
getCanvasMenuOptions(): IContextMenuValue[] {
let options: IContextMenuValue<string>[]
getCanvasMenuOptions(): (IContextMenuValue | null)[] {
let options: (IContextMenuValue<string> | null)[]
if (this.getMenuOptions) {
options = this.getMenuOptions()
} else {
@@ -8564,9 +8564,11 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
node,
newPos: this.calculateNewPosition(node, deltaX, deltaY)
})
} else {
// Non-node children (nested groups, reroutes)
child.move(deltaX, deltaY)
} else if (!(child instanceof LGraphGroup)) {
// Non-node, non-group children (reroutes, etc.)
// Skip groups here - they're already in allItems and will be
// processed in the main loop of moveChildNodesInGroupVueMode
child.move(deltaX, deltaY, true)
}
}
}

View File

@@ -416,7 +416,7 @@ export class LGraphNode
selected?: boolean
showAdvanced?: boolean
declare comfyMatchType?: Record<string, Record<string, string>>
declare comfyDynamic?: Record<string, object>
declare comfyClass?: string
declare isVirtualNode?: boolean
applyToGraph?(extraLinks?: LLink[]): void

View File

@@ -7,7 +7,9 @@ import type { IContextMenuValue } from './interfaces'
*/
const ENABLE_LEGACY_SUPPORT = true
type ContextMenuValueProvider = (...args: unknown[]) => IContextMenuValue[]
type ContextMenuValueProvider = (
...args: unknown[]
) => (IContextMenuValue | null)[]
class LegacyMenuCompat {
private originalMethods = new Map<string, ContextMenuValueProvider>()
@@ -37,16 +39,22 @@ class LegacyMenuCompat {
* @param preWrapperFn The method that existed before the wrapper
* @param prototype The prototype to verify wrapper installation
*/
registerWrapper(
methodName: keyof LGraphCanvas,
wrapperFn: ContextMenuValueProvider,
preWrapperFn: ContextMenuValueProvider,
registerWrapper<K extends keyof LGraphCanvas>(
methodName: K,
wrapperFn: LGraphCanvas[K],
preWrapperFn: LGraphCanvas[K],
prototype?: LGraphCanvas
) {
this.wrapperMethods.set(methodName, wrapperFn)
this.preWrapperMethods.set(methodName, preWrapperFn)
this.wrapperMethods.set(
methodName as string,
wrapperFn as unknown as ContextMenuValueProvider
)
this.preWrapperMethods.set(
methodName as string,
preWrapperFn as unknown as ContextMenuValueProvider
)
const isInstalled = prototype && prototype[methodName] === wrapperFn
this.wrapperInstalled.set(methodName, !!isInstalled)
this.wrapperInstalled.set(methodName as string, !!isInstalled)
}
/**
@@ -54,11 +62,17 @@ class LegacyMenuCompat {
* @param prototype The prototype to install on
* @param methodName The method name to track
*/
install(prototype: LGraphCanvas, methodName: keyof LGraphCanvas) {
install<K extends keyof LGraphCanvas>(
prototype: LGraphCanvas,
methodName: K
) {
if (!ENABLE_LEGACY_SUPPORT) return
const originalMethod = prototype[methodName]
this.originalMethods.set(methodName, originalMethod)
this.originalMethods.set(
methodName as string,
originalMethod as unknown as ContextMenuValueProvider
)
let currentImpl = originalMethod
@@ -66,13 +80,13 @@ class LegacyMenuCompat {
get() {
return currentImpl
},
set: (newImpl: ContextMenuValueProvider) => {
const fnKey = `${methodName}:${newImpl.toString().slice(0, 100)}`
set: (newImpl: LGraphCanvas[K]) => {
const fnKey = `${methodName as string}:${newImpl.toString().slice(0, 100)}`
if (!this.hasWarned.has(fnKey) && this.currentExtension) {
this.hasWarned.add(fnKey)
console.warn(
`%c[DEPRECATED]%c Monkey-patching ${methodName} is deprecated. (Extension: "${this.currentExtension}")\n` +
`%c[DEPRECATED]%c Monkey-patching ${methodName as string} is deprecated. (Extension: "${this.currentExtension}")\n` +
`Please use the new context menu API instead.\n\n` +
`See: https://docs.comfy.org/custom-nodes/js/context-menu-migration`,
'color: orange; font-weight: bold',
@@ -85,7 +99,15 @@ class LegacyMenuCompat {
}
/**
* Extract items that were added by legacy monkey patches
* Extract items that were added by legacy monkey patches.
*
* Uses set-based diffing by reference to reliably detect additions regardless
* of item reordering or replacement. Items present in patchedItems but not in
* originalItems (by reference equality) are considered additions.
*
* Note: If a monkey patch removes items (patchedItems has fewer unique items
* than originalItems), a warning is logged but we still return any new items.
*
* @param methodName The method name that was monkey-patched
* @param context The context to call methods with
* @param args Arguments to pass to the methods
@@ -95,7 +117,7 @@ class LegacyMenuCompat {
methodName: keyof LGraphCanvas,
context: LGraphCanvas,
...args: unknown[]
): IContextMenuValue[] {
): (IContextMenuValue | null)[] {
if (!ENABLE_LEGACY_SUPPORT) return []
if (this.isExtracting) return []
@@ -106,7 +128,7 @@ class LegacyMenuCompat {
this.isExtracting = true
const originalItems = originalMethod.apply(context, args) as
| IContextMenuValue[]
| (IContextMenuValue | null)[]
| undefined
if (!originalItems) return []
@@ -127,15 +149,26 @@ class LegacyMenuCompat {
const methodToCall = shouldSkipWrapper ? preWrapperMethod : currentMethod
const patchedItems = methodToCall.apply(context, args) as
| IContextMenuValue[]
| (IContextMenuValue | null)[]
| undefined
if (!patchedItems) return []
if (patchedItems.length > originalItems.length) {
return patchedItems.slice(originalItems.length) as IContextMenuValue[]
// Use set-based diff to detect additions by reference
const originalSet = new Set<IContextMenuValue | null>(originalItems)
const addedItems = patchedItems.filter((item) => !originalSet.has(item))
// Warn if items were removed (patched has fewer original items than expected)
const retainedOriginalCount = patchedItems.filter((item) =>
originalSet.has(item)
).length
if (retainedOriginalCount < originalItems.length) {
console.warn(
`[Context Menu Compat] Monkey patch for ${methodName} removed ${originalItems.length - retainedOriginalCount} original menu item(s). ` +
`This may cause unexpected behavior.`
)
}
return []
return addedItems
} catch (e) {
console.error('[Context Menu Compat] Failed to extract legacy items:', e)
return []

View File

@@ -807,6 +807,7 @@
"dark": "Dark",
"light": "Light",
"manageExtensions": "Manage Extensions",
"customNodesManager": "Custom Nodes Manager",
"settings": "Settings",
"help": "Help",
"queue": "Queue Panel"
@@ -1333,6 +1334,10 @@
"disable-metadata": {
"name": "Disable saving prompt metadata in files."
},
"enable-manager-legacy-ui": {
"name": "Use legacy Manager UI",
"tooltip": "Uses the legacy ComfyUI-Manager UI instead of the new UI."
},
"disable-all-custom-nodes": {
"name": "Disable loading all custom nodes."
},
@@ -2444,4 +2449,4 @@
"recentReleases": "Recent releases",
"helpCenterMenu": "Help Center Menu"
}
}
}

View File

@@ -11325,6 +11325,31 @@
}
}
},
"SamplerSEEDS2": {
"display_name": "SamplerSEEDS2",
"inputs": {
"solver_type": {
"name": "solver_type"
},
"eta": {
"name": "eta",
"tooltip": "Stochastic strength"
},
"s_noise": {
"name": "s_noise",
"tooltip": "SDE noise multiplier"
},
"r": {
"name": "r",
"tooltip": "Relative step size for the intermediate stage (c2 node)"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"SamplingPercentToSigma": {
"display_name": "SamplingPercentToSigma",
"inputs": {

View File

@@ -55,7 +55,6 @@
variant="gray"
:label="formattedDuration"
/>
<SquareChip v-if="fileFormat" variant="gray" :label="fileFormat" />
</div>
<!-- Media actions - show on hover or when playing -->
@@ -266,12 +265,6 @@ const formattedDuration = computed(() => {
return formatDuration(Number(duration))
})
const fileFormat = computed(() => {
if (!asset?.name) return ''
const parts = asset.name.split('.')
return parts.length > 1 ? parts[parts.length - 1].toUpperCase() : ''
})
const durationChipClasses = computed(() => {
if (fileKind.value === 'audio') {
return '-translate-y-11'
@@ -289,7 +282,7 @@ const showStaticChips = computed(
!!asset &&
!isHovered.value &&
!isVideoPlaying.value &&
(formattedDuration.value || fileFormat.value)
formattedDuration.value
)
// Show action overlay when hovered OR playing

View File

@@ -57,7 +57,10 @@ function useNodeDragIndividual() {
const selectedNodes = toValue(selectedNodeIds)
// capture the starting positions of all other selected nodes
if (selectedNodes?.has(nodeId) && selectedNodes.size > 1) {
// Only move other selected items if the dragged node is part of the selection
const isDraggedNodeInSelection = selectedNodes?.has(nodeId)
if (isDraggedNodeInSelection && selectedNodes.size > 1) {
otherSelectedNodesStartPositions = new Map()
for (const id of selectedNodes) {
@@ -73,9 +76,15 @@ function useNodeDragIndividual() {
otherSelectedNodesStartPositions = null
}
// Capture selected groups (filter from selectedItems which only contains selected items)
selectedGroups = toValue(selectedItems).filter(isLGraphGroup)
lastCanvasDelta = { x: 0, y: 0 }
// Capture selected groups only if the dragged node is part of the selection
// This prevents groups from moving when dragging an unrelated node
if (isDraggedNodeInSelection) {
selectedGroups = toValue(selectedItems).filter(isLGraphGroup)
lastCanvasDelta = { x: 0, y: 0 }
} else {
selectedGroups = null
lastCanvasDelta = null
}
mutations.setSource(LayoutSource.Vue)
}

View File

@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { legacyMenuCompat } from '@/lib/litegraph/src/contextMenuCompat'
import type { IContextMenuValue } from '@/lib/litegraph/src/litegraph'
import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
describe('contextMenuCompat', () => {
@@ -98,13 +99,15 @@ describe('contextMenuCompat', () => {
})
describe('extractLegacyItems', () => {
// Cache base items to ensure reference equality for set-based diffing
const baseItem1 = { content: 'Item 1', callback: () => {} }
const baseItem2 = { content: 'Item 2', callback: () => {} }
beforeEach(() => {
// Setup a mock original method
// Setup a mock original method that returns cached items
// This ensures reference equality when set-based diffing compares items
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
return [
{ content: 'Item 1', callback: () => {} },
{ content: 'Item 2', callback: () => {} }
]
return [baseItem1, baseItem2]
}
// Install compatibility layer
@@ -114,12 +117,13 @@ describe('contextMenuCompat', () => {
it('should extract items added by monkey patches', () => {
// Monkey-patch to add items
const original = LGraphCanvas.prototype.getCanvasMenuOptions
LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) {
const items = (original as any).apply(this, args)
items.push({ content: 'Custom Item 1', callback: () => {} })
items.push({ content: 'Custom Item 2', callback: () => {} })
return items
}
LGraphCanvas.prototype.getCanvasMenuOptions =
function (): (IContextMenuValue | null)[] {
const items = original.apply(this)
items.push({ content: 'Custom Item 1', callback: () => {} })
items.push({ content: 'Custom Item 2', callback: () => {} })
return items
}
// Extract legacy items
const legacyItems = legacyMenuCompat.extractLegacyItems(
@@ -142,8 +146,11 @@ describe('contextMenuCompat', () => {
expect(legacyItems).toHaveLength(0)
})
it('should return empty array when patched method returns same count', () => {
// Monkey-patch that replaces items but keeps same count
it('should detect replaced items as additions and warn about removed items', () => {
const warnSpy = vi.spyOn(console, 'warn')
// Monkey-patch that replaces items with different ones (same count)
// With set-based diffing, these are detected as new items since they're different references
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
return [
{ content: 'Replaced 1', callback: () => {} },
@@ -156,7 +163,13 @@ describe('contextMenuCompat', () => {
mockCanvas
)
expect(legacyItems).toHaveLength(0)
// Set-based diffing detects the replaced items as additions
expect(legacyItems).toHaveLength(2)
expect(legacyItems[0]).toMatchObject({ content: 'Replaced 1' })
expect(legacyItems[1]).toMatchObject({ content: 'Replaced 2' })
// Should warn about removed original items
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('removed'))
})
it('should handle errors gracefully', () => {
@@ -181,29 +194,36 @@ describe('contextMenuCompat', () => {
})
describe('integration', () => {
// Cache base items to ensure reference equality for set-based diffing
const integrationBaseItem = { content: 'Base Item', callback: () => {} }
const integrationBaseItem1 = { content: 'Base Item 1', callback: () => {} }
const integrationBaseItem2 = { content: 'Base Item 2', callback: () => {} }
it('should work with multiple extensions patching', () => {
// Setup base method
// Setup base method with cached item
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
return [{ content: 'Base Item', callback: () => {} }]
return [integrationBaseItem]
}
legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
// First extension patches
const original1 = LGraphCanvas.prototype.getCanvasMenuOptions
LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) {
const items = (original1 as any).apply(this, args)
items.push({ content: 'Extension 1 Item', callback: () => {} })
return items
}
LGraphCanvas.prototype.getCanvasMenuOptions =
function (): (IContextMenuValue | null)[] {
const items = original1.apply(this)
items.push({ content: 'Extension 1 Item', callback: () => {} })
return items
}
// Second extension patches
const original2 = LGraphCanvas.prototype.getCanvasMenuOptions
LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) {
const items = (original2 as any).apply(this, args)
items.push({ content: 'Extension 2 Item', callback: () => {} })
return items
}
LGraphCanvas.prototype.getCanvasMenuOptions =
function (): (IContextMenuValue | null)[] {
const items = original2.apply(this)
items.push({ content: 'Extension 2 Item', callback: () => {} })
return items
}
// Extract legacy items
const legacyItems = legacyMenuCompat.extractLegacyItems(
@@ -218,24 +238,22 @@ describe('contextMenuCompat', () => {
})
it('should extract legacy items only once even when called multiple times', () => {
// Setup base method
// Setup base method with cached items
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
return [
{ content: 'Base Item 1', callback: () => {} },
{ content: 'Base Item 2', callback: () => {} }
]
return [integrationBaseItem1, integrationBaseItem2]
}
legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
// Simulate legacy extension monkey-patching the prototype
const original = LGraphCanvas.prototype.getCanvasMenuOptions
LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) {
const items = (original as any).apply(this, args)
items.push({ content: 'Legacy Item 1', callback: () => {} })
items.push({ content: 'Legacy Item 2', callback: () => {} })
return items
}
LGraphCanvas.prototype.getCanvasMenuOptions =
function (): (IContextMenuValue | null)[] {
const items = original.apply(this)
items.push({ content: 'Legacy Item 1', callback: () => {} })
items.push({ content: 'Legacy Item 2', callback: () => {} })
return items
}
// Extract legacy items multiple times (simulating repeated menu opens)
const legacyItems1 = legacyMenuCompat.extractLegacyItems(
@@ -268,17 +286,19 @@ describe('contextMenuCompat', () => {
})
it('should not extract items from registered wrapper methods', () => {
// Setup base method
// Setup base method with cached item
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
return [{ content: 'Base Item', callback: () => {} }]
return [integrationBaseItem]
}
legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
// Create a wrapper that adds new API items (simulating useContextMenuTranslation)
const originalMethod = LGraphCanvas.prototype.getCanvasMenuOptions
const wrapperMethod = function (this: LGraphCanvas) {
const items = (originalMethod as any).apply(this, [])
const wrapperMethod = function (
this: LGraphCanvas
): (IContextMenuValue | null)[] {
const items = originalMethod.apply(this)
// Add new API items
items.push({ content: 'New API Item 1', callback: () => {} })
items.push({ content: 'New API Item 2', callback: () => {} })
@@ -306,16 +326,16 @@ describe('contextMenuCompat', () => {
})
it('should extract legacy items even when a wrapper is registered but not active', () => {
// Setup base method
// Setup base method with cached item
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
return [{ content: 'Base Item', callback: () => {} }]
return [integrationBaseItem]
}
legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
// Register a wrapper (but don't set it as the current method)
const originalMethod = LGraphCanvas.prototype.getCanvasMenuOptions
const wrapperMethod = function () {
const wrapperMethod = function (): (IContextMenuValue | null)[] {
return [{ content: 'Wrapper Item', callback: () => {} }]
}
legacyMenuCompat.registerWrapper(
@@ -327,11 +347,12 @@ describe('contextMenuCompat', () => {
// Monkey-patch with a different function (legacy extension)
const original = LGraphCanvas.prototype.getCanvasMenuOptions
LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) {
const items = (original as any).apply(this, args)
items.push({ content: 'Legacy Item', callback: () => {} })
return items
}
LGraphCanvas.prototype.getCanvasMenuOptions =
function (): (IContextMenuValue | null)[] {
const items = original.apply(this)
items.push({ content: 'Legacy Item', callback: () => {} })
return items
}
// Extract legacy items - should return the legacy item because current method is NOT the wrapper
const legacyItems = legacyMenuCompat.extractLegacyItems(

View File

@@ -118,8 +118,8 @@ describe('Autogrow', () => {
connectInput(node, 1, graph)
connectInput(node, 2, graph)
expect(node.inputs.length).toBe(4)
expect(node.inputs[0].name).toBe('test0')
expect(node.inputs[2].name).toBe('test2')
expect(node.inputs[0].name).toBe('0.test0')
expect(node.inputs[2].name).toBe('0.test2')
})
test('Can name by list of names', () => {
const graph = new LGraph()
@@ -130,8 +130,8 @@ describe('Autogrow', () => {
connectInput(node, 1, graph)
connectInput(node, 2, graph)
expect(node.inputs.length).toBe(3)
expect(node.inputs[0].name).toBe('a')
expect(node.inputs[2].name).toBe('c')
expect(node.inputs[0].name).toBe('0.a')
expect(node.inputs[2].name).toBe('0.c')
})
test('Can add autogrow with min input count', () => {
const node = testNode()