Compare commits

...

30 Commits

Author SHA1 Message Date
Austin Mroz
afc6ec8c9f Remove unused ts-expect-error 2026-02-11 15:36:58 -08:00
Austin Mroz
e30b08131a merge main 2026-02-11 15:21:03 -08:00
Johnpaul Chiwetelu
4fc1d2ef5b feat: No Explicit Any (#8601)
## Summary
- Add `typescript/no-explicit-any` rule to `.oxlintrc.json` to enforce
no explicit `any` types
- Fix all 40 instances of explicit `any` throughout the codebase
- Improve type safety with proper TypeScript types

## Changes Made

### Configuration
- Added `typescript/no-explicit-any` rule to `.oxlintrc.json`

### Type Fixes
- Replaced `any` with `unknown` for truly unknown types
- Updated generic type parameters to use `unknown` defaults instead of
`any`
- Fixed method `this` parameters to avoid variance issues
- Updated component props to match new generic types
- Fixed test mocks to use proper type assertions

### Key Files Modified
- `src/types/treeExplorerTypes.ts`: Updated TreeExplorerNode interface
generics
- `src/platform/settings/types.ts`: Fixed SettingParams generic default
- `src/lib/litegraph/src/LGraph.ts`: Fixed ParamsArray type constraint
- `src/extensions/core/electronAdapter.ts`: Fixed onChange callbacks
- `src/views/GraphView.vue`: Added proper type imports
- Multiple test files: Fixed type assertions and mocks

## Test Plan
- [x] All lint checks pass (`pnpm lint`)
- [x] TypeScript compilation succeeds (`pnpm typecheck`)
- [x] Pre-commit hooks pass
- [x] No regression in functionality

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8601-feat-add-typescript-no-explicit-any-rule-and-fix-all-instances-2fd6d73d365081fd9beef75d5a6daf5b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-02-12 00:13:48 +01:00
Benjamin Lu
92b7437d86 fix: scope manager button red dot to conflicts (#8810)
## Summary

Scope the top-menu Manager button red dot to manager conflict state
only, so release-update notifications do not appear on the Manager
button.

## Changes

- **What**:
- In `TopMenuSection`, remove release-store coupling and use only
`useConflictAcknowledgment().shouldShowRedDot` for the Manager button
indicator.
- Add a regression test in `TopMenuSection.test.ts` that keeps the
release red dot true while asserting the Manager button dot only appears
when the conflict red dot is true.

## Review Focus

- Confirm Manager button notification semantics are conflict-specific
and no longer mirror release notifications.
- Confirm the new test fails if release-store coupling is reintroduced.

## Screenshots (if applicable)

N/A

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8810-fix-scope-manager-button-red-dot-to-conflicts-3046d73d3650817887b9ca9c33919f48)
by [Unito](https://www.unito.io)
2026-02-11 15:00:25 -08:00
Simula_r
dd1fefe843 fix: credit display and top up and other UI display if personal membe… (#8784)
## Summary

Consolidate scattered role checks for credits, top-up, and subscribe
buttons into centralized workspace permissions (canTopUp,
canManageSubscription), ensuring "Add Credits" requires an active
subscription, subscribe buttons only appear when needed, and team
members see appropriately restricted billing UI.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8784-fix-credit-display-and-top-up-and-other-UI-display-if-personal-membe-3036d73d3650810fbc2de084f738943c)
by [Unito](https://www.unito.io)
2026-02-11 14:26:35 -08:00
Johnpaul Chiwetelu
adcb663b3e fix: link dragging offset on external monitors in Vue nodes mode (#8809)
## Summary

Fix link dragging offset when using Vue nodes mode on external monitors
with different DPI than the primary display.

## Changes

- **What**: Derive overlay canvas scale from actual canvas dimensions
(`canvas.width / canvas.clientWidth`) instead of
`window.devicePixelRatio`, fixing DPR mismatch. Map `LinkDirection.NONE`
to `'none'` in `convertDirection()` instead of falling through to
`'right'`.

## Before


https://github.com/user-attachments/assets/f5d04617-369f-4649-af60-11d31e27a75c



## After


https://github.com/user-attachments/assets/76434d2b-d485-43de-94f6-202a91f73edf


## Review Focus

The overlay canvas copies dimensions from the main canvas (which
includes DPR scaling from `resizeCanvas`). When the page loads on a
monitor whose DPR differs from what `resizeCanvas` used,
`window.devicePixelRatio` no longer matches the canvas's internal-to-CSS
ratio, causing all drawn link positions to be offset. The fix derives
scale directly from the canvas itself.

`LinkDirection.NONE = 0` is falsy, so it was caught by the `default`
case in `convertDirection()`, adding an unwanted directional curve to
moved input links.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8809-fix-link-dragging-offset-on-external-monitors-in-Vue-nodes-mode-3046d73d36508143b600f23f5fe07044)
by [Unito](https://www.unito.io)
2026-02-11 22:40:17 +01:00
AustinMroz
28b171168a New bottom button and badges (#8603)
- "Enter Subgraph" "Show advanced inputs" and a new "show node Errors"
button now use a combined button design at the bottom of the node.
- A new "Errors" tab is added to the right side panel
- After a failed queue, the label of an invalid widget is now red.
- Badges other than price are now displayed on the bottom of the node.
- Price badge will now truncate from the first space, prioritizing the
sizing of the node title
- An indicator for the node resize handle is now displayed while mousing
over the node.

<img width="669" height="233" alt="image"
src="https://github.com/user-attachments/assets/53b3b59c-830b-474d-8f20-07f557124af7"
/>


![resize](https://github.com/user-attachments/assets/e2473b5b-fe4d-4f1e-b1c3-57c23d2a0349)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-02-10 23:29:45 -08:00
Brian Jemilo II
0600af462e Merge branch 'main' into batch-drag-and-drop-images 2026-02-10 02:04:13 -06:00
Brian Jemilo II
939c2f5ae2 Merge branch 'main' into batch-drag-and-drop-images
# Conflicts:
#	src/lib/litegraph/src/LGraph.ts
2026-02-09 13:21:52 -06:00
Brian Jemilo II
7c1960f8cf Merge branch 'main' into batch-drag-and-drop-images 2026-02-04 15:06:20 -06:00
Brian Jemilo II
6c6f0dba74 Merge branch 'main' into batch-drag-and-drop-images 2026-02-02 23:29:09 -06:00
Brian Jemilo II
4252d58a04 Remove unused import 2026-02-02 22:25:15 -06:00
Brian Jemilo II
2ca98501a8 Remove unused import 2026-02-02 22:24:28 -06:00
Brian Jemilo II
758ed366c8 Clean up types 2026-02-02 22:24:06 -06:00
Brian Jemilo II
373af1390f Update to add additional check 2026-02-02 17:57:12 -06:00
Brian Jemilo II
daed3cb26e Update logic / typescript with test 2026-02-02 16:29:38 -06:00
Brian Jemilo II
7f7f3b8c25 Update tests for positionBatchNodes 2026-02-02 15:38:01 -06:00
Brian Jemilo II
55634e4734 Don't use height form getBounding() 2026-02-02 15:26:10 -06:00
Brian Jemilo II
d64df325a4 Merge branch 'main' into batch-drag-and-drop-images 2026-02-02 15:23:16 -06:00
Brian Jemilo II
3e69806cbb Merge branch 'main' into batch-drag-and-drop-images 2026-01-28 17:23:57 -06:00
Alexander Brown
1f8d5faff1 Merge branch 'main' into batch-drag-and-drop-images 2026-01-28 13:41:28 -08:00
Brian Jemilo II
7c00888398 Call graph.change() once 2026-01-23 19:28:24 -06:00
Brian Jemilo II
5bb3550fa2 Merge branch 'main' into batch-drag-and-drop-images 2026-01-23 19:28:04 -06:00
Brian Jemilo II
e26c0db8f0 Merge branch 'main' into batch-drag-and-drop-images 2026-01-23 18:27:42 -06:00
Brian Jemilo II
bd6df613af Updating types 2026-01-23 17:25:06 -06:00
Brian Jemilo II
55d38e87a7 Merge branch 'refs/heads/main' into batch-drag-and-drop-images 2026-01-23 17:24:13 -06:00
Brian Jemilo II
790432038c Remove comment 2026-01-23 15:35:57 -06:00
Brian Jemilo II
2fc43055e3 Change to function signature 2026-01-23 15:17:57 -06:00
Brian Jemilo II
44c4ebcc06 Use items only, files is just read only, added comment 2026-01-23 15:11:45 -06:00
Brian Jemilo II
dfb6b6b35d Batch Drag & Drop Images 2026-01-23 14:03:10 -06:00
71 changed files with 1419 additions and 570 deletions

View File

@@ -96,6 +96,7 @@
"typescript/restrict-template-expressions": "off",
"typescript/unbound-method": "off",
"typescript/no-floating-promises": "error",
"typescript/no-explicit-any": "error",
"vue/no-import-compiler-macros": "error",
"vue/no-dupe-keys": "error"
},

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 64 KiB

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 9">
<path d="M1.82148 8.68376C1.61587 8.68376 1.44996 8.60733 1.34177 8.46284C1.23057 8.31438 1.20157 8.10711 1.26219 7.89434L1.50561 7.03961C1.52502 6.97155 1.51151 6.89831 1.46918 6.8417C1.42684 6.7852 1.3606 6.75194 1.29025 6.75194H0.590376C0.384656 6.75194 0.21875 6.67562 0.110614 6.53113C-0.000591531 6.38256 -0.0295831 6.17529 0.0310774 5.96252L0.867308 3.03952L0.959638 2.71838C1.08375 2.28258 1.53638 1.9284 1.96878 1.9284H2.80622C2.90615 1.9284 2.99406 1.86177 3.02157 1.76508L3.29852 0.79284C3.4225 0.357484 3.87514 0.0033043 4.30753 0.0033043L6.09854 0.000112775L7.40967 0C7.61533 0 7.78124 0.0763259 7.88937 0.220813C8.00058 0.369269 8.02957 0.576538 7.96895 0.78931L7.59405 2.10572C7.4701 2.54096 7.01746 2.89503 6.58507 2.89503L4.79008 2.89844H3.95292C3.8531 2.89844 3.7653 2.96496 3.73762 3.06155L3.03961 5.49964C3.02008 5.56781 3.03359 5.64127 3.07604 5.69787C3.11837 5.75437 3.18461 5.78763 3.2549 5.78763C3.25507 5.78763 4.44105 5.78532 4.44105 5.78532H5.7483C5.95396 5.78532 6.11986 5.86164 6.228 6.00613C6.33921 6.1547 6.3682 6.36197 6.30754 6.57474L5.93263 7.89092C5.80869 8.32628 5.35605 8.68034 4.92366 8.68034L3.12872 8.68376H1.82148Z" fill="#8A8A8A"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -2,7 +2,7 @@ import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import type { MenuItem } from 'primevue/menuitem'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, defineComponent, h, nextTick, onMounted } from 'vue'
import { computed, defineComponent, h, nextTick, onMounted, ref } from 'vue'
import type { Component } from 'vue'
import { createI18n } from 'vue-i18n'
@@ -19,7 +19,11 @@ import { useExecutionStore } from '@/stores/executionStore'
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
const mockData = vi.hoisted(() => ({ isLoggedIn: false, isDesktop: false }))
const mockData = vi.hoisted(() => ({
isLoggedIn: false,
isDesktop: false,
setShowConflictRedDot: (_value: boolean) => {}
}))
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => {
@@ -36,6 +40,36 @@ vi.mock('@/platform/distribution/types', () => ({
return mockData.isDesktop
}
}))
vi.mock('@/platform/updates/common/releaseStore', () => ({
useReleaseStore: () => ({
shouldShowRedDot: computed(() => true)
})
}))
vi.mock(
'@/workbench/extensions/manager/composables/useConflictAcknowledgment',
() => {
const shouldShowConflictRedDot = ref(false)
mockData.setShowConflictRedDot = (value: boolean) => {
shouldShowConflictRedDot.value = value
}
return {
useConflictAcknowledgment: () => ({
shouldShowRedDot: shouldShowConflictRedDot
})
}
}
)
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
useManagerState: () => ({
shouldShowManagerButtons: computed(() => true),
openManager: vi.fn()
})
}))
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
currentUser: null,
@@ -114,6 +148,7 @@ describe('TopMenuSection', () => {
localStorage.clear()
mockData.isDesktop = false
mockData.isLoggedIn = false
mockData.setShowConflictRedDot(false)
})
describe('authentication state', () => {
@@ -330,4 +365,16 @@ describe('TopMenuSection', () => {
const model = menu.props('model') as MenuItem[]
expect(model[0]?.disabled).toBe(false)
})
it('shows manager red dot only for manager conflicts', async () => {
const wrapper = createWrapper()
// Release red dot is mocked as true globally for this test file.
expect(wrapper.find('span.bg-red-500').exists()).toBe(false)
mockData.setShowConflictRedDot(true)
await nextTick()
expect(wrapper.find('span.bg-red-500').exists()).toBe(true)
})
})

View File

@@ -145,7 +145,6 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
@@ -173,8 +172,6 @@ const sidebarTabStore = useSidebarTabStore()
const { activeJobsCount } = storeToRefs(queueStore)
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
const { activeSidebarTabId } = storeToRefs(sidebarTabStore)
const releaseStore = useReleaseStore()
const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
const { shouldShowRedDot: shouldShowConflictRedDot } =
useConflictAcknowledgment()
const isTopMenuHovered = ref(false)
@@ -236,10 +233,8 @@ const queueContextMenuItems = computed<MenuItem[]>(() => [
}
])
// Use either release red dot or conflict red dot
const shouldShowRedDot = computed((): boolean => {
const releaseRedDot = showReleaseRedDot.value
return releaseRedDot || shouldShowConflictRedDot.value
return shouldShowConflictRedDot.value
})
// Right side panel toggle

View File

