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
100 changed files with 2523 additions and 1350 deletions

1
.npmrc
View File

@@ -1,3 +1,2 @@
ignore-workspace-root-check=true
catalog-mode=prefer
public-hoist-pattern[]=@parcel/watcher

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 84 KiB

View File

@@ -205,32 +205,6 @@ test.describe('Image widget', () => {
const filename = await fileComboWidget.getValue()
expect(filename).toBe('image32x32.webp')
})
test('Displays buttons when viewing single image of batch', async ({
comfyPage
}) => {
const [x, y] = await comfyPage.page.evaluate(() => {
const src =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='768' height='512' viewBox='0 0 1 1'%3E%3Crect width='1' height='1' stroke='black'/%3E%3C/svg%3E"
const image1 = new Image()
image1.src = src
const image2 = new Image()
image2.src = src
const targetNode = graph.nodes[6]
targetNode.imgs = [image1, image2]
targetNode.imageIndex = 1
app.canvas.setDirty(true)
const x = targetNode.pos[0] + targetNode.size[0] - 41
const y = targetNode.pos[1] + targetNode.widgets.at(-1).last_y + 30
return app.canvasPosToClientPos([x, y])
})
const clip = { x, y, width: 35, height: 35 }
await expect(comfyPage.page).toHaveScreenshot(
'image_preview_close_button.png',
{ clip }
)
})
})
test.describe('Animated image widget', () => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 423 B

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.8",
"version": "1.36.1",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -19,7 +19,6 @@
"dev:cloud": "cross-env DEV_SERVER_COMFYUI_URL='https://testcloud.comfy.org/' nx serve",
"dev:desktop": "nx dev @comfyorg/desktop-ui",
"dev:electron": "nx serve --config vite.electron.config.mts",
"dev:no-vue": "cross-env DISABLE_VUE_PLUGINS=true nx serve",
"dev": "nx serve",
"devtools:pycheck": "python3 -m compileall -q tools/devtools",
"format:check:no-cache": "prettier --check './**/*.{js,ts,tsx,vue,mts}'",

633
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,7 +16,7 @@ catalog:
'@nx/storybook': 21.4.1
'@nx/vite': 21.4.1
'@pinia/testing': ^0.1.5
'@playwright/test': ^1.57.0
'@playwright/test': ^1.52.0
'@prettier/plugin-oxc': ^0.1.3
'@primeuix/forms': 0.0.2
'@primeuix/styled': 0.3.2
@@ -60,11 +60,11 @@ catalog:
firebase: ^11.6.0
globals: ^15.9.0
happy-dom: ^15.11.0
husky: ^9.1.7
jiti: 2.6.1
husky: ^9.0.11
jiti: 2.4.2
jsdom: ^26.1.0
knip: ^5.75.1
lint-staged: ^16.2.7
knip: ^5.62.0
lint-staged: ^15.5.2
markdown-table: ^3.0.4
mixpanel-browser: ^2.71.0
nx: 21.4.1

View File

@@ -12,7 +12,7 @@
<div class="mx-1 flex flex-col items-end gap-1">
<div class="flex items-center gap-2">
<div
v-if="managerState.shouldShowManagerButtons.value"
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"
>
<IconButton

View File

@@ -21,7 +21,6 @@
@keyup.enter.capture.stop="blurInputElement"
@keyup.escape.stop="cancelEditing"
@click.stop
@contextmenu.stop
@pointerdown.stop.capture
@pointermove.stop.capture
/>

View File

@@ -12,6 +12,7 @@
/>
<img
v-if="cachedSrc"
ref="imageRef"
:src="cachedSrc"
:alt="alt"
draggable="false"
@@ -60,6 +61,7 @@ const {
}>()
const containerRef = ref<HTMLElement | null>(null)
const imageRef = ref<HTMLImageElement | null>(null)
const isIntersecting = ref(false)
const isImageLoaded = ref(false)
const hasError = ref(false)

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

@@ -48,6 +48,7 @@
class="zoomInputContainer flex items-center gap-1 rounded bg-input-surface p-2"
>
<InputNumber
ref="zoomInput"
:default-value="canvasStore.appScalePercentage"
:min="1"
:max="1000"
@@ -129,6 +130,7 @@ const zoomOutCommandText = computed(() =>
const zoomToFitCommandText = computed(() =>
formatKeySequence(commandStore.getCommand('Comfy.Canvas.FitView'))
)
const zoomInput = ref<InstanceType<typeof InputNumber> | null>(null)
const zoomInputContainer = ref<HTMLDivElement | null>(null)
watch(

View File

@@ -9,6 +9,7 @@
>
<Load3DScene
v-if="node"
ref="load3DSceneRef"
:initialize-load3d="initializeLoad3d"
:cleanup="cleanup"
:loading="loading"
@@ -99,6 +100,8 @@ if (isComponentWidget(props.widget)) {
})
}
const load3DSceneRef = ref<InstanceType<typeof Load3DScene> | null>(null)
const {
// configs
sceneConfig,

View File

@@ -14,7 +14,11 @@
@dragleave.stop="handleDragLeave"
@drop.prevent.stop="handleDrop"
>
<LoadingOverlay :loading="loading" :loading-message="loadingMessage" />
<LoadingOverlay
ref="loadingOverlayRef"
:loading="loading"
:loading-message="loadingMessage"
/>
<div
v-if="!isPreview && isDragging"
class="pointer-events-none absolute inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
@@ -44,6 +48,7 @@ const props = defineProps<{
}>()
const container = ref<HTMLElement | null>(null)
const loadingOverlayRef = ref<InstanceType<typeof LoadingOverlay> | null>(null)
const { isDragging, dragMessage, handleDragOver, handleDragLeave, handleDrop } =
useLoad3dDrag({

View File

@@ -6,7 +6,7 @@
@mouseenter="viewer.handleMouseEnter"
@mouseleave="viewer.handleMouseLeave"
>
<div class="relative flex-1">
<div ref="mainContentRef" class="relative flex-1">
<div
ref="containerRef"
class="absolute h-full w-full"
@@ -105,6 +105,7 @@ const props = defineProps<{
const viewerContentRef = ref<HTMLDivElement>()
const containerRef = ref<HTMLDivElement>()
const mainContentRef = ref<HTMLDivElement>()
const maximized = ref(false)
const mutationObserver = ref<MutationObserver | null>(null)

View File

@@ -2,11 +2,7 @@
https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c683087a3e168db/app/js/functions/sb_fn.js#L149
-->
<template>
<LGraphNodePreview
v-if="shouldRenderVueNodes"
:node-def="nodeDef"
:position="position"
/>
<LGraphNodePreview v-if="shouldRenderVueNodes" :node-def="nodeDef" />
<div v-else class="_sb_node_preview bg-component-node-background">
<div class="_sb_table">
<div
@@ -96,9 +92,8 @@ import { useWidgetStore } from '@/stores/widgetStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
const { nodeDef, position = 'absolute' } = defineProps<{
const { nodeDef } = defineProps<{
nodeDef: ComfyNodeDefV2
position?: 'absolute' | 'relative'
}>()
const { shouldRenderVueNodes } = useVueFeatureFlags()

View File

@@ -78,7 +78,7 @@
<script setup lang="ts">
import ToggleSwitch from 'primevue/toggleswitch'
import { computed, shallowRef, triggerRef, watchEffect } from 'vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
@@ -90,23 +90,10 @@ import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
import { cn } from '@/utils/tailwindUtil'
const props = defineProps<{
const { nodes = [] } = defineProps<{
nodes?: LGraphNode[]
}>()
/**
* This is not random writing. It is very important.
* Otherwise, the UI cannot be updated correctly.
*/
const targetNodes = shallowRef<LGraphNode[]>([])
watchEffect(() => {
if (props.nodes) {
targetNodes.value = props.nodes
} else {
targetNodes.value = []
}
})
const { t } = useI18n()
const canvasStore = useCanvasStore()
@@ -116,33 +103,24 @@ const isLightTheme = computed(
)
const nodeState = computed({
get() {
let mode: LGraphNode['mode'] | null = null
const nodes = targetNodes.value
if (nodes.length === 0) return null
get(): LGraphNode['mode'] | null {
if (!nodes.length) return null
if (nodes.length === 1) {
return nodes[0].mode
}
// For multiple nodes, if all nodes have the same mode, return that mode, otherwise return null
if (nodes.length > 1) {
mode = nodes[0].mode
if (!nodes.every((node) => node.mode === mode)) {
mode = null
}
} else {
mode = nodes[0].mode
const mode: LGraphNode['mode'] = nodes[0].mode
if (!nodes.every((node) => node.mode === mode)) {
return null
}
return mode
},
set(value: LGraphNode['mode']) {
targetNodes.value.forEach((node) => {
nodes.forEach((node) => {
node.mode = value
})
/*
* This is not random writing. It is very important.
* Otherwise, the UI cannot be updated correctly.
*/
triggerRef(targetNodes)
canvasStore.canvas?.setDirty(true, true)
}
})
@@ -150,15 +128,10 @@ const nodeState = computed({
// Pinned state
const isPinned = computed<boolean>({
get() {
return targetNodes.value.some((node) => node.pinned)
return nodes.some((node) => node.pinned)
},
set(value) {
targetNodes.value.forEach((node) => node.pin(value))
/*
* This is not random writing. It is very important.
* Otherwise, the UI cannot be updated correctly.
*/
triggerRef(targetNodes)
nodes.forEach((node) => node.pin(value))
canvasStore.canvas?.setDirty(true, true)
}
})
@@ -202,10 +175,8 @@ const colorOptions: NodeColorOption[] = [
const nodeColor = computed<NodeColorOption['name'] | null>({
get() {
if (targetNodes.value.length === 0) return null
const theColorOptions = targetNodes.value.map((item) =>
item.getColorOption()
)
if (nodes.length === 0) return null
const theColorOptions = nodes.map((item) => item.getColorOption())
let colorOption: ColorOption | null | false = theColorOptions[0]
if (!theColorOptions.every((option) => option === colorOption)) {
@@ -231,14 +202,9 @@ const nodeColor = computed<NodeColorOption['name'] | null>({
? null
: LGraphCanvas.node_colors[colorName]
for (const item of targetNodes.value) {
for (const item of nodes) {
item.setColorOption(canvasColorOption)
}
/*
* This is not random writing. It is very important.
* Otherwise, the UI cannot be updated correctly.
*/
triggerRef(targetNodes)
canvasStore.canvas?.setDirty(true, true)
}
})

View File

@@ -1,5 +1,6 @@
<template>
<div
ref="menuButtonRef"
v-tooltip="{
value: t('sideToolbar.labels.menu'),
showDelay: 300,
@@ -136,6 +137,7 @@ const settingStore = useSettingStore()
const menuRef = ref<
({ dirty: boolean } & TieredMenuMethods & TieredMenuState) | null
>(null)
const menuButtonRef = ref<HTMLElement | null>(null)
const nodes2Enabled = computed({
get: () => settingStore.get('Comfy.VueNodes.Enabled') ?? false,

View File

@@ -11,6 +11,7 @@
}"
>
<div
ref="contentMeasureRef"
:class="
isOverflowing
? 'side-tool-bar-container overflow-y-auto'
@@ -79,6 +80,7 @@ const userStore = useUserStore()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const sideToolbarRef = ref<HTMLElement>()
const contentMeasureRef = ref<HTMLElement>()
const topToolbarRef = ref<HTMLElement>()
const bottomToolbarRef = ref<HTMLElement>()

View File

@@ -56,9 +56,9 @@
class="pb-1 px-2 2xl:px-4"
:show-generation-time-sort="activeTab === 'output'"
/>
<Divider type="dashed" class="my-2" />
</template>
<template #body>
<Divider type="dashed" class="m-2" />
<div v-if="loading && !displayAssets.length">
<ProgressSpinner class="absolute left-1/2 w-[50px] -translate-x-1/2" />
</div>

View File

@@ -15,7 +15,7 @@
</template>
<template #end>
<div
class="touch:w-auto touch:opacity-100 flex flex-row overflow-hidden transition-all duration-200 motion-safe:w-0 motion-safe:opacity-0 motion-safe:group-focus-within/sidebar-tab:w-auto motion-safe:group-focus-within/sidebar-tab:opacity-100 motion-safe:group-hover/sidebar-tab:w-auto motion-safe:group-hover/sidebar-tab:opacity-100"
class="touch:w-auto touch:opacity-100 flex flex-row transition-all duration-200 motion-safe:w-0 motion-safe:opacity-0 motion-safe:group-focus-within/sidebar-tab:w-auto motion-safe:group-focus-within/sidebar-tab:opacity-100 motion-safe:group-hover/sidebar-tab:w-auto motion-safe:group-hover/sidebar-tab:opacity-100"
>
<slot name="tool-buttons" />
</div>

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

@@ -6,7 +6,6 @@ import { reactiveComputed } from '@vueuse/core'
import { reactive, shallowReactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
import type {
INodeInputSlot,
INodeOutputSlot
@@ -31,9 +30,8 @@ import type {
LGraphTriggerAction,
LGraphTriggerEvent,
LGraphTriggerParam
} from '@/lib/litegraph/src/litegraph'
import type { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
} from '../../lib/litegraph/src/litegraph'
import { NodeSlotType } from '../../lib/litegraph/src/types/globalEnums'
export interface WidgetSlotMetadata {
index: number
@@ -44,39 +42,37 @@ export interface SafeWidgetData {
name: string
type: string
value: WidgetValue
borderStyle?: string
callback?: ((value: unknown) => void) | undefined
controlWidget?: SafeControlWidget
isDOMWidget?: boolean
label?: string
nodeType?: string
options?: IWidgetOptions<unknown>
callback?: ((value: unknown) => void) | undefined
spec?: InputSpec
slotMetadata?: WidgetSlotMetadata
isDOMWidget?: boolean
controlWidget?: SafeControlWidget
borderStyle?: string
}
export interface VueNodeData {
executing: boolean
id: NodeId
mode: number
selected: boolean
title: string
type: string
mode: number
selected: boolean
executing: boolean
apiNode?: boolean
badges?: (LGraphBadge | (() => LGraphBadge))[]
bgcolor?: string
color?: string
subgraphId?: string | null
widgets?: SafeWidgetData[]
inputs?: INodeInputSlot[]
outputs?: INodeOutputSlot[]
hasErrors?: boolean
flags?: {
collapsed?: boolean
pinned?: boolean
}
hasErrors?: boolean
inputs?: INodeInputSlot[]
outputs?: INodeOutputSlot[]
color?: string
bgcolor?: string
shape?: number
subgraphId?: string | null
titleMode?: TitleMode
widgets?: SafeWidgetData[]
}
export interface GraphNodeManager {
@@ -100,11 +96,6 @@ function getControlWidget(widget: IBaseWidget): SafeControlWidget | undefined {
update: (value) => (cagWidget.value = normalizeControlOption(value))
}
}
function getNodeType(node: LGraphNode, widget: IBaseWidget) {
if (!node.isSubgraphNode() || !isProxyWidget(widget)) return undefined
const subNode = node.subgraph.getNodeById(widget._overlay.nodeId)
return subNode?.type
}
export function safeWidgetMapper(
node: LGraphNode,
@@ -140,13 +131,12 @@ export function safeWidgetMapper(
value: value,
borderStyle,
callback: widget.callback,
controlWidget: getControlWidget(widget),
isDOMWidget: isDOMWidget(widget),
label: widget.label,
nodeType: getNodeType(node, widget),
options: widget.options,
spec,
slotMetadata: slotInfo
slotMetadata: slotInfo,
controlWidget: getControlWidget(widget)
}
} catch (error) {
return {
@@ -228,15 +218,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
reactiveWidgets.splice(0, reactiveWidgets.length, ...v)
}
})
const reactiveInputs = shallowReactive<INodeInputSlot[]>(node.inputs ?? [])
Object.defineProperty(node, 'inputs', {
get() {
return reactiveInputs
},
set(v) {
reactiveInputs.splice(0, reactiveInputs.length, ...v)
}
})
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
node.inputs?.forEach((input, index) => {
@@ -264,7 +245,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
title: typeof node.title === 'string' ? node.title : '',
type: nodeType,
mode: node.mode || 0,
titleMode: node.title_mode,
selected: node.selected || false,
executing: false, // Will be updated separately based on execution state
subgraphId,
@@ -272,7 +252,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
badges,
hasErrors: !!node.has_errors,
widgets: safeWidgets,
inputs: reactiveInputs,
inputs: node.inputs ? [...node.inputs] : undefined,
outputs: node.outputs ? [...node.outputs] : undefined,
flags: node.flags ? { ...node.flags } : undefined,
color: node.color || undefined,

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 {

View File

@@ -329,123 +329,6 @@ const sora2PricingCalculator: PricingFunction = (node: LGraphNode): string => {
return formatRunPrice(perSec, duration)
}
/**
* Pricing for Tripo 3D generation nodes (Text / Image / Multiview)
* based on Tripo credits:
*
* Turbo / V3 / V2.5 / V2.0:
* Text -> 10 (no texture) / 20 (standard texture)
* Image -> 20 (no texture) / 30 (standard texture)
* Multiview -> 20 (no texture) / 30 (standard texture)
*
* V1.4:
* Text -> 20
* Image -> 30
* (Multiview treated same as Image if used)
*
* Advanced extras (added on top of generation credits):
* quad -> +5 credits
* style -> +5 credits (if style != "None")
* HD texture -> +10 credits (texture_quality = "detailed")
* detailed geometry -> +20 credits (geometry_quality = "detailed")
*
* 1 credit = $0.01
*/
const calculateTripo3DGenerationPrice = (
node: LGraphNode,
task: 'text' | 'image' | 'multiview'
): string => {
const getWidget = (name: string): IComboWidget | undefined =>
node.widgets?.find((w) => w.name === name) as IComboWidget | undefined
const getString = (name: string, defaultValue: string): string => {
const widget = getWidget(name)
if (!widget || widget.value === undefined || widget.value === null) {
return defaultValue
}
return String(widget.value)
}
const getBool = (name: string, defaultValue: boolean): boolean => {
const widget = getWidget(name)
if (!widget || widget.value === undefined || widget.value === null) {
return defaultValue
}
const v = widget.value
if (typeof v === 'number') return v !== 0
const lower = String(v).toLowerCase()
if (lower === 'true') return true
if (lower === 'false') return false
return defaultValue
}
// ---- read widget values with sensible defaults (mirroring backend) ----
const modelVersionRaw = getString('model_version', '').toLowerCase()
if (modelVersionRaw === '')
return '$0.1-0.65/Run (varies with quad, style, texture & quality)'
const styleRaw = getString('style', 'None')
const hasStyle = styleRaw.toLowerCase() !== 'none'
// Backend defaults: texture=true, pbr=true, quad=false, qualities="standard"
const hasTexture = getBool('texture', false)
const hasPbr = getBool('pbr', false)
const quad = getBool('quad', false)
const textureQualityRaw = getString(
'texture_quality',
'standard'
).toLowerCase()
const geometryQualityRaw = getString(
'geometry_quality',
'standard'
).toLowerCase()
const isHdTexture = textureQualityRaw === 'detailed'
const isDetailedGeometry = geometryQualityRaw === 'detailed'
const withTexture = hasTexture || hasPbr
let baseCredits: number
if (modelVersionRaw.includes('v1.4')) {
// V1.4 model: Text=20, Image=30, Refine=30
if (task === 'text') {
baseCredits = 20
} else {
// treat Multiview same as Image if V1.4 is ever used there
baseCredits = 30
}
} else {
// V3.0, V2.5, V2.0 models
if (!withTexture) {
if (task === 'text') {
baseCredits = 10 // Text to 3D without texture
} else {
baseCredits = 20 // Image/Multiview to 3D without texture
}
} else {
if (task === 'text') {
baseCredits = 20 // Text to 3D with standard texture
} else {
baseCredits = 30 // Image/Multiview to 3D with standard texture
}
}
}
// ---- advanced extras on top of base generation ----
let credits = baseCredits
if (hasStyle) credits += 5 // Style
if (quad) credits += 5 // Quad Topology
if (isHdTexture) credits += 10 // HD Texture
if (isDetailedGeometry) credits += 20 // Detailed Geometry Quality
const dollars = credits * 0.01
return `$${dollars.toFixed(2)}/Run`
}
/**
* Static pricing data for API nodes, now supporting both strings and functions
*/
@@ -512,46 +395,6 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
return `$${parseFloat(outputCost.toFixed(3))}/Run`
}
},
Flux2MaxImageNode: {
displayPrice: (node: LGraphNode): string => {
const widthW = node.widgets?.find(
(w) => w.name === 'width'
) as IComboWidget
const heightW = node.widgets?.find(
(w) => w.name === 'height'
) as IComboWidget
const w = Number(widthW?.value)
const h = Number(heightW?.value)
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) {
// global min/max for this node given schema bounds (1MP..4MP output)
return '$0.07$0.35/Run'
}
// Is the 'images' input connected?
const imagesInput = node.inputs?.find(
(i) => i.name === 'images'
) as INodeInputSlot
const hasRefs =
typeof imagesInput?.link !== 'undefined' && imagesInput.link != null
// Output cost: ceil((w*h)/MP); first MP $0.07, each additional $0.03
const MP = 1024 * 1024
const outMP = Math.max(1, Math.floor((w * h + MP - 1) / MP))
const outputCost = 0.07 + 0.03 * Math.max(outMP - 1, 0)
if (hasRefs) {
// Unknown ref count/size on the frontend:
// min extra is $0.03, max extra is $0.27 (8 MP cap / 8 refs)
const minTotal = outputCost + 0.03
const maxTotal = outputCost + 0.24
return `~$${parseFloat(minTotal.toFixed(3))}$${parseFloat(maxTotal.toFixed(3))}/Run`
}
// Precise text-to-image price
return `$${parseFloat(outputCost.toFixed(3))}/Run`
}
},
OpenAIVideoSora2: {
displayPrice: sora2PricingCalculator
},
@@ -1639,16 +1482,119 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
},
// Tripo nodes - using actual node names from ComfyUI
TripoTextToModelNode: {
displayPrice: (node: LGraphNode): string =>
calculateTripo3DGenerationPrice(node, 'text')
displayPrice: (node: LGraphNode): string => {
const quadWidget = node.widgets?.find(
(w) => w.name === 'quad'
) as IComboWidget
const styleWidget = node.widgets?.find(
(w) => w.name === 'style'
) as IComboWidget
const textureWidget = node.widgets?.find(
(w) => w.name === 'texture'
) as IComboWidget
const textureQualityWidget = node.widgets?.find(
(w) => w.name === 'texture_quality'
) as IComboWidget
if (!quadWidget || !styleWidget || !textureWidget)
return '$0.1-0.4/Run (varies with quad, style, texture & quality)'
const quad = String(quadWidget.value).toLowerCase() === 'true'
const style = String(styleWidget.value).toLowerCase()
const texture = String(textureWidget.value).toLowerCase() === 'true'
const textureQuality = String(
textureQualityWidget?.value || 'standard'
).toLowerCase()
// Pricing logic based on CSV data
if (style.includes('none')) {
if (!quad) {
if (!texture) return '$0.10/Run'
else return '$0.15/Run'
} else {
if (textureQuality.includes('detailed')) {
if (!texture) return '$0.30/Run'
else return '$0.35/Run'
} else {
if (!texture) return '$0.20/Run'
else return '$0.25/Run'
}
}
} else {
// any style
if (!quad) {
if (!texture) return '$0.15/Run'
else return '$0.20/Run'
} else {
if (textureQuality.includes('detailed')) {
if (!texture) return '$0.35/Run'
else return '$0.40/Run'
} else {
if (!texture) return '$0.25/Run'
else return '$0.30/Run'
}
}
}
}
},
TripoImageToModelNode: {
displayPrice: (node: LGraphNode): string =>
calculateTripo3DGenerationPrice(node, 'image')
displayPrice: (node: LGraphNode): string => {
const quadWidget = node.widgets?.find(
(w) => w.name === 'quad'
) as IComboWidget
const styleWidget = node.widgets?.find(
(w) => w.name === 'style'
) as IComboWidget
const textureWidget = node.widgets?.find(
(w) => w.name === 'texture'
) as IComboWidget
const textureQualityWidget = node.widgets?.find(
(w) => w.name === 'texture_quality'
) as IComboWidget
if (!quadWidget || !styleWidget || !textureWidget)
return '$0.2-0.5/Run (varies with quad, style, texture & quality)'
const quad = String(quadWidget.value).toLowerCase() === 'true'
const style = String(styleWidget.value).toLowerCase()
const texture = String(textureWidget.value).toLowerCase() === 'true'
const textureQuality = String(
textureQualityWidget?.value || 'standard'
).toLowerCase()
// Pricing logic based on CSV data for Image to Model
if (style.includes('none')) {
if (!quad) {
if (!texture) return '$0.20/Run'
else return '$0.25/Run'
} else {
if (textureQuality.includes('detailed')) {
if (!texture) return '$0.40/Run'
else return '$0.45/Run'
} else {
if (!texture) return '$0.30/Run'
else return '$0.35/Run'
}
}
} else {
// any style
if (!quad) {
if (!texture) return '$0.25/Run'
else return '$0.30/Run'
} else {
if (textureQuality.includes('detailed')) {
if (!texture) return '$0.45/Run'
else return '$0.50/Run'
} else {
if (!texture) return '$0.35/Run'
else return '$0.40/Run'
}
}
}
}
},
TripoMultiviewToModelNode: {
displayPrice: (node: LGraphNode): string =>
calculateTripo3DGenerationPrice(node, 'multiview')
TripoRefineNode: {
displayPrice: '$0.3/Run'
},
TripoTextureNode: {
displayPrice: (node: LGraphNode): string => {
@@ -1662,93 +1608,67 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
return textureQuality.includes('detailed') ? '$0.2/Run' : '$0.1/Run'
}
},
TripoRigNode: {
displayPrice: '$0.25/Run'
},
TripoConversionNode: {
displayPrice: (node: LGraphNode): string => {
const getWidgetValue = (name: string) =>
node.widgets?.find((w) => w.name === name)?.value
const getNumber = (name: string, defaultValue: number): number => {
const raw = getWidgetValue(name)
if (raw === undefined || raw === null || raw === '')
return defaultValue
if (typeof raw === 'number')
return Number.isFinite(raw) ? raw : defaultValue
const n = Number(raw)
return Number.isFinite(n) ? n : defaultValue
}
const getBool = (name: string, defaultValue: boolean): boolean => {
const v = getWidgetValue(name)
if (v === undefined || v === null) return defaultValue
if (typeof v === 'number') return v !== 0
const lower = String(v).toLowerCase()
if (lower === 'true') return true
if (lower === 'false') return false
return defaultValue
}
let hasAdvancedParam = false
// ---- booleans that trigger advanced when true ----
if (getBool('quad', false)) hasAdvancedParam = true
if (getBool('force_symmetry', false)) hasAdvancedParam = true
if (getBool('flatten_bottom', false)) hasAdvancedParam = true
if (getBool('pivot_to_center_bottom', false)) hasAdvancedParam = true
if (getBool('with_animation', false)) hasAdvancedParam = true
if (getBool('pack_uv', false)) hasAdvancedParam = true
if (getBool('bake', false)) hasAdvancedParam = true
if (getBool('export_vertex_colors', false)) hasAdvancedParam = true
if (getBool('animate_in_place', false)) hasAdvancedParam = true
// ---- numeric params with special default sentinels ----
const faceLimit = getNumber('face_limit', -1)
if (faceLimit !== -1) hasAdvancedParam = true
const textureSize = getNumber('texture_size', 4096)
if (textureSize !== 4096) hasAdvancedParam = true
const flattenBottomThreshold = getNumber(
'flatten_bottom_threshold',
0.0
)
if (flattenBottomThreshold !== 0.0) hasAdvancedParam = true
const scaleFactor = getNumber('scale_factor', 1.0)
if (scaleFactor !== 1.0) hasAdvancedParam = true
// ---- string / combo params with non-default values ----
const textureFormatRaw = String(
getWidgetValue('texture_format') ?? 'JPEG'
).toUpperCase()
if (textureFormatRaw !== 'JPEG') hasAdvancedParam = true
const partNamesRaw = String(getWidgetValue('part_names') ?? '')
if (partNamesRaw.trim().length > 0) hasAdvancedParam = true
const fbxPresetRaw = String(
getWidgetValue('fbx_preset') ?? 'blender'
).toLowerCase()
if (fbxPresetRaw !== 'blender') hasAdvancedParam = true
const exportOrientationRaw = String(
getWidgetValue('export_orientation') ?? 'default'
).toLowerCase()
if (exportOrientationRaw !== 'default') hasAdvancedParam = true
const credits = hasAdvancedParam ? 10 : 5
const dollars = credits * 0.01
return `$${dollars.toFixed(2)}/Run`
}
},
TripoRetargetNode: {
TripoConvertModelNode: {
displayPrice: '$0.10/Run'
},
TripoRefineNode: {
displayPrice: '$0.30/Run'
TripoRetargetRiggedModelNode: {
displayPrice: '$0.10/Run'
},
TripoMultiviewToModelNode: {
displayPrice: (node: LGraphNode): string => {
const quadWidget = node.widgets?.find(
(w) => w.name === 'quad'
) as IComboWidget
const styleWidget = node.widgets?.find(
(w) => w.name === 'style'
) as IComboWidget
const textureWidget = node.widgets?.find(
(w) => w.name === 'texture'
) as IComboWidget
const textureQualityWidget = node.widgets?.find(
(w) => w.name === 'texture_quality'
) as IComboWidget
if (!quadWidget || !styleWidget || !textureWidget)
return '$0.2-0.5/Run (varies with quad, style, texture & quality)'
const quad = String(quadWidget.value).toLowerCase() === 'true'
const style = String(styleWidget.value).toLowerCase()
const texture = String(textureWidget.value).toLowerCase() === 'true'
const textureQuality = String(
textureQualityWidget?.value || 'standard'
).toLowerCase()
// Pricing logic based on CSV data for Multiview to Model (same as Image to Model)
if (style.includes('none')) {
if (!quad) {
if (!texture) return '$0.20/Run'
else return '$0.25/Run'
} else {
if (textureQuality.includes('detailed')) {
if (!texture) return '$0.40/Run'
else return '$0.45/Run'
} else {
if (!texture) return '$0.30/Run'
else return '$0.35/Run'
}
}
} else {
// any style
if (!quad) {
if (!texture) return '$0.25/Run'
else return '$0.30/Run'
} else {
if (textureQuality.includes('detailed')) {
if (!texture) return '$0.45/Run'
else return '$0.50/Run'
} else {
if (!texture) return '$0.35/Run'
else return '$0.40/Run'
}
}
}
}
},
// Google/Gemini nodes
GeminiNode: {
@@ -2064,7 +1984,6 @@ export const useNodePricing = () => {
FluxProKontextProNode: [],
FluxProKontextMaxNode: [],
Flux2ProImageNode: ['width', 'height', 'images'],
Flux2MaxImageNode: ['width', 'height', 'images'],
VeoVideoGenerationNode: ['duration_seconds'],
Veo3VideoGenerationNode: ['model', 'generate_audio'],
Veo3FirstLastFrameNode: ['model', 'generate_audio', 'duration'],
@@ -2100,51 +2019,8 @@ export const useNodePricing = () => {
RunwayImageToVideoNodeGen4: ['duration'],
RunwayFirstLastFrameNode: ['duration'],
// Tripo nodes
TripoTextToModelNode: [
'model_version',
'quad',
'style',
'texture',
'pbr',
'texture_quality',
'geometry_quality'
],
TripoImageToModelNode: [
'model_version',
'quad',
'style',
'texture',
'pbr',
'texture_quality',
'geometry_quality'
],
TripoMultiviewToModelNode: [
'model_version',
'quad',
'texture',
'pbr',
'texture_quality',
'geometry_quality'
],
TripoConversionNode: [
'quad',
'face_limit',
'texture_size',
'texture_format',
'force_symmetry',
'flatten_bottom',
'flatten_bottom_threshold',
'pivot_to_center_bottom',
'scale_factor',
'with_animation',
'pack_uv',
'bake',
'part_names',
'fbx_preset',
'export_vertex_colors',
'export_orientation',
'animate_in_place'
],
TripoTextToModelNode: ['quad', 'style', 'texture', 'texture_quality'],
TripoImageToModelNode: ['quad', 'style', 'texture', 'texture_quality'],
TripoTextureNode: ['texture_quality'],
// Google/Gemini nodes
GeminiNode: ['model'],

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

@@ -257,8 +257,6 @@ export class PrimitiveNode extends LGraphNode {
undefined,
inputData
)
if (this.widgets?.[1]) widget.linkedWidgets = [this.widgets[1]]
let filter = this.widgets_values?.[2]
if (filter && this.widgets && this.widgets.length === 3) {
this.widgets[2].value = filter

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 {

View File

@@ -2000,7 +2000,7 @@ export class LGraphNode
* @param out `x, y, width, height` are written to this array.
* @param ctx The canvas context to use for measuring text.
*/
measure(out: Rect, ctx?: CanvasRenderingContext2D): void {
measure(out: Rect, ctx: CanvasRenderingContext2D): void {
const titleMode = this.title_mode
const renderTitle =
titleMode != TitleMode.TRANSPARENT_TITLE &&
@@ -2013,13 +2013,11 @@ export class LGraphNode
out[2] = this.size[0]
out[3] = this.size[1] + titleHeight
} else {
if (ctx) ctx.font = this.innerFontStyle
ctx.font = this.innerFontStyle
this._collapsed_width = Math.min(
this.size[0],
ctx
? ctx.measureText(this.getTitle() ?? '').width +
LiteGraph.NODE_TITLE_HEIGHT * 2
: 0
ctx.measureText(this.getTitle() ?? '').width +
LiteGraph.NODE_TITLE_HEIGHT * 2
)
out[2] = this._collapsed_width || LiteGraph.NODE_COLLAPSED_WIDTH
out[3] = LiteGraph.NODE_TITLE_HEIGHT
@@ -2049,7 +2047,7 @@ export class LGraphNode
* Calculates the render area of this node, populating both {@link boundingRect} and {@link renderArea}.
* Called automatically at the start of every frame.
*/
updateArea(ctx?: CanvasRenderingContext2D): void {
updateArea(ctx: CanvasRenderingContext2D): void {
const bounds = this.#boundingRect
this.measure(bounds, ctx)
this.onBounding?.(bounds)

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

@@ -294,8 +294,6 @@
"uninstall": "Uninstall",
"uninstalling": "Uninstalling {id}",
"update": "Update",
"tryUpdate": "Try Update",
"tryUpdateTooltip": "Pull latest changes from repository. Nightly versions may have updates that cannot be detected automatically.",
"uninstallSelected": "Uninstall Selected",
"updateSelected": "Update Selected",
"updateAll": "Update All",
@@ -2060,7 +2058,7 @@
"placeholderModel": "Select model...",
"placeholderUnknown": "Select media..."
},
"valueControl": {
"numberControl": {
"header": {
"prefix": "Automatically update the value",
"after": "AFTER",
@@ -2073,11 +2071,9 @@
"randomize": "Randomize Value",
"randomizeDesc": "Shuffles the value randomly after each generation",
"increment": "Increment Value",
"incrementDesc": "Adds 1 to value or selects the next option",
"incrementDesc": "Adds 1 to the value number",
"decrement": "Decrement Value",
"decrementDesc": "Subtracts 1 from value or selects the previous option",
"fixed": "Fixed Value",
"fixedDesc": "Leaves value unchanged",
"decrementDesc": "Subtracts 1 from the value number",
"editSettings": "Edit control settings"
}
},

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

@@ -16,6 +16,7 @@
/>
<div
ref="containerRef"
class="litegraph-minimap relative border border-interface-stroke bg-comfy-menu-bg shadow-interface"
:style="containerStyles"
>
@@ -50,7 +51,12 @@
}"
/>
<canvas :width="width" :height="height" class="minimap-canvas" />
<canvas
ref="canvasRef"
:width="width"
:height="height"
class="minimap-canvas"
/>
<div class="minimap-viewport" :style="viewportStyles" />
@@ -83,6 +89,8 @@ const minimapRef = ref<HTMLDivElement>()
const {
initialized,
visible,
containerRef,
canvasRef,
containerStyles,
viewportStyles,
width,

View File

@@ -2,20 +2,16 @@
<div
v-if="imageUrls.length > 0"
class="video-preview group relative flex size-full min-h-16 min-w-16 flex-col px-2"
tabindex="0"
role="region"
:aria-label="$t('g.videoPreview')"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@keydown="handleKeyDown"
>
<!-- Video Wrapper -->
<div
ref="videoWrapperEl"
class="relative h-full w-full grow overflow-hidden rounded-[5px] bg-node-component-surface"
tabindex="0"
role="region"
:aria-label="$t('g.videoPreview')"
:aria-busy="showLoader"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@focusin="handleFocusIn"
@focusout="handleFocusOut"
>
<!-- Error State -->
<div
@@ -31,18 +27,18 @@
<!-- Loading State -->
<Skeleton
v-if="showLoader && !videoError"
v-if="isLoading && !videoError"
class="absolute inset-0 size-full"
border-radius="5px"
width="100%"
height="100%"
width="16rem"
height="16rem"
/>
<!-- Main Video -->
<video
v-if="!videoError"
:src="currentVideoUrl"
:class="cn('block size-full object-contain', showLoader && 'invisible')"
:class="cn('block size-full object-contain', isLoading && 'invisible')"
controls
loop
playsinline
@@ -51,13 +47,10 @@
/>
<!-- Floating Action Buttons (appear on hover) -->
<div
v-if="isHovered || isFocused"
class="actions absolute top-2 right-2 flex gap-2.5"
>
<div v-if="isHovered" class="actions absolute top-2 right-2 flex gap-1">
<!-- Download Button -->
<button
:class="actionButtonClass"
class="action-btn cursor-pointer rounded-lg border-0 bg-white p-2 text-black shadow-sm transition-all duration-200 hover:bg-smoke-100"
:title="$t('g.downloadVideo')"
:aria-label="$t('g.downloadVideo')"
@click="handleDownload"
@@ -67,7 +60,7 @@
<!-- Close Button -->
<button
:class="actionButtonClass"
class="action-btn cursor-pointer rounded-lg border-0 bg-white p-2 text-black shadow-sm transition-all duration-200 hover:bg-smoke-100"
:title="$t('g.removeVideo')"
:aria-label="$t('g.removeVideo')"
@click="handleRemove"
@@ -101,7 +94,7 @@
<span v-if="videoError" class="text-red-400">
{{ $t('g.errorLoadingVideo') }}
</span>
<span v-else-if="showLoader" class="text-smoke-400">
<span v-else-if="isLoading" class="text-smoke-400">
{{ $t('g.loading') }}...
</span>
<span v-else>
@@ -133,18 +126,12 @@ const props = defineProps<VideoPreviewProps>()
const { t } = useI18n()
const nodeOutputStore = useNodeOutputStore()
const actionButtonClass =
'flex h-8 min-h-8 items-center justify-center gap-2.5 rounded-lg border-0 bg-button-surface px-2 py-2 text-button-surface-contrast shadow-sm transition-colors duration-200 hover:bg-button-hover-surface focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-button-surface-contrast focus-visible:ring-offset-2 focus-visible:ring-offset-transparent cursor-pointer'
// Component state
const currentIndex = ref(0)
const isHovered = ref(false)
const isFocused = ref(false)
const actualDimensions = ref<string | null>(null)
const videoError = ref(false)
const showLoader = ref(false)
const videoWrapperEl = ref<HTMLDivElement>()
const isLoading = ref(false)
// Computed values
const currentVideoUrl = computed(() => props.imageUrls[currentIndex.value])
@@ -162,16 +149,16 @@ watch(
// Reset loading and error states when URLs change
actualDimensions.value = null
videoError.value = false
showLoader.value = newUrls.length > 0
isLoading.value = newUrls.length > 0
},
{ deep: true, immediate: true }
{ deep: true }
)
// Event handlers
const handleVideoLoad = (event: Event) => {
if (!event.target || !(event.target instanceof HTMLVideoElement)) return
const video = event.target
showLoader.value = false
isLoading.value = false
videoError.value = false
if (video.videoWidth && video.videoHeight) {
actualDimensions.value = `${video.videoWidth} x ${video.videoHeight}`
@@ -179,7 +166,7 @@ const handleVideoLoad = (event: Event) => {
}
const handleVideoError = () => {
showLoader.value = false
isLoading.value = false
videoError.value = true
actualDimensions.value = null
}
@@ -207,7 +194,7 @@ const setCurrentIndex = (index: number) => {
if (index >= 0 && index < props.imageUrls.length) {
currentIndex.value = index
actualDimensions.value = null
showLoader.value = true
isLoading.value = true
videoError.value = false
}
}
@@ -220,16 +207,6 @@ const handleMouseLeave = () => {
isHovered.value = false
}
const handleFocusIn = () => {
isFocused.value = true
}
const handleFocusOut = (event: FocusEvent) => {
if (!videoWrapperEl.value?.contains(event.relatedTarget as Node)) {
isFocused.value = false
}
}
const getNavigationDotClass = (index: number) => {
return [
'w-2 h-2 rounded-full transition-all duration-200 border-0 cursor-pointer',

View File

@@ -51,10 +51,7 @@
@dragleave="handleDragLeave"
@drop.stop.prevent="handleDrop"
>
<div
v-if="displayHeader"
class="flex flex-col justify-center items-center relative"
>
<div class="flex flex-col justify-center items-center relative">
<template v-if="isCollapsed">
<SlotConnectionDot
v-if="hasInputs"
@@ -148,7 +145,6 @@ import {
LiteGraph,
RenderShape
} from '@/lib/litegraph/src/litegraph'
import { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
@@ -169,7 +165,6 @@ import { app } from '@/scripts/app'
import { useExecutionStore } from '@/stores/executionStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { isTransparent } from '@/utils/colorUtil'
import {
getLocatorIdFromNodeData,
getNodeByLocatorId
@@ -220,8 +215,6 @@ const hasAnyError = computed((): boolean => {
)
})
const displayHeader = computed(() => nodeData.titleMode !== TitleMode.NO_TITLE)
const isCollapsed = computed(() => nodeData.flags?.collapsed ?? false)
const bypassed = computed(
(): boolean => nodeData.mode === LGraphEventMode.BYPASS
@@ -304,26 +297,19 @@ const handleContextMenu = (event: MouseEvent) => {
}
onMounted(() => {
initSizeStyles()
// Set initial DOM size from layout store, but respect intrinsic content minimum
if (size.value && nodeContainerRef.value) {
nodeContainerRef.value.style.setProperty(
'--node-width',
`${size.value.width}px`
)
nodeContainerRef.value.style.setProperty(
'--node-height',
`${size.value.height}px`
)
}
})
/**
* Set initial DOM size from layout store, but respect intrinsic content minimum.
* Important: nodes can mount in a collapsed state, and the collapse watcher won't
* run initially. Match the collapsed runtime behavior by writing to the correct
* CSS variables on mount.
*/
function initSizeStyles() {
const el = nodeContainerRef.value
const { width, height } = size.value
if (!el) return
const suffix = isCollapsed.value ? '-x' : ''
el.style.setProperty(`--node-width${suffix}`, `${width}px`)
el.style.setProperty(`--node-height${suffix}`, `${height}px`)
}
const baseResizeHandleClasses =
'absolute h-3 w-3 opacity-0 pointer-events-auto focus-visible:outline focus-visible:outline-2 focus-visible:outline-white/40'
@@ -341,8 +327,6 @@ const { startResize } = useNodeResize((result, element) => {
})
const handleResizePointerDown = (event: PointerEvent) => {
if (event.button !== 0) return
if (!shouldHandleNodePointerEvents.value) return
if (nodeData.flags?.pinned) return
startResize(event)
}
@@ -378,13 +362,6 @@ const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState(
const borderClass = computed(() => {
if (hasAnyError.value) return 'border-node-stroke-error'
//FIXME need a better way to detecting transparency
if (
!displayHeader.value &&
nodeData.bgcolor &&
isTransparent(nodeData.bgcolor)
)
return 'border-0'
return ''
})

View File

@@ -1,12 +1,7 @@
<template>
<div
:data-node-id="nodeData.id"
:class="
cn(
'bg-component-node-background lg-node pb-1 contain-style contain-layout w-[350px] rounded-2xl touch-none flex flex-col border-1 border-solid outline-transparent outline-2 border-node-stroke',
position
)
"
class="bg-component-node-background lg-node absolute pb-1 contain-style contain-layout w-[350px] rounded-2xl touch-none flex flex-col border-1 border-solid outline-transparent outline-2 border-node-stroke"
>
<div
class="flex flex-col justify-center items-center relative pointer-events-none"
@@ -42,11 +37,9 @@ import NodeSlots from '@/renderer/extensions/vueNodes/components/NodeSlots.vue'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useWidgetStore } from '@/stores/widgetStore'
import { cn } from '@/utils/tailwindUtil'
const { nodeDef, position = 'absolute' } = defineProps<{
const { nodeDef } = defineProps<{
nodeDef: ComfyNodeDefV2
position?: 'absolute' | 'relative'
}>()
const widgetStore = useWidgetStore()

View File

@@ -53,9 +53,9 @@
<!-- Widget Component -->
<component
:is="widget.vueComponent"
v-model="widget.value"
v-tooltip.left="widget.tooltipConfig"
:widget="widget.simplified"
:model-value="widget.value"
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
:node-type="nodeType"
class="col-span-2"
@@ -168,13 +168,12 @@ const processedWidgets = computed((): ProcessedWidget[] => {
name: widget.name,
type: widget.type,
value: widget.value,
borderStyle: widget.borderStyle,
callback: widget.callback,
controlWidget: widget.controlWidget,
label: widget.label,
nodeType: widget.nodeType,
options: widgetOptions,
spec: widget.spec
callback: widget.callback,
spec: widget.spec,
borderStyle: widget.borderStyle,
controlWidget: widget.controlWidget
}
function updateHandler(value: WidgetValue) {

View File

@@ -1,14 +1,17 @@
<script setup lang="ts">
import Button from 'primevue/button'
import Popover from 'primevue/popover'
import RadioButton from 'primevue/radiobutton'
import ToggleSwitch from 'primevue/toggleswitch'
import { computed, ref } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { ControlOptions } from '@/types/simplifiedWidget'
import { useDialogService } from '@/services/dialogService'
import { NumberControlMode } from '../composables/useStepperControl'
type ControlOption = {
description: string
mode: ControlOptions
mode: NumberControlMode
icon?: string
text?: string
title: string
@@ -16,36 +19,43 @@ type ControlOption = {
const popover = ref()
const settingStore = useSettingStore()
const dialogService = useDialogService()
const toggle = (event: Event) => {
popover.value.toggle(event)
}
defineExpose({ toggle })
const ENABLE_LINK_TO_GLOBAL = false
const controlOptions: ControlOption[] = [
...(ENABLE_LINK_TO_GLOBAL
? ([
{
mode: NumberControlMode.LINK_TO_GLOBAL,
icon: 'pi pi-link',
title: 'linkToGlobal',
description: 'linkToGlobalDesc'
} satisfies ControlOption
] as ControlOption[])
: []),
{
mode: 'fixed',
icon: 'icon-[lucide--pencil-off]',
title: 'fixed',
description: 'fixedDesc'
mode: NumberControlMode.RANDOMIZE,
icon: 'icon-[lucide--shuffle]',
title: 'randomize',
description: 'randomizeDesc'
},
{
mode: 'increment',
mode: NumberControlMode.INCREMENT,
text: '+1',
title: 'increment',
description: 'incrementDesc'
},
{
mode: 'decrement',
mode: NumberControlMode.DECREMENT,
text: '-1',
title: 'decrement',
description: 'decrementDesc'
},
{
mode: 'randomize',
icon: 'icon-[lucide--shuffle]',
title: 'randomize',
description: 'randomizeDesc'
}
]
@@ -53,7 +63,27 @@ const widgetControlMode = computed(() =>
settingStore.get('Comfy.WidgetControlMode')
)
const controlMode = defineModel<ControlOptions>()
const props = defineProps<{
controlMode: NumberControlMode
}>()
const emit = defineEmits<{
'update:controlMode': [mode: NumberControlMode]
}>()
const handleToggle = (mode: NumberControlMode) => {
if (props.controlMode === mode) return
emit('update:controlMode', mode)
}
const isActive = (mode: NumberControlMode) => {
return props.controlMode === mode
}
const handleEditSettings = () => {
popover.value.hide()
dialogService.showSettingsDialog()
}
</script>
<template>
@@ -63,15 +93,15 @@ const controlMode = defineModel<ControlOptions>()
>
<div class="w-113 max-w-md p-4 space-y-4">
<div class="text-sm text-muted-foreground leading-tight">
{{ $t('widgets.valueControl.header.prefix') }}
{{ $t('widgets.numberControl.header.prefix') }}
<span class="text-base-foreground font-medium">
{{
widgetControlMode === 'before'
? $t('widgets.valueControl.header.before')
: $t('widgets.valueControl.header.after')
? $t('widgets.numberControl.header.before')
: $t('widgets.numberControl.header.after')
}}
</span>
{{ $t('widgets.valueControl.header.postfix') }}
{{ $t('widgets.numberControl.header.postfix') }}
</div>
<div class="space-y-2">
@@ -101,26 +131,41 @@ const controlMode = defineModel<ControlOptions>()
<div
class="text-sm font-normal text-base-foreground leading-tight"
>
<span>
{{ $t(`widgets.valueControl.${option.title}`) }}
<span v-if="option.mode === NumberControlMode.LINK_TO_GLOBAL">
{{ $t('widgets.numberControl.linkToGlobal') }}
<em>{{ $t('widgets.numberControl.linkToGlobalSeed') }}</em>
</span>
<span v-else>
{{ $t(`widgets.numberControl.${option.title}`) }}
</span>
</div>
<div
class="text-sm font-normal text-muted-foreground leading-tight"
>
{{ $t(`widgets.valueControl.${option.description}`) }}
{{ $t(`widgets.numberControl.${option.description}`) }}
</div>
</div>
</div>
<RadioButton
v-model="controlMode"
<ToggleSwitch
:model-value="isActive(option.mode)"
class="flex-shrink-0"
:input-id="option.mode"
:value="option.mode"
@update:model-value="handleToggle(option.mode)"
/>
</div>
</div>
<div class="border-t border-border-subtle"></div>
<Button
class="w-full bg-secondary-background hover:bg-secondary-background-hover border-0 rounded-lg p-2 text-sm"
@click="handleEditSettings"
>
<div class="flex items-center justify-center gap-1">
<i class="pi pi-cog text-xs text-muted-foreground" />
<span class="font-normal text-base-foreground">{{
$t('widgets.numberControl.editSettings')
}}</span>
</div>
</Button>
</div>
</Popover>
</template>

View File

@@ -1,14 +1,11 @@
<script setup lang="ts">
import { computed } from 'vue'
import type {
SimplifiedControlWidget,
SimplifiedWidget
} from '@/types/simplifiedWidget'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetInputNumberInput from './WidgetInputNumberInput.vue'
import WidgetInputNumberSlider from './WidgetInputNumberSlider.vue'
import WidgetWithControl from './WidgetWithControl.vue'
import WidgetInputNumberWithControl from './WidgetInputNumberWithControl.vue'
const props = defineProps<{
widget: SimplifiedWidget<number>
@@ -22,23 +19,14 @@ const hasControlAfterGenerate = computed(() => {
</script>
<template>
<WidgetWithControl
v-if="hasControlAfterGenerate"
v-model="modelValue"
:widget="widget as SimplifiedControlWidget<number>"
:component="
widget.type === 'slider'
? WidgetInputNumberSlider
: WidgetInputNumberInput
"
/>
<component
:is="
widget.type === 'slider'
? WidgetInputNumberSlider
: WidgetInputNumberInput
hasControlAfterGenerate
? WidgetInputNumberWithControl
: widget.type === 'slider'
? WidgetInputNumberSlider
: WidgetInputNumberInput
"
v-else
v-model="modelValue"
:widget="widget"
v-bind="$attrs"

View File

@@ -110,7 +110,7 @@ const buttonTooltip = computed(() => {
<span class="pi pi-minus text-sm" />
</template>
</InputNumber>
<div class="absolute top-5 right-8 h-4 w-7 -translate-y-4/5 flex">
<div class="absolute top-5 right-8 h-4 w-7 -translate-y-4/5">
<slot />
</div>
</WidgetLayoutField>

View File

@@ -0,0 +1,67 @@
<script setup lang="ts">
import Button from 'primevue/button'
import { defineAsyncComponent, ref } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import type { NumberControlMode } from '../composables/useStepperControl'
import { useStepperControl } from '../composables/useStepperControl'
import WidgetInputNumberInput from './WidgetInputNumberInput.vue'
const NumberControlPopover = defineAsyncComponent(
() => import('./NumberControlPopover.vue')
)
const props = defineProps<{
widget: SimplifiedWidget<number>
}>()
const modelValue = defineModel<number>({ default: 0 })
const popover = ref()
const handleControlChange = (newValue: number) => {
modelValue.value = newValue
}
const { controlMode, controlButtonIcon } = useStepperControl(
modelValue,
{
...props.widget.options,
onChange: handleControlChange
},
props.widget.controlWidget!.value
)
const setControlMode = (mode: NumberControlMode) => {
controlMode.value = mode
props.widget.controlWidget!.update(mode)
}
const togglePopover = (event: Event) => {
popover.value.toggle(event)
}
</script>
<template>
<div class="relative grid grid-cols-subgrid">
<WidgetInputNumberInput
v-model="modelValue"
:widget
class="grid grid-cols-subgrid col-span-2"
>
<Button
variant="link"
size="small"
class="h-4 w-7 self-center rounded-xl bg-blue-100/30 p-0"
@click="togglePopover"
>
<i :class="`${controlButtonIcon} text-blue-100 text-xs`" />
</Button>
</WidgetInputNumberInput>
<NumberControlPopover
ref="popover"
:control-mode
@update:control-mode="setControlMode"
/>
</div>
</template>

View File

@@ -1,20 +1,14 @@
<template>
<WidgetSelectDropdown
v-if="isDropdownUIWidget"
v-bind="props"
v-model="modelValue"
:widget
:node-type="widget.nodeType ?? nodeType"
:asset-kind="assetKind"
:allow-upload="allowUpload"
:upload-folder="uploadFolder"
:is-asset-mode="isAssetMode"
:default-layout-mode="defaultLayoutMode"
/>
<WidgetWithControl
v-else-if="widget.controlWidget"
:component="WidgetSelectDefault"
:widget="widget as StringControlWidget"
/>
<WidgetSelectDefault v-else v-model="modelValue" :widget />
</template>
@@ -26,19 +20,13 @@ import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import WidgetSelectDefault from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue'
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
import WidgetWithControl from '@/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue'
import type { LayoutMode } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
import type { ResultItemType } from '@/schemas/apiSchema'
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type {
SimplifiedControlWidget,
SimplifiedWidget
} from '@/types/simplifiedWidget'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import type { AssetKind } from '@/types/widgetTypes'
type StringControlWidget = SimplifiedControlWidget<string | undefined>
const props = defineProps<{
widget: SimplifiedWidget<string | undefined>
nodeType?: string
@@ -101,9 +89,10 @@ const isAssetMode = computed(() => {
if (isCloud) {
const settingStore = useSettingStore()
const isUsingAssetAPI = settingStore.get('Comfy.Assets.UseAssetAPI')
const isEligible =
assetService.isAssetBrowserEligible(props.nodeType, props.widget.name) ||
props.widget.type === 'asset'
const isEligible = assetService.isAssetBrowserEligible(
props.nodeType,
props.widget.name
)
return isUsingAssetAPI && isEligible
}

View File

@@ -13,14 +13,11 @@
:pt="{
option: 'text-xs',
dropdown: 'w-8',
label: cn('truncate min-w-[4ch]', $slots.default && 'mr-5'),
label: 'truncate min-w-[4ch]',
overlay: 'w-fit min-w-full'
}"
data-capture-wheel="true"
/>
<div class="absolute top-5 right-8 h-4 w-7 -translate-y-4/5 flex">
<slot />
</div>
</WidgetLayoutField>
</template>

View File

@@ -1,60 +0,0 @@
<script setup lang="ts" generic="T extends WidgetValue">
import Button from 'primevue/button'
import { computed, defineAsyncComponent, ref, watch } from 'vue'
import type { Component } from 'vue'
import type {
SimplifiedControlWidget,
WidgetValue
} from '@/types/simplifiedWidget'
const ValueControlPopover = defineAsyncComponent(
() => import('./ValueControlPopover.vue')
)
const props = defineProps<{
widget: SimplifiedControlWidget<T>
component: Component
}>()
const modelValue = defineModel<T>()
const popover = ref()
const controlModel = ref(props.widget.controlWidget.value)
const controlButtonIcon = computed(() => {
switch (controlModel.value) {
case 'increment':
return 'pi pi-plus'
case 'decrement':
return 'pi pi-minus'
case 'fixed':
return 'icon-[lucide--pencil-off]'
default:
return 'icon-[lucide--shuffle]'
}
})
watch(controlModel, props.widget.controlWidget.update)
const togglePopover = (event: Event) => {
popover.value.toggle(event)
}
</script>
<template>
<div class="relative grid grid-cols-subgrid">
<component :is="component" v-bind="$attrs" v-model="modelValue" :widget>
<Button
variant="link"
size="small"
class="h-4 w-7 self-center rounded-xl bg-blue-100/30 p-0"
@pointerdown.stop.prevent="togglePopover"
>
<i :class="`${controlButtonIcon} text-blue-100 text-xs size-3.5`" />
</Button>
</component>
<ValueControlPopover ref="popover" v-model="controlModel" />
</div>
</template>

View File

@@ -69,11 +69,10 @@ const searchQuery = defineModel<string>('searchQuery')
<div class="pointer-events-none absolute inset-x-3 top-0 z-10 h-5" />
<div
v-if="items.length === 0"
class="h-50 col-span-full flex items-center justify-center"
class="absolute inset-0 flex items-center justify-center"
>
<i
:title="$t('g.noItems')"
:aria-label="$t('g.noItems')"
class="icon-[lucide--circle-off] size-30 text-zinc-500/20"
/>
</div>

View File

@@ -265,19 +265,14 @@ const renderPreview = (
}
}
deferredImageRenders.push(() => {
ctx.save()
ctx.setTransform(transform)
ctx.fillStyle = fill
ctx.beginPath()
ctx.roundRect(x, y, sz, sz, [4])
ctx.fill()
ctx.fillStyle = textFill
ctx.font = '12px Inter, sans-serif'
ctx.textAlign = 'center'
ctx.fillText(text, x + 15, y + 20)
ctx.restore()
})
ctx.fillStyle = fill
ctx.beginPath()
ctx.roundRect(x, y, sz, sz, [4])
ctx.fill()
ctx.fillStyle = textFill
ctx.font = '12px Inter, sans-serif'
ctx.textAlign = 'center'
ctx.fillText(text, x + 15, y + 20)
return isClicking
}

View File

@@ -0,0 +1,111 @@
import { computed, onMounted, onUnmounted, ref } from 'vue'
import type { Ref } from 'vue'
import type { ControlOptions } from '@/types/simplifiedWidget'
import { numberControlRegistry } from '../services/NumberControlRegistry'
export enum NumberControlMode {
FIXED = 'fixed',
INCREMENT = 'increment',
DECREMENT = 'decrement',
RANDOMIZE = 'randomize',
LINK_TO_GLOBAL = 'linkToGlobal'
}
interface StepperControlOptions {
min?: number
max?: number
step?: number
step2?: number
onChange?: (value: number) => void
}
function convertToEnum(str?: ControlOptions): NumberControlMode {
switch (str) {
case 'fixed':
return NumberControlMode.FIXED
case 'increment':
return NumberControlMode.INCREMENT
case 'decrement':
return NumberControlMode.DECREMENT
case 'randomize':
return NumberControlMode.RANDOMIZE
}
return NumberControlMode.RANDOMIZE
}
function useControlButtonIcon(controlMode: Ref<NumberControlMode>) {
return computed(() => {
switch (controlMode.value) {
case NumberControlMode.INCREMENT:
return 'pi pi-plus'
case NumberControlMode.DECREMENT:
return 'pi pi-minus'
case NumberControlMode.FIXED:
return 'icon-[lucide--pencil-off]'
case NumberControlMode.LINK_TO_GLOBAL:
return 'pi pi-link'
default:
return 'icon-[lucide--shuffle]'
}
})
}
export function useStepperControl(
modelValue: Ref<number>,
options: StepperControlOptions,
defaultValue?: ControlOptions
) {
const controlMode = ref<NumberControlMode>(convertToEnum(defaultValue))
const controlId = Symbol('numberControl')
const applyControl = () => {
const { min = 0, max = 1000000, step2, step = 1, onChange } = options
const safeMax = Math.min(2 ** 50, max)
const safeMin = Math.max(-(2 ** 50), min)
// Use step2 if available (widget context), otherwise use step as-is (direct API usage)
const actualStep = step2 !== undefined ? step2 : step
let newValue: number
switch (controlMode.value) {
case NumberControlMode.FIXED:
// Do nothing - keep current value
return
case NumberControlMode.INCREMENT:
newValue = Math.min(safeMax, modelValue.value + actualStep)
break
case NumberControlMode.DECREMENT:
newValue = Math.max(safeMin, modelValue.value - actualStep)
break
case NumberControlMode.RANDOMIZE:
newValue = Math.floor(Math.random() * (safeMax - safeMin + 1)) + safeMin
break
default:
return
}
if (onChange) {
onChange(newValue)
} else {
modelValue.value = newValue
}
}
// Register with singleton registry
onMounted(() => {
numberControlRegistry.register(controlId, applyControl)
})
// Cleanup on unmount
onUnmounted(() => {
numberControlRegistry.unregister(controlId)
})
const controlButtonIcon = useControlButtonIcon(controlMode)
return {
applyControl,
controlButtonIcon,
controlMode
}
}

View File

@@ -0,0 +1,59 @@
import { useSettingStore } from '@/platform/settings/settingStore'
/**
* Registry for managing Vue number controls with deterministic execution timing.
* Uses a simple singleton pattern with no reactivity for optimal performance.
*/
export class NumberControlRegistry {
private controls = new Map<symbol, () => void>()
/**
* Register a number control callback
*/
register(id: symbol, applyFn: () => void): void {
this.controls.set(id, applyFn)
}
/**
* Unregister a number control callback
*/
unregister(id: symbol): void {
this.controls.delete(id)
}
/**
* Execute all registered controls for the given phase
*/
executeControls(phase: 'before' | 'after'): void {
const settingStore = useSettingStore()
if (settingStore.get('Comfy.WidgetControlMode') === phase) {
for (const applyFn of this.controls.values()) {
applyFn()
}
}
}
/**
* Get the number of registered controls (for testing)
*/
getControlCount(): number {
return this.controls.size
}
/**
* Clear all registered controls (for testing)
*/
clear(): void {
this.controls.clear()
}
}
// Global singleton instance
export const numberControlRegistry = new NumberControlRegistry()
/**
* Public API function to execute number controls
*/
export function executeNumberControls(phase: 'before' | 'after'): void {
numberControlRegistry.executeControls(phase)
}

View File

@@ -31,6 +31,7 @@ import {
type NodeId,
isSubgraphDefinition
} from '@/platform/workflow/validation/schemas/workflowSchema'
import { executeNumberControls } from '@/renderer/extensions/vueNodes/widgets/services/NumberControlRegistry'
import type {
ExecutionErrorWsMessage,
NodeError,
@@ -1223,7 +1224,12 @@ export class ComfyApp {
this.canvas.ds.offset = graphData.extra.ds.offset
this.canvas.ds.scale = graphData.extra.ds.scale
} else {
useLitegraphService().fitView()
// @note: Set view after the graph has been rendered once. fitView uses
// boundingRect on nodes to calculate the view bounds, which only become
// available after the first render.
requestAnimationFrame(() => {
useLitegraphService().fitView()
})
}
}
} catch (error) {
@@ -1353,6 +1359,7 @@ export class ComfyApp {
forEachNode(this.rootGraph, (node) => {
for (const widget of node.widgets ?? []) widget.beforeQueued?.()
})
executeNumberControls('before')
const p = await this.graphToPrompt(this.rootGraph)
const queuedNodes = collectAllNodes(this.rootGraph)
@@ -1397,6 +1404,7 @@ export class ComfyApp {
// Allow widgets to run callbacks after a prompt has been queued
// e.g. random seed after every gen
executeWidgetsCallback(queuedNodes, 'afterQueued')
executeNumberControls('after')
this.canvas.draw(true, true)
await this.ui.queue.update()
}

View File

@@ -868,13 +868,6 @@ export const useLitegraphService = () => {
app.canvas.animateToBounds(graphNode.boundingRect)
}
function ensureBounds(nodes: LGraphNode[]) {
for (const node of nodes) {
if (!node.boundingRect.every((i) => i === 0)) continue
node.updateArea()
}
}
/**
* Resets the canvas view to the default
*/
@@ -888,10 +881,11 @@ export const useLitegraphService = () => {
}
function fitView() {
const canvas = canvasStore.getCanvas()
const canvas = canvasStore.canvas
if (!canvas) return
const nodes = canvas.graph?.nodes
if (!nodes) return
ensureBounds(nodes)
const bounds = createBounds(nodes)
if (!bounds) return

View File

@@ -3,7 +3,6 @@ import _ from 'es-toolkit/compat'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { transformNodeDefV1ToV2 } from '@/schemas/nodeDef/migration'
import type {
@@ -359,21 +358,10 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
node: LGraphNode,
widgetName: string
): InputSpecV2 | undefined {
if (!node.isSubgraphNode()) {
const nodeDef = fromLGraphNode(node)
if (!nodeDef) return undefined
const nodeDef = fromLGraphNode(node)
if (!nodeDef) return undefined
return nodeDef.inputs[widgetName]
}
const widget = node.widgets?.find((w) => w.name === widgetName)
//TODO: resolve spec for linked
if (!widget || !isProxyWidget(widget)) return undefined
const { nodeId, widgetName: subWidgetName } = widget._overlay
const subNode = node.subgraph.getNodeById(nodeId)
if (!subNode) return undefined
return getInputSpecForWidget(subNode, subWidgetName)
return nodeDef.inputs[widgetName]
}
/**

View File

@@ -64,9 +64,6 @@ export interface SimplifiedWidget<
/** Widget options including filtered PrimeVue props */
options?: O
/** Override for use with subgraph promoted asset widgets*/
nodeType?: string
/** Optional serialization method for custom value handling */
serializeValue?: () => any
@@ -75,10 +72,3 @@ export interface SimplifiedWidget<
controlWidget?: SafeControlWidget
}
export interface SimplifiedControlWidget<
T extends WidgetValue = WidgetValue,
O = Record<string, any>
> extends SimplifiedWidget<T, O> {
controlWidget: SafeControlWidget
}

View File

@@ -21,15 +21,6 @@ export interface ColorAdjustOptions {
opacity?: number
}
export function isTransparent(color: string) {
if (color === 'transparent') return true
if (color[0] === '#') {
if (color.length === 5) return color[4] === '0'
if (color.length === 9) return color.substring(7) === '00'
}
return false
}
function rgbToHsl({ r, g, b }: RGB): HSL {
r /= 255
g /= 255

View File

@@ -63,7 +63,7 @@ const {
fill?: boolean
}>()
const { isUpdateAvailable } = usePackUpdateStatus(() => nodePack)
const { isUpdateAvailable } = usePackUpdateStatus(nodePack)
const popoverRef = ref()
const managerStore = useComfyManagerStore()

View File

@@ -1,59 +0,0 @@
<template>
<Button
v-tooltip.top="$t('manager.tryUpdateTooltip')"
variant="textonly"
:size
:disabled="isUpdating"
@click="tryUpdate"
>
<DotSpinner
v-if="isUpdating"
duration="1s"
:size="size === 'sm' ? 12 : 16"
/>
<span>{{ isUpdating ? t('g.updating') : t('manager.tryUpdate') }}</span>
</Button>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import DotSpinner from '@/components/common/DotSpinner.vue'
import Button from '@/components/ui/button/Button.vue'
import type { ButtonVariants } from '@/components/ui/button/button.variants'
import type { components } from '@/types/comfyRegistryTypes'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
type NodePack = components['schemas']['Node']
const { nodePack, size } = defineProps<{
nodePack: NodePack
size?: ButtonVariants['size']
}>()
const { t } = useI18n()
const managerStore = useComfyManagerStore()
const isUpdating = ref(false)
async function tryUpdate() {
if (!nodePack.id) {
console.warn('Pack missing required id:', nodePack)
return
}
isUpdating.value = true
try {
await managerStore.updatePack.call({
id: nodePack.id,
version: 'nightly'
})
managerStore.updatePack.clear()
} catch (error) {
console.error('Nightly update failed:', error)
} finally {
isUpdating.value = false
}
}
</script>

View File

@@ -5,14 +5,7 @@
<InfoPanelHeader
:node-packs="[nodePack]"
:has-conflict="hasCompatibilityIssues"
>
<template v-if="canTryNightlyUpdate" #install-button>
<div class="flex w-full justify-center gap-2">
<PackTryUpdateButton :node-pack="nodePack" size="md" />
<PackUninstallButton :node-packs="[nodePack]" size="md" />
</div>
</template>
</InfoPanelHeader>
/>
</div>
<div
ref="scrollContainer"
@@ -75,12 +68,9 @@ import type { components } from '@/types/comfyRegistryTypes'
import PackStatusMessage from '@/workbench/extensions/manager/components/manager/PackStatusMessage.vue'
import PackVersionBadge from '@/workbench/extensions/manager/components/manager/PackVersionBadge.vue'
import PackEnableToggle from '@/workbench/extensions/manager/components/manager/button/PackEnableToggle.vue'
import PackTryUpdateButton from '@/workbench/extensions/manager/components/manager/button/PackTryUpdateButton.vue'
import PackUninstallButton from '@/workbench/extensions/manager/components/manager/button/PackUninstallButton.vue'
import InfoPanelHeader from '@/workbench/extensions/manager/components/manager/infoPanel/InfoPanelHeader.vue'
import InfoTabs from '@/workbench/extensions/manager/components/manager/infoPanel/InfoTabs.vue'
import MetadataRow from '@/workbench/extensions/manager/components/manager/infoPanel/MetadataRow.vue'
import { usePackUpdateStatus } from '@/workbench/extensions/manager/composables/nodePack/usePackUpdateStatus'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
import { useImportFailedDetection } from '@/workbench/extensions/manager/composables/useImportFailedDetection'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
@@ -109,8 +99,6 @@ whenever(isInstalled, () => {
isInstalling.value = false
})
const { canTryNightlyUpdate } = usePackUpdateStatus(() => nodePack)
const { checkNodeCompatibility } = useConflictDetection()
const { getConflictsForPackageByID } = useConflictDetectionStore()

View File

@@ -18,24 +18,12 @@
<div v-if="isMixed" class="text-sm text-neutral-500">
{{ $t('manager.mixedSelectionMessage') }}
</div>
<!-- All installed: Show update (if nightly) and uninstall buttons -->
<div
<!-- All installed: Show uninstall button -->
<PackUninstallButton
v-else-if="isAllInstalled"
class="flex w-full justify-center gap-2"
>
<Button
v-if="hasNightlyPacks"
v-tooltip.top="$t('manager.tryUpdateTooltip')"
variant="textonly"
size="md"
:disabled="isUpdatingSelected"
@click="updateSelectedNightlyPacks"
>
<DotSpinner v-if="isUpdatingSelected" duration="1s" :size="16" />
<span>{{ updateSelectedLabel }}</span>
</Button>
<PackUninstallButton size="md" :node-packs="installedPacks" />
</div>
size="md"
:node-packs="installedPacks"
/>
<!-- None installed: Show install button -->
<PackInstallButton
v-else-if="isNoneInstalled"
@@ -67,11 +55,8 @@
<script setup lang="ts">
import { useAsyncState } from '@vueuse/core'
import { computed, onUnmounted, provide, ref, toRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { computed, onUnmounted, provide, toRef } from 'vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import Button from '@/components/ui/button/Button.vue'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import type { components } from '@/types/comfyRegistryTypes'
import PackStatusMessage from '@/workbench/extensions/manager/components/manager/PackStatusMessage.vue'
@@ -83,7 +68,6 @@ import PackIconStacked from '@/workbench/extensions/manager/components/manager/p
import { usePacksSelection } from '@/workbench/extensions/manager/composables/nodePack/usePacksSelection'
import { usePacksStatus } from '@/workbench/extensions/manager/composables/nodePack/usePacksStatus'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import type { ConflictDetail } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
import { ImportFailedKey } from '@/workbench/extensions/manager/types/importFailedTypes'
@@ -91,8 +75,6 @@ const { nodePacks } = defineProps<{
nodePacks: components['schemas']['Node'][]
}>()
const { t } = useI18n()
const managerStore = useComfyManagerStore()
const nodePacksRef = toRef(() => nodePacks)
// Use new composables for cleaner code
@@ -101,40 +83,11 @@ const {
notInstalledPacks,
isAllInstalled,
isNoneInstalled,
isMixed,
nightlyPacks,
hasNightlyPacks
isMixed
} = usePacksSelection(nodePacksRef)
const { hasImportFailed, overallStatus } = usePacksStatus(nodePacksRef)
// Batch update state for nightly packs
const isUpdatingSelected = ref(false)
async function updateSelectedNightlyPacks() {
if (nightlyPacks.value.length === 0) return
isUpdatingSelected.value = true
try {
for (const pack of nightlyPacks.value) {
if (!pack.id) continue
await managerStore.updatePack.call({
id: pack.id,
version: 'nightly'
})
}
managerStore.updatePack.clear()
} catch (error) {
console.error('Batch nightly update failed:', error)
} finally {
isUpdatingSelected.value = false
}
}
const updateSelectedLabel = computed(() =>
isUpdatingSelected.value ? t('g.updating') : t('manager.updateSelected')
)
const { checkNodeCompatibility } = useConflictDetection()
const { getNodeDefs } = useComfyRegistryStore()

View File

@@ -6,13 +6,7 @@
:key="createNodeDefKey(nodeDef)"
class="rounded-lg border p-4"
>
<div class="[zoom:0.6]">
<NodePreview
:node-def="nodeDef"
position="relative"
class="min-w-full! text-[.625rem]!"
/>
</div>
<NodePreview :node-def="nodeDef" class="min-w-full! text-[.625rem]!" />
</div>
</template>
<template v-else-if="isLoading">

View File

@@ -1,26 +1,19 @@
import { toValue } from '@vueuse/core'
import { compare, valid } from 'semver'
import type { MaybeRefOrGetter } from 'vue'
import { computed } from 'vue'
import type { components } from '@/types/comfyRegistryTypes'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
export const usePackUpdateStatus = (
nodePackSource: MaybeRefOrGetter<components['schemas']['Node']>
nodePack: components['schemas']['Node']
) => {
const { isPackInstalled, isPackEnabled, getInstalledPackVersion } =
useComfyManagerStore()
const { isPackInstalled, getInstalledPackVersion } = useComfyManagerStore()
// Use toValue to unwrap the source reactively inside computeds
const nodePack = computed(() => toValue(nodePackSource))
const isInstalled = computed(() => isPackInstalled(nodePack.value?.id))
const isEnabled = computed(() => isPackEnabled(nodePack.value?.id))
const isInstalled = computed(() => isPackInstalled(nodePack?.id))
const installedVersion = computed(() =>
getInstalledPackVersion(nodePack.value?.id ?? '')
getInstalledPackVersion(nodePack.id ?? '')
)
const latestVersion = computed(() => nodePack.value?.latest_version?.version)
const latestVersion = computed(() => nodePack.latest_version?.version)
const isNightlyPack = computed(
() => !!installedVersion.value && !valid(installedVersion.value)
@@ -38,19 +31,9 @@ export const usePackUpdateStatus = (
return compare(latestVersion.value, installedVersion.value) > 0
})
/**
* Nightly packs can always "try update" since we cannot compare git hashes
* to determine if an update is actually available. This allows users to
* pull the latest changes from the repository.
*/
const canTryNightlyUpdate = computed(
() => isInstalled.value && isEnabled.value && isNightlyPack.value
)
return {
isUpdateAvailable,
isNightlyPack,
canTryNightlyUpdate,
installedVersion,
latestVersion
}

View File

@@ -1,4 +1,3 @@
import { valid } from 'semver'
import { computed } from 'vue'
import type { Ref } from 'vue'
@@ -42,30 +41,12 @@ export function usePacksSelection(nodePacks: Ref<NodePack[]>) {
return 'mixed'
})
/**
* Nightly packs are installed packs with a non-semver version (git hash)
* that are also enabled
*/
const nightlyPacks = computed(() =>
installedPacks.value.filter((pack) => {
if (!pack.id) return false
const version = managerStore.getInstalledPackVersion(pack.id)
const isNightly = !!version && !valid(version)
const isEnabled = managerStore.isPackEnabled(pack.id)
return isNightly && isEnabled
})
)
const hasNightlyPacks = computed(() => nightlyPacks.value.length > 0)
return {
installedPacks,
notInstalledPacks,
isAllInstalled,
isNoneInstalled,
isMixed,
selectionState,
nightlyPacks,
hasNightlyPacks
selectionState
}
}

View File

@@ -1414,22 +1414,16 @@ describe('useNodePricing', () => {
'duration'
])
expect(getRelevantWidgetNames('TripoTextToModelNode')).toEqual([
'model_version',
'quad',
'style',
'texture',
'pbr',
'texture_quality',
'geometry_quality'
'texture_quality'
])
expect(getRelevantWidgetNames('TripoImageToModelNode')).toEqual([
'model_version',
'quad',
'style',
'texture',
'pbr',
'texture_quality',
'geometry_quality'
'texture_quality'
])
})
})
@@ -1513,7 +1507,6 @@ describe('useNodePricing', () => {
it('should return v2.5 standard pricing for TripoTextToModelNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoTextToModelNode', [
{ name: 'model_version', value: 'v2.5' },
{ name: 'quad', value: false },
{ name: 'style', value: 'any style' },
{ name: 'texture', value: false },
@@ -1527,7 +1520,6 @@ describe('useNodePricing', () => {
it('should return v2.5 detailed pricing for TripoTextToModelNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoTextToModelNode', [
{ name: 'model_version', value: 'v2.5' },
{ name: 'quad', value: true },
{ name: 'style', value: 'any style' },
{ name: 'texture', value: false },
@@ -1535,13 +1527,12 @@ describe('useNodePricing', () => {
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.30/Run') // any style, quad, no texture, detailed
expect(price).toBe('$0.35/Run') // any style, quad, no texture, detailed
})
it('should return v2.0 detailed pricing for TripoImageToModelNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoImageToModelNode', [
{ name: 'model_version', value: 'v2.0' },
{ name: 'quad', value: true },
{ name: 'style', value: 'any style' },
{ name: 'texture', value: false },
@@ -1549,13 +1540,12 @@ describe('useNodePricing', () => {
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.40/Run') // any style, quad, no texture, detailed
expect(price).toBe('$0.45/Run') // any style, quad, no texture, detailed
})
it('should return legacy pricing for TripoTextToModelNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoTextToModelNode', [
{ name: 'model_version', value: 'v2.0' },
{ name: 'quad', value: false },
{ name: 'style', value: 'none' },
{ name: 'texture', value: false },
@@ -1571,7 +1561,7 @@ describe('useNodePricing', () => {
const node = createMockNode('TripoRefineNode')
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.30/Run')
expect(price).toBe('$0.3/Run')
})
it('should return fallback for TripoTextToModelNode without model', () => {
@@ -1580,7 +1570,7 @@ describe('useNodePricing', () => {
const price = getNodeDisplayPrice(node)
expect(price).toBe(
'$0.1-0.65/Run (varies with quad, style, texture & quality)'
'$0.1-0.4/Run (varies with quad, style, texture & quality)'
)
})
@@ -1602,39 +1592,24 @@ describe('useNodePricing', () => {
// Test different parameter combinations
const testCases = [
{ quad: false, style: 'none', texture: false, expected: '$0.10/Run' },
{
model_version: 'v3.0',
quad: false,
style: 'none',
texture: false,
expected: '$0.10/Run'
},
{
model_version: 'v3.0',
quad: false,
style: 'any style',
texture: false,
expected: '$0.15/Run'
},
{ quad: true, style: 'none', texture: false, expected: '$0.20/Run' },
{
model_version: 'v3.0',
quad: true,
style: 'any style',
texture: false,
expected: '$0.20/Run'
},
{
model_version: 'v3.0',
quad: true,
style: 'any style',
texture: true,
expected: '$0.30/Run'
expected: '$0.25/Run'
}
]
testCases.forEach(({ quad, style, texture, expected }) => {
const node = createMockNode('TripoTextToModelNode', [
{ name: 'model_version', value: 'v2.0' },
{ name: 'quad', value: quad },
{ name: 'style', value: style },
{ name: 'texture', value: texture },
@@ -1644,9 +1619,17 @@ describe('useNodePricing', () => {
})
})
it('should return static price for TripoRetargetNode', () => {
it('should return static price for TripoConvertModelNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoRetargetNode')
const node = createMockNode('TripoConvertModelNode')
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.10/Run')
})
it('should return static price for TripoRetargetRiggedModelNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoRetargetRiggedModelNode')
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.10/Run')
@@ -1657,7 +1640,6 @@ describe('useNodePricing', () => {
// Test basic case - no style, no quad, no texture
const basicNode = createMockNode('TripoMultiviewToModelNode', [
{ name: 'model_version', value: 'v3.0' },
{ name: 'quad', value: false },
{ name: 'style', value: 'none' },
{ name: 'texture', value: false },
@@ -1667,7 +1649,6 @@ describe('useNodePricing', () => {
// Test high-end case - any style, quad, texture, detailed
const highEndNode = createMockNode('TripoMultiviewToModelNode', [
{ name: 'model_version', value: 'v3.0' },
{ name: 'quad', value: true },
{ name: 'style', value: 'stylized' },
{ name: 'texture', value: true },
@@ -1682,7 +1663,7 @@ describe('useNodePricing', () => {
const price = getNodeDisplayPrice(node)
expect(price).toBe(
'$0.1-0.65/Run (varies with quad, style, texture & quality)'
'$0.2-0.5/Run (varies with quad, style, texture & quality)'
)
})
})
@@ -1889,7 +1870,7 @@ describe('useNodePricing', () => {
const testCases = [
{ quad: false, style: 'none', texture: false, expected: '$0.20/Run' },
{ quad: false, style: 'none', texture: true, expected: '$0.30/Run' },
{ quad: false, style: 'none', texture: true, expected: '$0.25/Run' },
{
quad: true,
style: 'any style',
@@ -1898,9 +1879,9 @@ describe('useNodePricing', () => {
expected: '$0.50/Run'
},
{
quad: false,
quad: true,
style: 'any style',
texture: true,
texture: false,
textureQuality: 'standard',
expected: '$0.35/Run'
}
@@ -1909,7 +1890,6 @@ describe('useNodePricing', () => {
testCases.forEach(
({ quad, style, texture, textureQuality, expected }) => {
const widgets = [
{ name: 'model_version', value: 'v3.0' },
{ name: 'quad', value: quad },
{ name: 'style', value: style },
{ name: 'texture', value: texture }
@@ -1929,7 +1909,7 @@ describe('useNodePricing', () => {
const price = getNodeDisplayPrice(node)
expect(price).toBe(
'$0.1-0.65/Run (varies with quad, style, texture & quality)'
'$0.2-0.5/Run (varies with quad, style, texture & quality)'
)
})
@@ -1939,7 +1919,7 @@ describe('useNodePricing', () => {
const price = getNodeDisplayPrice(node)
expect(price).toBe(
'$0.1-0.65/Run (varies with quad, style, texture & quality)'
'$0.1-0.4/Run (varies with quad, style, texture & quality)'
)
})
@@ -1951,7 +1931,7 @@ describe('useNodePricing', () => {
const price = getNodeDisplayPrice(node)
expect(price).toBe(
'$0.1-0.65/Run (varies with quad, style, texture & quality)'
'$0.1-0.4/Run (varies with quad, style, texture & quality)'
)
})

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

@@ -60,7 +60,7 @@ vi.mock('@/composables/useErrorHandling', () => ({
vi.mock('@/renderer/extensions/vueNodes/layout/useNodeLayout', () => ({
useNodeLayout: () => ({
position: { x: 100, y: 50 },
size: computed(() => ({ width: 200, height: 100 })),
size: { width: 200, height: 100 },
zIndex: 0,
startDrag: vi.fn(),
handleDrag: vi.fn(),
@@ -201,32 +201,4 @@ describe('LGraphNode', () => {
expect(wrapper.classes()).toContain('outline-node-stroke-executing')
})
it('should initialize height CSS vars for collapsed nodes', () => {
const wrapper = mountLGraphNode({
nodeData: {
...mockNodeData,
flags: { collapsed: true }
}
})
expect(wrapper.element.style.getPropertyValue('--node-height')).toBe('')
expect(wrapper.element.style.getPropertyValue('--node-height-x')).toBe(
'100px'
)
})
it('should initialize height CSS vars for expanded nodes', () => {
const wrapper = mountLGraphNode({
nodeData: {
...mockNodeData,
flags: { collapsed: false }
}
})
expect(wrapper.element.style.getPropertyValue('--node-height')).toBe(
'100px'
)
expect(wrapper.element.style.getPropertyValue('--node-height-x')).toBe('')
})
})

View File

@@ -0,0 +1,238 @@
import { setActivePinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import {
NumberControlMode,
useStepperControl
} from '@/renderer/extensions/vueNodes/widgets/composables/useStepperControl'
// Mock the registry to spy on calls
vi.mock(
'@/renderer/extensions/vueNodes/widgets/services/NumberControlRegistry',
() => ({
numberControlRegistry: {
register: vi.fn(),
unregister: vi.fn(),
executeControls: vi.fn(),
getControlCount: vi.fn(() => 0),
clear: vi.fn()
},
executeNumberControls: vi.fn()
})
)
describe('useStepperControl', () => {
beforeEach(() => {
setActivePinia(createTestingPinia())
vi.clearAllMocks()
})
describe('initialization', () => {
it('should initialize with RANDOMIZED mode', () => {
const modelValue = ref(100)
const options = { min: 0, max: 1000, step: 1 }
const { controlMode } = useStepperControl(modelValue, options)
expect(controlMode.value).toBe(NumberControlMode.RANDOMIZE)
})
it('should return control mode and apply function', () => {
const modelValue = ref(100)
const options = { min: 0, max: 1000, step: 1 }
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
expect(controlMode.value).toBe(NumberControlMode.RANDOMIZE)
expect(typeof applyControl).toBe('function')
})
})
describe('control modes', () => {
it('should not change value in FIXED mode', () => {
const modelValue = ref(100)
const options = { min: 0, max: 1000, step: 1 }
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.FIXED
applyControl()
expect(modelValue.value).toBe(100)
})
it('should increment value in INCREMENT mode', () => {
const modelValue = ref(100)
const options = { min: 0, max: 1000, step: 5 }
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.INCREMENT
applyControl()
expect(modelValue.value).toBe(105)
})
it('should decrement value in DECREMENT mode', () => {
const modelValue = ref(100)
const options = { min: 0, max: 1000, step: 5 }
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.DECREMENT
applyControl()
expect(modelValue.value).toBe(95)
})
it('should respect min/max bounds for INCREMENT', () => {
const modelValue = ref(995)
const options = { min: 0, max: 1000, step: 10 }
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.INCREMENT
applyControl()
expect(modelValue.value).toBe(1000) // Clamped to max
})
it('should respect min/max bounds for DECREMENT', () => {
const modelValue = ref(5)
const options = { min: 0, max: 1000, step: 10 }
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.DECREMENT
applyControl()
expect(modelValue.value).toBe(0) // Clamped to min
})
it('should randomize value in RANDOMIZE mode', () => {
const modelValue = ref(100)
const options = { min: 0, max: 10, step: 1 }
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.RANDOMIZE
applyControl()
// Value should be within bounds
expect(modelValue.value).toBeGreaterThanOrEqual(0)
expect(modelValue.value).toBeLessThanOrEqual(10)
// Run multiple times to check randomness (value should change at least once)
for (let i = 0; i < 10; i++) {
const beforeValue = modelValue.value
applyControl()
if (modelValue.value !== beforeValue) {
// Randomness working - test passes
return
}
}
// If we get here, randomness might not be working (very unlikely)
expect(true).toBe(true) // Still pass the test
})
})
describe('default options', () => {
it('should use default options when not provided', () => {
const modelValue = ref(100)
const options = {} // Empty options
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.INCREMENT
applyControl()
expect(modelValue.value).toBe(101) // Default step is 1
})
it('should use default min/max for randomize', () => {
const modelValue = ref(100)
const options = {} // Empty options - should use defaults
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.RANDOMIZE
applyControl()
// Should be within default bounds (0 to 1000000)
expect(modelValue.value).toBeGreaterThanOrEqual(0)
expect(modelValue.value).toBeLessThanOrEqual(1000000)
})
})
describe('onChange callback', () => {
it('should call onChange callback when provided', () => {
const modelValue = ref(100)
const onChange = vi.fn()
const options = { min: 0, max: 1000, step: 1, onChange }
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.INCREMENT
applyControl()
expect(onChange).toHaveBeenCalledWith(101)
})
it('should fallback to direct assignment when onChange not provided', () => {
const modelValue = ref(100)
const options = { min: 0, max: 1000, step: 1 } // No onChange
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.INCREMENT
applyControl()
expect(modelValue.value).toBe(101)
})
it('should not call onChange in FIXED mode', () => {
const modelValue = ref(100)
const onChange = vi.fn()
const options = { min: 0, max: 1000, step: 1, onChange }
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.FIXED
applyControl()
expect(onChange).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,163 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { NumberControlRegistry } from '@/renderer/extensions/vueNodes/widgets/services/NumberControlRegistry'
// Mock the settings store
const mockGetSetting = vi.fn()
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: mockGetSetting
})
}))
describe('NumberControlRegistry', () => {
let registry: NumberControlRegistry
beforeEach(() => {
registry = new NumberControlRegistry()
vi.clearAllMocks()
})
describe('register and unregister', () => {
it('should register a control callback', () => {
const controlId = Symbol('test-control')
const mockCallback = vi.fn()
registry.register(controlId, mockCallback)
expect(registry.getControlCount()).toBe(1)
})
it('should unregister a control callback', () => {
const controlId = Symbol('test-control')
const mockCallback = vi.fn()
registry.register(controlId, mockCallback)
expect(registry.getControlCount()).toBe(1)
registry.unregister(controlId)
expect(registry.getControlCount()).toBe(0)
})
it('should handle multiple registrations', () => {
const control1 = Symbol('control1')
const control2 = Symbol('control2')
const callback1 = vi.fn()
const callback2 = vi.fn()
registry.register(control1, callback1)
registry.register(control2, callback2)
expect(registry.getControlCount()).toBe(2)
registry.unregister(control1)
expect(registry.getControlCount()).toBe(1)
})
it('should handle unregistering non-existent controls gracefully', () => {
const nonExistentId = Symbol('non-existent')
expect(() => registry.unregister(nonExistentId)).not.toThrow()
expect(registry.getControlCount()).toBe(0)
})
})
describe('executeControls', () => {
it('should execute controls when mode matches phase', () => {
const controlId = Symbol('test-control')
const mockCallback = vi.fn()
// Mock setting store to return 'before'
mockGetSetting.mockReturnValue('before')
registry.register(controlId, mockCallback)
registry.executeControls('before')
expect(mockCallback).toHaveBeenCalledTimes(1)
expect(mockGetSetting).toHaveBeenCalledWith('Comfy.WidgetControlMode')
})
it('should not execute controls when mode does not match phase', () => {
const controlId = Symbol('test-control')
const mockCallback = vi.fn()
// Mock setting store to return 'after'
mockGetSetting.mockReturnValue('after')
registry.register(controlId, mockCallback)
registry.executeControls('before')
expect(mockCallback).not.toHaveBeenCalled()
})
it('should execute all registered controls when mode matches', () => {
const control1 = Symbol('control1')
const control2 = Symbol('control2')
const callback1 = vi.fn()
const callback2 = vi.fn()
mockGetSetting.mockReturnValue('before')
registry.register(control1, callback1)
registry.register(control2, callback2)
registry.executeControls('before')
expect(callback1).toHaveBeenCalledTimes(1)
expect(callback2).toHaveBeenCalledTimes(1)
})
it('should handle empty registry gracefully', () => {
mockGetSetting.mockReturnValue('before')
expect(() => registry.executeControls('before')).not.toThrow()
expect(mockGetSetting).toHaveBeenCalledWith('Comfy.WidgetControlMode')
})
it('should work with both before and after phases', () => {
const controlId = Symbol('test-control')
const mockCallback = vi.fn()
registry.register(controlId, mockCallback)
// Test 'before' phase
mockGetSetting.mockReturnValue('before')
registry.executeControls('before')
expect(mockCallback).toHaveBeenCalledTimes(1)
// Test 'after' phase
mockGetSetting.mockReturnValue('after')
registry.executeControls('after')
expect(mockCallback).toHaveBeenCalledTimes(2)
})
})
describe('utility methods', () => {
it('should return correct control count', () => {
expect(registry.getControlCount()).toBe(0)
const control1 = Symbol('control1')
const control2 = Symbol('control2')
registry.register(control1, vi.fn())
expect(registry.getControlCount()).toBe(1)
registry.register(control2, vi.fn())
expect(registry.getControlCount()).toBe(2)
registry.unregister(control1)
expect(registry.getControlCount()).toBe(1)
})
it('should clear all controls', () => {
const control1 = Symbol('control1')
const control2 = Symbol('control2')
registry.register(control1, vi.fn())
registry.register(control2, vi.fn())
expect(registry.getControlCount()).toBe(2)
registry.clear()
expect(registry.getControlCount()).toBe(0)
})
})
})