Compare commits

...

13 Commits

Author SHA1 Message Date
bymyself
c003930f0d fix: flatten slot context menu to avoid submenu overflow clipping
Amp-Thread-ID: https://ampcode.com/threads/T-019c3a65-ccea-72d8-855c-0d55837a3d13
2026-02-08 12:58:08 -08:00
GitHub Action
f93a82abc8 [automated] Apply ESLint and Oxfmt fixes 2026-02-06 09:33:16 +00:00
bymyself
87a03f8503 feat: port Rename and Disconnect to Vue slot label context menu
Amp-Thread-ID: https://ampcode.com/threads/T-019c3056-108e-7248-9c30-7d236197edcc
2026-02-06 01:30:50 -08:00
bymyself
76373a6eea feat: slot context menu 'Connect to...' and auto-pan during link drag
Add right-click context menu on slot dots with 'Connect to...' submenu
listing compatible existing nodes. Uses Vue/PrimeVue ContextMenu pattern
matching NodeContextMenu.vue. Finds compatible nodes via
LiteGraph.isValidConnection, filters wildcards/bypassed/connected inputs,
sorts by Y position, caps at 15 results.

Add auto-panning when dragging links near canvas edges. Integrated into
useSlotLinkInteraction via useAutoPan composable. Velocity-based rAF
panning that recomputes canvas coordinates after each offset change.