@@ -12,9 +12,9 @@
nodeContent: ({ context }) => ({
class: 'group/tree-node',
onClick: (e: MouseEvent) =>
onNodeContentClick(e, context.node as RenderedTreeExplorerNode),
onNodeContentClick(e, context.node as RenderedTreeExplorerNode<T>),
onContextmenu: (e: MouseEvent) =>
handleContextMenu(e, context.node as RenderedTreeExplorerNode)
handleContextMenu(e, context.node as RenderedTreeExplorerNode<T>)
}),
nodeToggleButton: () => ({
onClick: (e: MouseEvent) => {
@@ -36,15 +36,11 @@
</Tree>
<ContextMenu ref="menu" :model="menuItems" />
</template>
<script setup lang="ts">
defineOptions({
inheritAttrs: false
})
<script setup lang="ts" generic="T">
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem, MenuItemCommandEvent } from 'primevue/menuitem'
import Tree from 'primevue/tree'
import { computed, provide, ref } from 'vue'
import { computed, provide, ref, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
@@ -60,6 +56,10 @@ import type {
} from '@/types/treeExplorerTypes'
import { combineTrees, findNodeByKey } from '@/utils/treeUtil'
defineOptions({
inheritAttrs: false
})
const expandedKeys = defineModel<Record<string, boolean>>('expandedKeys', {
required: true
})
@@ -69,13 +69,13 @@ const selectionKeys = defineModel<Record<string, boolean>>('selectionKeys')
const storeSelectionKeys = selectionKeys.value !== undefined
const props = defineProps<{
root: TreeExplorerNode
root: TreeExplorerNode<T>
class?: string
}>()
const emit = defineEmits<{
(e: 'nodeClick', node: RenderedTreeExplorerNode, event: MouseEvent): void
(e: 'nodeDelete', node: RenderedTreeExplorerNode): void
(e: 'contextMenu', node: RenderedTreeExplorerNode, event: MouseEvent): void
(e: 'nodeClick', node: RenderedTreeExplorerNode<T>, event: MouseEvent): void
(e: 'nodeDelete', node: RenderedTreeExplorerNode<T>): void
(e: 'contextMenu', node: RenderedTreeExplorerNode<T>, event: MouseEvent): void
}>()
const {
@@ -83,19 +83,19 @@ const {
getAddFolderMenuItem,
handleFolderCreation,
addFolderCommand
} = useTreeFolderOperations(
/* expandNode */ (node: TreeExplorerNode) => {
} = useTreeFolderOperations<T>(
/* expandNode */ (node: TreeExplorerNode<T>) => {
expandedKeys.value[node.key] = true
}
)
const renderedRoot = computed<RenderedTreeExplorerNode>(() => {
const renderedRoot = computed<RenderedTreeExplorerNode<T>>(() => {
const renderedRoot = fillNodeInfo(props.root)
return newFolderNode.value
? combineTrees(renderedRoot, newFolderNode.value)
: renderedRoot
})
const getTreeNodeIcon = (node: TreeExplorerNode) => {
const getTreeNodeIcon = (node: TreeExplorerNode<T>) => {
if (node.getIcon) {
const icon = node.getIcon()
if (icon) {
@@ -111,7 +111,9 @@ const getTreeNodeIcon = (node: TreeExplorerNode) => {
const isExpanded = expandedKeys.value?.[node.key] ?? false
return isExpanded ? 'pi pi-folder-open' : 'pi pi-folder'
}
const fillNodeInfo = (node: TreeExplorerNode): RenderedTreeExplorerNode => {
const fillNodeInfo = (
node: TreeExplorerNode<T>
): RenderedTreeExplorerNode<T> => {
const children = node.children?.map(fillNodeInfo) ?? []
const totalLeaves = node.leaf
? 1
@@ -128,7 +130,7 @@ const fillNodeInfo = (node: TreeExplorerNode): RenderedTreeExplorerNode => {
}
const onNodeContentClick = async (
e: MouseEvent,
node: RenderedTreeExplorerNode
node: RenderedTreeExplorerNode<T>
) => {
if (!storeSelectionKeys) {
selectionKeys.value = {}
@@ -139,20 +141,22 @@ const onNodeContentClick = async (
emit('nodeClick', node, e)
}
const menu = ref<InstanceType<typeof ContextMenu> | null>(null)
const menuTargetNode = ref<RenderedTreeExplorerNode | null>(null)
const menuTargetNode = shallowRef<RenderedTreeExplorerNode<T> | null>(null)
const extraMenuItems = computed(() => {
return menuTargetNode.value?.contextMenuItems
? typeof menuTargetNode.value.contextMenuItems === 'function'
? menuTargetNode.value.contextMenuItems(menuTargetNode.value)
: menuTargetNode.value.contextMenuItems
const node = menuTargetNode.value
return node?.contextMenuItems
? typeof node.contextMenuItems === 'function'
? node.contextMenuItems(node)
: node.contextMenuItems
: []
})
const renameEditingNode = ref<RenderedTreeExplorerNode | null>(null)
const renameEditingNode = shallowRef<RenderedTreeExplorerNode<T> | null>(null)
const errorHandling = useErrorHandling()
const handleNodeLabelEdit = async (
node: RenderedTreeExplorerNode,
n: RenderedTreeExplorerNode,
newName: string
) => {
const node = n as RenderedTreeExplorerNode<T>
await errorHandling.wrapWithErrorHandlingAsync(
async () => {
if (node.key === newFolderNode.value?.key) {
@@ -170,35 +174,36 @@ const handleNodeLabelEdit = async (
provide(InjectKeyHandleEditLabelFunction, handleNodeLabelEdit)
const { t } = useI18n()
const renameCommand = (node: RenderedTreeExplorerNode) => {
const renameCommand = (node: RenderedTreeExplorerNode<T>) => {
renameEditingNode.value = node
}
const deleteCommand = async (node: RenderedTreeExplorerNode) => {
const deleteCommand = async (node: RenderedTreeExplorerNode<T>) => {
await node.handleDelete?.()
emit('nodeDelete', node)
}
const menuItems = computed<MenuItem[]>(() =>
[
getAddFolderMenuItem(menuTargetNode.value),
const menuItems = computed<MenuItem[]>(() => {
const node = menuTargetNode.value
return [
getAddFolderMenuItem(node),
{
label: t('g.rename'),
icon: 'pi pi-file-edit',
command: () => {
if (menuTargetNode.value) {
renameCommand(menuTargetNode.value)
if (node) {
renameCommand(node)
}
},
visible: menuTargetNode.value?.handleRename !== undefined
visible: node?.handleRename !== undefined
},
{
label: t('g.delete'),
icon: 'pi pi-trash',
command: async () => {
if (menuTargetNode.value) {
await deleteCommand(menuTargetNode.value)
if (node) {
await deleteCommand(node)
}
},
visible: menuTargetNode.value?.handleDelete !== undefined,
visible: node?.handleDelete !== undefined,
isAsync: true // The delete command can be async
},
...extraMenuItems.value
@@ -210,9 +215,12 @@ const menuItems = computed<MenuItem[]>(() =>
})
: undefined
}))
)
})
const handleContextMenu = (e: MouseEvent, node: RenderedTreeExplorerNode) => {
const handleContextMenu = (
e: MouseEvent,
node: RenderedTreeExplorerNode<T>
) => {
menuTargetNode.value = node
emit('contextMenu', node, e)
if (menuItems.value.filter((item) => item.visible).length > 0) {
@@ -224,15 +232,13 @@ const wrapCommandWithErrorHandler = (
command: (event: MenuItemCommandEvent) => void,
{ isAsync = false }: { isAsync: boolean }
) => {
const node = menuTargetNode.value
return isAsync
? errorHandling.wrapWithErrorHandlingAsync(
command as (event: MenuItemCommandEvent) => Promise<void>,
menuTargetNode.value?.handleError
)
: errorHandling.wrapWithErrorHandling(
command,
menuTargetNode.value?.handleError
node?.handleError
)
: errorHandling.wrapWithErrorHandling(command, node?.handleError)
}
defineExpose({

View File

@@ -36,7 +36,7 @@
</div>
</template>
<script setup lang="ts">
<script setup lang="ts" generic="T">
import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'
import Badge from 'primevue/badge'
import { computed, inject, ref } from 'vue'
@@ -53,17 +53,17 @@ import type {
} from '@/types/treeExplorerTypes'
const props = defineProps<{
node: RenderedTreeExplorerNode
node: RenderedTreeExplorerNode<T>
}>()
const emit = defineEmits<{
(
e: 'itemDropped',
node: RenderedTreeExplorerNode,
data: RenderedTreeExplorerNode
node: RenderedTreeExplorerNode<T>,
data: RenderedTreeExplorerNode<T>
): void
(e: 'dragStart', node: RenderedTreeExplorerNode): void
(e: 'dragEnd', node: RenderedTreeExplorerNode): void
(e: 'dragStart', node: RenderedTreeExplorerNode<T>): void
(e: 'dragEnd', node: RenderedTreeExplorerNode<T>): void
}>()
const nodeBadgeText = computed<string>(() => {
@@ -80,7 +80,7 @@ const showNodeBadgeText = computed<boolean>(() => nodeBadgeText.value !== '')
const isEditing = computed<boolean>(() => props.node.isEditingLabel ?? false)
const handleEditLabel = inject(InjectKeyHandleEditLabelFunction)
const handleRename = (newName: string) => {
handleEditLabel?.(props.node, newName)
handleEditLabel?.(props.node as RenderedTreeExplorerNode, newName)
}
const container = ref<HTMLElement | null>(null)
@@ -117,9 +117,13 @@ if (props.node.droppable) {
onDrop: async (event) => {
const dndData = event.source.data as TreeExplorerDragAndDropData
if (dndData.type === 'tree-explorer-node') {
await props.node.handleDrop?.(dndData)
await props.node.handleDrop?.(dndData as TreeExplorerDragAndDropData<T>)
canDrop.value = false
emit('itemDropped', props.node, dndData.data)
emit(
'itemDropped',
props.node,
dndData.data as RenderedTreeExplorerNode<T>
)
}
},
onDragEnter: (event) => {

View File

@@ -70,31 +70,17 @@
@click="onSelectLink"
/>
<div
class="absolute right-4 top-2 cursor-pointer"
class="absolute right-3 top-2.5 cursor-pointer"
@click="onCopyLink"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
>
<g clip-path="url(#clip0_2127_14348)">
<path
d="M2.66634 10.6666C1.93301 10.6666 1.33301 10.0666 1.33301 9.33325V2.66659C1.33301 1.93325 1.93301 1.33325 2.66634 1.33325H9.33301C10.0663 1.33325 10.6663 1.93325 10.6663 2.66659M6.66634 5.33325H13.333C14.0694 5.33325 14.6663 5.93021 14.6663 6.66658V13.3333C14.6663 14.0696 14.0694 14.6666 13.333 14.6666H6.66634C5.92996 14.6666 5.33301 14.0696 5.33301 13.3333V6.66658C5.33301 5.93021 5.92996 5.33325 6.66634 5.33325Z"
stroke="white"
stroke-width="1.3"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<defs>
<clipPath id="clip0_2127_14348">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</svg>
<i
:class="
cn(
'pi size-4',
justCopied ? 'pi-check text-green-500' : 'pi-copy'
)
"
/>
</div>
</div>
</div>
@@ -118,6 +104,7 @@ import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogStore } from '@/stores/dialogStore'
@@ -130,6 +117,7 @@ const loading = ref(false)
const email = ref('')
const step = ref<'email' | 'link'>('email')
const generatedLink = ref('')
const justCopied = ref(false)
const isValidEmail = computed(() => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
@@ -161,6 +149,10 @@ async function onCreateLink() {
async function onCopyLink() {
try {
await navigator.clipboard.writeText(generatedLink.value)
justCopied.value = true
setTimeout(() => {
justCopied.value = false
}, 759)
toast.add({
severity: 'success',
summary: t('workspacePanel.inviteMemberDialog.linkCopied'),

View File

@@ -14,11 +14,13 @@ import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { cn } from '@/utils/tailwindUtil'
import TabError from './TabError.vue'
import TabInfo from './info/TabInfo.vue'
import TabGlobalParameters from './parameters/TabGlobalParameters.vue'
import TabNodes from './parameters/TabNodes.vue'
@@ -33,6 +35,7 @@ import {
import SubgraphEditor from './subgraph/SubgraphEditor.vue'
const canvasStore = useCanvasStore()
const executionStore = useExecutionStore()
const rightSidePanelStore = useRightSidePanelStore()
const settingStore = useSettingStore()
const { t } = useI18n()
@@ -87,10 +90,25 @@ function closePanel() {
type RightSidePanelTabList = Array<{
label: () => string
value: RightSidePanelTab
icon?: string
}>
//FIXME all errors if nothing selected?
const selectedNodeErrors = computed(() =>
selectedNodes.value
.map((node) => executionStore.getNodeErrors(`${node.id}`))
.filter((nodeError) => !!nodeError)
)
const tabs = computed<RightSidePanelTabList>(() => {
const list: RightSidePanelTabList = []
if (selectedNodeErrors.value.length) {
list.push({
label: () => t('g.error'),
value: 'error',
icon: 'icon-[lucide--octagon-alert] bg-node-stroke-error ml-1'
})
}
list.push({
label: () =>
@@ -271,6 +289,7 @@ function handleProxyWidgetsUpdate(value: ProxyWidgetsProperty) {
:value="tab.value"
>
{{ tab.label() }}
<i v-if="tab.icon" :class="cn(tab.icon, 'size-4')" />
</Tab>
</TabList>
</nav>
@@ -288,6 +307,7 @@ function handleProxyWidgetsUpdate(value: ProxyWidgetsProperty) {
:node="selectedSingleNode"
/>
<template v-else>
<TabError v-if="activeTab === 'error'" :errors="selectedNodeErrors" />
<TabSubgraphInputs
v-if="activeTab === 'parameters' && isSingleSubgraphNode"
:node="selectedSingleNode as SubgraphNode"

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import type { NodeError } from '@/schemas/apiSchema'
const { t } = useI18n()
defineProps<{
errors: NodeError[]
}>()
const { copyToClipboard } = useCopyToClipboard()
</script>
<template>
<div class="m-4">
<Button class="w-full" @click="copyToClipboard(JSON.stringify(errors))">
{{ t('g.copy') }}
<i class="icon-[lucide--copy] size-4" />
</Button>
</div>
<div
v-for="(error, index) in errors.flatMap((ne) => ne.errors)"
:key="index"
class="px-2"
>
<h3 class="text-error" v-text="error.message" />
<div class="text-muted-foreground" v-text="error.details" />
</div>
</template>

View File

@@ -50,7 +50,8 @@
<template #before-label="{ node: treeNode }">
<span
v-if="
treeNode.data?.isModified || !treeNode.data?.isPersisted
(treeNode.data as ComfyWorkflow)?.isModified ||
!(treeNode.data as ComfyWorkflow)?.isPersisted
"
>*</span
>

View File

@@ -215,7 +215,11 @@ const renderedBookmarkedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(
}
)
const treeExplorerRef = ref<InstanceType<typeof TreeExplorer> | null>(null)
interface TreeExplorerExposed {
addFolderCommand: (targetNodeKey: string) => void
}
const treeExplorerRef = ref<TreeExplorerExposed | null>(null)
defineExpose({
addNewBookmarkFolder: () => treeExplorerRef.value?.addFolderCommand('root')
})

View File

@@ -63,7 +63,7 @@ onUnmounted(() => {
})
const expandedKeys = inject(InjectKeyExpandedKeys)
const handleItemDrop = (node: RenderedTreeExplorerNode) => {
const handleItemDrop = (node: RenderedTreeExplorerNode<ComfyNodeDefImpl>) => {
if (!expandedKeys) return
expandedKeys.value[node.key] = true
}

View File

@@ -55,63 +55,61 @@
/>
</Popover>
<!-- Credits Section (PERSONAL and OWNER only) -->
<template v-if="showCreditsSection">
<div class="flex items-center gap-2 px-4 py-2">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<Skeleton
v-if="isLoadingBalance"
width="4rem"
height="1.25rem"
class="w-full"
/>
<span v-else class="text-base font-semibold text-base-foreground">{{
displayedCredits
}}</span>
<i
v-tooltip="{ value: $t('credits.unified.tooltip'), showDelay: 300 }"
class="icon-[lucide--circle-help] mr-auto cursor-help text-base text-muted-foreground"
/>
<!-- Subscribed: Show Add Credits button -->
<Button
v-if="isActiveSubscription && isWorkspaceSubscribed"
variant="secondary"
size="sm"
class="text-base-foreground"
data-testid="add-credits-button"
@click="handleTopUp"
>
{{ $t('subscription.addCredits') }}
</Button>
<!-- Unsubscribed: Show Subscribe button -->
<SubscribeButton
v-else-if="isPersonalWorkspace"
:fluid="false"
:label="
isCancelled
? $t('subscription.resubscribe')
: $t('workspaceSwitcher.subscribe')
"
size="sm"
variant="gradient"
/>
<!-- Non-personal workspace: Show pricing table -->
<Button
v-else
variant="primary"
size="sm"
@click="handleOpenPlansAndPricing"
>
{{
isCancelled
? $t('subscription.resubscribe')
: $t('workspaceSwitcher.subscribe')
}}
</Button>
</div>
<!-- Credits Section -->
<Divider class="mx-0 my-2" />
</template>
<div class="flex items-center gap-2 px-4 py-2">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<Skeleton
v-if="isLoadingBalance"
width="4rem"
height="1.25rem"
class="w-full"
/>
<span v-else class="text-base font-semibold text-base-foreground">{{
displayedCredits
}}</span>
<i
v-tooltip="{ value: $t('credits.unified.tooltip'), showDelay: 300 }"
class="icon-[lucide--circle-help] mr-auto cursor-help text-base text-muted-foreground"
/>
<!-- Add Credits (subscribed + personal or workspace owner only) -->
<Button
v-if="isActiveSubscription && permissions.canTopUp"
variant="secondary"
size="sm"
class="text-base-foreground"
data-testid="add-credits-button"
@click="handleTopUp"
>
{{ $t('subscription.addCredits') }}
</Button>
<!-- Subscribe/Resubscribe (only when not subscribed or cancelled) -->
<SubscribeButton
v-if="showSubscribeAction && isPersonalWorkspace"
:fluid="false"
:label="
isCancelled
? $t('subscription.resubscribe')
: $t('workspaceSwitcher.subscribe')
"
size="sm"
variant="gradient"
/>
<Button
v-if="showSubscribeAction && !isPersonalWorkspace"
variant="primary"
size="sm"
@click="handleOpenPlansAndPricing"
>
{{
isCancelled
? $t('subscription.resubscribe')
: $t('workspaceSwitcher.subscribe')
}}
</Button>
</div>
<Divider class="mx-0 my-2" />
<!-- Plans & Pricing (PERSONAL and OWNER only) -->
<div
@@ -228,10 +226,9 @@ const workspaceStore = useTeamWorkspaceStore()
const {
initState,
workspaceName,
isInPersonalWorkspace: isPersonalWorkspace,
isWorkspaceSubscribed
isInPersonalWorkspace: isPersonalWorkspace
} = storeToRefs(workspaceStore)
const { workspaceRole } = useWorkspaceUI()
const { permissions } = useWorkspaceUI()
const workspaceSwitcherPopover = ref<InstanceType<typeof Popover> | null>(null)
const emit = defineEmits<{
@@ -275,13 +272,15 @@ const canUpgrade = computed(() => {
})
const showPlansAndPricing = computed(
() => isPersonalWorkspace.value || workspaceRole.value === 'owner'
() => permissions.value.canManageSubscription
)
const showManagePlan = computed(
() => showPlansAndPricing.value && isActiveSubscription.value
() => permissions.value.canManageSubscription && isActiveSubscription.value
)
const showCreditsSection = computed(
() => isPersonalWorkspace.value || workspaceRole.value === 'owner'
const showSubscribeAction = computed(
() =>
permissions.value.canManageSubscription &&
(!isActiveSubscription.value || isCancelled.value)
)
const handleOpenUserSettings = () => {

View File

@@ -36,7 +36,9 @@ export const usePriceBadge = () => {
return badges
}
function isCreditsBadge(badge: LGraphBadge | (() => LGraphBadge)): boolean {
function isCreditsBadge(
badge: Partial<LGraphBadge> | (() => Partial<LGraphBadge>)
): boolean {
const badgeInstance = typeof badge === 'function' ? badge() : badge
return badgeInstance.icon?.image === componentIconSvg
}
@@ -61,6 +63,7 @@ export const usePriceBadge = () => {
}
return {
getCreditsBadge,
isCreditsBadge,
updateSubgraphCredits
}
}

View File

@@ -1,5 +1,5 @@
import type { MenuItem } from 'primevue/menuitem'
import { ref } from 'vue'
import { shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
@@ -8,12 +8,14 @@ import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
* Use this to handle folder operations in a tree.
* @param expandNode - The function to expand a node.
*/
export function useTreeFolderOperations(
expandNode: (node: RenderedTreeExplorerNode) => void
export function useTreeFolderOperations<T>(
expandNode: (node: RenderedTreeExplorerNode<T>) => void
) {
const { t } = useI18n()
const newFolderNode = ref<RenderedTreeExplorerNode | null>(null)
const addFolderTargetNode = ref<RenderedTreeExplorerNode | null>(null)
const newFolderNode = shallowRef<RenderedTreeExplorerNode<T> | null>(null)
const addFolderTargetNode = shallowRef<RenderedTreeExplorerNode<T> | null>(
null
)
// Generate a unique temporary key for the new folder
const generateTempKey = (parentKey: string) => {
@@ -37,7 +39,7 @@ export function useTreeFolderOperations(
* The command to add a folder to a node via the context menu
* @param targetNode - The node where the folder will be added under
*/
const addFolderCommand = (targetNode: RenderedTreeExplorerNode) => {
const addFolderCommand = (targetNode: RenderedTreeExplorerNode<T>) => {
expandNode(targetNode)
newFolderNode.value = {
key: generateTempKey(targetNode.key),
@@ -49,13 +51,13 @@ export function useTreeFolderOperations(
totalLeaves: 0,
badgeText: '',
isEditingLabel: true
}
} as RenderedTreeExplorerNode<T>
addFolderTargetNode.value = targetNode
}
// Generate the "Add Folder" menu item
const getAddFolderMenuItem = (
targetNode: RenderedTreeExplorerNode | null
targetNode: RenderedTreeExplorerNode<T> | null
): MenuItem => {
return {
label: t('g.newFolder'),

View File

@@ -7,8 +7,13 @@ import type {
} from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { app } from '@/scripts/app'
import { isImageNode } from '@/utils/litegraphUtil'
import { pasteImageNode, usePaste } from './usePaste'
import { createNode, isImageNode } from '@/utils/litegraphUtil'
import {
cloneDataTransfer,
pasteImageNode,
pasteImageNodes,
usePaste
} from './usePaste'
function createMockNode() {
return {
@@ -86,6 +91,7 @@ vi.mock('@/lib/litegraph/src/litegraph', () => ({
}))
vi.mock('@/utils/litegraphUtil', () => ({
createNode: vi.fn(),
isAudioNode: vi.fn(),
isImageNode: vi.fn(),
isVideoNode: vi.fn()
@@ -99,34 +105,32 @@ describe('pasteImageNode', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(mockCanvas.graph!.add).mockImplementation(
(node: LGraphNode | LGraphGroup) => node as LGraphNode
(node: LGraphNode | LGraphGroup | null) => node as LGraphNode
)
})
it('should create new LoadImage node when no image node provided', () => {
it('should create new LoadImage node when no image node provided', async () => {
const mockNode = createMockNode()
vi.mocked(LiteGraph.createNode).mockReturnValue(
mockNode as unknown as LGraphNode
)
vi.mocked(createNode).mockResolvedValue(mockNode as unknown as LGraphNode)
const file = createImageFile()
const dataTransfer = createDataTransfer([file])
pasteImageNode(mockCanvas as unknown as LGraphCanvas, dataTransfer.items)
await pasteImageNode(
mockCanvas as unknown as LGraphCanvas,
dataTransfer.items
)
expect(LiteGraph.createNode).toHaveBeenCalledWith('LoadImage')
expect(mockNode.pos).toEqual([100, 200])
expect(mockCanvas.graph!.add).toHaveBeenCalledWith(mockNode)
expect(mockCanvas.graph!.change).toHaveBeenCalled()
expect(createNode).toHaveBeenCalledWith(mockCanvas, 'LoadImage')
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
})
it('should use existing image node when provided', () => {
it('should use existing image node when provided', async () => {
const mockNode = createMockNode()
const file = createImageFile()
const dataTransfer = createDataTransfer([file])
pasteImageNode(
await pasteImageNode(
mockCanvas as unknown as LGraphCanvas,
dataTransfer.items,
mockNode as unknown as LGraphNode
@@ -136,13 +140,13 @@ describe('pasteImageNode', () => {
expect(mockNode.pasteFiles).toHaveBeenCalledWith([file])
})
it('should handle multiple image files', () => {
it('should handle multiple image files', async () => {
const mockNode = createMockNode()
const file1 = createImageFile('test1.png')
const file2 = createImageFile('test2.jpg', 'image/jpeg')
const dataTransfer = createDataTransfer([file1, file2])
pasteImageNode(
await pasteImageNode(
mockCanvas as unknown as LGraphCanvas,
dataTransfer.items,
mockNode as unknown as LGraphNode
@@ -152,11 +156,11 @@ describe('pasteImageNode', () => {
expect(mockNode.pasteFiles).toHaveBeenCalledWith([file1, file2])
})
it('should do nothing when no image files present', () => {
it('should do nothing when no image files present', async () => {
const mockNode = createMockNode()
const dataTransfer = createDataTransfer()
pasteImageNode(
await pasteImageNode(
mockCanvas as unknown as LGraphCanvas,
dataTransfer.items,
mockNode as unknown as LGraphNode
@@ -166,13 +170,13 @@ describe('pasteImageNode', () => {
expect(mockNode.pasteFiles).not.toHaveBeenCalled()
})
it('should filter non-image items', () => {
it('should filter non-image items', async () => {
const mockNode = createMockNode()
const imageFile = createImageFile()
const textFile = new File([''], 'test.txt', { type: 'text/plain' })
const dataTransfer = createDataTransfer([textFile, imageFile])
pasteImageNode(
await pasteImageNode(
mockCanvas as unknown as LGraphCanvas,
dataTransfer.items,
mockNode as unknown as LGraphNode
@@ -183,21 +187,61 @@ describe('pasteImageNode', () => {
})
})
describe('pasteImageNodes', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should create multiple nodes for multiple files', async () => {
const mockNode1 = createMockNode()
const mockNode2 = createMockNode()
vi.mocked(createNode)
.mockResolvedValueOnce(mockNode1 as unknown as LGraphNode)
.mockResolvedValueOnce(mockNode2 as unknown as LGraphNode)
const file1 = createImageFile('test1.png')
const file2 = createImageFile('test2.jpg', 'image/jpeg')
const fileList = createDataTransfer([file1, file2]).files
const result = await pasteImageNodes(
mockCanvas as unknown as LGraphCanvas,
fileList
)
expect(createNode).toHaveBeenCalledTimes(2)
expect(createNode).toHaveBeenNthCalledWith(1, mockCanvas, 'LoadImage')
expect(createNode).toHaveBeenNthCalledWith(2, mockCanvas, 'LoadImage')
expect(mockNode1.pasteFile).toHaveBeenCalledWith(file1)
expect(mockNode2.pasteFile).toHaveBeenCalledWith(file2)
expect(result).toEqual([mockNode1, mockNode2])
})
it('should handle empty file list', async () => {
const fileList = createDataTransfer([]).files
const result = await pasteImageNodes(
mockCanvas as unknown as LGraphCanvas,
fileList
)
expect(createNode).not.toHaveBeenCalled()
expect(result).toEqual([])
})
})
describe('usePaste', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCanvas.current_node = null
mockWorkspaceStore.shiftDown = false
vi.mocked(mockCanvas.graph!.add).mockImplementation(
(node: LGraphNode | LGraphGroup) => node as LGraphNode
(node: LGraphNode | LGraphGroup | null) => node as LGraphNode
)
})
it('should handle image paste', async () => {
const mockNode = createMockNode()
vi.mocked(LiteGraph.createNode).mockReturnValue(
mockNode as unknown as LGraphNode
)
vi.mocked(createNode).mockResolvedValue(mockNode as unknown as LGraphNode)
usePaste()
@@ -207,7 +251,7 @@ describe('usePaste', () => {
document.dispatchEvent(event)
await vi.waitFor(() => {
expect(LiteGraph.createNode).toHaveBeenCalledWith('LoadImage')
expect(createNode).toHaveBeenCalledWith(mockCanvas, 'LoadImage')
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
})
})
@@ -312,3 +356,62 @@ describe('usePaste', () => {
})
})
})
describe('cloneDataTransfer', () => {
it('should clone string data', () => {
const original = new DataTransfer()
original.setData('text/plain', 'test text')
original.setData('text/html', '<p>test html</p>')
const cloned = cloneDataTransfer(original)
expect(cloned.getData('text/plain')).toBe('test text')
expect(cloned.getData('text/html')).toBe('<p>test html</p>')
})
it('should clone files', () => {
const file1 = createImageFile('test1.png')
const file2 = createImageFile('test2.jpg', 'image/jpeg')
const original = createDataTransfer([file1, file2])
const cloned = cloneDataTransfer(original)
// Files are added from both .files and .items, causing duplicates
expect(cloned.files.length).toBeGreaterThanOrEqual(2)
expect(Array.from(cloned.files)).toContain(file1)
expect(Array.from(cloned.files)).toContain(file2)
})
it('should preserve dropEffect and effectAllowed', () => {
const original = new DataTransfer()
original.dropEffect = 'copy'
original.effectAllowed = 'copyMove'
const cloned = cloneDataTransfer(original)
expect(cloned.dropEffect).toBe('copy')
expect(cloned.effectAllowed).toBe('copyMove')
})
it('should handle empty DataTransfer', () => {
const original = new DataTransfer()
const cloned = cloneDataTransfer(original)
expect(cloned.types.length).toBe(0)
expect(cloned.files.length).toBe(0)
})
it('should clone both string data and files', () => {
const file = createImageFile()
const original = createDataTransfer([file])
original.setData('text/plain', 'test')
const cloned = cloneDataTransfer(original)
expect(cloned.getData('text/plain')).toBe('test')
// Files are added from both .files and .items
expect(cloned.files.length).toBeGreaterThanOrEqual(1)
expect(Array.from(cloned.files)).toContain(file)
})
})

View File

@@ -6,9 +6,41 @@ import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/w
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isAudioNode, isImageNode, isVideoNode } from '@/utils/litegraphUtil'
import {
createNode,
isAudioNode,
isImageNode,
isVideoNode
} from '@/utils/litegraphUtil'
import { shouldIgnoreCopyPaste } from '@/workbench/eventHelpers'
export function cloneDataTransfer(original: DataTransfer): DataTransfer {
const persistent = new DataTransfer()
// Copy string data
for (const type of original.types) {
const data = original.getData(type)
if (data) {
persistent.setData(type, data)
}
}
for (const item of original.items) {
if (item.kind === 'file') {
const file = item.getAsFile()
if (file) {
persistent.items.add(file)
}
}
}
// Preserve dropEffect and effectAllowed
persistent.dropEffect = original.dropEffect
persistent.effectAllowed = original.effectAllowed
return persistent
}
function pasteClipboardItems(data: DataTransfer): boolean {
const rawData = data.getData('text/html')
const match = rawData.match(/data-metadata="([A-Za-z0-9+/=]+)"/)?.[1]
@@ -48,27 +80,37 @@ function pasteItemsOnNode(
)
}
export function pasteImageNode(
export async function pasteImageNode(
canvas: LGraphCanvas,
items: DataTransferItemList,
imageNode: LGraphNode | null = null
): void {
const {
graph,
graph_mouse: [posX, posY]
} = canvas
): Promise<LGraphNode | null> {
// No image node selected: add a new one
if (!imageNode) {
// No image node selected: add a new one
const newNode = LiteGraph.createNode('LoadImage')
if (newNode) {
newNode.pos = [posX, posY]
imageNode = graph?.add(newNode) ?? null
}
graph?.change()
imageNode = await createNode(canvas, 'LoadImage')
}
pasteItemsOnNode(items, imageNode, 'image')
return imageNode
}
export async function pasteImageNodes(
canvas: LGraphCanvas,
fileList: FileList
): Promise<LGraphNode[]> {
const nodes: LGraphNode[] = []
for (const file of fileList) {
const transfer = new DataTransfer()
transfer.items.add(file)
const imageNode = await pasteImageNode(canvas, transfer.items)
if (imageNode) {
nodes.push(imageNode)
}
}
return nodes
}
/**
@@ -93,6 +135,7 @@ export const usePaste = () => {
const { graph } = canvas
let data: DataTransfer | string | null = e.clipboardData
if (!data) throw new Error('No clipboard data on clipboard event')
data = cloneDataTransfer(data)
const { items } = data
@@ -114,7 +157,7 @@ export const usePaste = () => {
// Look for image paste data
for (const item of items) {
if (item.type.startsWith('image/')) {
pasteImageNode(canvas as LGraphCanvas, items, imageNode)
await pasteImageNode(canvas as LGraphCanvas, items, imageNode)
return
} else if (item.type.startsWith('video/')) {
if (!videoNode) {

View File

@@ -9,7 +9,7 @@ import {
VramManagement
} from '@/types/serverArgs'
export type ServerConfigValue = string | number | true | null | undefined
export type ServerConfigValue = string | number | boolean | null | undefined
export interface ServerConfig<T> extends FormItem {
id: string
@@ -19,7 +19,7 @@ export interface ServerConfig<T> extends FormItem {
getValue?: (value: T) => Record<string, ServerConfigValue>
}
export const SERVER_CONFIG_ITEMS: ServerConfig<any>[] = [
export const SERVER_CONFIG_ITEMS = [
// Network settings
{
id: 'listen',

View File

@@ -19,7 +19,7 @@ import { electronAPI as getElectronAPI } from '@/utils/envUtil'
const toastStore = useToastStore()
const { staticUrls, buildDocsUrl } = useExternalLink()
const onChangeRestartApp = (newValue: string, oldValue: string) => {
const onChangeRestartApp = (newValue: unknown, oldValue: unknown) => {
// Add a delay to allow changes to take effect before restarting.
if (oldValue !== undefined && newValue !== oldValue) {
electronAPI.restartApp('Restart ComfyUI to apply changes.', 1500)

View File

@@ -1,3 +1,4 @@
import type { ComfyExtension } from '@/types/comfy'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { app } from '../../scripts/app'
@@ -5,10 +6,17 @@ import { ComfyWidgets } from '../../scripts/widgets'
// Adds defaults for quickly adding nodes with middle click on the input/output
interface SlotDefaultsExtension extends ComfyExtension {
suggestionsNumber: { value: number } | null
slot_types_default_out: Record<string, string[]>
slot_types_default_in: Record<string, string[]>
setDefaults(maxNum?: number | null): void
}
app.registerExtension({
name: 'Comfy.SlotDefaults',
suggestionsNumber: null,
init() {
init(this: SlotDefaultsExtension) {
LiteGraph.search_filter_enabled = true
LiteGraph.middle_click_slot_add_default_node = true
this.suggestionsNumber = app.ui.settings.addSetting({
@@ -24,13 +32,13 @@ app.registerExtension({
},
defaultValue: 5,
onChange: (newVal) => {
this.setDefaults(newVal)
this.setDefaults(newVal as number)
}
})
},
slot_types_default_out: {},
slot_types_default_in: {},
async beforeRegisterNodeDef(nodeType, nodeData) {
async beforeRegisterNodeDef(this: SlotDefaultsExtension, nodeType, nodeData) {
var nodeId = nodeData.name
const inputs = nodeData['input']?.['required'] //only show required inputs to reduce the mess also not logical to create node with optional inputs
for (const inputKey in inputs) {
@@ -83,22 +91,23 @@ app.registerExtension({
}
}
var maxNum = this.suggestionsNumber.value
var maxNum = this.suggestionsNumber?.value
this.setDefaults(maxNum)
},
setDefaults(maxNum?: number | null) {
setDefaults(this: SlotDefaultsExtension, maxNum?: number | null) {
LiteGraph.slot_types_default_out = {}
LiteGraph.slot_types_default_in = {}
const max = maxNum ?? undefined
for (const type in this.slot_types_default_out) {
LiteGraph.slot_types_default_out[type] = this.slot_types_default_out[
type
].slice(0, maxNum)
].slice(0, max)
}
for (const type in this.slot_types_default_in) {
LiteGraph.slot_types_default_in[type] = this.slot_types_default_in[
type
].slice(0, maxNum)
].slice(0, max)
}
}
})

View File

@@ -48,6 +48,17 @@ describe('LGraph', () => {
expect(result1).toEqual(result2)
})
it('should handle adding null node gracefully', () => {
const graph = new LGraph()
const initialNodeCount = graph.nodes.length
const result = graph.add(null)
expect(result).toBeUndefined()
expect(graph.nodes.length).toBe(initialNodeCount)
})
test('can be instantiated', ({ expect }) => {
// @ts-expect-error Intentional - extra holds any / all consumer data that should be serialised
const graph = new LGraph({ extra: 'TestGraph' })

View File

@@ -90,12 +90,13 @@ export interface LGraphState {
lastRerouteId: number
}
type ParamsArray<
T extends Record<any, any>,
K extends MethodNames<T>
> = Parameters<T[K]>[1] extends undefined
? Parameters<T[K]> | Parameters<T[K]>[0]
: Parameters<T[K]>
type ParamsArray<T, K extends MethodNames<T>> = Parameters<
Extract<T[K], (...args: never[]) => unknown>
>[1] extends undefined
?
| Parameters<Extract<T[K], (...args: never[]) => unknown>>
| Parameters<Extract<T[K], (...args: never[]) => unknown>>[0]
: Parameters<Extract<T[K], (...args: never[]) => unknown>>
/** Configuration used by {@link LGraph} `config`. */
export interface LGraphConfig {
@@ -895,7 +896,7 @@ export class LGraph
* @deprecated Use options object instead
*/
add(
node: LGraphNode | LGraphGroup,
node: LGraphNode | LGraphGroup | null,
skipComputeOrder?: boolean
): LGraphNode | null | undefined
add(

View File

@@ -5060,7 +5060,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
octx.save()
const scale = window.devicePixelRatio
const scale = overlayCanvas.width / (overlayCanvas.clientWidth || 1)
octx.setTransform(scale, 0, 0, scale, 0, 0)
this.ds.toCanvasContext(octx)

View File

@@ -68,6 +68,7 @@
"icon": "Icon",
"color": "Color",
"error": "Error",
"enterSubgraph": "Enter Subgraph",
"resizeFromBottomRight": "Resize from bottom-right corner",
"resizeFromTopRight": "Resize from top-right corner",
"resizeFromBottomLeft": "Resize from bottom-left corner",

View File

@@ -172,16 +172,11 @@
</div>
</div>
<div class="flex flex-col lg:flex-row gap-6 pt-9">
<div class="flex flex-col shrink-0">
<div class="flex flex-col gap-3">
<div class="flex flex-col lg:flex-row lg:items-stretch gap-6 pt-6">
<div class="flex flex-col">
<div class="flex flex-col gap-3 h-full">
<div
:class="
cn(
'relative flex flex-col gap-6 rounded-2xl p-5',
'bg-modal-panel-background'
)
"
class="relative flex flex-col gap-6 rounded-2xl p-5 bg-modal-panel-background justify-between h-full"
>
<Button
variant="muted-textonly"
@@ -246,9 +241,15 @@
</tbody>
</table>
<div class="flex items-center justify-between">
<div
v-if="
isActiveSubscription &&
!showZeroState &&
permissions.canTopUp
"
class="flex items-center justify-between"
>
<Button
v-if="isActiveSubscription && !showZeroState"
variant="secondary"
class="p-2 min-h-8 rounded-lg text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
@click="handleAddApiCredits"
@@ -296,7 +297,11 @@
<!-- Members invoice card -->
<div
v-if="isActiveSubscription && !isInPersonalWorkspace"
v-if="
isActiveSubscription &&
!isInPersonalWorkspace &&
permissions.canManageSubscription
"
class="mt-6 flex gap-1 rounded-2xl border border-interface-stroke p-6 justify-between items-center text-sm"
>
<div class="flex flex-col gap-2">
@@ -319,7 +324,10 @@
</div>
<!-- View More Details - Outside main content -->
<div class="flex items-center gap-2 py-6">
<div
v-if="permissions.canManageSubscription"
class="flex items-center gap-2 py-6"
>
<i class="pi pi-external-link text-muted"></i>
<a
href="https://www.comfy.org/cloud/pricing"
@@ -366,7 +374,7 @@ import { cn } from '@/utils/tailwindUtil'
const workspaceStore = useTeamWorkspaceStore()
const { isWorkspaceSubscribed, isInPersonalWorkspace, members } =
storeToRefs(workspaceStore)
const { permissions, workspaceRole } = useWorkspaceUI()
const { permissions } = useWorkspaceUI()
const { t, n } = useI18n()
const toast = useToast()
@@ -421,7 +429,7 @@ const isCancelled = computed(
// Show subscribe prompt to owners without active subscription
// Don't show if subscription is cancelled (still active until end date)
const showSubscribePrompt = computed(() => {
if (workspaceRole.value !== 'owner') return false
if (!permissions.value.canManageSubscription) return false
if (isCancelled.value) return false
if (isInPersonalWorkspace.value) return !isActiveSubscription.value
return !isWorkspaceSubscribed.value

View File

@@ -72,7 +72,7 @@ import FormItem from '@/components/common/FormItem.vue'
import PanelTemplate from '@/components/dialog/content/setting/PanelTemplate.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import type { ServerConfig } from '@/constants/serverConfig'
import type { ServerConfig, ServerConfigValue } from '@/constants/serverConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { FormItem as FormItemType } from '@/platform/settings/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
@@ -133,7 +133,7 @@ onBeforeUnmount(() => {
})
})
const translateItem = (item: ServerConfig<any>): FormItemType => {
const translateItem = (item: ServerConfig<ServerConfigValue>): FormItemType => {
return {
...item,
name: t(`serverConfigItems.${item.id}.name`, item.name),

View File

@@ -165,7 +165,9 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultsByInstallVersion: {
'1.25.0': 'legacy'
},
onChange: async (newValue: string, oldValue?: string) => {
onChange: async (val: unknown, old?: unknown) => {
const newValue = val as string
const oldValue = old as string | undefined
if (!oldValue) return
const settingStore = useSettingStore()
@@ -194,7 +196,8 @@ export const CORE_SETTINGS: SettingParams[] = [
{ value: 'select', text: 'Select' }
],
versionAdded: '1.27.4',
onChange: async (newValue: string) => {
onChange: async (val: unknown) => {
const newValue = val as string
const settingStore = useSettingStore()
const navigationMode = settingStore.get('Comfy.Canvas.NavigationMode')
@@ -223,7 +226,8 @@ export const CORE_SETTINGS: SettingParams[] = [
{ value: 'zoom', text: 'Zoom in/out' }
],
versionAdded: '1.27.4',
onChange: async (newValue: string) => {
onChange: async (val: unknown) => {
const newValue = val as string
const settingStore = useSettingStore()
const navigationMode = settingStore.get('Comfy.Canvas.NavigationMode')
@@ -569,7 +573,8 @@ export const CORE_SETTINGS: SettingParams[] = [
type: 'combo',
options: ['Disabled', 'Top'],
tooltip: 'Enable the redesigned top menu bar.',
migrateDeprecatedValue: (value: string) => {
migrateDeprecatedValue: (val: unknown) => {
const value = val as string
// Floating is now supported by dragging the docked actionbar off.
if (value === 'Floating') {
return 'Top'
@@ -585,7 +590,8 @@ export const CORE_SETTINGS: SettingParams[] = [
type: 'combo',
options: ['Sidebar', 'Topbar'],
defaultValue: 'Topbar',
migrateDeprecatedValue: (value: string) => {
migrateDeprecatedValue: (val: unknown) => {
const value = val as string
if (value === 'Topbar (2nd-row)') {
return 'Topbar'
}
@@ -615,9 +621,8 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: [] as Keybinding[],
versionAdded: '1.3.7',
versionModified: '1.7.3',
migrateDeprecatedValue: (
value: (Keybinding & { targetSelector?: string })[]
) => {
migrateDeprecatedValue: (val: unknown) => {
const value = val as (Keybinding & { targetSelector?: string })[]
return value.map((keybinding) => {
if (keybinding.targetSelector === '#graph-canvas') {
keybinding.targetElementId = 'graph-canvas-container'
@@ -886,7 +891,8 @@ export const CORE_SETTINGS: SettingParams[] = [
type: 'hidden',
defaultValue: 'dark',
versionModified: '1.6.7',
migrateDeprecatedValue(value: string) {
migrateDeprecatedValue(val: unknown) {
const value = val as string
// Legacy custom palettes were prefixed with 'custom_'
return value.startsWith('custom_') ? value.replace('custom_', '') : value
}

View File

@@ -118,7 +118,7 @@ describe('useSettingStore', () => {
name: 'test.setting',
type: 'text',
defaultValue: 'default',
migrateDeprecatedValue: (value: string) => value.toUpperCase()
migrateDeprecatedValue: (val: unknown) => (val as string).toUpperCase()
}
store.settingValues['test.setting'] = 'oldvalue'

View File

@@ -26,11 +26,11 @@ export interface SettingOption {
value?: string | number
}
export interface SettingParams<TValue = any> extends FormItem {
export interface SettingParams<TValue = unknown> extends FormItem {
id: keyof Settings
defaultValue: TValue | (() => TValue)
defaultsByInstallVersion?: Record<`${number}.${number}.${number}`, TValue>
onChange?: (newValue: TValue, oldValue?: TValue) => void
onChange?(newValue: TValue, oldValue?: TValue): void
// By default category is id.split('.'). However, changing id to assign
// new category has poor backward compatibility. Use this field to overwrite
// default category from id.

View File

@@ -14,6 +14,7 @@ interface WorkspacePermissions {
canLeaveWorkspace: boolean
canAccessWorkspaceMenu: boolean
canManageSubscription: boolean
canTopUp: boolean
}
/** UI configuration for workspace role */
@@ -44,7 +45,8 @@ function getPermissions(
canRemoveMembers: false,
canLeaveWorkspace: false,
canAccessWorkspaceMenu: false,
canManageSubscription: true
canManageSubscription: true,
canTopUp: true
}
}
@@ -57,7 +59,8 @@ function getPermissions(
canRemoveMembers: true,
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManageSubscription: true
canManageSubscription: true,
canTopUp: true
}
}
@@ -70,7 +73,8 @@ function getPermissions(
canRemoveMembers: false,
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManageSubscription: false
canManageSubscription: false,
canTopUp: false
}
}

View File

@@ -68,6 +68,7 @@ export class LitegraphLinkAdapter {
case LinkDirection.DOWN:
return 'down'
case LinkDirection.CENTER:
case LinkDirection.NONE:
return 'none'
default:
return 'right'

View File

@@ -23,7 +23,7 @@
:class="
cn(
'-translate-x-1/2 w-3',
hasSlotError &&
hasError &&
'before:ring-2 before:ring-error before:ring-offset-0 before:size-4 before:absolute before:rounded-full before:pointer-events-none'
)
"
@@ -40,7 +40,7 @@
:class="
cn(
'truncate text-node-component-slot-text',
hasSlotError && 'text-error font-medium'
hasError && 'text-error font-medium'
)
"
>
@@ -65,19 +65,19 @@ 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 { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
import { useExecutionStore } from '@/stores/executionStore'
import { cn } from '@/utils/tailwindUtil'
import SlotConnectionDot from './SlotConnectionDot.vue'
interface InputSlotProps {
slotData: INodeSlot
compatible?: boolean
connected?: boolean
dotOnly?: boolean
hasError?: boolean
index: number
nodeType?: string
nodeId?: string
slotData: INodeSlot
index: number
connected?: boolean
compatible?: boolean
dotOnly?: boolean
socketless?: boolean
}
@@ -91,18 +91,6 @@ const hasNoLabel = computed(
)
const dotOnly = computed(() => props.dotOnly || hasNoLabel.value)
const executionStore = useExecutionStore()
const hasSlotError = computed(() => {
const nodeErrors = executionStore.lastNodeErrors?.[props.nodeId ?? '']
if (!nodeErrors) return false
const slotName = props.slotData.name
return nodeErrors.errors.some(
(error) => error.extra_info?.input_name === slotName
)
})
const renderError = ref<string | null>(null)
const { toastErrorHandler } = useErrorHandling()

View File

@@ -7,11 +7,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type {
LGraph,
LGraphNode,
LGraphNode as LGLGraphNode,
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import NodeHeader from '@/renderer/extensions/vueNodes/components/NodeHeader.vue'
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
const mockApp: { rootGraph?: Partial<LGraph> } = vi.hoisted(() => ({}))
@@ -56,7 +56,7 @@ vi.mock('@/i18n', () => ({
}
}))
describe('NodeHeader - Subgraph Functionality', () => {
describe('Vue Node - Subgraph Functionality', () => {
// Helper to setup common mocks
const setupMocks = async (isSubgraph = true, hasGraph = true) => {
if (hasGraph) mockApp.rootGraph = {}
@@ -64,7 +64,7 @@ describe('NodeHeader - Subgraph Functionality', () => {
vi.mocked(getNodeByLocatorId).mockReturnValue({
isSubgraphNode: (): this is SubgraphNode => isSubgraph
} as LGraphNode)
} as LGLGraphNode)
}
beforeEach(() => {
@@ -89,8 +89,8 @@ describe('NodeHeader - Subgraph Functionality', () => {
flags: {}
})
const createWrapper = (props = {}) => {
return mount(NodeHeader, {
const createWrapper = (props: { nodeData: VueNodeData }) => {
return mount(LGraphNode, {
props,
global: {
plugins: [createTestingPinia({ createSpy: vi.fn })],
@@ -106,8 +106,7 @@ describe('NodeHeader - Subgraph Functionality', () => {
await setupMocks(true) // isSubgraph = true
const wrapper = createWrapper({
nodeData: createMockNodeData('test-node-1'),
readonly: false
nodeData: createMockNodeData('test-node-1')
})
await wrapper.vm.$nextTick()
@@ -120,8 +119,7 @@ describe('NodeHeader - Subgraph Functionality', () => {
await setupMocks(false) // isSubgraph = false
const wrapper = createWrapper({
nodeData: createMockNodeData('test-node-1'),
readonly: false
nodeData: createMockNodeData('test-node-1')
})
await wrapper.vm.$nextTick()
@@ -130,29 +128,11 @@ describe('NodeHeader - Subgraph Functionality', () => {
expect(subgraphButton.exists()).toBe(false)
})
it('should emit enter-subgraph event when button is clicked', async () => {
await setupMocks(true) // isSubgraph = true
const wrapper = createWrapper({
nodeData: createMockNodeData('test-node-1'),
readonly: false
})
await wrapper.vm.$nextTick()
const subgraphButton = wrapper.find('[data-testid="subgraph-enter-button"]')
await subgraphButton.trigger('click')
expect(wrapper.emitted('enter-subgraph')).toBeTruthy()
expect(wrapper.emitted('enter-subgraph')).toHaveLength(1)
})
it('should handle subgraph context correctly', async () => {
await setupMocks(true) // isSubgraph = true
const wrapper = createWrapper({
nodeData: createMockNodeData('test-node-1', 'subgraph-id'),
readonly: false
nodeData: createMockNodeData('test-node-1', 'subgraph-id')
})
await wrapper.vm.$nextTick()
@@ -167,26 +147,11 @@ describe('NodeHeader - Subgraph Functionality', () => {
expect(subgraphButton.exists()).toBe(true)
})
it('should handle missing graph gracefully', async () => {
await setupMocks(true, false) // isSubgraph = true, hasGraph = false
const wrapper = createWrapper({
nodeData: createMockNodeData('test-node-1'),
readonly: false
})
await wrapper.vm.$nextTick()
const subgraphButton = wrapper.find('[data-testid="subgraph-enter-button"]')
expect(subgraphButton.exists()).toBe(false)
})
it('should prevent event propagation on double click', async () => {
await setupMocks(true) // isSubgraph = true
const wrapper = createWrapper({
nodeData: createMockNodeData('test-node-1'),
readonly: false
nodeData: createMockNodeData('test-node-1')
})
await wrapper.vm.$nextTick()

View File

@@ -9,7 +9,7 @@
:data-node-id="nodeData.id"
:class="
cn(
'bg-component-node-background lg-node absolute text-sm',
'group/node bg-node-component-header-surface lg-node absolute text-sm',
'contain-style contain-layout min-w-[225px] min-h-(--node-height) w-(--node-width)',
shapeClass,
'touch-none flex flex-col',
@@ -28,11 +28,9 @@
muted,
'ring-4 ring-primary-500 bg-primary-500/10': isDraggingOver
},
shouldHandleNodePointerEvents && !nodeData.flags?.ghost
? 'pointer-events-auto'
: 'pointer-events-none',
!isCollapsed && ' pb-1'
: 'pointer-events-none'
)
"
:style="[
@@ -40,7 +38,8 @@
transform: `translate(${position.x ?? 0}px, ${(position.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`,
zIndex: zIndex,
opacity: nodeOpacity,
'--component-node-background': applyLightThemeColor(nodeData.bgcolor)
'--component-node-background': applyLightThemeColor(nodeData.bgcolor),
backgroundColor: applyLightThemeColor(nodeData?.color)
}
]"
v-bind="remainingPointerHandlers"
@@ -71,9 +70,9 @@
<NodeHeader
:node-data="nodeData"
:collapsed="isCollapsed"
:price-badges="badges.pricing"
@collapse="handleCollapse"
@update:title="handleHeaderTitleUpdate"
@enter-subgraph="handleEnterSubgraph"
/>
</div>
@@ -89,7 +88,7 @@
/>
<template v-if="!isCollapsed">
<div class="relative mb-1">
<div class="relative">
<!-- Progress bar for executing state -->
<div
v-if="executing && progress !== undefined"
@@ -105,7 +104,7 @@
</div>
<div
class="flex flex-1 flex-col gap-1 pb-2"
class="flex flex-1 flex-col gap-1 pt-1 pb-3 bg-component-node-background rounded-b-2xl"
:data-testid="`node-body-${nodeData.id}`"
>
<NodeSlots :node-data="nodeData" />
@@ -120,42 +119,75 @@
v-if="shouldShowPreviewImg"
:image-url="latestPreviewUrl"
/>
<!-- Show advanced inputs button for subgraph nodes -->
<div v-if="showAdvancedInputsButton" class="flex justify-center px-3">
<button
:class="
cn(
WidgetInputBaseClass,
'w-full h-7 flex justify-center items-center gap-2 text-sm px-3 outline-0 ring-0 truncate',
'transition-all cursor-pointer hover:bg-accent-background duration-150 active:scale-95'
)
"
@click.stop="showAdvancedState = !showAdvancedState"
>
<template v-if="showAdvancedState">
<i class="icon-[lucide--chevron-up] size-4" />
<span>{{ t('rightSidePanel.hideAdvancedInputsButton') }}</span>
</template>
<template v-else>
<i class="icon-[lucide--settings-2] size-4" />
<span>{{ t('rightSidePanel.showAdvancedInputsButton') }} </span>
</template>
</button>
</div>
<NodeBadges v-bind="badges" :pricing="undefined" />
</div>
</template>
<Button
variant="textonly"
:class="
cn(
'w-full h-12 rounded-b-2xl -mt-5 pt-7 pb-2 -z-1 text-xs',
hasAnyError && 'hover:bg-destructive-background-hover'
)
"
as-child
>
<button
v-if="hasAnyError"
@click.stop="useRightSidePanelStore().openPanel('error')"
>
<span>{{ t('g.error') }}</span>
<i class="icon-[lucide--info] size-4" />
</button>
<button
v-else-if="lgraphNode?.isSubgraphNode()"
data-testid="subgraph-enter-button"
@click.stop="handleEnterSubgraph"
>
<span>{{ t('g.enterSubgraph') }}</span>
<i class="icon-[comfy--workflow] size-4" />
</button>
<button
v-else-if="showAdvancedState || showAdvancedInputsButton"
@click.stop="showAdvancedState = !showAdvancedState"
>
<template v-if="showAdvancedState">
<span>{{ t('rightSidePanel.hideAdvancedInputsButton') }}</span>
<i class="icon-[lucide--chevron-up] size-4" />
</template>
<template v-else>
<span>{{ t('rightSidePanel.showAdvancedInputsButton') }} </span>
<i class="icon-[lucide--settings-2] size-4" />
</template>
</button>
</Button>
<!-- Resize handle (bottom-right only) -->
<div
v-if="!isCollapsed && nodeData.resizable !== false"
role="button"
:aria-label="t('g.resizeFromBottomRight')"
:class="
cn(baseResizeHandleClasses, '-right-1 -bottom-1 cursor-se-resize')
cn(
baseResizeHandleClasses,
'-right-1 -bottom-1 cursor-se-resize group-hover/node:opacity-100'
)
"
@pointerdown.stop="handleResizePointerDown"
/>
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 12 12"
class="w-2/5 h-2/5 top-1 left-1 absolute"
>
<path
d="M11 1L1 11M11 6L6 11"
stroke="var(--color-muted-foreground)"
stroke-width="0.975"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
</div>
</template>
@@ -172,6 +204,7 @@ import {
} from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
import { useErrorHandling } from '@/composables/useErrorHandling'
@@ -189,10 +222,12 @@ import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import NodeBadges from '@/renderer/extensions/vueNodes/components/NodeBadges.vue'
import SlotConnectionDot from '@/renderer/extensions/vueNodes/components/SlotConnectionDot.vue'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/composables/useNodePointerInteractions'
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
import { usePartitionedBadges } from '@/renderer/extensions/vueNodes/composables/usePartitionedBadges'
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState'
import { useNodeDrag } from '@/renderer/extensions/vueNodes/layout/useNodeDrag'
@@ -212,7 +247,6 @@ import {
import { cn } from '@/utils/tailwindUtil'
import { useNodeResize } from '../interactions/resize/useNodeResize'
import { WidgetInputBaseClass } from '../widgets/components/layout'
import LivePreview from './LivePreview.vue'
import NodeContent from './NodeContent.vue'
import NodeHeader from './NodeHeader.vue'
@@ -299,6 +333,7 @@ const { position, size, zIndex } = useNodeLayout(() => nodeData.id)
const { pointerHandlers } = useNodePointerInteractions(() => nodeData.id)
const { onPointerdown, ...remainingPointerHandlers } = pointerHandlers
const { startDrag } = useNodeDrag()
const badges = usePartitionedBadges(nodeData)
async function nodeOnPointerdown(event: PointerEvent) {
if (event.altKey && lgraphNode.value) {
@@ -405,7 +440,7 @@ const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState(
)
const borderClass = computed(() => {
if (hasAnyError.value) return 'border-node-stroke-error'
if (hasAnyError.value) return 'border-node-stroke-error bg-error'
//FIXME need a better way to detecting transparency
if (
!displayHeader.value &&

View File

@@ -0,0 +1,55 @@
<script setup lang="ts">
import NodeBadge from '@/renderer/extensions/vueNodes/components/NodeBadge.vue'
import type { NodeBadgeProps } from '@/renderer/extensions/vueNodes/components/NodeBadge.vue'
defineProps<{
hasComfyBadge: boolean
core: NodeBadgeProps[]
extension: NodeBadgeProps[]
}>()
</script>
<template>
<div
v-if="hasComfyBadge || core.length || extension.length"
class="flex h-5 w-full gap-2 px-2 text-muted-foreground"
>
<div
v-if="hasComfyBadge"
class="rounded-full bg-component-node-widget-background size-6 flex justify-center items-center"
>
<i class="icon-[comfy--comfy-c] size-3" />
</div>
<div
v-if="core.length"
class="rounded-full bg-component-node-widget-background h-6 flex justify-center items-center overflow-clip"
>
<template v-for="(badge, index) of core" :key="badge.text">
<div
v-if="index !== 0"
class="border-muted-foreground border-r h-4 mr-0.5 pr-0.5"
/>
<NodeBadge
bg-color="transparent"
v-bind="badge"
class="h-6 first:pl-2 last:pr-2"
/>
</template>
</div>
<div
v-if="extension.length"
class="rounded-full bg-component-node-widget-background h-6 flex justify-center items-center overflow-clip"
>
<template v-for="(badge, index) of extension" :key="badge.text">
<div
v-if="index !== 0"
class="border-muted-foreground border-r h-4 mr-0.5 pr-0.5"
/>
<NodeBadge
bg-color="transparent"
v-bind="badge"
class="h-6 first:pl-2 last:pr-2"
/>
</template>
</div>
</div>
</template>

View File

@@ -7,20 +7,16 @@
:class="
cn(
'lg-node-header text-sm py-2 pl-2 pr-3 w-full min-w-0',
'text-node-component-header bg-node-component-header-surface',
'text-node-component-header',
headerShapeClass
)
"
:style="{
backgroundColor: applyLightThemeColor(nodeData?.color),
opacity: useSettingStore().get('Comfy.Node.Opacity') ?? 1
}"
:data-testid="`node-header-${nodeData?.id || ''}`"
@dblclick="handleDoubleClick"
>
<div class="flex items-center justify-between gap-2.5 min-w-0">
<!-- Collapse/Expand Button -->
<div class="relative grow-1 flex items-center gap-2.5 min-w-0 flex-1">
<div class="relative flex items-center gap-2.5 min-w-0 shrink-1 mr-auto">
<div class="flex shrink-0 items-center px-0.5">
<Button
size="icon-sm"
@@ -41,17 +37,13 @@
/>
</Button>
</div>
<div v-if="isSubgraphNode" class="icon-[comfy--workflow] size-4" />
<div v-if="isApiNode" class="icon-[lucide--component] size-4" />
<!-- Node Title -->
<div
v-tooltip.top="tooltipConfig"
class="flex min-w-0 flex-1 items-center gap-2"
data-testid="node-title"
>
<div class="truncate min-w-0 flex-1">
<div class="truncate flex-1">
<EditableText
:model-value="displayTitle"
:is-editing="isEditing"
@@ -63,56 +55,46 @@
</div>
</div>
<div class="flex shrink-0 items-center justify-between gap-2">
<NodeBadge
v-for="badge of nodeBadges"
:key="badge.text"
v-bind="badge"
/>
<NodeBadge v-if="statusBadge" v-bind="statusBadge" />
<i-comfy:pin
v-if="isPinned"
class="size-5"
data-testid="node-pin-indicator"
/>
<Button
v-if="isSubgraphNode"
v-tooltip.top="enterSubgraphTooltipConfig"
variant="textonly"
size="sm"
data-testid="subgraph-enter-button"
class="text-node-component-header h-5 px-0.5"
@click.stop="handleEnterSubgraph"
@dblclick.stop
<template v-for="badge in priceBadges ?? []" :key="badge.required">
<span
:class="
cn(
'flex h-5 bg-component-node-widget-background p-1 items-center text-xs shrink-0',
badge.rest ? 'rounded-l-full pr-1' : 'rounded-full'
)
"
>
<span>{{ $t('g.edit') }}</span>
<i class="icon-[lucide--scaling] size-5" />
</Button>
</div>
<i class="h-full icon-[lucide--component] bg-amber-400" />
<span class="truncate" v-text="badge.required" />
</span>
<span
v-if="badge.rest"
class="truncate -ml-2.5 grow-1 basis-0 bg-component-node-widget-background rounded-r-full max-w-max min-w-0"
>
<span class="pr-2" v-text="badge.rest" />
</span>
</template>
<NodeBadge v-if="statusBadge" v-bind="statusBadge" />
<i
v-if="isPinned"
class="size-5 icon-[comfy--pin]"
data-testid="node-pin-indicator"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onErrorCaptured, ref, toValue, watch } from 'vue'
import { computed, onErrorCaptured, ref, watch } from 'vue'
import EditableText from '@/components/common/EditableText.vue'
import Button from '@/components/ui/button/Button.vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useNodePricing } from '@/composables/node/useNodePricing'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { st } from '@/i18n'
import { LGraphEventMode, RenderShape } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import NodeBadge from '@/renderer/extensions/vueNodes/components/NodeBadge.vue'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeStyleUtils'
import { app } from '@/scripts/app'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import {
getLocatorIdFromNodeData,
getNodeByLocatorId
} from '@/utils/graphTraversalUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { cn } from '@/utils/tailwindUtil'
@@ -121,6 +103,7 @@ import type { NodeBadgeProps } from './NodeBadge.vue'
interface NodeHeaderProps {
nodeData?: VueNodeData
collapsed?: boolean
priceBadges?: { required: string; rest?: string }[]
}
const { nodeData, collapsed } = defineProps<NodeHeaderProps>()
@@ -128,7 +111,6 @@ const { nodeData, collapsed } = defineProps<NodeHeaderProps>()
const emit = defineEmits<{
collapse: []
'update:title': [newTitle: string]
'enter-subgraph': []
}>()
// Error boundary implementation
@@ -156,10 +138,6 @@ const tooltipConfig = computed(() => {
return createTooltipConfig(description)
})
const enterSubgraphTooltipConfig = computed(() => {
return createTooltipConfig(st('enterSubgraph', 'Enter Subgraph'))
})
const resolveTitle = (info: VueNodeData | undefined) => {
const untitledLabel = st('g.untitled', 'Untitled')
return resolveNodeDisplayName(info ?? null, {
@@ -185,71 +163,7 @@ const statusBadge = computed((): NodeBadgeProps | undefined =>
: undefined
)
// Use per-node pricing revision to re-compute badges only when this node's pricing updates
const {
getRelevantWidgetNames,
hasDynamicPricing,
getInputGroupPrefixes,
getInputNames,
getNodeRevisionRef
} = useNodePricing()
// Cache pricing metadata (won't change during node lifetime)
const isDynamicPricing = computed(() =>
nodeData?.apiNode ? hasDynamicPricing(nodeData.type) : false
)
const relevantPricingWidgets = computed(() =>
nodeData?.apiNode ? getRelevantWidgetNames(nodeData.type) : []
)
const inputGroupPrefixes = computed(() =>
nodeData?.apiNode ? getInputGroupPrefixes(nodeData.type) : []
)
const relevantInputNames = computed(() =>
nodeData?.apiNode ? getInputNames(nodeData.type) : []
)
const nodeBadges = computed<NodeBadgeProps[]>(() => {
// For ALL API nodes: access per-node revision ref to detect when async pricing evaluation completes
// This is needed even for static pricing because JSONata 2.x evaluation is async
if (nodeData?.apiNode && nodeData?.id != null) {
// Access per-node revision ref to establish dependency (each node has its own ref)
void getNodeRevisionRef(nodeData.id).value
// For dynamic pricing, also track widget values and input connections
if (isDynamicPricing.value) {
// Access only the widget values that affect pricing (from widgetValueStore)
const relevantNames = relevantPricingWidgets.value
const widgetStore = useWidgetValueStore()
if (relevantNames.length > 0 && nodeData?.id != null) {
for (const name of relevantNames) {
// Access value from store to create reactive dependency
void widgetStore.getWidget(nodeData.id, name)?.value
}
}
// Access input connections for regular inputs
const inputNames = relevantInputNames.value
if (inputNames.length > 0) {
nodeData?.inputs?.forEach((inp) => {
if (inp.name && inputNames.includes(inp.name)) {
void inp.link // Access link to create reactive dependency
}
})
}
// Access input connections for input_groups (e.g., autogrow inputs)
const groupPrefixes = inputGroupPrefixes.value
if (groupPrefixes.length > 0) {
nodeData?.inputs?.forEach((inp) => {
if (
groupPrefixes.some((prefix) => inp.name?.startsWith(prefix + '.'))
) {
void inp.link // Access link to create reactive dependency
}
})
}
}
}
return [...(nodeData?.badges ?? [])].map(toValue)
})
const isPinned = computed(() => Boolean(nodeData?.flags?.pinned))
const isApiNode = computed(() => Boolean(nodeData?.apiNode))
const headerShapeClass = computed(() => {
if (collapsed) {
@@ -272,22 +186,6 @@ const headerShapeClass = computed(() => {
}
})
// Subgraph detection
const isSubgraphNode = computed(() => {
if (!nodeData?.id) return false
// Get the underlying LiteGraph node
const graph = app.rootGraph
if (!graph) return false
const locatorId = getLocatorIdFromNodeData(nodeData)
const litegraphNode = getNodeByLocatorId(graph, locatorId)
// Use the official type guard method
return litegraphNode?.isSubgraphNode() ?? false
})
// Watch for external changes to the node title or type
watch(
() => [nodeData?.title, nodeData?.type] as const,
@@ -320,8 +218,4 @@ const handleTitleEdit = (newTitle: string) => {
const handleTitleCancel = () => {
isEditing.value = false
}
const handleEnterSubgraph = () => {
emit('enter-subgraph')
}
</script>

View File

@@ -47,6 +47,7 @@
boundingRect: [0, 0, 0, 0]
}"
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
:has-error="widget.hasError"
:index="widget.slotMetadata.index"
:socketless="widget.simplified.spec?.socketless"
dot-only
@@ -60,7 +61,12 @@
:widget="widget.simplified"
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
:node-type="nodeType"
class="col-span-2"
:class="
cn(
'col-span-2',
widget.hasError && 'text-node-stroke-error font-bold'
)
"
@update:model-value="widget.updateHandler"
/>
</div>
@@ -95,6 +101,7 @@ import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { useExecutionStore } from '@/stores/executionStore'
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
@@ -109,6 +116,7 @@ const { nodeData } = defineProps<NodeWidgetsProps>()
const { shouldHandleNodePointerEvents, forwardEventToCanvas } =
useCanvasInteractions()
const { bringNodeToFront } = useNodeZIndex()
const executionStore = useExecutionStore()
function handleWidgetPointerEvent(event: PointerEvent) {
if (shouldHandleNodePointerEvents.value) return
@@ -146,21 +154,23 @@ const { getWidgetTooltip, createTooltipConfig } = useNodeTooltips(
const widgetValueStore = useWidgetValueStore()
interface ProcessedWidget {
name: string
type: string
vueComponent: Component
simplified: SimplifiedWidget
value: WidgetValue
updateHandler: (value: WidgetValue) => void
tooltipConfig: TooltipOptions
slotMetadata?: WidgetSlotMetadata
hidden: boolean
advanced: boolean
hasLayoutSize: boolean
hasError: boolean
hidden: boolean
name: string
simplified: SimplifiedWidget
tooltipConfig: TooltipOptions
type: string
updateHandler: (value: WidgetValue) => void
value: WidgetValue
vueComponent: Component
slotMetadata?: WidgetSlotMetadata
}
const processedWidgets = computed((): ProcessedWidget[] => {
if (!nodeData?.widgets) return []
const nodeErrors = executionStore.lastNodeErrors?.[nodeData.id ?? '']
const nodeId = nodeData.id
const { widgets } = nodeData
@@ -220,6 +230,13 @@ const processedWidgets = computed((): ProcessedWidget[] => {
const tooltipConfig = createTooltipConfig(tooltipText)
result.push({
advanced: widget.options?.advanced ?? false,
hasLayoutSize: widget.hasLayoutSize ?? false,
hasError:
nodeErrors?.errors?.some(
(error) => error.extra_info?.input_name === widget.name
) ?? false,
hidden: widget.options?.hidden ?? false,
name: widget.name,
type: widget.type,
vueComponent,
@@ -227,10 +244,7 @@ const processedWidgets = computed((): ProcessedWidget[] => {
value,
updateHandler,
tooltipConfig,
slotMetadata,
hidden: widget.options?.hidden ?? false,
advanced: widget.options?.advanced ?? false,
hasLayoutSize: widget.hasLayoutSize ?? false
slotMetadata
})
}

View File

@@ -0,0 +1,135 @@
import { trim } from 'es-toolkit'
import { computed, toValue } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useNodePricing } from '@/composables/node/useNodePricing'
import { usePriceBadge } from '@/composables/node/usePriceBadge'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { NodeBadgeProps } from '@/renderer/extensions/vueNodes/components/NodeBadge.vue'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { NodeBadgeMode } from '@/types/nodeSource'
function splitAroundFirstSpace(text: string): [string, string | undefined] {
const index = text.indexOf(' ')
if (index === -1) return [text, undefined]
return [text.slice(0, index), text.slice(index + 1)]
}
export function usePartitionedBadges(nodeData: VueNodeData) {
// Use per-node pricing revision to re-compute badges only when this node's pricing updates
const {
getRelevantWidgetNames,
hasDynamicPricing,
getInputGroupPrefixes,
getInputNames,
getNodeRevisionRef
} = useNodePricing()
const { isCreditsBadge } = usePriceBadge()
const settingStore = useSettingStore()
// Cache pricing metadata (won't change during node lifetime)
const isDynamicPricing = computed(() =>
nodeData?.apiNode ? hasDynamicPricing(nodeData.type) : false
)
const relevantPricingWidgets = computed(() =>
nodeData?.apiNode ? getRelevantWidgetNames(nodeData.type) : []
)
const inputGroupPrefixes = computed(() =>
nodeData?.apiNode ? getInputGroupPrefixes(nodeData.type) : []
)
const relevantInputNames = computed(() =>
nodeData?.apiNode ? getInputNames(nodeData.type) : []
)
const unpartitionedBadges = computed<NodeBadgeProps[]>(() => {
// For ALL API nodes: access per-node revision ref to detect when async pricing evaluation completes
// This is needed even for static pricing because JSONata 2.x evaluation is async
if (nodeData?.apiNode && nodeData?.id != null) {
// Access per-node revision ref to establish dependency (each node has its own ref)
void getNodeRevisionRef(nodeData.id).value
// For dynamic pricing, also track widget values and input connections
if (isDynamicPricing.value) {
// Access only the widget values that affect pricing (from widgetValueStore)
const relevantNames = relevantPricingWidgets.value
const widgetStore = useWidgetValueStore()
if (relevantNames.length > 0 && nodeData?.id != null) {
for (const name of relevantNames) {
// Access value from store to create reactive dependency
void widgetStore.getWidget(nodeData.id, name)?.value
}
}
// Access input connections for regular inputs
const inputNames = relevantInputNames.value
if (inputNames.length > 0) {
nodeData?.inputs?.forEach((inp) => {
if (inp.name && inputNames.includes(inp.name)) {
void inp.link // Access link to create reactive dependency
}
})
}
// Access input connections for input_groups (e.g., autogrow inputs)
const groupPrefixes = inputGroupPrefixes.value
if (groupPrefixes.length > 0) {
nodeData?.inputs?.forEach((inp) => {
if (
groupPrefixes.some((prefix) => inp.name?.startsWith(prefix + '.'))
) {
void inp.link // Access link to create reactive dependency
}
})
}
}
}
return [...(nodeData?.badges ?? [])].map(toValue)
})
const nodeDef = useNodeDefStore().nodeDefsByName[nodeData.type]
return computed(() => {
const displaySource = settingStore.get(
'Comfy.NodeBadge.NodeSourceBadgeMode'
)
const isCoreNode =
nodeDef?.isCoreNode && displaySource === NodeBadgeMode.ShowAll
const core: NodeBadgeProps[] = []
const extension: NodeBadgeProps[] = []
const pricing: { required: string; rest?: string }[] = []
if (
settingStore.get('Comfy.NodeBadge.NodeLifeCycleBadgeMode') !==
NodeBadgeMode.None
) {
const lifecycleText = nodeDef?.nodeLifeCycleBadgeText ?? ''
const trimmed = trim(lifecycleText, ['[', ']'])
if (trimmed) core.push({ text: trimmed })
}
if (
settingStore.get('Comfy.NodeBadge.NodeIdBadgeMode') !== NodeBadgeMode.None
)
core.push({ text: `#${nodeData.id}` })
const sourceText = nodeDef?.nodeSource?.badgeText
if (
!nodeDef?.isCoreNode &&
displaySource !== NodeBadgeMode.None &&
sourceText
)
core.push({ text: sourceText })
for (const badge of unpartitionedBadges.value.slice(1)) {
if (!badge.text) continue
if (isCreditsBadge(badge)) {
const [required, rest] = splitAroundFirstSpace(badge.text)
pricing.push({ required, rest })
continue
}
extension.push(badge)
}
return {
hasComfyBadge: isCoreNode && pricing.length === 0,
core,
extension,
pricing
}
})
}

200
src/scripts/app.test.ts Normal file
View File

@@ -0,0 +1,200 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type {
LGraph,
LGraphCanvas,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import { ComfyApp } from './app'
import { createNode } from '@/utils/litegraphUtil'
import { pasteImageNode, pasteImageNodes } from '@/composables/usePaste'
import { getWorkflowDataFromFile } from '@/scripts/metadata/parser'
vi.mock('@/utils/litegraphUtil', () => ({
createNode: vi.fn(),
isImageNode: vi.fn(),
isVideoNode: vi.fn(),
isAudioNode: vi.fn(),
executeWidgetsCallback: vi.fn(),
fixLinkInputSlots: vi.fn()
}))
vi.mock('@/composables/usePaste', () => ({
pasteImageNode: vi.fn(),
pasteImageNodes: vi.fn()
}))
vi.mock('@/scripts/metadata/parser', () => ({
getWorkflowDataFromFile: vi.fn()
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn(() => ({
addAlert: vi.fn(),
add: vi.fn(),
remove: vi.fn()
}))
}))
function createMockNode(options: { [K in keyof LGraphNode]?: any } = {}) {
return {
id: 1,
pos: [0, 0],
size: [200, 100],
type: 'LoadImage',
connect: vi.fn(),
getBounding: vi.fn(() => new Float64Array([0, 0, 200, 100])),
...options
} as LGraphNode
}
function createMockCanvas(): Partial<LGraphCanvas> {
const mockGraph: Partial<LGraph> = {
change: vi.fn()
}
return {
graph: mockGraph as LGraph,
selectItems: vi.fn()
}
}
function createTestFile(name: string, type: string): File {
return new File([''], name, { type })
}
describe('ComfyApp', () => {
let app: ComfyApp
let mockCanvas: LGraphCanvas
beforeEach(() => {
vi.clearAllMocks()
app = new ComfyApp()
mockCanvas = createMockCanvas() as LGraphCanvas
app.canvas = mockCanvas as LGraphCanvas
})
describe('handleFileList', () => {
it('should create image nodes for each file in the list', async () => {
const mockNode1 = createMockNode({ id: 1 })
const mockNode2 = createMockNode({ id: 2 })
const mockBatchNode = createMockNode({ id: 3, type: 'BatchImagesNode' })
vi.mocked(pasteImageNodes).mockResolvedValue([mockNode1, mockNode2])
vi.mocked(createNode).mockResolvedValue(mockBatchNode)
const file1 = createTestFile('test1.png', 'image/png')
const file2 = createTestFile('test2.jpg', 'image/jpeg')
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file1)
dataTransfer.items.add(file2)
const { files } = dataTransfer
await app.handleFileList(files)
expect(pasteImageNodes).toHaveBeenCalledWith(mockCanvas, files)
expect(createNode).toHaveBeenCalledWith(mockCanvas, 'BatchImagesNode')
expect(mockCanvas.selectItems).toHaveBeenCalledWith([
mockNode1,
mockNode2,
mockBatchNode
])
expect(mockNode1.connect).toHaveBeenCalledWith(0, mockBatchNode, 0)
expect(mockNode2.connect).toHaveBeenCalledWith(0, mockBatchNode, 1)
})
it('should not proceed if batch node creation fails', async () => {
const mockNode1 = createMockNode({ id: 1 })
vi.mocked(pasteImageNodes).mockResolvedValue([mockNode1])
vi.mocked(createNode).mockResolvedValue(null)
const file = createTestFile('test.png', 'image/png')
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
await app.handleFileList(dataTransfer.files)
expect(mockCanvas.selectItems).not.toHaveBeenCalled()
expect(mockNode1.connect).not.toHaveBeenCalled()
})
it('should handle empty file list', async () => {
const dataTransfer = new DataTransfer()
await expect(app.handleFileList(dataTransfer.files)).rejects.toThrow()
})
it('should not process unsupported file types', async () => {
const invalidFile = createTestFile('test.pdf', 'application/pdf')
const dataTransfer = new DataTransfer()
dataTransfer.items.add(invalidFile)
await app.handleFileList(dataTransfer.files)
expect(pasteImageNodes).not.toHaveBeenCalled()
expect(createNode).not.toHaveBeenCalled()
})
})
describe('positionBatchNodes', () => {
it('should position batch node to the right of first node', () => {
const mockNode1 = createMockNode({
pos: [100, 200],
getBounding: vi.fn(() => new Float64Array([100, 200, 300, 400]))
})
const mockBatchNode = createMockNode({ pos: [0, 0] })
app.positionBatchNodes([mockNode1], mockBatchNode)
expect(mockBatchNode.pos).toEqual([500, 230])
})
it('should stack multiple image nodes vertically', () => {
const mockNode1 = createMockNode({
pos: [100, 200],
type: 'LoadImage',
getBounding: vi.fn(() => new Float64Array([100, 200, 300, 400]))
})
const mockNode2 = createMockNode({ pos: [0, 0], type: 'LoadImage' })
const mockNode3 = createMockNode({ pos: [0, 0], type: 'LoadImage' })
const mockBatchNode = createMockNode({ pos: [0, 0] })
app.positionBatchNodes([mockNode1, mockNode2, mockNode3], mockBatchNode)
expect(mockNode1.pos).toEqual([100, 200])
expect(mockNode2.pos).toEqual([100, 594])
expect(mockNode3.pos).toEqual([100, 963])
})
it('should call graph change once for all nodes', () => {
const mockNode1 = createMockNode({
getBounding: vi.fn(() => new Float64Array([100, 200, 300, 400]))
})
const mockBatchNode = createMockNode()
app.positionBatchNodes([mockNode1], mockBatchNode)
expect(mockCanvas.graph?.change).toHaveBeenCalledTimes(1)
})
})
describe('handleFile', () => {
it('should handle image files by creating LoadImage node', async () => {
vi.mocked(getWorkflowDataFromFile).mockResolvedValue({})
const mockNode = createMockNode()
vi.mocked(createNode).mockResolvedValue(mockNode)
const imageFile = createTestFile('test.png', 'image/png')
await app.handleFile(imageFile)
expect(createNode).toHaveBeenCalledWith(mockCanvas, 'LoadImage')
expect(pasteImageNode).toHaveBeenCalledWith(
mockCanvas,
expect.any(DataTransferItemList),
mockNode
)
})
})
})

View File

@@ -84,6 +84,7 @@ import {
} from '@/utils/graphTraversalUtil'
import {
executeWidgetsCallback,
createNode,
fixLinkInputSlots,
isImageNode
} from '@/utils/litegraphUtil'
@@ -108,7 +109,7 @@ import { type ComfyWidgetConstructor } from './widgets'
import { ensureCorrectLayoutScale } from '@/renderer/extensions/vueNodes/layout/ensureCorrectLayoutScale'
import { extractFileFromDragEvent } from '@/utils/eventUtils'
import { getWorkflowDataFromFile } from '@/scripts/metadata/parser'
import { pasteImageNode } from '@/composables/usePaste'
import { pasteImageNode, pasteImageNodes } from '@/composables/usePaste'
export const ANIM_PREVIEW_WIDGET = '$$comfy_animation_preview'
@@ -553,7 +554,13 @@ export class ComfyApp {
const workspace = useWorkspaceStore()
try {
workspace.spinner = true
await this.handleFile(fileMaybe, 'file_drop')
if (fileMaybe instanceof File) {
await this.handleFile(fileMaybe, 'file_drop')
}
if (fileMaybe instanceof FileList) {
await this.handleFileList(fileMaybe)
}
} finally {
workspace.spinner = false
}
@@ -1488,7 +1495,8 @@ export class ComfyApp {
if (file.type.startsWith('image')) {
const transfer = new DataTransfer()
transfer.items.add(file)
pasteImageNode(this.canvas, transfer.items)
const imageNode = await createNode(this.canvas, 'LoadImage')
await pasteImageNode(this.canvas, transfer.items, imageNode)
return
}
@@ -1567,6 +1575,50 @@ export class ComfyApp {
this.showErrorOnFileLoad(file)
}
/**
* Loads multiple files, connects to a batch node, and selects them
* @param {FileList} fileList
*/
async handleFileList(fileList: FileList) {
if (fileList[0].type.startsWith('image')) {
const imageNodes = await pasteImageNodes(this.canvas, fileList)
const batchImagesNode = await createNode(this.canvas, 'BatchImagesNode')
if (!batchImagesNode) return
this.positionBatchNodes(imageNodes, batchImagesNode)
this.canvas.selectItems([...imageNodes, batchImagesNode])
Array.from(imageNodes).forEach((imageNode, index) => {
imageNode.connect(0, batchImagesNode, index)
})
}
}
/**
* Positions batched nodes in drag and drop
* @param nodes
* @param batchNode
*/
positionBatchNodes(nodes: LGraphNode[], batchNode: LGraphNode): void {
const [x, y, width] = nodes[0].getBounding()
batchNode.pos = [ x + width + 100, y + 30 ]
// Retrieving Node Height is inconsistent
let height = 0;
if (nodes[0].type === 'LoadImage') {
height = 344
}
nodes.forEach((node, index) => {
if (index > 0) {
node.pos = [ x, y + (height * index) + (25 * (index + 1)) ]
}
});
this.canvas.graph?.change()
}
// @deprecated
isApiJson(data: unknown): data is ComfyApiWorkflow {
if (!_.isObject(data) || Array.isArray(data)) {

View File

@@ -137,32 +137,40 @@ export const useExtensionService = () => {
}
}
type FunctionPropertyNames<T> = {
[K in keyof T]: T[K] extends (...args: unknown[]) => unknown ? K : never
}[keyof T]
type RemoveLastAppParam<T> = T extends (
...args: [...infer Rest, ComfyApp]
) => infer R
? (...args: Rest) => R
: T
type ComfyExtensionParamsWithoutApp<T extends keyof ComfyExtension> =
RemoveLastAppParam<ComfyExtension[T]>
type KnownExtensionMethods = Exclude<keyof ComfyExtension, number | symbol> &
string
type ComfyExtensionMethod<T extends KnownExtensionMethods> =
ComfyExtension[T] extends (...args: unknown[]) => unknown
? ComfyExtension[T]
: (...args: unknown[]) => unknown
type ComfyExtensionParamsWithoutApp<T extends KnownExtensionMethods> =
RemoveLastAppParam<ComfyExtensionMethod<T>>
/**
* Invoke an extension callback
* @param {keyof ComfyExtension} method The extension callback to execute
* @param {unknown[]} args Any arguments to pass to the callback
* @returns
*/
const invokeExtensions = <T extends FunctionPropertyNames<ComfyExtension>>(
const invokeExtensions = <T extends KnownExtensionMethods>(
method: T,
...args: Parameters<ComfyExtensionParamsWithoutApp<T>>
) => {
const results: ReturnType<ComfyExtension[T]>[] = []
const results: ReturnType<ComfyExtensionMethod<T>>[] = []
for (const ext of extensionStore.enabledExtensions) {
if (method in ext) {
try {
results.push(ext[method](...args, app))
const fn = ext[method]
if (typeof fn === 'function') {
results.push(fn.call(ext, ...args, app))
}
} catch (error) {
console.error(
`Error calling extension '${ext.name}' method '${method}'`,
@@ -183,9 +191,7 @@ export const useExtensionService = () => {
* @param {...unknown} args Any arguments to pass to the callback
* @returns
*/
const invokeExtensionsAsync = async <
T extends FunctionPropertyNames<ComfyExtension>
>(
const invokeExtensionsAsync = async <T extends KnownExtensionMethods>(
method: T,
...args: Parameters<ComfyExtensionParamsWithoutApp<T>>
) => {
@@ -193,12 +199,17 @@ export const useExtensionService = () => {
extensionStore.enabledExtensions.map(async (ext) => {
if (method in ext) {
try {
const fn = ext[method]
if (typeof fn !== 'function') {
return
}
// Set current extension name for legacy compatibility tracking
if (method === 'setup') {
legacyMenuCompat.setCurrentExtension(ext.name)
}
const result = await ext[method](...args, app)
const result = await fn.call(ext, ...args, app)
// Clear current extension after setup
if (method === 'setup') {

View File

@@ -880,7 +880,6 @@ export const useLitegraphService = () => {
const graph = useWorkflowStore().activeSubgraph ?? app.graph
// @ts-expect-error fixme ts strict error
graph.add(node)
// @ts-expect-error fixme ts strict error
return node

View File

@@ -144,7 +144,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
if (existingOutput && outputs) {
for (const k in outputs) {
const existingValue = existingOutput[k]
const newValue = (outputs as Record<NodeLocatorId, any>)[k]
const newValue = (outputs as Record<NodeLocatorId, unknown>)[k]
if (Array.isArray(existingValue) && Array.isArray(newValue)) {
existingOutput[k] = existingValue.concat(newValue)

View File

@@ -636,11 +636,11 @@ describe('useModelToNodeStore', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
expect(modelToNodeStore.getNodeProvider(null as any)).toBeUndefined()
expect(modelToNodeStore.getNodeProvider(undefined as any)).toBeUndefined()
expect(modelToNodeStore.getNodeProvider(123 as any)).toBeUndefined()
expect(modelToNodeStore.getAllNodeProviders(null as any)).toEqual([])
expect(modelToNodeStore.getAllNodeProviders(undefined as any)).toEqual([])
expect(modelToNodeStore.getNodeProvider(null)).toBeUndefined()
expect(modelToNodeStore.getNodeProvider(undefined)).toBeUndefined()
expect(modelToNodeStore.getNodeProvider(123)).toBeUndefined()
expect(modelToNodeStore.getAllNodeProviders(null)).toEqual([])
expect(modelToNodeStore.getAllNodeProviders(undefined)).toEqual([])
})
})
})

View File

@@ -104,7 +104,8 @@ export const useModelToNodeStore = defineStore('modelToNode', () => {
* @param modelType The name of the model type to get the node provider for.
* @returns The node provider for the given model type name.
*/
function getNodeProvider(modelType: string): ModelNodeProvider | undefined {
function getNodeProvider(modelType: unknown): ModelNodeProvider | undefined {
if (typeof modelType !== 'string') return undefined
registerDefaults()
return findProvidersWithFallback(modelType)?.[0]
}
@@ -115,7 +116,8 @@ export const useModelToNodeStore = defineStore('modelToNode', () => {
* @param modelType The name of the model type to get the node providers for.
* @returns The list of all valid node providers for the given model type name.
*/
function getAllNodeProviders(modelType: string): ModelNodeProvider[] {
function getAllNodeProviders(modelType: unknown): ModelNodeProvider[] {
if (typeof modelType !== 'string') return []
registerDefaults()
return findProvidersWithFallback(modelType) ?? []
}

View File

@@ -31,6 +31,13 @@ enum TaskItemDisplayStatus {
Cancelled = 'Cancelled'
}
interface ResultItemInit extends ResultItem {
nodeId: NodeId
mediaType: string
format?: string
frame_rate?: number
}
export class ResultItemImpl {
filename: string
subfolder: string
@@ -44,7 +51,7 @@ export class ResultItemImpl {
format?: string
frame_rate?: number
constructor(obj: Record<string, any>) {
constructor(obj: ResultItemInit) {
this.filename = obj.filename ?? ''
this.subfolder = obj.subfolder ?? ''
this.type = obj.type ?? ''

View File

@@ -2,7 +2,7 @@ import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import type { ServerConfig } from '@/constants/serverConfig'
import type { ServerConfig, ServerConfigValue } from '@/constants/serverConfig'
import type { FormItem } from '@/platform/settings/types'
import { useServerConfigStore } from '@/stores/serverConfigStore'
@@ -28,7 +28,7 @@ describe('useServerConfigStore', () => {
})
it('should load server configs with default values', () => {
const configs: ServerConfig<any>[] = [
const configs: ServerConfig<ServerConfigValue>[] = [
{
...dummyFormItem,
id: 'test.config1',
@@ -50,7 +50,7 @@ describe('useServerConfigStore', () => {
})
it('should load server configs with provided values', () => {
const configs: ServerConfig<any>[] = [
const configs: ServerConfig<ServerConfigValue>[] = [
{
...dummyFormItem,
id: 'test.config1',
@@ -68,7 +68,7 @@ describe('useServerConfigStore', () => {
})
it('should organize configs by category', () => {
const configs: ServerConfig<any>[] = [
const configs: ServerConfig<ServerConfigValue>[] = [
{
...dummyFormItem,
id: 'test.config1',
@@ -97,7 +97,7 @@ describe('useServerConfigStore', () => {
})
it('should generate server config values excluding defaults', () => {
const configs: ServerConfig<any>[] = [
const configs: ServerConfig<ServerConfigValue>[] = [
{
...dummyFormItem,
id: 'test.config1',
@@ -121,12 +121,12 @@ describe('useServerConfigStore', () => {
})
it('should generate launch arguments with custom getValue function', () => {
const configs: ServerConfig<any>[] = [
const configs: ServerConfig<ServerConfigValue>[] = [
{
...dummyFormItem,
id: 'test.config1',
defaultValue: 'default1',
getValue: (value: string) => ({ customArg: value })
getValue: (value: ServerConfigValue) => ({ customArg: value })
},
{
...dummyFormItem,
@@ -146,7 +146,7 @@ describe('useServerConfigStore', () => {
})
it('should not include default values in launch arguments', () => {
const configs: ServerConfig<any>[] = [
const configs: ServerConfig<ServerConfigValue>[] = [
{
...dummyFormItem,
id: 'test.config1',
@@ -170,7 +170,7 @@ describe('useServerConfigStore', () => {
})
it('should not include nullish values in launch arguments', () => {
const configs: ServerConfig<any>[] = [
const configs: ServerConfig<ServerConfigValue>[] = [
{ ...dummyFormItem, id: 'test.config1', defaultValue: 'default1' },
{ ...dummyFormItem, id: 'test.config2', defaultValue: 'default2' },
{ ...dummyFormItem, id: 'test.config3', defaultValue: 'default3' },

View File

@@ -4,6 +4,7 @@ import { computed, ref, watch } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
export type RightSidePanelTab =
| 'error'
| 'parameters'
| 'nodes'
| 'settings'

View File

@@ -259,5 +259,5 @@ export interface ComfyExtension {
*/
onAuthUserLogout?(): Promise<void> | void
[key: string]: any
[key: string]: unknown
}

View File

@@ -7,8 +7,8 @@ export interface TreeNode extends PrimeVueTreeNode {
children?: this[]
}
export interface TreeExplorerNode<T = any> extends TreeNode {
data?: T
export interface TreeExplorerNode<T = unknown> extends TreeNode {
readonly data?: T
children?: this[]
icon?: string
/**
@@ -46,7 +46,7 @@ export interface TreeExplorerNode<T = any> extends TreeNode {
/** Function to handle dropping a node */
handleDrop?: (
this: TreeExplorerNode<T>,
data: TreeExplorerDragAndDropData
data: TreeExplorerDragAndDropData<T>
) => void | Promise<void>
/** Function to handle clicking a node */
handleClick?: (
@@ -58,10 +58,12 @@ export interface TreeExplorerNode<T = any> extends TreeNode {
/** Extra context menu items */
contextMenuItems?:
| MenuItem[]
| ((targetNode: RenderedTreeExplorerNode) => MenuItem[])
| ((targetNode: RenderedTreeExplorerNode<T>) => MenuItem[])
}
export interface RenderedTreeExplorerNode<T = any> extends TreeExplorerNode<T> {
export interface RenderedTreeExplorerNode<
T = unknown
> extends TreeExplorerNode<T> {
children?: this[]
icon: string
type: 'folder' | 'node'
@@ -73,7 +75,7 @@ export interface RenderedTreeExplorerNode<T = any> extends TreeExplorerNode<T> {
isEditingLabel?: boolean
}
export type TreeExplorerDragAndDropData<T = any> = {
export type TreeExplorerDragAndDropData<T = unknown> = {
type: 'tree-explorer-node'
data: RenderedTreeExplorerNode<T>
}

View File

@@ -33,6 +33,45 @@ describe('eventUtils', () => {
expect(actual).toBe(fileWithWorkflowMaybeWhoKnows)
})
it('should handle drops with multiple image files', async () => {
const imageFile1 = new File([new Uint8Array()], 'image1.png', {
type: 'image/png'
})
const imageFile2 = new File([new Uint8Array()], 'image2.jpg', {
type: 'image/jpeg'
})
const dataTransfer = new DataTransfer()
dataTransfer.items.add(imageFile1)
dataTransfer.items.add(imageFile2)
const event = new FakeDragEvent('drop', { dataTransfer })
const actual = await extractFileFromDragEvent(event)
expect(actual).toBeDefined()
expect((actual as FileList).length).toBe(2)
expect((actual as FileList)[0]).toBe(imageFile1)
expect((actual as FileList)[1]).toBe(imageFile2)
})
it('should return undefined when dropping multiple non-image files', async () => {
const file1 = new File([new Uint8Array()], 'file1.txt', {
type: 'text/plain'
})
const file2 = new File([new Uint8Array()], 'file2.txt', {
type: 'text/plain'
})
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file1)
dataTransfer.items.add(file2)
const event = new FakeDragEvent('drop', { dataTransfer })
const actual = await extractFileFromDragEvent(event)
expect(actual).toBe(undefined)
})
// Skip until we can setup MSW
it.skip('should handle drops with URLs', async () => {
const urlWithWorkflow = 'https://fakewebsite.notreal/fake_workflow.json'

View File

@@ -1,14 +1,14 @@
export async function extractFileFromDragEvent(
event: DragEvent
): Promise<File | undefined> {
): Promise<File | FileList | undefined> {
if (!event.dataTransfer) return
// Dragging from Chrome->Firefox there is a file but its a bmp, so ignore that
if (
event.dataTransfer.files.length &&
event.dataTransfer.files[0].type !== 'image/bmp'
) {
return event.dataTransfer.files[0]
const { files } = event.dataTransfer
// Dragging from Chrome->Firefox there is a file, but it's a bmp, so ignore it
if (files.length === 1 && files[0].type !== 'image/bmp') {
return files[0]
} else if (files.length > 1 && Array.from(files).every(hasImageType)) {
return files
}
// Try loading the first URI in the transfer list
@@ -25,3 +25,7 @@ export async function extractFileFromDragEvent(
const blob = await response.blob()
return new File([blob], uri, { type: blob.type })
}
function hasImageType({ type }: File): boolean {
return type.startsWith('image')
}

View File

@@ -1,13 +1,93 @@
import { describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { ISerialisedGraph } from '@/lib/litegraph/src/types/serialisation'
import type { IWidget } from '@/lib/litegraph/src/types/widgets'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import {
compressWidgetInputSlots,
createNode,
migrateWidgetsValues
} from '@/utils/litegraphUtil'
vi.mock('@/lib/litegraph/src/litegraph', () => ({
LiteGraph: {
createNode: vi.fn()
}
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn(() => ({
addAlert: vi.fn(),
add: vi.fn(),
remove: vi.fn()
}))
}))
vi.mock('@/i18n', () => ({
t: vi.fn((key: string) => key)
}))
describe('createNode', () => {
let mockCanvas: any
let mockGraph: any
beforeEach(() => {
vi.clearAllMocks()
mockGraph = {
add: vi.fn((node) => node),
change: vi.fn()
}
mockCanvas = {
graph: mockGraph,
graph_mouse: [100, 200]
}
})
it('should create a node successfully', async () => {
const mockNode = { pos: [0, 0] }
vi.mocked(LiteGraph.createNode).mockReturnValue(mockNode as any)
const result = await createNode(mockCanvas, 'LoadImage')
expect(LiteGraph.createNode).toHaveBeenCalledWith('LoadImage')
expect(mockNode.pos).toEqual([100, 200])
expect(mockGraph.add).toHaveBeenCalledWith(mockNode)
expect(mockGraph.change).toHaveBeenCalled()
expect(result).toBe(mockNode)
})
it('should return null when name is empty', async () => {
const result = await createNode(mockCanvas, '')
expect(LiteGraph.createNode).not.toHaveBeenCalled()
expect(result).toBeNull()
})
it('should handle graph being null', async () => {
const mockNode = { pos: [0, 0] }
mockCanvas.graph = null
vi.mocked(LiteGraph.createNode).mockReturnValue(mockNode as any)
const result = await createNode(mockCanvas, 'LoadImage')
expect(mockNode.pos).toEqual([0, 0])
expect(result).toBeNull()
})
it('should set position based on canvas graph_mouse', async () => {
mockCanvas.graph_mouse = [250, 350]
const mockNode = { pos: [0, 0] }
vi.mocked(LiteGraph.createNode).mockReturnValue(mockNode as any)
await createNode(mockCanvas, 'LoadAudio')
expect(mockNode.pos).toEqual([250, 350])
})
})
describe('migrateWidgetsValues', () => {
it('should remove widget values for forceInput inputs', () => {
const inputDefs: Record<string, InputSpec> = {

View File

@@ -1,6 +1,11 @@
import _ from 'es-toolkit/compat'
import type { ColorOption, LGraph } from '@/lib/litegraph/src/litegraph'
import type {
ColorOption,
LGraph,
LGraphCanvas,
} from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import {
LGraphGroup,
LGraphNode,
@@ -18,6 +23,8 @@ import type {
WidgetCallbackOptions
} from '@/lib/litegraph/src/types/widgets'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { t } from '@/i18n'
type ImageNode = LGraphNode & { imgs: HTMLImageElement[] | undefined }
type VideoNode = LGraphNode & {
@@ -25,6 +32,35 @@ type VideoNode = LGraphNode & {
imgs: HTMLVideoElement[] | undefined
}
/**
* Extract & Promisify Litegraph.createNode to allow for positioning
* @param canvas
* @param name
*/
export async function createNode(
canvas: LGraphCanvas,
name: string
): Promise<LGraphNode | null> {
if (!name) {
return null
}
const { graph, graph_mouse: [ posX, posY ] } = canvas
const newNode = LiteGraph.createNode(name)
await new Promise(r => setTimeout(r, 0))
if (newNode && graph) {
newNode.pos = [ posX, posY ]
const addedNode = graph.add(newNode) ?? null
if (addedNode) graph.change()
return addedNode
} else {
useToastStore().addAlert(t('assetBrowser.failedToCreateNode'))
return null
}
}
export function isImageNode(node: LGraphNode | undefined): node is ImageNode {
if (!node) return false
return (

View File

@@ -50,6 +50,7 @@ import { useCoreCommands } from '@/composables/useCoreCommands'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useProgressFavicon } from '@/composables/useProgressFavicon'
import { SERVER_CONFIG_ITEMS } from '@/constants/serverConfig'
import type { ServerConfig, ServerConfigValue } from '@/constants/serverConfig'
import { i18n, loadLocale } from '@/i18n'
import ModelImportProgressDialog from '@/platform/assets/components/ModelImportProgressDialog.vue'
import { isCloud, isDesktop } from '@/platform/distribution/types'
@@ -344,7 +345,7 @@ const onGraphReady = () => {
// Load server config
wrapWithErrorHandling(useServerConfigStore().loadServerConfig)(
SERVER_CONFIG_ITEMS,
SERVER_CONFIG_ITEMS as ServerConfig<ServerConfigValue>[],
settingStore.get('Comfy.Server.ServerConfigValues')
)

View File

@@ -6,7 +6,23 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import NodeConflictDialogContent from '@/workbench/extensions/manager/components/manager/NodeConflictDialogContent.vue'
import type { ConflictDetectionResult } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
import type {
ConflictDetail,
ConflictDetectionResult
} from '@/workbench/extensions/manager/types/conflictDetectionTypes'
// Type for component VM
interface NodeConflictDialogVM {
importFailedExpanded: boolean
conflictsExpanded: boolean
extensionsExpanded: boolean
allConflictDetails: ConflictDetail[]
importFailedConflicts: string[]
}
function getVM(wrapper: ReturnType<typeof mount>): NodeConflictDialogVM {
return wrapper.vm as Partial<NodeConflictDialogVM> as NodeConflictDialogVM
}
// Mock getConflictMessage utility
vi.mock('@/utils/conflictMessageUtil', () => ({
@@ -288,25 +304,28 @@ describe('NodeConflictDialogContent', () => {
await importFailedHeader.trigger('click')
// Verify import failed panel is open
expect((wrapper.vm as any).importFailedExpanded).toBe(true)
expect((wrapper.vm as any).conflictsExpanded).toBe(false)
expect((wrapper.vm as any).extensionsExpanded).toBe(false)
const vm1 = getVM(wrapper)
expect(vm1.importFailedExpanded).toBe(true)
expect(vm1.conflictsExpanded).toBe(false)
expect(vm1.extensionsExpanded).toBe(false)
// Open conflicts panel
await conflictsHeader.trigger('click')
// Verify conflicts panel is open and others are closed
expect((wrapper.vm as any).importFailedExpanded).toBe(false)
expect((wrapper.vm as any).conflictsExpanded).toBe(true)
expect((wrapper.vm as any).extensionsExpanded).toBe(false)
const vm2 = getVM(wrapper)
expect(vm2.importFailedExpanded).toBe(false)
expect(vm2.conflictsExpanded).toBe(true)
expect(vm2.extensionsExpanded).toBe(false)
// Open extensions panel
await extensionsHeader.trigger('click')
// Verify extensions panel is open and others are closed
expect((wrapper.vm as any).importFailedExpanded).toBe(false)
expect((wrapper.vm as any).conflictsExpanded).toBe(false)
expect((wrapper.vm as any).extensionsExpanded).toBe(true)
const vm3 = getVM(wrapper)
expect(vm3.importFailedExpanded).toBe(false)
expect(vm3.conflictsExpanded).toBe(false)
expect(vm3.extensionsExpanded).toBe(true)
})
})
@@ -451,10 +470,12 @@ describe('NodeConflictDialogContent', () => {
const wrapper = createWrapper()
// Verify that import_failed conflicts are filtered out from main conflicts
const vm = wrapper.vm as any
const vm = getVM(wrapper)
expect(vm.allConflictDetails).toHaveLength(3) // Should not include import_failed
expect(
vm.allConflictDetails.every((c: any) => c.type !== 'import_failed')
vm.allConflictDetails.every(
(c: ConflictDetail) => c.type !== 'import_failed'
)
).toBe(true)
})
@@ -463,7 +484,7 @@ describe('NodeConflictDialogContent', () => {
const wrapper = createWrapper()
// Verify that only import_failed packages are extracted
const vm = wrapper.vm as any
const vm = getVM(wrapper)
expect(vm.importFailedConflicts).toHaveLength(1)
expect(vm.importFailedConflicts[0]).toBe('Test Package 3')
})

View File

@@ -75,7 +75,7 @@ describe('PackVersionBadge', () => {
const mountComponent = ({
props = {}
}: Record<string, any> = {}): VueWrapper => {
}: { props?: Record<string, unknown> } = {}): VueWrapper => {
const i18n = createI18n({
legacy: false,
locale: 'en',

View File

@@ -17,6 +17,14 @@ import enMessages from '@/locales/en/main.json' with { type: 'json' }
import PackVersionSelectorPopover from './PackVersionSelectorPopover.vue'
interface PackVersionSelectorVM {
getVersionCompatibility: (version: string) => unknown
}
function getVM(wrapper: VueWrapper): PackVersionSelectorVM {
return wrapper.vm as Partial<PackVersionSelectorVM> as PackVersionSelectorVM
}
// Default mock versions for reference
const defaultMockVersions = [
{
@@ -106,7 +114,7 @@ describe('PackVersionSelectorPopover', () => {
const mountComponent = ({
props = {}
}: Record<string, any> = {}): VueWrapper => {
}: { props?: Record<string, unknown> } = {}): VueWrapper => {
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -481,7 +489,7 @@ describe('PackVersionSelectorPopover', () => {
mockCheckNodeCompatibility.mockClear()
// Trigger compatibility check by accessing getVersionCompatibility
const vm = wrapper.vm as any
const vm = getVM(wrapper)
vm.getVersionCompatibility('1.0.0')
// Verify that checkNodeCompatibility was called with correct data
@@ -569,7 +577,7 @@ describe('PackVersionSelectorPopover', () => {
})
await waitForPromises()
const vm = wrapper.vm as any
const vm = getVM(wrapper)
// Clear previous calls from component mounting/rendering
mockCheckNodeCompatibility.mockClear()

View File

@@ -17,7 +17,7 @@ vi.mock('es-toolkit/compat', async () => {
const actual = await vi.importActual('es-toolkit/compat')
return {
...actual,
debounce: <T extends (...args: any[]) => any>(fn: T) => fn
debounce: <T extends (...args: unknown[]) => unknown>(fn: T) => fn
}
})
@@ -61,7 +61,10 @@ describe('PackEnableToggle', () => {
const mountComponent = ({
props = {},
installedPacks = {}
}: Record<string, any> = {}): VueWrapper => {
}: {
props?: Record<string, unknown>
installedPacks?: Record<string, unknown>
} = {}): VueWrapper => {
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -73,7 +76,9 @@ describe('PackEnableToggle', () => {
enablePack: mockEnablePack,
disablePack: mockDisablePack,
installedPacks
} as any)
} as Partial<ReturnType<typeof useComfyManagerStore>> as ReturnType<
typeof useComfyManagerStore
>)
return mount(PackEnableToggle, {
props: {