Amp-Thread-ID: https://ampcode.com/threads/T-019c3056-108e-7248-9c30-7d236197edcc
2026-02-06 01:22:35 -08:00
Johnpaul Chiwetelu
d05e4eac58 fix: include subfolder in asset download URL for audio/video files (#8684)
## Summary

- `getAssetUrl()` was constructing `/view` URLs without the `subfolder`
query parameter, causing backend to return "file not found" for assets
stored in subfolders (common for audio/video outputs)
- Preview/playback was unaffected because it uses `preview_url` from
`ResultItemImpl.url` which correctly includes `subfolder`
- Fixed `getAssetUrl()` to include `subfolder` from
`asset.user_metadata` when present
- Simplified download functions to prefer `preview_url` (already
correct) with `getAssetUrl` as fallback

## Test plan

- [ ] Generate audio/video output (e.g. via SaveAudio node) that saves
to a subfolder
- [ ] Right-click the asset in the assets sidebar and click Download —
should download successfully
- [ ] Select multiple audio/video assets and use bulk download — should
download all
- [ ] Verify image downloads still work as before
- [ ] Verify cloud environment downloads still work (uses `preview_url`
path)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

## Release Notes

* **New Features**
  * Added support for organizing and downloading assets from subfolders.

* **Refactor**
* Improved asset URL generation and download handling for better
reliability and performance.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-06 05:06:11 +01:00
Comfy Org PR Bot
7f509cc018 1.39.8 (#8678)
Patch version increment to 1.39.8

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8678-1-39-8-2ff6d73d36508144b359cdd15822f6fb)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-05 19:30:45 -08:00
Christian Byrne
23c8757447 fix: increase active node border weight from 2 to 3 (#8654)
## Summary

Increase stroke/outline weight for active node states to improve
visibility during workflow execution (COM-7770).

## Changes

- **What**: Increased border/stroke weight from 2 to 3 for active nodes
in both Vue Nodes and LiteGraph renderers
  - Vue Nodes: `outline-2` → `outline-3` in `LGraphNode.vue`
- LiteGraph: `lineWidth: 3` for `running` stroke (was default 1) and
`executionError` stroke (was 2) in `litegraphService.ts`
  - Updated test assertion to match

## Review Focus

Minimal visual change. The `executionError` lineWidth was also bumped
from 2 → 3 so error states remain at least as prominent as running
states.

> **Note:**
[#8603](https://github.com/Comfy-Org/ComfyUI_frontend/pull/8603) (by
@AustinMroz) also modifies `LGraphNode.vue` with a larger restructuring
(bottom buttons, badges, resize handle). The two PRs do not conflict —
#8603 does not touch the outline/border state classes or
`litegraphService.ts`, so both changes merge cleanly.

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-05 17:44:19 -08:00
Johnpaul Chiwetelu
7d3d00858a feat: add download button to audio preview player (#8628)
## Summary
- Adds a download icon button to the `AudioPreviewPlayer` widget for
PreviewAudio and SaveAudio nodes
- Reuses the existing `downloadFile` utility (same as video download)
- Button appears inline next to volume/options controls, matching the
player's existing UI style

## Test plan
- [x] Add a PreviewAudio or SaveAudio node, run a workflow that produces
audio output
- [x] Verify the download icon appears in the audio player controls
- [x] Click the download button and confirm the audio file downloads
correctly
- [x] Verify the button does not appear when no audio is loaded


https://github.com/user-attachments/assets/7fb2df16-82a6-40aa-a938-aed57032e30b

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8628-feat-add-download-button-to-audio-preview-player-2fe6d73d365081e3997fc45d3bb8cffc)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-02-06 01:35:32 +01:00
Christian Byrne
478cfc0b5e feat: replace puzzle icon with extensions-blocks icon for manager button (#8644)
## Summary

Replace the manager button puzzle icon with a custom extensions-blocks
SVG icon and add a "Manage Extensions" text label to the top bar button.

## Changes

- **What**: Swap `icon-[lucide--puzzle]` →
`icon-[comfy--extensions-blocks]` in TopMenuSection, ComfyMenuButton,
and ManagerDialog. Add visible "Manage Extensions" label (hidden below
md). Align tooltip with new label text.

## Review Focus

- Visual appearance of the new icon at different sizes
- Button layout with text label at md+ breakpoints
- Red dot notification positioning with wider button

Fixes COM-12161

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8644-feat-replace-puzzle-icon-with-extensions-blocks-icon-for-manager-button-2fe6d73d3650815c8867efc5a0842ef7)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-02-05 16:33:26 -08:00
Johnpaul Chiwetelu
90a701dd67 Road to No Explicit Any Part 11 (#8565)
## Summary

This PR removes `any` types from widgets, services, stores, and test
files, replacing them with proper TypeScript types.

### Key Changes

#### Type Safety Improvements
- Replaced `any` with `unknown`, explicit types, or proper interfaces
across widgets and services
- Added proper type imports (TgpuRoot, Point, StyleValue, etc.)
- Created typed interfaces (NumericWidgetOptions, TestWindow,
ImportFailureDetail, etc.)
- Fixed function return types to be non-nullable where appropriate
- Added type guards and null checks instead of non-null assertions
- Used `ComponentProps` from vue-component-type-helpers for component
testing

#### Widget System
- Added index signature to IWidgetOptions for Record compatibility
- Centralized disabled logic in WidgetInputNumberInput
- Moved template type assertions to computed properties
- Fixed ComboWidget getOptionLabel type assertions
- Improved remote widget type handling with runtime checks

#### Services & Stores
- Fixed getOrCreateViewer to return non-nullable values
- Updated addNodeOnGraph to use specific options type `{ pos?: Point }`
- Added proper type assertions for settings store retrieval
- Fixed executionIdToCurrentId return type (string | undefined)

#### Test Infrastructure
- Exported GraphOrSubgraph from litegraph barrel to avoid circular
dependencies
- Updated test fixtures with proper TypeScript types (TestInfo,
LGraphNode)
- Replaced loose Record types with ComponentProps in tests
- Added proper error handling in WebSocket fixture

#### Code Organization
- Created shared i18n-types module for locale data types
- Made ImportFailureDetail non-exported (internal use only)
- Added @public JSDoc tag to ElectronWindow type
- Fixed console.log usage in scripts to use allowed methods

### Files Changed

**Widgets & Components:**
-
src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumberInput.vue
-
src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue
-
src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue
- src/renderer/extensions/vueNodes/widgets/components/WidgetTextarea.vue
-
src/renderer/extensions/vueNodes/widgets/composables/useRemoteWidget.ts
- src/lib/litegraph/src/widgets/ComboWidget.ts
- src/lib/litegraph/src/types/widgets.ts
- src/components/common/LazyImage.vue
- src/components/load3d/Load3dViewerContent.vue

**Services & Stores:**
- src/services/litegraphService.ts
- src/services/load3dService.ts
- src/services/colorPaletteService.ts
- src/stores/maskEditorStore.ts
- src/stores/nodeDefStore.ts
- src/platform/settings/settingStore.ts
- src/platform/workflow/management/stores/workflowStore.ts

**Composables & Utils:**
- src/composables/node/useWatchWidget.ts
- src/composables/useCanvasDrop.ts
- src/utils/widgetPropFilter.ts
- src/utils/queueDisplay.ts
- src/utils/envUtil.ts

**Test Files:**
- browser_tests/fixtures/ComfyPage.ts
- browser_tests/fixtures/ws.ts
- browser_tests/tests/actionbar.spec.ts
-
src/workbench/extensions/manager/components/manager/skeleton/PackCardGridSkeleton.test.ts
- src/lib/litegraph/src/subgraph/subgraphUtils.test.ts
- src/components/rightSidePanel/shared.test.ts
- src/platform/cloud/subscription/composables/useSubscription.test.ts
-
src/platform/workflow/persistence/composables/useWorkflowPersistence.test.ts

**Scripts & Types:**
- scripts/i18n-types.ts (new shared module)
- scripts/diff-i18n.ts
- scripts/check-unused-i18n-keys.ts
- src/workbench/extensions/manager/types/conflictDetectionTypes.ts
- src/types/algoliaTypes.ts
- src/types/simplifiedWidget.ts

**Infrastructure:**
- src/lib/litegraph/src/litegraph.ts (added GraphOrSubgraph export)
- src/lib/litegraph/src/infrastructure/CustomEventTarget.ts
- src/platform/assets/services/assetService.ts

**Stories:**
- apps/desktop-ui/src/views/InstallView.stories.ts
- src/components/queue/job/JobDetailsPopover.stories.ts

**Extension Manager:**
- src/workbench/extensions/manager/composables/useConflictDetection.ts
- src/workbench/extensions/manager/composables/useManagerQueue.ts
- src/workbench/extensions/manager/services/comfyManagerService.ts
- src/workbench/extensions/manager/utils/conflictMessageUtil.ts

### Testing

- [x] All TypeScript type checking passes (`pnpm typecheck`)
- [x] ESLint passes without errors (`pnpm lint`)
- [x] Format checks pass (`pnpm format:check`)
- [x] Knip (unused exports) passes (`pnpm knip`)
- [x] Pre-commit and pre-push hooks pass

Part of the "Road to No Explicit Any" initiative.

### Previous PRs in this series:
- Part 2: #7401
- Part 3: #7935
- Part 4: #7970
- Part 5: #8064
- Part 6: #8083
- Part 7: #8092
- Part 8 Group 1: #8253
- Part 8 Group 2: #8258
- Part 8 Group 3: #8304
- Part 8 Group 4: #8314
- Part 8 Group 5: #8329
- Part 8 Group 6: #8344
- Part 8 Group 7: #8459
- Part 8 Group 8: #8496
- Part 9: #8498
- Part 10: #8499

---------

Co-authored-by: Comfy Org PR Bot <snomiao+comfy-pr@gmail.com>
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-05 16:29:28 -08:00
Johnpaul Chiwetelu
7f81e1afac ci: filter snapshot update job to only run @screenshot tagged tests (#8629)
## Summary
- Adds `--grep @screenshot` to the Playwright command in the
update-snapshots CI workflow
- Skips ~146 non-screenshot tests that don't produce any snapshot files,
reducing CI time and resource usage

## Details
All tests that call `toHaveScreenshot` are already tagged with
`@screenshot` (either at the `test.describe` or individual `test`
level). The snapshot update job was previously running every test
unnecessarily.

The `--grep` CLI flag is ANDed with each project's existing
`grep`/`grepInvert` settings, so all projects continue to work
correctly:
- `chromium`: `@screenshot` AND NOT `@mobile`
- `chromium-2x`: `@screenshot` AND `@2x`
- `mobile-chrome`: `@screenshot` AND `@mobile`

## Test plan
- [x] Trigger the update-snapshots workflow on a PR with the "New
Browser Test Expectations" label and verify only screenshot-tagged tests
run
- [x] Verify snapshot files are still correctly updated
2026-02-05 15:18:21 -08:00
AustinMroz
e26283e754 Revert delay of layout initialization (#8619)
#7591 added a one tick delay to layout initialization in an attempt to
resolve some layouting discrepancies. However, it appears to have
reintroduced node scaling issues and introduced a new bug that prevents
cloning nodes with alt+drag in vue.

Alternatives methods of resolving the original issue are being
investigated, but this change was causing more harm than good.

The prior PR included other changes (like a testing fix). Those changes
remain beneficial and do not need to be reverted.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8619-Revert-delay-of-layout-initialization-2fe6d73d365081fc9111c9457ea9752d)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-02-05 09:17:41 -08:00
Jin Yi
1ca6e57ac4 fix: skip node replacement API call when feature is disabled (#8618) 2026-02-05 16:37:18 +09:00
98 changed files with 2323 additions and 233 deletions

View File

@@ -109,7 +109,7 @@ jobs:
# Run sharded tests with snapshot updates (browsers pre-installed in container)
- name: Update snapshots (Shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
id: playwright-tests
run: pnpm exec playwright test --update-snapshots --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
run: pnpm exec playwright test --update-snapshots --grep @screenshot --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
continue-on-error: true
- name: Stage changed snapshot files

View File

@@ -1,6 +1,8 @@
// eslint-disable-next-line storybook/no-renderer-packages
import type { Meta, StoryObj } from '@storybook/vue3'
import type { ElectronAPI } from '@comfyorg/comfyui-electron-types'
import { nextTick, provide } from 'vue'
import type { ElectronWindow } from '@/utils/envUtil'
import { createMemoryHistory, createRouter } from 'vue-router'
import InstallView from './InstallView.vue'
@@ -42,16 +44,21 @@ const meta: Meta<typeof InstallView> = {
const router = createMockRouter()
// Mock electron API
;(window as any).electronAPI = {
;(window as ElectronWindow).electronAPI = {
getPlatform: () => 'darwin',
Config: {
getDetectedGpu: () => Promise.resolve('mps')
},
Events: {
trackEvent: (_eventName: string, _data?: any) => {}
trackEvent: (
_eventName: string,
_data?: Record<string, unknown>
) => {}
},
installComfyUI: (_options: any) => {},
changeTheme: (_theme: any) => {},
installComfyUI: (
_options: Parameters<ElectronAPI['installComfyUI']>[0]
) => {},
changeTheme: (_theme: Parameters<ElectronAPI['changeTheme']>[0]) => {},
getSystemPaths: () =>
Promise.resolve({
defaultInstallPath: '/Users/username/ComfyUI'
@@ -240,8 +247,8 @@ export const DesktopSettings: Story = {
export const WindowsPlatform: Story = {
render: () => {
// Override the platform to Windows
;(window as any).electronAPI.getPlatform = () => 'win32'
;(window as any).electronAPI.Config.getDetectedGpu = () =>
;(window as ElectronWindow).electronAPI.getPlatform = () => 'win32'
;(window as ElectronWindow).electronAPI.Config.getDetectedGpu = () =>
Promise.resolve('nvidia')
return {
@@ -259,8 +266,8 @@ export const MacOSPlatform: Story = {
name: 'macOS Platform',
render: () => {
// Override the platform to macOS
;(window as any).electronAPI.getPlatform = () => 'darwin'
;(window as any).electronAPI.Config.getDetectedGpu = () =>
;(window as ElectronWindow).electronAPI.getPlatform = () => 'darwin'
;(window as ElectronWindow).electronAPI.Config.getDetectedGpu = () =>
Promise.resolve('mps')
return {
@@ -327,7 +334,7 @@ export const ManualInstall: Story = {
export const ErrorState: Story = {
render: () => {
// Override validation to return an error
;(window as any).electronAPI.validateInstallPath = () =>
;(window as ElectronWindow).electronAPI.validateInstallPath = () =>
Promise.resolve({
isValid: false,
exists: false,
@@ -375,7 +382,7 @@ export const ErrorState: Story = {
export const WarningState: Story = {
render: () => {
// Override validation to return a warning about non-default drive
;(window as any).electronAPI.validateInstallPath = () =>
;(window as ElectronWindow).electronAPI.validateInstallPath = () =>
Promise.resolve({
isValid: true,
exists: false,

View File

@@ -1,5 +1,9 @@
import { test as base } from '@playwright/test'
interface TestWindow extends Window {
__ws__?: Record<string, WebSocket>
}
export const webSocketFixture = base.extend<{
ws: { trigger(data: unknown, url?: string): Promise<void> }
}>({

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 80 KiB

View File

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

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16"><path d="M6.66667 14V5.33333C6.66667 5.15652 6.59643 4.98695 6.4714 4.86193C6.34638 4.7369 6.17681 4.66667 6 4.66667H2.66667C2.48986 4.66667 2.32029 4.7369 2.19526 4.86193C2.07024 4.98695 2 5.15652 2 5.33333V13.3333C2 13.5101 2.07024 13.6797 2.19526 13.8047C2.32029 13.9298 2.48986 14 2.66667 14H10.6667C10.8435 14 11.013 13.9298 11.1381 13.8047C11.2631 13.6797 11.3333 13.5101 11.3333 13.3333V10C11.3333 9.82319 11.2631 9.65362 11.1381 9.5286C11.013 9.40357 10.8435 9.33333 10.6667 9.33333H2M10 2H13.3333C13.7015 2 14 2.29848 14 2.66667V6C14 6.36819 13.7015 6.66667 13.3333 6.66667H10C9.63181 6.66667 9.33333 6.36819 9.33333 6V2.66667C9.33333 2.29848 9.63181 2 10 2Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 837 B

View File

@@ -2,10 +2,7 @@
import { execSync } from 'child_process'
import * as fs from 'fs'
import { globSync } from 'glob'
interface LocaleData {
[key: string]: any
}
import type { LocaleData } from './i18n-types'
// Configuration
const SOURCE_PATTERNS = ['src/**/*.{js,ts,vue}', '!src/locales/**/*']
@@ -45,7 +42,7 @@ function getStagedLocaleFiles(): string[] {
}
// Extract all keys from a nested object
function extractKeys(obj: any, prefix = ''): string[] {
function extractKeys(obj: LocaleData, prefix = ''): string[] {
const keys: string[] = []
for (const [key, value] of Object.entries(obj)) {
@@ -166,17 +163,17 @@ async function checkNewUnusedKeys() {
// Report results
if (unusedNewKeys.length > 0) {
console.log('\n⚠ Warning: Found unused NEW i18n keys:\n')
console.warn('\n⚠ Warning: Found unused NEW i18n keys:\n')
for (const key of unusedNewKeys.sort()) {
console.log(` - ${key}`)
console.warn(` - ${key}`)
}
console.log(`\n✨ Total unused new keys: ${unusedNewKeys.length}`)
console.log(
console.warn(`\n✨ Total unused new keys: ${unusedNewKeys.length}`)
console.warn(
'\nThese keys were added but are not used anywhere in the codebase.'
)
console.log('Consider using them or removing them in a future update.')
console.warn('Consider using them or removing them in a future update.')
// Changed from process.exit(1) to process.exit(0) for warning only
process.exit(0)

View File

@@ -7,6 +7,7 @@ import {
writeFileSync
} from 'fs'
import { dirname, join } from 'path'
import type { LocaleData } from './i18n-types'
// Ensure directories exist
function ensureDir(dir: string) {
@@ -41,8 +42,8 @@ function getAllJsonFiles(dir: string): string[] {
}
// Find additions in new object compared to base
function findAdditions(base: any, updated: any): Record<string, any> {
const additions: Record<string, any> = {}
function findAdditions(base: LocaleData, updated: LocaleData): LocaleData {
const additions: LocaleData = {}
for (const key in updated) {
if (!(key in base)) {
@@ -74,7 +75,7 @@ function capture(srcLocaleDir: string, tempBaseDir: string) {
ensureDir(dirname(targetPath))
writeFileSync(targetPath, readFileSync(file, 'utf8'))
}
console.log('Captured current locale files to temp/base/')
console.warn('Captured current locale files to temp/base/')
}
// Diff command
@@ -94,7 +95,7 @@ function diff(srcLocaleDir: string, tempBaseDir: string, tempDiffDir: string) {
if (Object.keys(additions).length > 0) {
ensureDir(dirname(diffPath))
writeFileSync(diffPath, JSON.stringify(additions, null, 2))
console.log(`Wrote diff to ${diffPath}`)
console.warn(`Wrote diff to ${diffPath}`)
}
}
}
@@ -116,9 +117,9 @@ switch (command) {
// Remove temp directory recursively
if (existsSync('temp')) {
rmSync('temp', { recursive: true, force: true })
console.log('Removed temp directory')
console.warn('Removed temp directory')
}
break
default:
console.log('Please specify either "capture" or "diff" command')
console.error('Please specify either "capture" or "diff" command')
}

5
scripts/i18n-types.ts Normal file
View File

@@ -0,0 +1,5 @@
/**
* Shared types for i18n-related scripts
*/
export type LocaleData = { [key: string]: string | LocaleData }

View File

@@ -19,12 +19,14 @@
<Button
v-tooltip.bottom="customNodesManagerTooltipConfig"
variant="secondary"
size="icon"
:aria-label="t('menu.customNodesManager')"
:aria-label="t('menu.manageExtensions')"
class="relative"
@click="openCustomNodeManager"
>
<i class="icon-[lucide--puzzle] size-4" />
<i class="icon-[comfy--extensions-blocks] size-4" />
<span class="not-md:hidden">
{{ t('menu.manageExtensions') }}
</span>
<span
v-if="shouldShowRedDot"
class="absolute top-0.5 right-1 size-2 rounded-full bg-red-500"
@@ -220,7 +222,7 @@ const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
)
const customNodesManagerTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.customNodesManager'))
buildTooltipConfig(t('menu.manageExtensions'))
)
const queueContextMenu = ref<InstanceType<typeof ContextMenu> | null>(null)
const queueContextMenuItems = computed<MenuItem[]>(() => [

View File

@@ -38,6 +38,7 @@
<script setup lang="ts">
import Skeleton from 'primevue/skeleton'
import { computed, onUnmounted, ref, watch } from 'vue'
import type { StyleValue } from 'vue'
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
import { useMediaCache } from '@/services/mediaCacheService'
@@ -55,7 +56,7 @@ const {
alt?: string
containerClass?: ClassValue
imageClass?: ClassValue
imageStyle?: Record<string, any>
imageStyle?: StyleValue
rootMargin?: string
}>()

View File

@@ -43,6 +43,8 @@
</Transition>
</div>
<NodeContextMenu />
<SlotContextMenu />
<SlotLabelContextMenu />
</template>
<script setup lang="ts">
@@ -70,6 +72,8 @@ import { useCommandStore } from '@/stores/commandStore'
import type { ComfyCommandImpl } from '@/stores/commandStore'
import NodeContextMenu from './NodeContextMenu.vue'
import SlotContextMenu from '@/renderer/extensions/vueNodes/components/SlotContextMenu.vue'
import SlotLabelContextMenu from '@/renderer/extensions/vueNodes/components/SlotLabelContextMenu.vue'
import FrameNodes from './selectionToolbox/FrameNodes.vue'
import NodeOptionsButton from './selectionToolbox/NodeOptionsButton.vue'
import VerticalDivider from './selectionToolbox/VerticalDivider.vue'

View File

@@ -131,7 +131,7 @@ const { isDragging, dragMessage, handleDragOver, handleDragLeave, handleDrop } =
onModelDrop: async (file) => {
await viewer.handleModelDrop(file)
},
disabled: viewer.isPreview.value || isStandaloneMode
disabled: viewer.isPreview.value || !!isStandaloneMode
})
onMounted(async () => {

View File

@@ -158,7 +158,7 @@ export const Queued: Story = {
prompt_id: 'p1'
}
}
} as any
}
return { args: { ...args, jobId } }
},
@@ -217,7 +217,7 @@ export const QueuedParallel: Story = {
prompt_id: 'p2'
}
}
} as any
}
return { args: { ...args, jobId } }
},
@@ -258,7 +258,7 @@ export const Running: Story = {
prompt_id: 'p1'
}
}
} as any
}
return { args: { ...args, jobId } }
},
@@ -303,7 +303,7 @@ export const QueuedZeroAheadSingleRunning: Story = {
prompt_id: 'p1'
}
}
} as any
}
return { args: { ...args, jobId } }
},
@@ -360,7 +360,7 @@ export const QueuedZeroAheadMultiRunning: Story = {
prompt_id: 'p2'
}
}
} as any
}
return { args: { ...args, jobId } }
},

View File

@@ -138,7 +138,6 @@ describe('flatAndCategorizeSelectedItems', () => {
expect(result.nodes).toEqual([testNode1])
expect(result.groups).toEqual([testGroup1, testGroup2])
expect(result.nodeToParentGroup.get(testNode1)).toBe(testGroup2)
expect(result.nodeToParentGroup.has(testGroup2 as any)).toBe(false)
})
it('should handle mixed selection of nodes and groups', () => {

View File

@@ -230,7 +230,7 @@ const extraMenuItems = computed(() => [
{
key: 'manage-extensions',
label: t('menu.manageExtensions'),
icon: 'icon-[lucide--puzzle]',
icon: 'icon-[comfy--extensions-blocks]',
command: showManageExtensions
}
])

View File

@@ -52,7 +52,7 @@ export interface SafeWidgetData {
isDOMWidget?: boolean
label?: string
nodeType?: string
options?: IWidgetOptions<unknown>
options?: IWidgetOptions
spec?: InputSpec
slotMetadata?: WidgetSlotMetadata
}
@@ -145,7 +145,7 @@ interface SharedWidgetEnhancements {
/** Widget label */
label?: string
/** Widget options */
options?: Record<string, any>
options?: IWidgetOptions
}
/**
@@ -170,7 +170,7 @@ export function getSharedWidgetEnhancements(
? 'ring ring-component-node-widget-advanced'
: undefined,
label: widget.label,
options: widget.options
options: widget.options as IWidgetOptions
}
}
@@ -432,7 +432,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
} else {
// Not during workflow loading - initialize layout immediately
// This handles individual node additions during normal operation
requestAnimationFrame(initializeVueNodeLayout)
initializeVueNodeLayout()
}
// Call original callback if provided

View File

@@ -47,7 +47,7 @@ export const useComputedWithWidgetWatch = (
const { widgetNames, triggerCanvasRedraw = false } = options
// Create a reactive trigger based on widget values
const widgetValues = ref<Record<string, any>>({})
const widgetValues = ref<Record<string, unknown>>({})
// Initialize widget observers
if (node.widgets) {
@@ -56,7 +56,7 @@ export const useComputedWithWidgetWatch = (
: node.widgets
// Initialize current values
const currentValues: Record<string, any> = {}
const currentValues: Record<string, unknown> = {}
widgetsToObserve.forEach((widget) => {
currentValues[widget.name] = widget.value
})

View File

@@ -2,7 +2,7 @@ import type { Ref } from 'vue'
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import { usePragmaticDroppable } from '@/composables/usePragmaticDragAndDrop'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, Point } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
@@ -33,7 +33,7 @@ export const useCanvasDrop = (canvasRef: Ref<HTMLCanvasElement | null>) => {
if (node.data instanceof ComfyNodeDefImpl) {
const nodeDef = node.data
const pos = [...basePos]
const pos: Point = [...basePos]
// Add an offset on y to make sure after adding the node, the cursor
// is on the node (top left corner)
pos[1] += LiteGraph.NODE_TITLE_HEIGHT

View File

@@ -2,7 +2,7 @@ import type { NeverNever, PickNevers } from '@/lib/litegraph/src/types/utility'
type EventListeners<T> = {
readonly [K in keyof T]:
| ((this: EventTarget, ev: CustomEvent<T[K]>) => any)
| ((this: EventTarget, ev: CustomEvent<T[K]>) => unknown)
| EventListenerObject
| null
}

View File

@@ -68,7 +68,7 @@ describe.skip('subgraphUtils', () => {
describe.skip('findUsedSubgraphIds', () => {
it('should handle graph with no subgraphs', () => {
const graph = new LGraph()
const registry = new Map<UUID, any>()
const registry = new Map<UUID, LGraph>()
const result = findUsedSubgraphIds(graph, registry)
expect(result.size).toBe(0)
@@ -87,7 +87,7 @@ describe.skip('subgraphUtils', () => {
const node2 = createTestSubgraphNode(subgraph2)
subgraph1.add(node2)
const registry = new Map<UUID, any>([
const registry = new Map<UUID, LGraph>([
[subgraph1.id, subgraph1],
[subgraph2.id, subgraph2]
])
@@ -115,7 +115,7 @@ describe.skip('subgraphUtils', () => {
const node3 = createTestSubgraphNode(subgraph1, { id: 3 })
subgraph2.add(node3)
const registry = new Map<UUID, any>([
const registry = new Map<UUID, LGraph>([
[subgraph1.id, subgraph1],
[subgraph2.id, subgraph2]
])
@@ -139,7 +139,7 @@ describe.skip('subgraphUtils', () => {
rootGraph.add(node2)
// Only register subgraph1
const registry = new Map<UUID, any>([[subgraph1.id, subgraph1]])
const registry = new Map<UUID, LGraph>([[subgraph1.id, subgraph1]])
const result = findUsedSubgraphIds(rootGraph, registry)
expect(result.size).toBe(2)

View File

@@ -37,6 +37,14 @@ export interface IWidgetOptions<TValues = unknown[]> {
getOptionLabel?: (value?: string | null) => string
callback?: IWidget['callback']
iconClass?: string
// Vue widget options
disabled?: boolean
useGrouping?: boolean
placeholder?: string
showThumbnails?: boolean
showItemNavigators?: boolean
hidden?: boolean
}
interface IWidgetSliderOptions extends IWidgetOptions<number[]> {

View File

@@ -35,11 +35,10 @@ export class ComboWidget
override get _displayValue() {
if (this.computedDisabled) return ''
if (this.options.getOptionLabel) {
const getOptionLabel = this.options.getOptionLabel
if (getOptionLabel) {
try {
return this.options.getOptionLabel(
this.value ? String(this.value) : null
)
return getOptionLabel(this.value ? String(this.value) : null)
} catch (e) {
console.error('Failed to map value:', e)
return this.value ? String(this.value) : ''
@@ -155,9 +154,12 @@ export class ComboWidget
}
const menu = new LiteGraph.ContextMenu([], menuOptions)
const getOptionLabel = this.options.getOptionLabel
for (const value of values_list) {
try {
const label = this.options.getOptionLabel(String(value))
const label = getOptionLabel
? getOptionLabel(String(value))
: String(value)
menu.addItem(label, value, menuOptions)
} catch (err) {
console.error('Failed to map value:', err)

View File

@@ -2018,6 +2018,20 @@
}
}
},
"CreateList": {
"display_name": "إنشاء قائمة",
"inputs": {
"inputs": {
"name": "المدخلات"
}
},
"outputs": {
"0": {
"name": "قائمة",
"tooltip": null
}
}
},
"CreateVideo": {
"description": "إنشاء فيديو من الصور.",
"display_name": "إنشاء فيديو",
@@ -13940,6 +13954,9 @@
"bpm": {
"name": "الإيقاع (BPM)"
},
"cfg_scale": {
"name": "cfg_scale"
},
"clip": {
"name": "clip"
},
@@ -13949,6 +13966,10 @@
"duration": {
"name": "المدة"
},
"generate_audio_codes": {
"name": "توليد رموز الصوت",
"tooltip": "تفعيل نموذج اللغة الكبير (LLM) الذي يولد رموز الصوت. قد يكون بطيئًا لكنه سيزيد من جودة الصوت الناتج. قم بإيقاف هذا الخيار إذا كنت تقدم مرجعًا صوتيًا للنموذج."
},
"keyscale": {
"name": "المقام الموسيقي"
},
@@ -13964,8 +13985,17 @@
"tags": {
"name": "الوسوم"
},
"temperature": {
"name": "درجة الحرارة"
},
"timesignature": {
"name": "توقيع الإيقاع"
},
"top_k": {
"name": "top_k"
},
"top_p": {
"name": "top_p"
}
},
"outputs": {
@@ -14900,6 +14930,28 @@
}
}
},
"VAEDecodeAudioTiled": {
"display_name": "فك ترميز الصوت بواسطة VAE (مجزأ)",
"inputs": {
"overlap": {
"name": "تداخل"
},
"samples": {
"name": "عينات"
},
"tile_size": {
"name": "حجم التجزئة"
},
"vae": {
"name": "vae"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"VAEDecodeHunyuan3D": {
"display_name": "فك ترميز VAE Hunyuan3D",
"inputs": {

View File

@@ -10,6 +10,7 @@
"download": "Download",
"downloadImage": "Download image",
"downloadVideo": "Download video",
"downloadAudio": "Download audio",
"editOrMaskImage": "Edit or mask image",
"editImage": "Edit image",
"decrement": "Decrement",

View File

@@ -2020,6 +2020,20 @@
}
}
},
"CreateList": {
"display_name": "Create List",
"inputs": {
"inputs": {
"name": "inputs"
}
},
"outputs": {
"0": {
"name": "list",
"tooltip": null
}
}
},
"CreateVideo": {
"display_name": "Create Video",
"description": "Create a video from images.",
@@ -11492,8 +11506,8 @@
}
},
"ReferenceTimbreAudio": {
"display_name": "ReferenceTimbreAudio",
"description": "This node sets the reference audio for timbre (for ace step 1.5)",
"display_name": "Reference Audio",
"description": "This node sets the reference audio for ace step 1.5",
"inputs": {
"conditioning": {
"name": "conditioning"
@@ -14057,6 +14071,22 @@
"keyscale": {
"name": "keyscale"
},
"generate_audio_codes": {
"name": "generate_audio_codes",
"tooltip": "Enable the LLM that generates audio codes. This can be slow but will increase the quality of the generated audio. Turn this off if you are giving the model an audio reference."
},
"cfg_scale": {
"name": "cfg_scale"
},
"temperature": {
"name": "temperature"
},
"top_p": {
"name": "top_p"
},
"top_k": {
"name": "top_k"
},
"control_after_generate": {
"name": "control after generate"
}
@@ -15060,6 +15090,28 @@
}
}
},
"VAEDecodeAudioTiled": {
"display_name": "VAE Decode Audio (Tiled)",
"inputs": {
"samples": {
"name": "samples"
},
"vae": {
"name": "vae"
},
"tile_size": {
"name": "tile_size"
},
"overlap": {
"name": "overlap"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"VAEDecodeHunyuan3D": {
"display_name": "VAEDecodeHunyuan3D",
"inputs": {

View File

@@ -2018,6 +2018,20 @@
}
}
},
"CreateList": {
"display_name": "Crear lista",
"inputs": {
"inputs": {
"name": "entradas"
}
},
"outputs": {
"0": {
"name": "lista",
"tooltip": null
}
}
},
"CreateVideo": {
"description": "Crea un video a partir de imágenes.",
"display_name": "Crear video",
@@ -6418,9 +6432,7 @@
"Load3D": {
"display_name": "Cargar 3D",
"inputs": {
"clear": {
"": "limpiar"
},
"clear": {},
"height": {
"name": "alto"
},
@@ -6431,10 +6443,10 @@
"name": "archivo_modelo"
},
"upload 3d model": {
"": "subir modelo 3D"
"es": "Subir modelo 3D"
},
"upload extra resources": {
"": "subir recursos adicionales"
"es": "Subir recursos adicionales"
},
"width": {
"name": "ancho"
@@ -13946,6 +13958,9 @@
"bpm": {
"name": "bpm"
},
"cfg_scale": {
"name": "cfg_scale"
},
"clip": {
"name": "clip"
},
@@ -13955,6 +13970,10 @@
"duration": {
"name": "duración"
},
"generate_audio_codes": {
"name": "generar_códigos_de_audio",
"tooltip": "Activa el LLM que genera códigos de audio. Esto puede ser lento, pero aumentará la calidad del audio generado. Desactívalo si proporcionas una referencia de audio al modelo."
},
"keyscale": {
"name": "escala tonal"
},
@@ -13970,8 +13989,17 @@
"tags": {
"name": "etiquetas"
},
"temperature": {
"name": "temperatura"
},
"timesignature": {
"name": "compás"
},
"top_k": {
"name": "top_k"
},
"top_p": {
"name": "top_p"
}
},
"outputs": {
@@ -14906,6 +14934,28 @@
}
}
},
"VAEDecodeAudioTiled": {
"display_name": "VAE Decodificar audio (en mosaico)",
"inputs": {
"overlap": {
"name": "superposición"
},
"samples": {
"name": "muestras"
},
"tile_size": {
"name": "tamaño_de_mosaico"
},
"vae": {
"name": "vae"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"VAEDecodeHunyuan3D": {
"display_name": "VAEDecodeHunyuan3D",
"inputs": {

View File

@@ -2020,6 +2020,20 @@
}
}
},
"CreateList": {
"display_name": "ایجاد لیست",
"inputs": {
"inputs": {
"name": "ورودی‌ها"
}
},
"outputs": {
"0": {
"name": "لیست",
"tooltip": null
}
}
},
"CreateVideo": {
"description": "ایجاد ویدیو از تصاویر.",
"display_name": "ایجاد ویدیو",
@@ -6427,7 +6441,9 @@
"Load3D": {
"display_name": "بارگذاری ۳بعدی و انیمیشن",
"inputs": {
"clear": {},
"clear": {
"": "پاک‌سازی"
},
"height": {
"name": "ارتفاع"
},
@@ -6437,8 +6453,12 @@
"model_file": {
"name": "فایل مدل"
},
"upload 3d model": {},
"upload extra resources": {},
"upload 3d model": {
"": "بارگذاری مدل سه‌بعدی"
},
"upload extra resources": {
"": "بارگذاری منابع اضافی"
},
"width": {
"name": "عرض"
}
@@ -13953,6 +13973,9 @@
"bpm": {
"name": "ضرب در دقیقه (BPM)"
},
"cfg_scale": {
"name": "cfg_scale"
},
"clip": {
"name": "clip"
},
@@ -13962,6 +13985,10 @@
"duration": {
"name": "مدت زمان"
},
"generate_audio_codes": {
"name": "تولید کدهای صوتی",
"tooltip": "فعال‌سازی LLM برای تولید کدهای صوتی. این کار ممکن است کند باشد اما کیفیت صدای تولیدشده را افزایش می‌دهد. اگر به مدل یک مرجع صوتی می‌دهید، این گزینه را غیرفعال کنید."
},
"keyscale": {
"name": "گام"
},
@@ -13977,8 +14004,17 @@
"tags": {
"name": "برچسب‌ها"
},
"temperature": {
"name": "temperature"
},
"timesignature": {
"name": "امضای زمانی"
},
"top_k": {
"name": "top_k"
},
"top_p": {
"name": "top_p"
}
},
"outputs": {
@@ -14913,6 +14949,28 @@
}
}
},
"VAEDecodeAudioTiled": {
"display_name": "رمزگشایی VAE صوتی (کاشی‌بندی‌شده)",
"inputs": {
"overlap": {
"name": "همپوشانی"
},
"samples": {
"name": "نمونه‌ها"
},
"tile_size": {
"name": "اندازه کاشی"
},
"vae": {
"name": "vae"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"VAEDecodeHunyuan3D": {
"display_name": "VAEDecodeHunyuan3D",
"inputs": {

View File

@@ -2018,6 +2018,20 @@
}
}
},
"CreateList": {
"display_name": "Créer une liste",
"inputs": {
"inputs": {
"name": "entrées"
}
},
"outputs": {
"0": {
"name": "liste",
"tooltip": null
}
}
},
"CreateVideo": {
"description": "Créer une vidéo à partir dimages.",
"display_name": "Créer une vidéo",
@@ -13946,6 +13960,9 @@
"bpm": {
"name": "bpm"
},
"cfg_scale": {
"name": "cfg_scale"
},
"clip": {
"name": "clip"
},
@@ -13955,6 +13972,10 @@
"duration": {
"name": "durée"
},
"generate_audio_codes": {
"name": "générer des codes audio",
"tooltip": "Activez le LLM qui génère des codes audio. Cela peut être lent mais augmentera la qualité de laudio généré. Désactivez cette option si vous fournissez une référence audio au modèle."
},
"keyscale": {
"name": "tonalité"
},
@@ -13970,8 +13991,17 @@
"tags": {
"name": "tags"
},
"temperature": {
"name": "température"
},
"timesignature": {
"name": "signature rythmique"
},
"top_k": {
"name": "top_k"
},
"top_p": {
"name": "top_p"
}
},
"outputs": {
@@ -14906,6 +14936,28 @@
}
}
},
"VAEDecodeAudioTiled": {
"display_name": "Décoder Audio VAE (par tuiles)",
"inputs": {
"overlap": {
"name": "chevauchement"
},
"samples": {
"name": "échantillons"
},
"tile_size": {
"name": "taille de tuile"
},
"vae": {
"name": "vae"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"VAEDecodeHunyuan3D": {
"display_name": "VAEDecodeHunyuan3D",
"inputs": {

View File

@@ -2018,6 +2018,20 @@
}
}
},
"CreateList": {
"display_name": "リストを作成",
"inputs": {
"inputs": {
"name": "入力"
}
},
"outputs": {
"0": {
"name": "リスト",
"tooltip": null
}
}
},
"CreateVideo": {
"description": "画像から動画を作成します。",
"display_name": "動画を作成",
@@ -6418,9 +6432,7 @@
"Load3D": {
"display_name": "3Dを読み込む",
"inputs": {
"clear": {
"": "クリア"
},
"clear": "クリア",
"height": {
"name": "高さ"
},
@@ -6430,12 +6442,8 @@
"model_file": {
"name": "モデルファイル"
},
"upload 3d model": {
"": "3Dモデルをアップロード"
},
"upload extra resources": {
"": "追加リソースをアップロード"
},
"upload 3d model": "3Dモデルをアップロード",
"upload extra resources": "追加リソースをアップロード",
"width": {
"name": "幅"
}
@@ -13946,6 +13954,9 @@
"bpm": {
"name": "BPM"
},
"cfg_scale": {
"name": "cfgスケール"
},
"clip": {
"name": "clip"
},
@@ -13955,6 +13966,10 @@
"duration": {
"name": "長さ"
},
"generate_audio_codes": {
"name": "オーディオコードを生成",
"tooltip": "オーディオコードを生成するLLMを有効にします。これにより生成されるオーディオの品質が向上しますが、処理が遅くなる場合があります。モデルにオーディオリファレンスを与える場合はオフにしてください。"
},
"keyscale": {
"name": "キー"
},
@@ -13970,8 +13985,17 @@
"tags": {
"name": "タグ"
},
"temperature": {
"name": "温度"
},
"timesignature": {
"name": "拍子"
},
"top_k": {
"name": "top_k"
},
"top_p": {
"name": "top_p"
}
},
"outputs": {
@@ -14906,6 +14930,28 @@
}
}
},
"VAEDecodeAudioTiled": {
"display_name": "VAEデコードオーディオタイル",
"inputs": {
"overlap": {
"name": "オーバーラップ"
},
"samples": {
"name": "サンプル"
},
"tile_size": {
"name": "タイルサイズ"
},
"vae": {
"name": "vae"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"VAEDecodeHunyuan3D": {
"display_name": "VAEDecodeHunyuan3D",
"inputs": {

View File

@@ -2018,6 +2018,20 @@
}
}
},
"CreateList": {
"display_name": "목록 만들기",
"inputs": {
"inputs": {
"name": "입력값"
}
},
"outputs": {
"0": {
"name": "목록",
"tooltip": null
}
}
},
"CreateVideo": {
"description": "이미지로부터 비디오를 생성합니다.",
"display_name": "비디오 생성",
@@ -6418,7 +6432,7 @@
"Load3D": {
"display_name": "3D 불러오기",
"inputs": {
"clear": {},
"clear": "지우기",
"height": {
"name": "높이"
},
@@ -6428,12 +6442,8 @@
"model_file": {
"name": "모델 파일"
},
"upload 3d model": {
"ko": "3D 모델 업로드"
},
"upload extra resources": {
"ko": "추가 리소스 업로드"
},
"upload 3d model": "3D 모델 업로드",
"upload extra resources": "추가 리소스 업로드",
"width": {
"name": "너비"
}
@@ -13944,6 +13954,9 @@
"bpm": {
"name": "BPM"
},
"cfg_scale": {
"name": "cfg_scale"
},
"clip": {
"name": "clip"
},
@@ -13953,6 +13966,10 @@
"duration": {
"name": "길이"
},
"generate_audio_codes": {
"name": "오디오 코드 생성",
"tooltip": "오디오 코드를 생성하는 LLM을 활성화합니다. 느릴 수 있지만 생성된 오디오의 품질이 향상됩니다. 모델에 오디오 참조를 제공하는 경우 이 옵션을 끄세요."
},
"keyscale": {
"name": "조성"
},
@@ -13968,8 +13985,17 @@
"tags": {
"name": "태그"
},
"temperature": {
"name": "temperature"
},
"timesignature": {
"name": "박자"
},
"top_k": {
"name": "top_k"
},
"top_p": {
"name": "top_p"
}
},
"outputs": {
@@ -14904,6 +14930,28 @@
}
}
},
"VAEDecodeAudioTiled": {
"display_name": "VAE 오디오 디코드 (타일)",
"inputs": {
"overlap": {
"name": "오버랩"
},
"samples": {
"name": "샘플"
},
"tile_size": {
"name": "타일 크기"
},
"vae": {
"name": "vae"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"VAEDecodeHunyuan3D": {
"display_name": "VAEDecodeHunyuan3D",
"inputs": {

View File

@@ -2020,6 +2020,20 @@
}
}
},
"CreateList": {
"display_name": "Criar Lista",
"inputs": {
"inputs": {
"name": "entradas"
}
},
"outputs": {
"0": {
"name": "lista",
"tooltip": null
}
}
},
"CreateVideo": {
"description": "Crie um vídeo a partir de imagens.",
"display_name": "Criar Vídeo",
@@ -6427,7 +6441,9 @@
"Load3D": {
"display_name": "Carregar 3D & Animação",
"inputs": {
"clear": {},
"clear": {
"": "limpar"
},
"height": {
"name": "altura"
},
@@ -6437,8 +6453,12 @@
"model_file": {
"name": "arquivo_do_modelo"
},
"upload 3d model": {},
"upload extra resources": {},
"upload 3d model": {
"": "enviar modelo 3D"
},
"upload extra resources": {
"": "enviar recursos extras"
},
"width": {
"name": "largura"
}
@@ -13953,6 +13973,9 @@
"bpm": {
"name": "bpm"
},
"cfg_scale": {
"name": "cfg_scale"
},
"clip": {
"name": "clip"
},
@@ -13962,6 +13985,10 @@
"duration": {
"name": "duração"
},
"generate_audio_codes": {
"name": "gerar_códigos_de_áudio",
"tooltip": "Ativa o LLM que gera códigos de áudio. Isso pode ser lento, mas aumentará a qualidade do áudio gerado. Desative se você fornecer uma referência de áudio ao modelo."
},
"keyscale": {
"name": "escala tonal"
},
@@ -13977,8 +14004,17 @@
"tags": {
"name": "tags"
},
"temperature": {
"name": "temperatura"
},
"timesignature": {
"name": "fórmula de compasso"
},
"top_k": {
"name": "top_k"
},
"top_p": {
"name": "top_p"
}
},
"outputs": {
@@ -14913,6 +14949,28 @@
}
}
},
"VAEDecodeAudioTiled": {
"display_name": "VAE Decodificar Áudio (Em Blocos)",
"inputs": {
"overlap": {
"name": "sobreposição"
},
"samples": {
"name": "amostras"
},
"tile_size": {
"name": "tamanho_do_bloco"
},
"vae": {
"name": "vae"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"VAEDecodeHunyuan3D": {
"display_name": "VAEDecodeHunyuan3D",
"inputs": {

View File

@@ -2018,6 +2018,20 @@
}
}
},
"CreateList": {
"display_name": "Создать список",
"inputs": {
"inputs": {
"name": "входные данные"
}
},
"outputs": {
"0": {
"name": "список",
"tooltip": null
}
}
},
"CreateVideo": {
"description": "Создайте видео из изображений.",
"display_name": "Создать видео",
@@ -6418,7 +6432,9 @@
"Load3D": {
"display_name": "Загрузить 3D",
"inputs": {
"clear": "Очистить",
"clear": {
"": "Очистить"
},
"height": {
"name": "высота"
},
@@ -6428,8 +6444,12 @@
"model_file": {
"name": "файл модели"
},
"upload 3d model": "Загрузить 3D-модель",
"upload extra resources": "Загрузить дополнительные ресурсы",
"upload 3d model": {
"": "Загрузить 3D-модель"
},
"upload extra resources": {
"": "Загрузить дополнительные ресурсы"
},
"width": {
"name": "ширина"
}
@@ -13940,6 +13960,9 @@
"bpm": {
"name": "bpm"
},
"cfg_scale": {
"name": "cfg_scale"
},
"clip": {
"name": "clip"
},
@@ -13949,6 +13972,10 @@
"duration": {
"name": "длительность"
},
"generate_audio_codes": {
"name": "генерировать аудиокоды",
"tooltip": "Включить LLM, который генерирует аудиокоды. Это может быть медленно, но повысит качество сгенерированного аудио. Отключите, если вы предоставляете модели аудиореференс."
},
"keyscale": {
"name": "тональность"
},
@@ -13964,8 +13991,17 @@
"tags": {
"name": "теги"
},
"temperature": {
"name": "температура"
},
"timesignature": {
"name": "размер"
},
"top_k": {
"name": "top_k"
},
"top_p": {
"name": "top_p"
}
},
"outputs": {
@@ -14900,6 +14936,28 @@
}
}
},
"VAEDecodeAudioTiled": {
"display_name": "VAE декодирование аудио (плитками)",
"inputs": {
"overlap": {
"name": "перекрытие"
},
"samples": {
"name": "образцы"
},
"tile_size": {
"name": "размер плитки"
},
"vae": {
"name": "vae"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"VAEDecodeHunyuan3D": {
"display_name": "VAEDecodeHunyuan3D",
"inputs": {

View File

@@ -2018,6 +2018,20 @@
}
}
},
"CreateList": {
"display_name": "Liste Oluştur",
"inputs": {
"inputs": {
"name": "girdiler"
}
},
"outputs": {
"0": {
"name": "liste",
"tooltip": null
}
}
},
"CreateVideo": {
"description": "Görüntülerden bir video oluşturun.",
"display_name": "Video Oluştur",
@@ -13946,6 +13960,9 @@
"bpm": {
"name": "bpm"
},
"cfg_scale": {
"name": "cfg ölçeği"
},
"clip": {
"name": "clip"
},
@@ -13955,6 +13972,10 @@
"duration": {
"name": "süre"
},
"generate_audio_codes": {
"name": "ses kodlarını üret",
"tooltip": "Ses kodlarını üreten LLM'i etkinleştir. Bu işlem yavaş olabilir ancak üretilen sesin kalitesini artırır. Eğer modele bir ses referansı veriyorsanız bunu kapatın."
},
"keyscale": {
"name": "ton anahtarı"
},
@@ -13970,8 +13991,17 @@
"tags": {
"name": "etiketler"
},
"temperature": {
"name": "sıcaklık"
},
"timesignature": {
"name": "zaman imzası"
},
"top_k": {
"name": "top_k"
},
"top_p": {
"name": "top_p"
}
},
"outputs": {
@@ -14906,6 +14936,28 @@
}
}
},
"VAEDecodeAudioTiled": {
"display_name": "VAE Sesini Çöz (Döşemeli)",
"inputs": {
"overlap": {
"name": "örtüşme"
},
"samples": {
"name": "örnekler"
},
"tile_size": {
"name": "döşeme boyutu"
},
"vae": {
"name": "vae"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"VAEDecodeHunyuan3D": {
"display_name": "VAEKodÇözmeHunyuan3D",
"inputs": {

View File

@@ -2018,6 +2018,20 @@
}
}
},
"CreateList": {
"display_name": "建立清單",
"inputs": {
"inputs": {
"name": "輸入"
}
},
"outputs": {
"0": {
"name": "清單",
"tooltip": null
}
}
},
"CreateVideo": {
"description": "從圖片建立影片。",
"display_name": "建立影片",
@@ -13944,6 +13958,9 @@
"bpm": {
"name": "每分鐘節拍數"
},
"cfg_scale": {
"name": "cfg_scale"
},
"clip": {
"name": "clip"
},
@@ -13953,6 +13970,10 @@
"duration": {
"name": "時長"
},
"generate_audio_codes": {
"name": "產生音訊編碼",
"tooltip": "啟用產生音訊編碼的 LLM。這可能會較慢但能提升產生音訊的品質。如果你已提供模型音訊參考請關閉此選項。"
},
"keyscale": {
"name": "調性"
},
@@ -13968,8 +13989,17 @@
"tags": {
"name": "標籤"
},
"temperature": {
"name": "temperature"
},
"timesignature": {
"name": "拍號"
},
"top_k": {
"name": "top_k"
},
"top_p": {
"name": "top_p"
}
},
"outputs": {
@@ -14904,6 +14934,28 @@
}
}
},
"VAEDecodeAudioTiled": {
"display_name": "VAE 解碼音訊(分塊)",
"inputs": {
"overlap": {
"name": "重疊"
},
"samples": {
"name": "樣本"
},
"tile_size": {
"name": "分塊大小"
},
"vae": {
"name": "vae"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"VAEDecodeHunyuan3D": {
"display_name": "VAE 解碼 Hunyuan3D",
"inputs": {

View File

@@ -2020,6 +2020,20 @@
}
}
},
"CreateList": {
"display_name": "创建列表",
"inputs": {
"inputs": {
"name": "输入"
}
},
"outputs": {
"0": {
"name": "列表",
"tooltip": null
}
}
},
"CreateVideo": {
"description": "从图像创建视频。",
"display_name": "创建视频",
@@ -13953,6 +13967,9 @@
"bpm": {
"name": "每分钟节拍数"
},
"cfg_scale": {
"name": "cfg_scale"
},
"clip": {
"name": "clip"
},
@@ -13962,6 +13979,10 @@
"duration": {
"name": "时长"
},
"generate_audio_codes": {
"name": "生成音频代码",
"tooltip": "启用生成音频代码的LLM。这可能较慢但会提升生成音频的质量。如果你为模型提供了音频参考请关闭此选项。"
},
"keyscale": {
"name": "调式"
},
@@ -13977,8 +13998,17 @@
"tags": {
"name": "标签"
},
"temperature": {
"name": "temperature"
},
"timesignature": {
"name": "拍号"
},
"top_k": {
"name": "top_k"
},
"top_p": {
"name": "top_p"
}
},
"outputs": {
@@ -14913,6 +14943,28 @@
}
}
},
"VAEDecodeAudioTiled": {
"display_name": "VAE解码音频分块",
"inputs": {
"overlap": {
"name": "重叠"
},
"samples": {
"name": "样本"
},
"tile_size": {
"name": "分块大小"
},
"vae": {
"name": "vae"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"VAEDecodeHunyuan3D": {
"display_name": "VAE解码Hunyuan3D",
"inputs": {

View File

@@ -65,15 +65,8 @@ export function useMediaAssetActions() {
try {
const filename = targetAsset.name
let downloadUrl: string
// In cloud, use preview_url directly (from cloud storage)
// In OSS/localhost, use the /view endpoint
if (isCloud && targetAsset.preview_url) {
downloadUrl = targetAsset.preview_url
} else {
downloadUrl = getAssetUrl(targetAsset)
}
// Prefer preview_url (already includes subfolder) with getAssetUrl as fallback
const downloadUrl = targetAsset.preview_url || getAssetUrl(targetAsset)
downloadFile(downloadUrl, filename)
@@ -103,15 +96,8 @@ export function useMediaAssetActions() {
try {
assets.forEach((asset) => {
const filename = asset.name
let downloadUrl: string
// In cloud, use preview_url directly (from GCS or other cloud storage)
// In OSS/localhost, use the /view endpoint
if (isCloud && asset.preview_url) {
downloadUrl = asset.preview_url
} else {
downloadUrl = getAssetUrl(asset)
}
// Prefer preview_url (already includes subfolder) with getAssetUrl as fallback
const downloadUrl = asset.preview_url || getAssetUrl(asset)
downloadFile(downloadUrl, filename)
})

View File

@@ -487,7 +487,7 @@ function createAssetService() {
url: string
name: string
tags?: string[]
user_metadata?: Record<string, any>
user_metadata?: Record<string, unknown>
preview_id?: string
}): Promise<AssetItem & { created_new: boolean }> {
const res = await api.fetchApi(ASSETS_ENDPOINT, {
@@ -525,7 +525,7 @@ function createAssetService() {
data: string
name: string
tags?: string[]
user_metadata?: Record<string, any>
user_metadata?: Record<string, unknown>
}): Promise<AssetItem & { created_new: boolean }> {
// Validate that data is a data URL
if (!params.data || !params.data.startsWith('data:')) {

View File

@@ -8,7 +8,7 @@ import { getAssetType } from './assetTypeUtil'
/**
* Get the download/view URL for an asset
* Constructs the proper URL with filename encoding and type parameter
* Constructs the proper URL with filename encoding, type, and subfolder parameters
*
* @param asset The asset to get URL for
* @param defaultType Default type if asset doesn't have tags (default: 'output')
@@ -23,7 +23,12 @@ export function getAssetUrl(
defaultType: 'input' | 'output' = 'output'
): string {
const assetType = getAssetType(asset, defaultType)
return api.apiURL(
`/view?filename=${encodeURIComponent(asset.name)}&type=${assetType}`
)
const subfolder = asset.user_metadata?.subfolder
const params = new URLSearchParams()
params.set('filename', asset.name)
params.set('type', assetType)
if (typeof subfolder === 'string' && subfolder) {
params.set('subfolder', subfolder)
}
return api.apiURL(`/view?${params}`)
}

View File

@@ -38,7 +38,7 @@ vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: vi.fn(() => ({
wrapWithErrorHandlingAsync: vi.fn(
(fn, errorHandler) =>
async (...args: any[]) => {
async (...args: Parameters<typeof fn>) => {
try {
return await fn(...args)
} catch (error) {

View File

@@ -22,7 +22,8 @@ function mockSettingStore(enabled: boolean) {
return enabled
}
return false
})
}),
load: vi.fn().mockResolvedValue(undefined)
})
}
@@ -227,6 +228,16 @@ describe('useNodeReplacementStore', () => {
consoleErrorSpy.mockRestore()
})
it('should not fetch when feature is disabled', async () => {
vi.mocked(fetchNodeReplacements).mockResolvedValue({})
store = createStore(false)
await store.load()
expect(fetchNodeReplacements).not.toHaveBeenCalled()
expect(store.isLoaded).toBe(false)
})
it('should not re-fetch when called twice', async () => {
vi.mocked(fetchNodeReplacements).mockResolvedValue(mockReplacements)
store = createStore()

View File

@@ -15,7 +15,7 @@ export const useNodeReplacementStore = defineStore('nodeReplacement', () => {
)
async function load() {
if (isLoaded.value) return
if (isLoaded.value || !isEnabled.value) return
try {
replacements.value = await fetchNodeReplacements()

View File

@@ -1198,7 +1198,7 @@ export const CORE_SETTINGS: SettingParams[] = [
tooltip:
'When enabled, missing nodes can be automatically replaced with their newer equivalents if a replacement mapping exists.',
type: 'boolean',
defaultValue: true,
defaultValue: false,
experimental: true,
versionAdded: '1.40.0'
}

View File

@@ -46,7 +46,7 @@ function onChange(
}
export const useSettingStore = defineStore('setting', () => {
const settingValues = ref<Record<string, any>>({})
const settingValues = ref<Partial<Settings>>({})
const settingsById = ref<Record<string, SettingParams>>({})
const {
@@ -87,7 +87,7 @@ export const useSettingStore = defineStore('setting', () => {
* @param key - The key of the setting to check.
* @returns Whether the setting exists.
*/
function exists(key: string) {
function exists<K extends keyof Settings>(key: K) {
return settingValues.value[key] !== undefined
}
@@ -118,7 +118,7 @@ export const useSettingStore = defineStore('setting', () => {
*/
function get<K extends keyof Settings>(key: K): Settings[K] {
// Clone the value when returning to prevent external mutations
return _.cloneDeep(settingValues.value[key] ?? getDefaultValue(key))
return _.cloneDeep(settingValues.value[key] ?? getDefaultValue(key)!)
}
/**

View File

@@ -222,7 +222,7 @@ interface WorkflowStore {
activeSubgraph: Subgraph | undefined
/** Updates the {@link subgraphNamePath} and {@link isSubgraphActive} values. */
updateActiveGraph: () => void
executionIdToCurrentId: (id: string) => any
executionIdToCurrentId: (id: string) => string | undefined
nodeIdToNodeLocatorId: (nodeId: NodeId, subgraph?: Subgraph) => NodeLocatorId
nodeToNodeLocatorId: (node: LGraphNode) => NodeLocatorId
nodeExecutionIdToNodeLocatorId: (
@@ -718,7 +718,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
}
//FIXME: use existing util function
const executionIdToCurrentId = (id: string) => {
const executionIdToCurrentId = (id: string): string | undefined => {
const subgraph = activeSubgraph.value
// Short-circuit: ID belongs to the parent workflow / no active subgraph

View File

@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as I18n from 'vue-i18n'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { WorkflowDraftSnapshot } from '@/platform/workflow/persistence/base/draftCache'
import { useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistence'
import { useWorkflowDraftStore } from '@/platform/workflow/persistence/stores/workflowDraftStore'
import { defaultGraphJSON } from '@/scripts/defaultGraph'
@@ -191,7 +192,7 @@ describe('useWorkflowPersistence', () => {
const drafts = JSON.parse(
localStorage.getItem('Comfy.Workflow.Drafts') ?? '{}'
) as Record<string, any>
) as Record<string, WorkflowDraftSnapshot>
expect(Object.keys(drafts).length).toBe(32)
expect(drafts['workflows/Draft0.json']).toBeUndefined()

View File

@@ -31,6 +31,7 @@
@click="onClick"
@dblclick="onDoubleClick"
@pointerdown="onPointerDown"
@contextmenu.stop.prevent="onSlotContextMenu"
/>
<!-- Slot Name -->
@@ -43,6 +44,7 @@
hasSlotError && 'text-error font-medium'
)
"
@contextmenu.stop.prevent="onLabelContextMenu"
>
{{
slotData.label ||
@@ -65,6 +67,10 @@ import { useSlotLinkDragUIState } from '@/renderer/core/canvas/links/slotLinkDra
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import {
showSlotMenu,
showSlotLabelMenu
} from '@/renderer/extensions/vueNodes/composables/useSlotContextMenu'
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
import { useExecutionStore } from '@/stores/executionStore'
import { cn } from '@/utils/tailwindUtil'
@@ -147,4 +153,22 @@ const { onClick, onDoubleClick, onPointerDown } = useSlotLinkInteraction({
index: props.index,
type: 'input'
})
function onSlotContextMenu(event: MouseEvent) {
if (!props.nodeId) return
showSlotMenu(event, {
nodeId: props.nodeId,
slotIndex: props.index,
isInput: true
})
}
function onLabelContextMenu(event: MouseEvent) {
if (!props.nodeId) return
showSlotLabelMenu(event, {
nodeId: props.nodeId,
slotIndex: props.index,
isInput: true
})
}
</script>

View File

@@ -184,7 +184,7 @@ describe('LGraphNode', () => {
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
expect(wrapper.classes()).toContain('outline-2')
expect(wrapper.classes()).toContain('outline-3')
expect(wrapper.classes()).toContain('outline-node-component-outline')
})

View File

@@ -17,7 +17,7 @@
// hover (only when node should handle events)
shouldHandleNodePointerEvents &&
'hover:ring-7 ring-node-component-ring',
'outline-transparent outline-2 focus-visible:outline-node-component-outline',
'outline-transparent outline-3 focus-visible:outline-node-component-outline',
borderClass,
outlineClass,
cursorClass,

View File

@@ -36,6 +36,7 @@ import type {
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import { RenderShape } from '@/lib/litegraph/src/litegraph'
import NodeHeader from '@/renderer/extensions/vueNodes/components/NodeHeader.vue'
import NodeSlots from '@/renderer/extensions/vueNodes/components/NodeSlots.vue'
@@ -69,8 +70,11 @@ const nodeData = computed<VueNodeData>(() => {
options: {
hidden: input.hidden,
advanced: input.advanced,
values: input.type === 'COMBO' ? input.options : undefined // For combo widgets
}
values:
input.type === 'COMBO' && Array.isArray(input.options)
? input.options
: undefined
} satisfies IWidgetOptions
}))
const inputs: INodeInputSlot[] = Object.entries(nodeDef.inputs || {})

View File

@@ -3,7 +3,11 @@
<div v-else v-tooltip.right="tooltipConfig" :class="slotWrapperClass">
<div class="relative h-full flex items-center min-w-0">
<!-- Slot Name -->
<span v-if="!dotOnly" class="truncate text-node-component-slot-text">
<span
v-if="!dotOnly"
class="truncate text-node-component-slot-text"
@contextmenu.stop.prevent="onLabelContextMenu"
>
{{ slotData.localized_name || slotData.name || `Output ${index}` }}
</span>
</div>
@@ -13,6 +17,7 @@
class="w-3 translate-x-1/2"
:slot-data
@pointerdown="onPointerDown"
@contextmenu.stop.prevent="onSlotContextMenu"
/>
</div>
</template>
@@ -26,6 +31,10 @@ import type { INodeSlot } from '@/lib/litegraph/src/litegraph'
import { useSlotLinkDragUIState } from '@/renderer/core/canvas/links/slotLinkDragUIState'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import {
showSlotMenu,
showSlotLabelMenu
} from '@/renderer/extensions/vueNodes/composables/useSlotContextMenu'
import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
import { cn } from '@/utils/tailwindUtil'
@@ -112,4 +121,22 @@ const { onPointerDown } = useSlotLinkInteraction({
index: props.index,
type: 'output'
})
function onSlotContextMenu(event: MouseEvent) {
if (!props.nodeId) return
showSlotMenu(event, {
nodeId: props.nodeId,
slotIndex: props.index,
isInput: false
})
}
function onLabelContextMenu(event: MouseEvent) {
if (!props.nodeId) return
showSlotLabelMenu(event, {
nodeId: props.nodeId,
slotIndex: props.index,
isInput: false
})
}
</script>

View File

@@ -0,0 +1,149 @@
<template>
<ContextMenu
ref="contextMenu"
:model="menuItems"
class="max-h-[80vh] overflow-y-auto"
@show="onMenuShow"
@hide="onMenuHide"
>
<template #item="{ item, props: itemProps }">
<a v-bind="itemProps.action" class="flex items-center gap-2 px-3 py-1.5">
<span class="flex-1">{{ item.label }}</span>
</a>
</template>
</ContextMenu>
</template>
<script setup lang="ts">
import { useElementBounding, useRafFn } from '@vueuse/core'
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem } from 'primevue/menuitem'
import { computed, onMounted, onUnmounted, ref, watchEffect } from 'vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import {
connectSlots,
findCompatibleTargets,
registerSlotMenuInstance
} from '@/renderer/extensions/vueNodes/composables/useSlotContextMenu'
import type { SlotMenuContext } from '@/renderer/extensions/vueNodes/composables/useSlotContextMenu'
const contextMenu = ref<InstanceType<typeof ContextMenu>>()
const isOpen = ref(false)
const activeContext = ref<SlotMenuContext | null>(null)
const canvasStore = useCanvasStore()
const lgCanvas = canvasStore.getCanvas()
const { left: canvasLeft, top: canvasTop } = useElementBounding(lgCanvas.canvas)
const worldPosition = ref({ x: 0, y: 0 })
let lastScale = 0
let lastOffsetX = 0
let lastOffsetY = 0
function updateMenuPosition() {
if (!isOpen.value) return
const menuInstance = contextMenu.value as unknown as {
container?: HTMLElement
}
const menuEl = menuInstance?.container
if (!menuEl) return
const { scale, offset } = lgCanvas.ds
if (
scale === lastScale &&
offset[0] === lastOffsetX &&
offset[1] === lastOffsetY
) {
return
}
lastScale = scale
lastOffsetX = offset[0]
lastOffsetY = offset[1]
const screenX = (worldPosition.value.x + offset[0]) * scale + canvasLeft.value
const screenY = (worldPosition.value.y + offset[1]) * scale + canvasTop.value
menuEl.style.left = `${screenX}px`
menuEl.style.top = `${screenY}px`
}
const { resume: startSync, pause: stopSync } = useRafFn(updateMenuPosition, {
immediate: false
})
watchEffect(() => {
if (isOpen.value) {
startSync()
} else {
stopSync()
}
})
const menuItems = computed<MenuItem[]>(() => {
const ctx = activeContext.value
if (!ctx) return []
const targets = findCompatibleTargets(ctx)
if (targets.length === 0) {
return [{ label: 'No compatible nodes', disabled: true }]
}
return [
{ label: 'Connect to...', disabled: true },
{ separator: true },
...targets.map((target) => ({
label: `${target.slotInfo.name} @ ${target.node.title || target.node.type}`,
command: () => {
connectSlots(ctx, target)
hide()
}
}))
]
})
function show(event: MouseEvent, context: SlotMenuContext) {
activeContext.value = context
const screenX = event.clientX - canvasLeft.value
const screenY = event.clientY - canvasTop.value
const { scale, offset } = lgCanvas.ds
worldPosition.value = {
x: screenX / scale - offset[0],
y: screenY / scale - offset[1]
}
lastScale = scale
lastOffsetX = offset[0]
lastOffsetY = offset[1]
isOpen.value = true
contextMenu.value?.show(event)
}
function hide() {
contextMenu.value?.hide()
}
function onMenuShow() {
isOpen.value = true
}
function onMenuHide() {
isOpen.value = false
activeContext.value = null
}
defineExpose({ show, hide, isOpen })
onMounted(() => {
registerSlotMenuInstance({ show, hide, isOpen })
})
onUnmounted(() => {
registerSlotMenuInstance(null)
})
</script>

View File

@@ -0,0 +1,165 @@
<template>
<ContextMenu
ref="contextMenu"
:model="menuItems"
class="max-h-[80vh] overflow-y-auto"
@show="onMenuShow"
@hide="onMenuHide"
>
<template #item="{ item, props: itemProps }">
<a v-bind="itemProps.action" class="flex items-center gap-2 px-3 py-1.5">
<span class="flex-1">{{ item.label }}</span>
</a>
</template>
</ContextMenu>
</template>
<script setup lang="ts">
import { useElementBounding, useRafFn } from '@vueuse/core'
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem } from 'primevue/menuitem'
import { computed, onMounted, onUnmounted, ref, watchEffect } from 'vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import {
canRenameSlot,
disconnectSlotLinks,
hasConnectedLinks,
registerSlotLabelMenuInstance,
renameSlot
} from '@/renderer/extensions/vueNodes/composables/useSlotContextMenu'
import type { SlotMenuContext } from '@/renderer/extensions/vueNodes/composables/useSlotContextMenu'
const contextMenu = ref<InstanceType<typeof ContextMenu>>()
const isOpen = ref(false)
const activeContext = ref<SlotMenuContext | null>(null)
const canvasStore = useCanvasStore()
const lgCanvas = canvasStore.getCanvas()
const { left: canvasLeft, top: canvasTop } = useElementBounding(lgCanvas.canvas)
const worldPosition = ref({ x: 0, y: 0 })
let lastScale = 0
let lastOffsetX = 0
let lastOffsetY = 0
function updateMenuPosition() {
if (!isOpen.value) return
const menuInstance = contextMenu.value as unknown as {
container?: HTMLElement
}
const menuEl = menuInstance?.container
if (!menuEl) return
const { scale, offset } = lgCanvas.ds
if (
scale === lastScale &&
offset[0] === lastOffsetX &&
offset[1] === lastOffsetY
) {
return
}
lastScale = scale
lastOffsetX = offset[0]
lastOffsetY = offset[1]
const screenX = (worldPosition.value.x + offset[0]) * scale + canvasLeft.value
const screenY = (worldPosition.value.y + offset[1]) * scale + canvasTop.value
menuEl.style.left = `${screenX}px`
menuEl.style.top = `${screenY}px`
}
const { resume: startSync, pause: stopSync } = useRafFn(updateMenuPosition, {
immediate: false
})
watchEffect(() => {
if (isOpen.value) {
startSync()
} else {
stopSync()
}
})
const menuItems = computed<MenuItem[]>(() => {
const ctx = activeContext.value
if (!ctx) return []
const items: MenuItem[] = []
if (canRenameSlot(ctx)) {
items.push({
label: 'Rename slot',
command: () => {
const newName = window.prompt('New name for slot:')
if (newName != null) {
renameSlot(ctx, newName)
}
hide()
}
})
}
if (hasConnectedLinks(ctx)) {
items.push({
label: 'Disconnect links',
command: () => {
disconnectSlotLinks(ctx)
hide()
}
})
}
if (items.length === 0) {
return [{ label: 'No actions available', disabled: true }]
}
return items
})
function show(event: MouseEvent, context: SlotMenuContext) {
activeContext.value = context
const screenX = event.clientX - canvasLeft.value
const screenY = event.clientY - canvasTop.value
const { scale, offset } = lgCanvas.ds
worldPosition.value = {
x: screenX / scale - offset[0],
y: screenY / scale - offset[1]
}
lastScale = scale
lastOffsetX = offset[0]
lastOffsetY = offset[1]
isOpen.value = true
contextMenu.value?.show(event)
}
function hide() {
contextMenu.value?.hide()
}
function onMenuShow() {
isOpen.value = true
}
function onMenuHide() {
isOpen.value = false
activeContext.value = null
}
defineExpose({ show, hide, isOpen })
onMounted(() => {
registerSlotLabelMenuInstance({ show, hide, isOpen })
})
onUnmounted(() => {
registerSlotLabelMenuInstance(null)
})
</script>

View File

@@ -0,0 +1,179 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { mockDs, mockSetDirty } = vi.hoisted(() => {
const mockDs = { offset: [0, 0] as number[], scale: 1 }
const mockSetDirty = vi.fn()
return { mockDs, mockSetDirty }
})
vi.mock('@/scripts/app', () => ({
app: {
canvas: {
canvas: {
getBoundingClientRect: () => ({
left: 0,
right: 800,
top: 0,
bottom: 600,
width: 800,
height: 600
})
},
ds: mockDs,
setDirty: mockSetDirty
}
}
}))
import { useAutoPan } from './useAutoPan'
describe('useAutoPan', () => {
let rafCallbacks: Array<(timestamp: number) => void>
beforeEach(() => {
vi.clearAllMocks()
mockDs.offset = [0, 0]
mockDs.scale = 1
rafCallbacks = []
vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((cb) => {
rafCallbacks.push(cb as (timestamp: number) => void)
return rafCallbacks.length
})
vi.spyOn(globalThis, 'cancelAnimationFrame').mockImplementation(() => {})
vi.spyOn(performance, 'now').mockReturnValue(0)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('does not start panning when pointer is in the center', () => {
const onPan = vi.fn()
const { updatePointer } = useAutoPan(onPan)
updatePointer(400, 300)
expect(rafCallbacks).toHaveLength(0)
})
it('starts panning when pointer enters left edge zone', () => {
const onPan = vi.fn()
const { updatePointer } = useAutoPan(onPan)
updatePointer(10, 300)
expect(rafCallbacks).toHaveLength(1)
})
it('starts panning when pointer enters right edge zone', () => {
const onPan = vi.fn()
const { updatePointer } = useAutoPan(onPan)
updatePointer(790, 300)
expect(rafCallbacks).toHaveLength(1)
})
it('starts panning when pointer enters top edge zone', () => {
const onPan = vi.fn()
const { updatePointer } = useAutoPan(onPan)
updatePointer(400, 10)
expect(rafCallbacks).toHaveLength(1)
})
it('starts panning when pointer enters bottom edge zone', () => {
const onPan = vi.fn()
const { updatePointer } = useAutoPan(onPan)
updatePointer(400, 590)
expect(rafCallbacks).toHaveLength(1)
})
it('stops panning when stop() is called', () => {
const onPan = vi.fn()
const { updatePointer, stop } = useAutoPan(onPan)
updatePointer(10, 300)
expect(rafCallbacks).toHaveLength(1)
stop()
rafCallbacks[0](100)
expect(onPan).not.toHaveBeenCalled()
})
it('calls onPan callback with canvas-space deltas', () => {
const onPan = vi.fn()
const { updatePointer } = useAutoPan(onPan)
updatePointer(10, 300)
expect(rafCallbacks).toHaveLength(1)
rafCallbacks[0](100)
expect(onPan).toHaveBeenCalledTimes(1)
const [dx, dy] = onPan.mock.calls[0]
expect(dx).toBeGreaterThan(0)
expect(dy).toBe(0)
})
it('modifies ds.offset when panning', () => {
const onPan = vi.fn()
const { updatePointer } = useAutoPan(onPan)
updatePointer(10, 300)
rafCallbacks[0](100)
expect(mockDs.offset[0]).toBeGreaterThan(0)
expect(mockDs.offset[1]).toBe(0)
})
it('speed scales with proximity to edge', () => {
const onPanClose = vi.fn()
const controlsClose = useAutoPan(onPanClose)
controlsClose.updatePointer(5, 300)
rafCallbacks[0](100)
controlsClose.stop()
const dxClose = onPanClose.mock.calls[0][0]
mockDs.offset = [0, 0]
rafCallbacks = []
const onPanFar = vi.fn()
const controlsFar = useAutoPan(onPanFar)
controlsFar.updatePointer(40, 300)
rafCallbacks[0](100)
controlsFar.stop()
const dxFar = onPanFar.mock.calls[0][0]
expect(Math.abs(dxClose)).toBeGreaterThan(Math.abs(dxFar))
})
it('marks canvas as dirty when panning', () => {
const onPan = vi.fn()
const { updatePointer } = useAutoPan(onPan)
updatePointer(10, 300)
rafCallbacks[0](100)
expect(mockSetDirty).toHaveBeenCalledWith(true, true)
})
it('does not call onPan when velocity is zero', () => {
const onPan = vi.fn()
const { updatePointer } = useAutoPan(onPan)
updatePointer(10, 300)
updatePointer(400, 300)
rafCallbacks[0](100)
expect(onPan).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,116 @@
import { app } from '@/scripts/app'
const EDGE_PX = 48
const MAX_SPEED = 900
interface AutoPanState {
active: boolean
rafId: number | null
lastTime: number
velocityX: number
velocityY: number
lastClientX: number
lastClientY: number
}
interface AutoPanControls {
updatePointer: (clientX: number, clientY: number) => void
stop: () => void
}
export function useAutoPan(
onPan: (dxCanvas: number, dyCanvas: number) => void
): AutoPanControls {
const state: AutoPanState = {
active: false,
rafId: null,
lastTime: 0,
velocityX: 0,
velocityY: 0,
lastClientX: 0,
lastClientY: 0
}
function computeVelocity(clientX: number, clientY: number): [number, number] {
const canvas = app.canvas?.canvas
if (!canvas) return [0, 0]
const rect = canvas.getBoundingClientRect()
let vx = 0
let vy = 0
const distLeft = clientX - rect.left
const distRight = rect.right - clientX
const distTop = clientY - rect.top
const distBottom = rect.bottom - clientY
if (distLeft < EDGE_PX) vx = ((EDGE_PX - distLeft) / EDGE_PX) * MAX_SPEED
else if (distRight < EDGE_PX)
vx = -(((EDGE_PX - distRight) / EDGE_PX) * MAX_SPEED)
if (distTop < EDGE_PX) vy = ((EDGE_PX - distTop) / EDGE_PX) * MAX_SPEED
else if (distBottom < EDGE_PX)
vy = -(((EDGE_PX - distBottom) / EDGE_PX) * MAX_SPEED)
return [vx, vy]
}
function tick(timestamp: number): void {
if (!state.active) return
const [vx, vy] = computeVelocity(state.lastClientX, state.lastClientY)
state.velocityX = vx
state.velocityY = vy
if (vx === 0 && vy === 0) {
state.rafId = requestAnimationFrame(tick)
return
}
const ds = app.canvas?.ds
if (!ds) {
stop()
return
}
const dt = Math.min((timestamp - state.lastTime) / 1000, 0.1)
state.lastTime = timestamp
const dxCanvas = (vx * dt) / ds.scale
const dyCanvas = (vy * dt) / ds.scale
ds.offset[0] += dxCanvas
ds.offset[1] += dyCanvas
app.canvas?.setDirty(true, true)
onPan(dxCanvas, dyCanvas)
state.rafId = requestAnimationFrame(tick)
}
function updatePointer(clientX: number, clientY: number): void {
state.lastClientX = clientX
state.lastClientY = clientY
if (!state.active) {
const [vx, vy] = computeVelocity(clientX, clientY)
if (vx !== 0 || vy !== 0) {
state.active = true
state.lastTime = performance.now()
state.rafId = requestAnimationFrame(tick)
}
}
}
function stop(): void {
state.active = false
if (state.rafId !== null) {
cancelAnimationFrame(state.rafId)
state.rafId = null
}
state.velocityX = 0
state.velocityY = 0
}
return { updatePointer, stop }
}

View File

@@ -0,0 +1,376 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
const { mockGraph, mockCanvas } = vi.hoisted(() => {
const mockGraph = {
_nodes: [] as any[],
getNodeById: vi.fn(),
beforeChange: vi.fn(),
afterChange: vi.fn()
}
const mockCanvas = {
graph: mockGraph as any,
setDirty: vi.fn()
}
return { mockGraph, mockCanvas }
})
vi.mock('@/scripts/app', () => ({
app: { canvas: mockCanvas }
}))
vi.mock('@/lib/litegraph/src/litegraph', () => ({
LiteGraph: {
isValidConnection: vi.fn((a: unknown, b: unknown) => a === b)
}
}))
import { connectSlots, findCompatibleTargets } from './useSlotContextMenu'
function createMockNode(overrides: Record<string, unknown> = {}) {
return {
id: overrides.id ?? '1',
pos: overrides.pos ?? [0, 0],
title: overrides.title ?? 'TestNode',
type: overrides.type ?? 'TestType',
mode: overrides.mode ?? LGraphEventMode.ALWAYS,
inputs: overrides.inputs ?? [],
outputs: overrides.outputs ?? [],
connect: vi.fn(),
...overrides
} as unknown as LGraphNode & { connect: ReturnType<typeof vi.fn> }
}
describe('findCompatibleTargets', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGraph._nodes = []
mockCanvas.graph = mockGraph
})
it('returns empty array when graph is null', () => {
mockCanvas.graph = null as any
const result = findCompatibleTargets({
nodeId: '1',
slotIndex: 0,
isInput: true
})
expect(result).toEqual([])
})
it('returns empty array when source node is not found', () => {
mockGraph.getNodeById.mockReturnValue(null)
const result = findCompatibleTargets({
nodeId: '99',
slotIndex: 0,
isInput: true
})
expect(result).toEqual([])
})
it('returns empty array when source slot has wildcard type "*"', () => {
const source = createMockNode({
id: '1',
inputs: [{ type: '*', link: null }]
})
mockGraph.getNodeById.mockReturnValue(source)
mockGraph._nodes = [source]
const result = findCompatibleTargets({
nodeId: '1',
slotIndex: 0,
isInput: true
})
expect(result).toEqual([])
})
it('returns empty array when source slot has wildcard type ""', () => {
const source = createMockNode({
id: '1',
outputs: [{ type: '', links: [] }]
})
mockGraph.getNodeById.mockReturnValue(source)
mockGraph._nodes = [source]
const result = findCompatibleTargets({
nodeId: '1',
slotIndex: 0,
isInput: false
})
expect(result).toEqual([])
})
it('returns empty array when source slot has wildcard type 0', () => {
const source = createMockNode({
id: '1',
outputs: [{ type: 0, links: [] }]
})
mockGraph.getNodeById.mockReturnValue(source)
mockGraph._nodes = [source]
const result = findCompatibleTargets({
nodeId: '1',
slotIndex: 0,
isInput: false
})
expect(result).toEqual([])
})
it('finds compatible output nodes when source is input', () => {
const source = createMockNode({
id: '1',
inputs: [{ type: 'IMAGE', link: null }]
})
const candidate = createMockNode({
id: '2',
outputs: [{ type: 'IMAGE', links: [] }]
})
mockGraph.getNodeById.mockReturnValue(source)
mockGraph._nodes = [source, candidate]
const result = findCompatibleTargets({
nodeId: '1',
slotIndex: 0,
isInput: true
})
expect(result).toHaveLength(1)
expect(result[0].node).toBe(candidate)
expect(result[0].slotIndex).toBe(0)
})
it('finds compatible input nodes when source is output', () => {
const source = createMockNode({
id: '1',
outputs: [{ type: 'MODEL', links: [] }]
})
const candidate = createMockNode({
id: '2',
inputs: [{ type: 'MODEL', link: null }]
})
mockGraph.getNodeById.mockReturnValue(source)
mockGraph._nodes = [source, candidate]
const result = findCompatibleTargets({
nodeId: '1',
slotIndex: 0,
isInput: false
})
expect(result).toHaveLength(1)
expect(result[0].node).toBe(candidate)
expect(result[0].slotIndex).toBe(0)
})
it('skips bypassed nodes', () => {
const source = createMockNode({
id: '1',
inputs: [{ type: 'IMAGE', link: null }]
})
const bypassed = createMockNode({
id: '2',
mode: LGraphEventMode.NEVER,
outputs: [{ type: 'IMAGE', links: [] }]
})
mockGraph.getNodeById.mockReturnValue(source)
mockGraph._nodes = [source, bypassed]
const result = findCompatibleTargets({
nodeId: '1',
slotIndex: 0,
isInput: true
})
expect(result).toEqual([])
})
it('skips already-connected inputs', () => {
const source = createMockNode({
id: '1',
outputs: [{ type: 'MODEL', links: [] }]
})
const connected = createMockNode({
id: '2',
inputs: [{ type: 'MODEL', link: 42 }]
})
mockGraph.getNodeById.mockReturnValue(source)
mockGraph._nodes = [source, connected]
const result = findCompatibleTargets({
nodeId: '1',
slotIndex: 0,
isInput: false
})
expect(result).toEqual([])
})
it('skips wildcard-typed candidate slots', () => {
const source = createMockNode({
id: '1',
inputs: [{ type: 'IMAGE', link: null }]
})
const wildcardCandidate = createMockNode({
id: '2',
outputs: [{ type: '*', links: [] }]
})
mockGraph.getNodeById.mockReturnValue(source)
mockGraph._nodes = [source, wildcardCandidate]
const result = findCompatibleTargets({
nodeId: '1',
slotIndex: 0,
isInput: true
})
expect(result).toEqual([])
})
it('sorts results by node Y position', () => {
const source = createMockNode({
id: '1',
inputs: [{ type: 'IMAGE', link: null }]
})
const nodeHigh = createMockNode({
id: '2',
pos: [0, 300],
outputs: [{ type: 'IMAGE', links: [] }]
})
const nodeLow = createMockNode({
id: '3',
pos: [0, 100],
outputs: [{ type: 'IMAGE', links: [] }]
})
mockGraph.getNodeById.mockReturnValue(source)
mockGraph._nodes = [source, nodeHigh, nodeLow]
const result = findCompatibleTargets({
nodeId: '1',
slotIndex: 0,
isInput: true
})
expect(result).toHaveLength(2)
expect(result[0].node).toBe(nodeLow)
expect(result[1].node).toBe(nodeHigh)
})
it('limits results to maxResults', () => {
const source = createMockNode({
id: '1',
inputs: [{ type: 'IMAGE', link: null }]
})
const candidates = Array.from({ length: 5 }, (_, i) =>
createMockNode({
id: String(i + 2),
pos: [0, i * 100],
outputs: [{ type: 'IMAGE', links: [] }]
})
)
mockGraph.getNodeById.mockReturnValue(source)
mockGraph._nodes = [source, ...candidates]
const result = findCompatibleTargets(
{ nodeId: '1', slotIndex: 0, isInput: true },
3
)
expect(result).toHaveLength(3)
})
it('does not include the source node itself', () => {
const source = createMockNode({
id: '1',
inputs: [{ type: 'IMAGE', link: null }],
outputs: [{ type: 'IMAGE', links: [] }]
})
mockGraph.getNodeById.mockReturnValue(source)
mockGraph._nodes = [source]
const result = findCompatibleTargets({
nodeId: '1',
slotIndex: 0,
isInput: true
})
expect(result).toEqual([])
})
})
describe('connectSlots', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCanvas.graph = mockGraph
})
it('calls graph.beforeChange and afterChange', () => {
const source = createMockNode({ id: '1', outputs: [{ type: 'MODEL' }] })
const target = createMockNode({
id: '2',
inputs: [{ type: 'MODEL', link: null }]
})
mockGraph.getNodeById.mockReturnValue(source)
connectSlots(
{ nodeId: '1', slotIndex: 0, isInput: false },
{ node: target, slotIndex: 0, slotInfo: target.inputs[0] }
)
expect(mockGraph.beforeChange).toHaveBeenCalled()
expect(mockGraph.afterChange).toHaveBeenCalled()
})
it('connects source output to target input', () => {
const source = createMockNode({ id: '1', outputs: [{ type: 'MODEL' }] })
const target = createMockNode({
id: '2',
inputs: [{ type: 'MODEL', link: null }]
})
mockGraph.getNodeById.mockReturnValue(source)
connectSlots(
{ nodeId: '1', slotIndex: 0, isInput: false },
{ node: target, slotIndex: 0, slotInfo: target.inputs[0] }
)
expect(source.connect).toHaveBeenCalledWith(0, target, 0)
})
it('connects target output to source input when source is input', () => {
const source = createMockNode({
id: '1',
inputs: [{ type: 'IMAGE', link: null }]
})
const target = createMockNode({ id: '2', outputs: [{ type: 'IMAGE' }] })
mockGraph.getNodeById.mockReturnValue(source)
connectSlots(
{ nodeId: '1', slotIndex: 0, isInput: true },
{ node: target, slotIndex: 0, slotInfo: target.outputs[0] }
)
expect(target.connect).toHaveBeenCalledWith(0, source, 0)
})
it('does nothing when graph is null', () => {
mockCanvas.graph = null as any
const target = createMockNode({ id: '2' })
connectSlots(
{ nodeId: '1', slotIndex: 0, isInput: false },
{ node: target, slotIndex: 0, slotInfo: {} as any }
)
expect(target.connect).not.toHaveBeenCalled()
})
it('marks canvas as dirty after connecting', () => {
const source = createMockNode({ id: '1', outputs: [{ type: 'MODEL' }] })
const target = createMockNode({
id: '2',
inputs: [{ type: 'MODEL', link: null }]
})
mockGraph.getNodeById.mockReturnValue(source)
connectSlots(
{ nodeId: '1', slotIndex: 0, isInput: false },
{ node: target, slotIndex: 0, slotInfo: target.inputs[0] }
)
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
})
})

View File

@@ -0,0 +1,203 @@
import type { Ref } from 'vue'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type {
INodeInputSlot,
INodeOutputSlot,
IWidgetInputSlot
} from '@/lib/litegraph/src/interfaces'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { app } from '@/scripts/app'
interface SlotMenuContext {
nodeId: NodeId
slotIndex: number
isInput: boolean
}
interface CompatibleTarget {
node: LGraphNode
slotIndex: number
slotInfo: INodeInputSlot | INodeOutputSlot
}
interface SlotMenuInstance {
show: (event: MouseEvent, context: SlotMenuContext) => void
hide: () => void
isOpen: Ref<boolean>
}
let slotMenuInstance: SlotMenuInstance | null = null
export function registerSlotMenuInstance(
instance: SlotMenuInstance | null
): void {
slotMenuInstance = instance
}
export function showSlotMenu(
event: MouseEvent,
context: SlotMenuContext
): void {
slotMenuInstance?.show(event, context)
}
function isWildcardType(type: unknown): boolean {
return type === '*' || type === '' || type === 0
}
export function findCompatibleTargets(
context: SlotMenuContext,
maxResults: number = 15
): CompatibleTarget[] {
const graph = app.canvas?.graph
if (!graph) return []
const sourceNode = graph.getNodeById(context.nodeId)
if (!sourceNode) return []
const sourceSlot = context.isInput
? sourceNode.inputs?.[context.slotIndex]
: sourceNode.outputs?.[context.slotIndex]
if (!sourceSlot) return []
if (isWildcardType(sourceSlot.type)) return []
const results: CompatibleTarget[] = []
for (const candidate of graph._nodes) {
if (candidate.id === sourceNode.id) continue
if (candidate.mode === LGraphEventMode.NEVER) continue
if (context.isInput) {
if (!candidate.outputs) continue
for (let i = 0; i < candidate.outputs.length; i++) {
const output = candidate.outputs[i]
if (isWildcardType(output.type)) continue
if (LiteGraph.isValidConnection(output.type, sourceSlot.type)) {
results.push({ node: candidate, slotIndex: i, slotInfo: output })
}
}
} else {
if (!candidate.inputs) continue
for (let i = 0; i < candidate.inputs.length; i++) {
const input = candidate.inputs[i]
if (input.link != null) continue
if (isWildcardType(input.type)) continue
if (LiteGraph.isValidConnection(sourceSlot.type, input.type)) {
results.push({ node: candidate, slotIndex: i, slotInfo: input })
}
}
}
}
results.sort((a, b) => a.node.pos[1] - b.node.pos[1])
return results.slice(0, maxResults)
}
export function connectSlots(
context: SlotMenuContext,
target: CompatibleTarget
): void {
const graph = app.canvas?.graph
if (!graph) return
const sourceNode = graph.getNodeById(context.nodeId)
if (!sourceNode) return
graph.beforeChange()
if (context.isInput) {
target.node.connect(target.slotIndex, sourceNode, context.slotIndex)
} else {
sourceNode.connect(context.slotIndex, target.node, target.slotIndex)
}
graph.afterChange()
app.canvas?.setDirty(true, true)
}
export function renameSlot(context: SlotMenuContext, newLabel: string): void {
const graph = app.canvas?.graph
if (!graph) return
const node = graph.getNodeById(context.nodeId)
if (!node) return
const slotInfo = context.isInput
? node.getInputInfo(context.slotIndex)
: node.getOutputInfo(context.slotIndex)
if (!slotInfo) return
graph.beforeChange()
slotInfo.label = newLabel
app.canvas?.setDirty(true, true)
graph.afterChange()
}
export function disconnectSlotLinks(context: SlotMenuContext): void {
const graph = app.canvas?.graph
if (!graph) return
const node = graph.getNodeById(context.nodeId)
if (!node) return
graph.beforeChange()
if (context.isInput) {
node.disconnectInput(context.slotIndex, true)
} else {
node.disconnectOutput(context.slotIndex)
}
graph.afterChange()
app.canvas?.setDirty(true, true)
}
export function canRenameSlot(context: SlotMenuContext): boolean {
const graph = app.canvas?.graph
if (!graph) return false
const node = graph.getNodeById(context.nodeId)
if (!node) return false
const slotInfo = context.isInput
? node.inputs?.[context.slotIndex]
: node.outputs?.[context.slotIndex]
if (!slotInfo) return false
if (slotInfo.nameLocked) return false
if (
context.isInput &&
'link' in slotInfo &&
(slotInfo as IWidgetInputSlot).widget
)
return false
return true
}
export function hasConnectedLinks(context: SlotMenuContext): boolean {
const graph = app.canvas?.graph
if (!graph) return false
const node = graph.getNodeById(context.nodeId)
if (!node) return false
if (context.isInput) {
const input = node.inputs?.[context.slotIndex]
return input?.link != null
}
const output = node.outputs?.[context.slotIndex]
return (output?.links?.length ?? 0) > 0
}
let slotLabelMenuInstance: SlotMenuInstance | null = null
export function registerSlotLabelMenuInstance(
instance: SlotMenuInstance | null
): void {
slotLabelMenuInstance = instance
}
export function showSlotLabelMenu(
event: MouseEvent,
context: SlotMenuContext
): void {
slotLabelMenuInstance?.show(event, context)
}
export type { SlotMenuContext }

View File

@@ -29,6 +29,7 @@ import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { Point } from '@/renderer/core/layout/types'
import { toPoint } from '@/renderer/core/layout/utils/geometry'
import { createSlotLinkDragContext } from '@/renderer/extensions/vueNodes/composables/slotLinkDragContext'
import { useAutoPan } from '@/renderer/extensions/vueNodes/composables/useAutoPan'
import { augmentToCanvasPointerEvent } from '@/renderer/extensions/vueNodes/utils/eventUtils'
import { app } from '@/scripts/app'
import { createRafBatch } from '@/utils/rafBatch'
@@ -128,6 +129,21 @@ export function useSlotLinkInteraction({
// Per-drag drag-state context (non-reactive caches + RAF batching)
const dragContext = createSlotLinkDragContext()
const autoPan = useAutoPan(() => {
const data = dragContext.pendingPointerMove
const clientX = data?.clientX ?? state.pointer.client.x
const clientY = data?.clientY ?? state.pointer.client.y
const [canvasX, canvasY] = conversion.clientPosToCanvasPos([
clientX,
clientY
])
updatePointerPosition(clientX, clientY, canvasX, canvasY)
if (activeAdapter) {
activeAdapter.linkConnector.state.snapLinksPos = [canvasX, canvasY]
app.canvas?.setDirty(true, true)
}
})
const resolveRenderLinkSource = (link: RenderLink): Point | null => {
if (link.fromReroute) {
const rerouteLayout = layoutStore.getRerouteLayout(link.fromReroute.id)
@@ -286,6 +302,7 @@ export function useSlotLinkInteraction({
if (state.pointerId != null) {
clearCanvasPointerHistory(state.pointerId)
}
autoPan.stop()
activeAdapter?.reset()
pointerSession.clear()
endDrag()
@@ -416,6 +433,7 @@ export function useSlotLinkInteraction({
clientY: event.clientY,
target: event.target
}
autoPan.updatePointer(event.clientX, event.clientY)
raf.schedule()
}

View File

@@ -16,10 +16,12 @@ import type { ChartData } from 'chart.js'
import Chart from 'primevue/chart'
import { computed } from 'vue'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import type { ChartInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
type ChartWidgetOptions = NonNullable<ChartInputSpec['options']>
type ChartWidgetOptions = NonNullable<ChartInputSpec['options']> &
IWidgetOptions
const value = defineModel<ChartData>({ required: true })

View File

@@ -38,10 +38,12 @@ import {
filterWidgetProps
} from '@/utils/widgetPropFilter'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import { WidgetInputBaseClass } from './layout'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
type WidgetOptions = { format?: ColorFormat } & Record<string, unknown>
type WidgetOptions = IWidgetOptions & { format?: ColorFormat }
const props = defineProps<{
widget: SimplifiedWidget<string, WidgetOptions>

View File

@@ -67,30 +67,40 @@ function updateValue(e: UIEvent) {
const { target } = e
if (!(target instanceof HTMLInputElement)) return
const parsed = evaluateInput(unformatValue(target.value))
if (parsed !== undefined)
modelValue.value = Math.min(
filteredProps.value.max,
Math.max(filteredProps.value.min, parsed)
)
else target.value = formattedValue.value
if (parsed !== undefined) {
const max = filteredProps.value.max ?? Number.MAX_VALUE
const min = filteredProps.value.min ?? -Number.MAX_VALUE
modelValue.value = Math.min(max, Math.max(min, parsed))
} else target.value = formattedValue.value
textEdit.value = false
}
const canDecrement = computed(
() =>
modelValue.value > filteredProps.value.min &&
!props.widget.options?.disabled
)
const canIncrement = computed(
() =>
modelValue.value < filteredProps.value.max &&
!props.widget.options?.disabled
)
interface NumericWidgetOptions {
min: number
max: number
step?: number
step2?: number
precision?: number
disabled?: boolean
useGrouping?: boolean
}
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, INPUT_EXCLUDED_PROPS)
)
const filteredProps = computed(() => {
const filtered = filterWidgetProps(props.widget.options, INPUT_EXCLUDED_PROPS)
return filtered as Partial<NumericWidgetOptions>
})
const isDisabled = computed(() => props.widget.options?.disabled ?? false)
const canDecrement = computed(() => {
const min = filteredProps.value.min ?? -Number.MAX_VALUE
return modelValue.value > min && !isDisabled.value
})
const canIncrement = computed(() => {
const max = filteredProps.value.max ?? Number.MAX_VALUE
return modelValue.value < max && !isDisabled.value
})
// Get the precision value for proper number formatting
const precision = computed(() => {
@@ -108,7 +118,7 @@ const stepValue = computed(() => {
// Use step / 10 for custom large step values (> 10) to match litegraph behavior
// This is important for extensions like Impact Pack that use custom step values (e.g., 640)
// We skip default step values (1, 10) to avoid affecting normal widgets
const step = props.widget.options?.step
const step = props.widget.options?.step as number | undefined
if (step !== undefined && step > 10) {
return Number(step) / 10
}
@@ -140,17 +150,16 @@ const buttonsDisabled = computed(() => {
})
function updateValueBy(delta: number) {
modelValue.value = Math.min(
filteredProps.value.max,
Math.max(filteredProps.value.min, modelValue.value + delta)
)
const max = filteredProps.value.max ?? Number.MAX_VALUE
const min = filteredProps.value.min ?? -Number.MAX_VALUE
modelValue.value = Math.min(max, Math.max(min, modelValue.value + delta))
}
const dragValue = ref<number>()
const dragDelta = ref(0)
function handleMouseDown(e: PointerEvent) {
if (e.button > 0) return
if (props.widget.options?.disabled) return
if (isDisabled.value) return
const { target } = e
if (!(target instanceof HTMLElement)) return
target.setPointerCapture(e.pointerId)
@@ -163,10 +172,9 @@ function handleMouseMove(e: PointerEvent) {
const unclippedValue =
dragValue.value + ((dragDelta.value / 10) | 0) * stepValue.value
dragDelta.value %= 10
dragValue.value = Math.min(
filteredProps.value.max,
Math.max(filteredProps.value.min, unclippedValue)
)
const max = filteredProps.value.max ?? Number.MAX_VALUE
const min = filteredProps.value.min ?? -Number.MAX_VALUE
dragValue.value = Math.min(max, Math.max(min, unclippedValue))
}
function handleMouseUp() {
const newValue = dragValue.value
@@ -248,7 +256,7 @@ const sliderWidth = computed(() => {
:value="formattedValue"
role="spinbutton"
tabindex="0"
:disabled="widget.options?.disabled"
:disabled="isDisabled"
autocomplete="off"
autocorrect="off"
spellcheck="false"

View File

@@ -5,6 +5,7 @@ import type { InputTextProps } from 'primevue/inputtext'
import Textarea from 'primevue/textarea'
import { describe, expect, it } from 'vitest'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetInputText from './WidgetInputText.vue'
@@ -18,7 +19,7 @@ describe('WidgetInputText Value Binding', () => {
name: 'test_input',
type: 'string',
value,
options,
options: options as IWidgetOptions,
callback
})

View File

@@ -53,7 +53,7 @@ const props = defineProps<Props>()
const modelValue = defineModel<string | undefined>({
default(props: Props) {
return props.widget.options?.values?.[0] || ''
return props.widget.options?.values?.[0] ?? ''
}
})

View File

@@ -31,7 +31,7 @@ describe('WidgetSelectDropdown custom label mapping', () => {
value: string = 'img_001.png',
options: {
values?: string[]
getOptionLabel?: (value: string | null) => string
getOptionLabel?: (value?: string | null) => string
} = {},
spec?: ComboInputSpec
): SimplifiedWidget<string | undefined> => ({
@@ -82,7 +82,7 @@ describe('WidgetSelectDropdown custom label mapping', () => {
describe('when custom labels are provided via getOptionLabel', () => {
it('displays custom labels while preserving original values', () => {
const getOptionLabel = vi.fn((value: string | null) => {
const getOptionLabel = vi.fn((value?: string | null) => {
if (!value) return 'No file'
const mapping: Record<string, string> = {
'img_001.png': 'Vacation Photo',
@@ -112,7 +112,7 @@ describe('WidgetSelectDropdown custom label mapping', () => {
})
it('emits original values when items with custom labels are selected', async () => {
const getOptionLabel = vi.fn((value: string | null) => {
const getOptionLabel = vi.fn((value?: string | null) => {
if (!value) return 'No file'
return `Custom: ${value}`
})
@@ -134,7 +134,7 @@ describe('WidgetSelectDropdown custom label mapping', () => {
})
it('falls back to original value when label mapping fails', () => {
const getOptionLabel = vi.fn((value: string | null) => {
const getOptionLabel = vi.fn((value?: string | null) => {
if (value === 'photo_abc.jpg') {
throw new Error('Mapping failed')
}
@@ -163,7 +163,7 @@ describe('WidgetSelectDropdown custom label mapping', () => {
})
it('falls back to original value when label mapping returns empty string', () => {
const getOptionLabel = vi.fn((value: string | null) => {
const getOptionLabel = vi.fn((value?: string | null) => {
if (value === 'photo_abc.jpg') {
return ''
}
@@ -185,7 +185,7 @@ describe('WidgetSelectDropdown custom label mapping', () => {
})
it('falls back to original value when label mapping returns undefined', () => {
const getOptionLabel = vi.fn((value: string | null) => {
const getOptionLabel = vi.fn((value?: string | null) => {
if (value === 'hash789.png') {
return undefined as unknown as string
}
@@ -209,7 +209,7 @@ describe('WidgetSelectDropdown custom label mapping', () => {
describe('output items with custom label mapping', () => {
it('applies custom label mapping to output items from queue history', () => {
const getOptionLabel = vi.fn((value: string | null) => {
const getOptionLabel = vi.fn((value?: string | null) => {
if (!value) return 'No file'
return `Output: ${value}`
})

View File

@@ -57,7 +57,7 @@ provide(
const modelValue = defineModel<string | undefined>({
default(props: Props) {
return props.widget.options?.values?.[0] || ''
return props.widget.options?.values?.[0] ?? ''
}
})
@@ -73,7 +73,8 @@ const combinedProps = computed(() => ({
}))
const getAssetData = () => {
const nodeType = props.widget.options?.nodeType ?? props.nodeType
const nodeType: string | undefined =
props.widget.options?.nodeType ?? props.nodeType
if (props.isAssetMode && nodeType) {
return useAssetWidgetData(toRef(nodeType))
}
@@ -134,11 +135,11 @@ const inputItems = computed<FormDropdownItem[]>(() => {
return []
}
return values.map((value: string, index: number) => ({
return values.map((value, index) => ({
id: `input-${index}`,
preview_url: getMediaUrl(value, 'input'),
name: value,
label: getDisplayLabel(value)
preview_url: getMediaUrl(String(value), 'input'),
name: String(value),
label: getDisplayLabel(String(value))
}))
})
const outputItems = computed<FormDropdownItem[]>(() => {

View File

@@ -15,8 +15,8 @@
v-model="modelValue"
:class="cn(WidgetInputBaseClass, 'size-full text-xs resize-none')"
:placeholder
:readonly="widget.options?.read_only"
:disabled="widget.options?.read_only"
:readonly="isReadOnly"
:disabled="isReadOnly"
fluid
data-capture-wheel="true"
@pointerdown.capture.stop
@@ -58,4 +58,6 @@ const filteredProps = computed(() =>
const displayName = computed(() => widget.label || widget.name)
const id = useId()
const isReadOnly = computed(() => widget.options?.read_only ?? false)
</script>

View File

@@ -0,0 +1,86 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import AudioPreviewPlayer from '@/renderer/extensions/vueNodes/widgets/components/audio/AudioPreviewPlayer.vue'
const mockToastAdd = vi.fn()
vi.mock('primevue/usetoast', () => ({
useToast: () => ({ add: mockToastAdd })
}))
vi.mock('@/base/common/downloadUtil', () => ({
downloadFile: vi.fn()
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
})
function mountPlayer(modelValue?: string) {
return mount(AudioPreviewPlayer, {
props: {
modelValue,
hideWhenEmpty: false
},
global: {
plugins: [i18n],
components: { Button },
stubs: {
TieredMenu: true,
Slider: true
}
}
})
}
function findDownloadButton(wrapper: ReturnType<typeof mountPlayer>) {
return wrapper.find('[aria-label="g.downloadAudio"]')
}
describe('AudioPreviewPlayer', () => {
describe('download button', () => {
it('shows download button when audio is loaded', () => {
const wrapper = mountPlayer('http://example.com/audio.mp3')
expect(findDownloadButton(wrapper).exists()).toBe(true)
})
it('hides download button when no audio is loaded', () => {
const wrapper = mountPlayer()
expect(findDownloadButton(wrapper).exists()).toBe(false)
})
it('calls downloadFile when download button is clicked', async () => {
const { downloadFile } = await import('@/base/common/downloadUtil')
const wrapper = mountPlayer('http://example.com/audio.mp3')
await findDownloadButton(wrapper).trigger('click')
expect(downloadFile).toHaveBeenCalledWith('http://example.com/audio.mp3')
})
it('shows toast on download failure', async () => {
const { downloadFile } = await import('@/base/common/downloadUtil')
vi.mocked(downloadFile).mockImplementation(() => {
throw new Error('download failed')
})
const wrapper = mountPlayer('http://example.com/audio.mp3')
await findDownloadButton(wrapper).trigger('click')
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'error'
})
)
vi.mocked(downloadFile).mockReset()
})
})
})

View File

@@ -16,11 +16,11 @@
<!-- Left Actions -->
<div class="relative flex shrink-0 items-center justify-start gap-2">
<!-- Play/Pause Button -->
<div
role="button"
:tabindex="0"
<Button
variant="textonly"
size="unset"
:aria-label="$t('g.playPause')"
class="flex size-6 cursor-pointer items-center justify-center rounded hover:bg-interface-menu-component-surface-hovered"
class="size-6 rounded"
@click="togglePlayPause"
>
<i
@@ -28,7 +28,7 @@
class="text-secondary icon-[lucide--play] size-4"
/>
<i v-else class="text-secondary icon-[lucide--pause] size-4" />
</div>
</Button>
<!-- Time Display -->
<div class="text-sm font-normal text-nowrap text-base-foreground">
@@ -57,11 +57,11 @@
<!-- Right Actions -->
<div class="relative flex shrink-0 items-center justify-start gap-2">
<!-- Volume Button -->
<div
role="button"
:tabindex="0"
<Button
variant="textonly"
size="unset"
:aria-label="$t('g.volume')"
class="flex size-6 cursor-pointer items-center justify-center rounded hover:bg-interface-menu-component-surface-hovered"
class="size-6 rounded"
@click="toggleMute"
>
<i
@@ -73,19 +73,32 @@
class="text-secondary icon-[lucide--volume-1] size-4"
/>
<i v-else class="text-secondary icon-[lucide--volume-x] size-4" />
</div>
</Button>
<!-- Download Button -->
<Button
v-if="modelValue"
size="icon-sm"
variant="textonly"
:aria-label="$t('g.downloadAudio')"
:title="$t('g.downloadAudio')"
class="size-6 hover:bg-interface-menu-component-surface-hovered"
@click="handleDownload"
>
<i class="text-secondary icon-[lucide--download] size-4" />
</Button>
<!-- Options Button -->
<div
<Button
v-if="showOptionsButton"
role="button"
:tabindex="0"
variant="textonly"
size="unset"
:aria-label="$t('g.moreOptions')"
class="flex size-6 cursor-pointer items-center justify-center rounded hover:bg-interface-menu-component-surface-hovered"
class="size-6 rounded"
@click="toggleOptionsMenu"
>
<i class="text-secondary icon-[lucide--more-vertical] size-4" />
</div>
</Button>
</div>
<!-- Options Menu -->
@@ -137,11 +150,16 @@ import { computed, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { whenever } from '@vueuse/core'
import { useToast } from 'primevue/usetoast'
import { downloadFile } from '@/base/common/downloadUtil'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
import { formatTime } from '../../utils/audioUtils'
const { t } = useI18n()
const toast = useToast()
const props = withDefaults(
defineProps<{
@@ -187,6 +205,20 @@ const togglePlayPause = () => {
isPlaying.value = !isPlaying.value
}
const handleDownload = () => {
if (!modelValue.value) return
try {
downloadFile(modelValue.value)
} catch {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('g.failedToDownloadFile'),
life: 3000
})
}
}
const toggleMute = () => {
if (audioRef.value) {
isMuted.value = !isMuted.value

View File

@@ -32,7 +32,7 @@ async function getAuthHeaders() {
return {}
}
const dataCache = new Map<string, CacheEntry<any>>()
const dataCache = new Map<string, CacheEntry<unknown>>()
const createCacheKey = (config: RemoteWidgetConfig): string => {
const { route, query_params = {}, refresh = 0 } = config
@@ -49,7 +49,9 @@ const getBackoff = (retryCount: number) =>
Math.min(1000 * Math.pow(2, retryCount), 512)
const isInitialized = (entry: CacheEntry<unknown> | undefined) =>
entry?.data && entry?.timestamp && entry.timestamp > 0
entry?.data !== undefined &&
entry?.timestamp !== undefined &&
entry.timestamp > 0
const isStale = (entry: CacheEntry<unknown> | undefined, ttl: number) =>
entry?.timestamp && Date.now() - entry.timestamp >= ttl
@@ -128,9 +130,11 @@ export function useRemoteWidget<
return !isLoaded && isInitialized(dataCache.get(cacheKey))
}
const onFirstLoad = (data: T[]) => {
const onFirstLoad = (data: T | T[]) => {
isLoaded = true
widget.value = data[0]
const nextValue =
Array.isArray(data) && data.length > 0 ? data[0] : undefined
widget.value = nextValue ?? (Array.isArray(data) ? defaultValue : data)
widget.callback?.(widget.value)
node.graph?.setDirtyCanvas(true)
}
@@ -138,13 +142,16 @@ export function useRemoteWidget<
const fetchValue = async () => {
const entry = dataCache.get(cacheKey)
if (isFailed(entry)) return entry!.data
if (isFailed(entry)) return entry!.data as T
const isValid =
isInitialized(entry) && (isPermanent || !isStale(entry, refresh))
if (isValid || isBackingOff(entry) || isFetching(entry)) return entry!.data
if (isValid || isBackingOff(entry) || isFetching(entry))
return entry!.data as T
const currentEntry: CacheEntry<T> = entry || { data: defaultValue }
const currentEntry: CacheEntry<T> = (entry as
| CacheEntry<T>
| undefined) || { data: defaultValue }
dataCache.set(cacheKey, currentEntry)
try {

View File

@@ -188,7 +188,7 @@ export const useColorPaletteService = () => {
* @param schema - The Zod schema object to analyze.
* @returns Array of optional key names.
*/
const getOptionalKeys = (schema: z.ZodObject<any, any>) => {
const getOptionalKeys = (schema: z.ZodObject<z.ZodRawShape>) => {
const optionalKeys: string[] = []
const shape = schema.shape

View File

@@ -162,7 +162,7 @@ export const useLitegraphService = () => {
const state =
useExecutionStore().nodeLocationProgressStates[nodeLocatorId]?.state
if (state === 'running') {
return { color: '#0f0' }
return { color: '#0f0', lineWidth: 3 }
}
}
node.strokeStyles['dragOver'] = function (this: LGraphNode) {
@@ -172,7 +172,7 @@ export const useLitegraphService = () => {
}
node.strokeStyles['executionError'] = function (this: LGraphNode) {
if (app.lastExecutionError?.node_id == this.id) {
return { color: '#f0f', lineWidth: 2 }
return { color: '#f0f', lineWidth: 3 }
}
}
}
@@ -849,7 +849,7 @@ export const useLitegraphService = () => {
function addNodeOnGraph(
nodeDef: ComfyNodeDefV1 | ComfyNodeDefV2,
options: Record<string, any> = {}
options: Record<string, unknown> & { pos?: Point } = {}
): LGraphNode {
options.pos ??= getCanvasCenter()

View File

@@ -32,7 +32,6 @@ type UseLoad3dViewerFn = (node?: LGraphNode) => {
handleModelDrop: (file: File) => Promise<void>
handleSeek: (progress: number) => void
needApplyChanges: { value: boolean }
[key: string]: unknown
}
// Type for SkeletonUtils module
@@ -81,7 +80,7 @@ interface Load3DNode extends LGraphNode {
syncLoad3dConfig?: () => void
}
const viewerInstances = new Map<NodeId, any>()
const viewerInstances = new Map<NodeId, ReturnType<UseLoad3dViewerFn>>()
export class Load3dService {
private static instance: Load3dService
@@ -165,12 +164,15 @@ export class Load3dService {
* Only works after useLoad3dViewer has been loaded.
* Returns null if module not yet loaded - use async version instead.
*/
getOrCreateViewerSync(node: LGraphNode, useLoad3dViewer: UseLoad3dViewerFn) {
getOrCreateViewerSync<T extends UseLoad3dViewerFn>(
node: LGraphNode,
useLoad3dViewer: T
): ReturnType<T> {
if (!viewerInstances.has(node.id)) {
viewerInstances.set(node.id, useLoad3dViewer(node))
}
return viewerInstances.get(node.id)
return viewerInstances.get(node.id) as ReturnType<T>
}
removeViewer(node: LGraphNode) {
@@ -288,6 +290,7 @@ export class Load3dService {
async handleViewerClose(node: LGraphNode) {
const viewer = await useLoad3dService().getOrCreateViewer(node)
if (!viewer) return
if (viewer.needApplyChanges.value) {
await viewer.applyChanges()

View File

@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
import _ from 'es-toolkit/compat'
import type { TgpuRoot } from 'typegpu'
import {
BrushShape,
@@ -71,7 +72,7 @@ export const useMaskEditorStore = defineStore('maskEditor', () => {
const canvasHistory = useCanvasHistory(20)
const tgpuRoot = ref<any>(null)
const tgpuRoot = ref<TgpuRoot | null>(null)
const colorInput = ref<HTMLInputElement | null>(null)

View File

@@ -86,7 +86,7 @@ export class ComfyNodeDefImpl
// V2 fields
readonly inputs: Record<string, InputSpecV2>
readonly outputs: OutputSpecV2[]
readonly hidden?: Record<string, any>
readonly hidden?: Record<string, boolean>
// ComfyNodeDefImpl fields
readonly nodeSource: NodeSource

View File

@@ -72,7 +72,7 @@ export interface NodesIndexSuggestion {
exact_nb_hits: number
facets: {
exact_matches: Record<string, number>
analytics: Record<string, any>
analytics: Record<string, unknown>
}
}
objectID: RegistryNodePack['id']

View File

@@ -3,6 +3,7 @@
* Removes all DOM manipulation and positioning concerns
*/
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
/** Valid types for widget values */
export type WidgetValue =
@@ -39,7 +40,7 @@ export type SafeControlWidget = {
export interface SimplifiedWidget<
T extends WidgetValue = WidgetValue,
O = Record<string, any>
O extends IWidgetOptions = IWidgetOptions
> {
/** Display name of the widget */
name: string
@@ -68,7 +69,7 @@ export interface SimplifiedWidget<
nodeType?: string
/** Optional serialization method for custom value handling */
serializeValue?: () => any
serializeValue?: () => unknown
/** Optional input specification backing this widget */
spec?: InputSpecV2
@@ -78,7 +79,7 @@ export interface SimplifiedWidget<
export interface SimplifiedControlWidget<
T extends WidgetValue = WidgetValue,
O = Record<string, any>
O extends IWidgetOptions = IWidgetOptions
> extends SimplifiedWidget<T, O> {
controlWidget: SafeControlWidget
}

View File

@@ -1,7 +1,11 @@
import type { ElectronAPI } from '@comfyorg/comfyui-electron-types'
// Extend Window interface to include electronAPI
type ElectronWindow = typeof window & {
/**
* Extend Window interface to include electronAPI
* Used by desktop-ui app storybook stories
* @public
*/
export type ElectronWindow = typeof window & {
electronAPI?: ElectronAPI
}

View File

@@ -4,7 +4,7 @@ import { formatDuration } from '@/utils/formatUtil'
import { clampPercentInt, formatPercent0 } from '@/utils/numberUtil'
export type BuildJobDisplayCtx = {
t: (k: string, v?: Record<string, any>) => string
t: (k: string, v?: Record<string, unknown>) => string
locale: string
formatClockTimeFn: (ts: number, locale: string) => string
isActive: boolean

View File

@@ -55,13 +55,13 @@ export const BADGE_EXCLUDED_PROPS = [
* @param excludeList - List of property names to exclude
* @returns Filtered props object
*/
export function filterWidgetProps<T extends Record<string, any>>(
export function filterWidgetProps<T extends object>(
props: T | undefined,
excludeList: readonly string[]
): Partial<T> {
if (!props) return {}
const filtered: Record<string, any> = {}
const filtered: Record<string, unknown> = {}
for (const [key, value] of Object.entries(props)) {
if (!excludeList.includes(key)) {
filtered[key] = value

View File

@@ -6,7 +6,7 @@
class="manager-dialog"
>
<template #leftPanelHeaderTitle>
<i class="icon-[lucide--puzzle]" />
<i class="icon-[comfy--extensions-blocks]" />
<h2 class="text-neutral text-base">{{ $t('manager.title') }}</h2>
</template>
<template #leftPanel>

View File

@@ -5,6 +5,7 @@ import PrimeVue from 'primevue/config'
import { describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import type { ComponentProps } from 'vue-component-type-helpers'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
@@ -12,9 +13,11 @@ import GridSkeleton from './GridSkeleton.vue'
import PackCardSkeleton from './PackCardSkeleton.vue'
describe('GridSkeleton', () => {
const mountComponent = ({
function mountComponent({
props = {}
}: Record<string, any> = {}): VueWrapper => {
}: {
props?: Partial<ComponentProps<typeof GridSkeleton>>
} = {}): VueWrapper {
const i18n = createI18n({
legacy: false,
locale: 'en',

View File

@@ -19,6 +19,7 @@ import type {
ConflictDetail,
ConflictDetectionResponse,
ConflictDetectionResult,
ImportFailureMap,
Node,
NodeRequirements,
SystemEnvironment
@@ -336,7 +337,7 @@ export function useConflictDetection() {
* Gets installed packages and checks each one for import failures using bulk API.
* @returns Promise that resolves to import failure data
*/
async function fetchImportFailInfo(): Promise<Record<string, any>> {
async function fetchImportFailInfo(): Promise<ImportFailureMap> {
try {
const comfyManagerService = useComfyManagerService()
@@ -362,7 +363,7 @@ export function useConflictDetection() {
if (bulkResult) {
// Filter out null values (packages without import failures)
const importFailures: Record<string, any> = {}
const importFailures: ImportFailureMap = {}
Object.entries(bulkResult).forEach(([packageId, failInfo]) => {
if (failInfo !== null) {
@@ -389,10 +390,7 @@ export function useConflictDetection() {
* @returns Array of conflict detection results for failed imports
*/
function detectImportFailConflicts(
importFailInfo: Record<
string,
{ error?: string; traceback?: string } | null
>
importFailInfo: ImportFailureMap
): ConflictDetectionResult[] {
const results: ConflictDetectionResult[] = []
if (!importFailInfo || typeof importFailInfo !== 'object') {

View File

@@ -24,7 +24,7 @@ const MANAGER_WS_TASK_STARTED_NAME = 'cm-task-started'
export const useManagerQueue = (
taskHistory: Ref<ManagerTaskHistory>,
taskQueue: Ref<ManagerTaskQueue>,
installedPacks: Ref<Record<string, any>>
installedPacks: Ref<Record<string, unknown>>
) => {
// Task queue state (read-only from server)
const maxHistoryItems = ref(64)

View File

@@ -156,7 +156,7 @@ export const useComfyManagerService = () => {
const getImportFailInfo = async (signal?: AbortSignal) => {
const errorContext = 'Fetching import failure information'
return executeRequest<any>(
return executeRequest<Record<string, unknown>>(
() => managerApiClient.get(ManagerRoute.IMPORT_FAIL_INFO, { signal }),
{ errorContext }
)

View File

@@ -78,3 +78,16 @@ export interface ConflictDetectionResponse {
results: ConflictDetectionResult[]
detected_system_environment?: Partial<SystemEnvironment>
}
/**
* Detailed information about a Python import failure
*/
interface ImportFailureDetail {
error?: string
traceback?: string
}
/**
* Map of package IDs to their import failure information
*/
export type ImportFailureMap = Record<string, ImportFailureDetail | null>

View File

@@ -10,7 +10,7 @@ import type { ConflictDetail } from '@/workbench/extensions/manager/types/confli
*/
export function getConflictMessage(
conflict: ConflictDetail,
t: (key: string, params?: Record<string, any>) => string
t: (key: string, params?: Record<string, unknown>) => string
): string {
const messageKey = `manager.conflicts.conflictMessages.${conflict.type}`
@@ -53,7 +53,7 @@ export function getConflictMessage(
*/
export function getJoinedConflictMessages(
conflicts: ConflictDetail[],
t: (key: string, params?: Record<string, any>) => string,
t: (key: string, params?: Record<string, unknown>) => string,
separator = '; '
): string {
return conflicts