Compare commits
35 Commits
fix/codera
...
backport-7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10e5bc872e | ||
|
|
9ec20f26d4 | ||
|
|
c6ae695799 | ||
|
|
d73b35938b | ||
|
|
e7afb2b0d7 | ||
|
|
9d721c96ec | ||
|
|
ea564170d1 | ||
|
|
8080dc7cd7 | ||
|
|
ce18d4dfc6 | ||
|
|
884f0a73c0 | ||
|
|
408b188a37 | ||
|
|
9a9239c161 | ||
|
|
a8d0cfe058 | ||
|
|
b7b4608e7b | ||
|
|
d2281f64c9 | ||
|
|
463a04eb5a | ||
|
|
dfc58c0c1d | ||
|
|
59966243fc | ||
|
|
1393e438d3 | ||
|
|
574e5bd991 | ||
|
|
903c830dd0 | ||
|
|
8c651f22a0 | ||
|
|
36027a858f | ||
|
|
a26fc1cd8f | ||
|
|
0371dca1b6 | ||
|
|
1c914c7dff | ||
|
|
a68c169179 | ||
|
|
0fed12a62d | ||
|
|
8238afacf2 | ||
|
|
14c12350eb | ||
|
|
292fd5eb68 | ||
|
|
47aab2c8e1 | ||
|
|
c5312d6963 | ||
|
|
43e11c6873 | ||
|
|
4a041bead2 |
1
.npmrc
@@ -1,2 +1,3 @@
|
||||
ignore-workspace-root-check=true
|
||||
catalog-mode=prefer
|
||||
public-hoist-pattern[]=@parcel/watcher
|
||||
|
||||
92
browser_tests/assets/groups/nested-groups-1-inner-node.json
Normal file
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"id": "2ba0b800-2f13-4f21-b8d6-c6cdb0152cae",
|
||||
"revision": 0,
|
||||
"last_node_id": 17,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 17,
|
||||
"type": "VAEDecode",
|
||||
"pos": [
|
||||
318.8446183157076,
|
||||
355.3961392345528
|
||||
],
|
||||
"size": [
|
||||
225,
|
||||
102
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEDecode"
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [
|
||||
{
|
||||
"id": 4,
|
||||
"title": "Outer Group",
|
||||
"bounding": [
|
||||
-46.25245366331014,
|
||||
-150.82497138023245,
|
||||
1034.4034361963616,
|
||||
1007.338460439933
|
||||
],
|
||||
"color": "#3f789e",
|
||||
"font_size": 24,
|
||||
"flags": {}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "Inner Group",
|
||||
"bounding": [
|
||||
80.96059074101554,
|
||||
28.123757436778178,
|
||||
718.286373661183,
|
||||
691.2397164539732
|
||||
],
|
||||
"color": "#3f789e",
|
||||
"font_size": 24,
|
||||
"flags": {}
|
||||
}
|
||||
],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.7121393732101533,
|
||||
"offset": [
|
||||
289.18242848011835,
|
||||
367.0747755524199
|
||||
]
|
||||
},
|
||||
"frontendVersion": "1.35.5",
|
||||
"VHS_latentpreview": false,
|
||||
"VHS_latentpreviewrate": 0,
|
||||
"VHS_MetadataImage": true,
|
||||
"VHS_KeepIntermediate": true,
|
||||
"workflowRendererVersion": "Vue"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1653,6 +1653,55 @@ export class ComfyPage {
|
||||
}, focusMode)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the position of a group by title.
|
||||
* @param title The title of the group to find
|
||||
* @returns The group's canvas position
|
||||
* @throws Error if group not found
|
||||
*/
|
||||
async getGroupPosition(title: string): Promise<Position> {
|
||||
const pos = await this.page.evaluate((title) => {
|
||||
const groups = window['app'].graph.groups
|
||||
const group = groups.find((g: { title: string }) => g.title === title)
|
||||
if (!group) return null
|
||||
return { x: group.pos[0], y: group.pos[1] }
|
||||
}, title)
|
||||
if (!pos) throw new Error(`Group "${title}" not found`)
|
||||
return pos
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag a group by its title.
|
||||
* @param options.name The title of the group to drag
|
||||
* @param options.deltaX Horizontal drag distance in screen pixels
|
||||
* @param options.deltaY Vertical drag distance in screen pixels
|
||||
*/
|
||||
async dragGroup(options: {
|
||||
name: string
|
||||
deltaX: number
|
||||
deltaY: number
|
||||
}): Promise<void> {
|
||||
const { name, deltaX, deltaY } = options
|
||||
const screenPos = await this.page.evaluate((title) => {
|
||||
const app = window['app']
|
||||
const groups = app.graph.groups
|
||||
const group = groups.find((g: { title: string }) => g.title === title)
|
||||
if (!group) return null
|
||||
// Position in the title area of the group
|
||||
const clientPos = app.canvasPosToClientPos([
|
||||
group.pos[0] + 50,
|
||||
group.pos[1] + 15
|
||||
])
|
||||
return { x: clientPos[0], y: clientPos[1] }
|
||||
}, name)
|
||||
if (!screenPos) throw new Error(`Group "${name}" not found`)
|
||||
|
||||
await this.dragAndDrop(screenPos, {
|
||||
x: screenPos.x + deltaX,
|
||||
y: screenPos.y + deltaY
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const testComfySnapToGridGridSize = 50
|
||||
|
||||
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 75 KiB |
@@ -32,4 +32,42 @@ test.describe('Vue Node Groups', () => {
|
||||
'vue-groups-fit-to-contents.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('should move nested groups together when dragging outer group', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('groups/nested-groups-1-inner-node')
|
||||
|
||||
// Get initial positions with null guards
|
||||
const outerInitial = await comfyPage.getGroupPosition('Outer Group')
|
||||
const innerInitial = await comfyPage.getGroupPosition('Inner Group')
|
||||
|
||||
const initialOffsetX = innerInitial.x - outerInitial.x
|
||||
const initialOffsetY = innerInitial.y - outerInitial.y
|
||||
|
||||
// Drag the outer group
|
||||
const dragDelta = { x: 100, y: 80 }
|
||||
await comfyPage.dragGroup({
|
||||
name: 'Outer Group',
|
||||
deltaX: dragDelta.x,
|
||||
deltaY: dragDelta.y
|
||||
})
|
||||
|
||||
// Use retrying assertion to wait for positions to update
|
||||
await expect(async () => {
|
||||
const outerFinal = await comfyPage.getGroupPosition('Outer Group')
|
||||
const innerFinal = await comfyPage.getGroupPosition('Inner Group')
|
||||
|
||||
const finalOffsetX = innerFinal.x - outerFinal.x
|
||||
const finalOffsetY = innerFinal.y - outerFinal.y
|
||||
|
||||
// Both groups should have moved
|
||||
expect(outerFinal.x).not.toBe(outerInitial.x)
|
||||
expect(innerFinal.x).not.toBe(innerInitial.x)
|
||||
|
||||
// The relative offset should be maintained (inner group moved with outer)
|
||||
expect(finalOffsetX).toBeCloseTo(initialOffsetX, 0)
|
||||
expect(finalOffsetY).toBeCloseTo(initialOffsetY, 0)
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 80 KiB |
@@ -205,6 +205,32 @@ test.describe('Image widget', () => {
|
||||
const filename = await fileComboWidget.getValue()
|
||||
expect(filename).toBe('image32x32.webp')
|
||||
})
|
||||
test('Displays buttons when viewing single image of batch', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const [x, y] = await comfyPage.page.evaluate(() => {
|
||||
const src =
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='768' height='512' viewBox='0 0 1 1'%3E%3Crect width='1' height='1' stroke='black'/%3E%3C/svg%3E"
|
||||
const image1 = new Image()
|
||||
image1.src = src
|
||||
const image2 = new Image()
|
||||
image2.src = src
|
||||
const targetNode = graph.nodes[6]
|
||||
targetNode.imgs = [image1, image2]
|
||||
targetNode.imageIndex = 1
|
||||
app.canvas.setDirty(true)
|
||||
|
||||
const x = targetNode.pos[0] + targetNode.size[0] - 41
|
||||
const y = targetNode.pos[1] + targetNode.widgets.at(-1).last_y + 30
|
||||
return app.canvasPosToClientPos([x, y])
|
||||
})
|
||||
|
||||
const clip = { x, y, width: 35, height: 35 }
|
||||
await expect(comfyPage.page).toHaveScreenshot(
|
||||
'image_preview_close_button.png',
|
||||
{ clip }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Animated image widget', () => {
|
||||
|
||||
|
After Width: | Height: | Size: 423 B |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.35.7",
|
||||
"version": "1.35.8",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -19,6 +19,7 @@
|
||||
"dev:cloud": "cross-env DEV_SERVER_COMFYUI_URL='https://testcloud.comfy.org/' nx serve",
|
||||
"dev:desktop": "nx dev @comfyorg/desktop-ui",
|
||||
"dev:electron": "nx serve --config vite.electron.config.mts",
|
||||
"dev:no-vue": "cross-env DISABLE_VUE_PLUGINS=true nx serve",
|
||||
"dev": "nx serve",
|
||||
"devtools:pycheck": "python3 -m compileall -q tools/devtools",
|
||||
"format:check:no-cache": "prettier --check './**/*.{js,ts,tsx,vue,mts}'",
|
||||
|
||||
633
pnpm-lock.yaml
generated
@@ -16,7 +16,7 @@ catalog:
|
||||
'@nx/storybook': 21.4.1
|
||||
'@nx/vite': 21.4.1
|
||||
'@pinia/testing': ^0.1.5
|
||||
'@playwright/test': ^1.52.0
|
||||
'@playwright/test': ^1.57.0
|
||||
'@prettier/plugin-oxc': ^0.1.3
|
||||
'@primeuix/forms': 0.0.2
|
||||
'@primeuix/styled': 0.3.2
|
||||
@@ -60,11 +60,11 @@ catalog:
|
||||
firebase: ^11.6.0
|
||||
globals: ^15.9.0
|
||||
happy-dom: ^15.11.0
|
||||
husky: ^9.0.11
|
||||
jiti: 2.4.2
|
||||
husky: ^9.1.7
|
||||
jiti: 2.6.1
|
||||
jsdom: ^26.1.0
|
||||
knip: ^5.62.0
|
||||
lint-staged: ^15.5.2
|
||||
knip: ^5.75.1
|
||||
lint-staged: ^16.2.7
|
||||
markdown-table: ^3.0.4
|
||||
mixpanel-browser: ^2.71.0
|
||||
nx: 21.4.1
|
||||
|
||||
@@ -10,48 +10,66 @@
|
||||
</div>
|
||||
|
||||
<div class="mx-1 flex flex-col items-end gap-1">
|
||||
<div
|
||||
class="actionbar-container pointer-events-auto flex h-12 items-center rounded-lg border border-interface-stroke px-2 shadow-interface"
|
||||
>
|
||||
<ActionBarButtons />
|
||||
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
ref="legacyCommandsContainerRef"
|
||||
class="[&:not(:has(*>*:not(:empty)))]:hidden"
|
||||
></div>
|
||||
<ComfyActionbar />
|
||||
<IconButton
|
||||
v-tooltip.bottom="queueHistoryTooltipConfig"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="relative mr-2 text-base-foreground transition-colors duration-200 ease-in-out bg-secondary-background hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
|
||||
:aria-pressed="isQueueOverlayExpanded"
|
||||
:aria-label="
|
||||
t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
|
||||
"
|
||||
@click="toggleQueueOverlay"
|
||||
v-if="managerState.shouldShowManagerButtons.value"
|
||||
class="pointer-events-auto flex h-12 shrink-0 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
|
||||
>
|
||||
<i class="icon-[lucide--history] size-4" />
|
||||
<span
|
||||
v-if="queuedCount > 0"
|
||||
class="absolute -top-1 -right-1 min-w-[16px] rounded-full bg-primary-background py-0.25 text-[10px] font-medium leading-[14px] text-white"
|
||||
<IconButton
|
||||
v-tooltip.bottom="customNodesManagerTooltipConfig"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="text-base-foreground transition-colors duration-200 ease-in-out bg-secondary-background hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
|
||||
:aria-label="t('menu.customNodesManager')"
|
||||
@click="openCustomNodeManager"
|
||||
>
|
||||
{{ queuedCount }}
|
||||
</span>
|
||||
</IconButton>
|
||||
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
|
||||
<LoginButton v-else-if="isDesktop" />
|
||||
<IconButton
|
||||
v-if="!isRightSidePanelOpen"
|
||||
v-tooltip.bottom="rightSidePanelTooltipConfig"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="mr-2 text-base-foreground transition-colors duration-200 ease-in-out bg-secondary-background hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
|
||||
:aria-label="t('rightSidePanel.togglePanel')"
|
||||
@click="rightSidePanelStore.togglePanel"
|
||||
<i class="icon-[lucide--puzzle] size-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="actionbar-container pointer-events-auto flex h-12 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
|
||||
>
|
||||
<i class="icon-[lucide--panel-right] size-4" />
|
||||
</IconButton>
|
||||
<ActionBarButtons />
|
||||
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
|
||||
<div
|
||||
ref="legacyCommandsContainerRef"
|
||||
class="[&:not(:has(*>*:not(:empty)))]:hidden"
|
||||
></div>
|
||||
<ComfyActionbar />
|
||||
<IconButton
|
||||
v-tooltip.bottom="queueHistoryTooltipConfig"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="relative mr-2 text-base-foreground transition-colors duration-200 ease-in-out bg-secondary-background hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
|
||||
:aria-pressed="isQueueOverlayExpanded"
|
||||
:aria-label="
|
||||
t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
|
||||
"
|
||||
@click="toggleQueueOverlay"
|
||||
>
|
||||
<i class="icon-[lucide--history] size-4" />
|
||||
<span
|
||||
v-if="queuedCount > 0"
|
||||
class="absolute -top-1 -right-1 min-w-[16px] rounded-full bg-primary-background py-0.25 text-[10px] font-medium leading-[14px] text-white"
|
||||
>
|
||||
{{ queuedCount }}
|
||||
</span>
|
||||
</IconButton>
|
||||
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
|
||||
<LoginButton v-else-if="isDesktop" />
|
||||
<IconButton
|
||||
v-if="!isRightSidePanelOpen"
|
||||
v-tooltip.bottom="rightSidePanelTooltipConfig"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="mr-2 text-base-foreground transition-colors duration-200 ease-in-out bg-secondary-background hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
|
||||
:aria-label="t('rightSidePanel.togglePanel')"
|
||||
@click="rightSidePanelStore.togglePanel"
|
||||
>
|
||||
<i class="icon-[lucide--panel-right] size-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
<QueueProgressOverlay
|
||||
v-model:expanded="isQueueOverlayExpanded"
|
||||
@@ -74,18 +92,23 @@ import ActionBarButtons from '@/components/topbar/ActionBarButtons.vue'
|
||||
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
||||
import LoginButton from '@/components/topbar/LoginButton.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const managerState = useManagerState()
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
const isDesktop = isElectron()
|
||||
const { t } = useI18n()
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
const isQueueOverlayExpanded = ref(false)
|
||||
const queueStore = useQueueStore()
|
||||
const isTopMenuHovered = ref(false)
|
||||
@@ -93,6 +116,9 @@ const queuedCount = computed(() => queueStore.pendingTasks.length)
|
||||
const queueHistoryTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
|
||||
)
|
||||
const customNodesManagerTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('menu.customNodesManager'))
|
||||
)
|
||||
|
||||
// Right side panel toggle
|
||||
const { isOpen: isRightSidePanelOpen } = storeToRefs(rightSidePanelStore)
|
||||
@@ -112,10 +138,20 @@ onMounted(() => {
|
||||
const toggleQueueOverlay = () => {
|
||||
isQueueOverlayExpanded.value = !isQueueOverlayExpanded.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.actionbar-container {
|
||||
background-color: var(--comfy-menu-bg);
|
||||
const openCustomNodeManager = async () => {
|
||||
try {
|
||||
await managerState.openManager({
|
||||
initialTab: ManagerTab.All,
|
||||
showToastOnLegacyError: false
|
||||
})
|
||||
} catch (error) {
|
||||
try {
|
||||
toastErrorHandler(error)
|
||||
} catch (toastError) {
|
||||
console.error(error)
|
||||
console.error(toastError)
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</script>
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
@keyup.enter.capture.stop="blurInputElement"
|
||||
@keyup.escape.stop="cancelEditing"
|
||||
@click.stop
|
||||
@contextmenu.stop
|
||||
@pointerdown.stop.capture
|
||||
@pointermove.stop.capture
|
||||
/>
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
/>
|
||||
<img
|
||||
v-if="cachedSrc"
|
||||
ref="imageRef"
|
||||
:src="cachedSrc"
|
||||
:alt="alt"
|
||||
draggable="false"
|
||||
@@ -61,7 +60,6 @@ const {
|
||||
}>()
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const imageRef = ref<HTMLImageElement | null>(null)
|
||||
const isIntersecting = ref(false)
|
||||
const isImageLoaded = ref(false)
|
||||
const hasError = ref(false)
|
||||
|
||||
@@ -48,7 +48,6 @@
|
||||
class="zoomInputContainer flex items-center gap-1 rounded bg-input-surface p-2"
|
||||
>
|
||||
<InputNumber
|
||||
ref="zoomInput"
|
||||
:default-value="canvasStore.appScalePercentage"
|
||||
:min="1"
|
||||
:max="1000"
|
||||
@@ -130,7 +129,6 @@ const zoomOutCommandText = computed(() =>
|
||||
const zoomToFitCommandText = computed(() =>
|
||||
formatKeySequence(commandStore.getCommand('Comfy.Canvas.FitView'))
|
||||
)
|
||||
const zoomInput = ref<InstanceType<typeof InputNumber> | null>(null)
|
||||
const zoomInputContainer = ref<HTMLDivElement | null>(null)
|
||||
|
||||
watch(
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
>
|
||||
<Load3DScene
|
||||
v-if="node"
|
||||
ref="load3DSceneRef"
|
||||
:initialize-load3d="initializeLoad3d"
|
||||
:cleanup="cleanup"
|
||||
:loading="loading"
|
||||
@@ -100,8 +99,6 @@ if (isComponentWidget(props.widget)) {
|
||||
})
|
||||
}
|
||||
|
||||
const load3DSceneRef = ref<InstanceType<typeof Load3DScene> | null>(null)
|
||||
|
||||
const {
|
||||
// configs
|
||||
sceneConfig,
|
||||
|
||||
@@ -14,11 +14,7 @@
|
||||
@dragleave.stop="handleDragLeave"
|
||||
@drop.prevent.stop="handleDrop"
|
||||
>
|
||||
<LoadingOverlay
|
||||
ref="loadingOverlayRef"
|
||||
:loading="loading"
|
||||
:loading-message="loadingMessage"
|
||||
/>
|
||||
<LoadingOverlay :loading="loading" :loading-message="loadingMessage" />
|
||||
<div
|
||||
v-if="!isPreview && isDragging"
|
||||
class="pointer-events-none absolute inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
@@ -48,7 +44,6 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const container = ref<HTMLElement | null>(null)
|
||||
const loadingOverlayRef = ref<InstanceType<typeof LoadingOverlay> | null>(null)
|
||||
|
||||
const { isDragging, dragMessage, handleDragOver, handleDragLeave, handleDrop } =
|
||||
useLoad3dDrag({
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
@mouseenter="viewer.handleMouseEnter"
|
||||
@mouseleave="viewer.handleMouseLeave"
|
||||
>
|
||||
<div ref="mainContentRef" class="relative flex-1">
|
||||
<div class="relative flex-1">
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="absolute h-full w-full"
|
||||
@@ -105,7 +105,6 @@ const props = defineProps<{
|
||||
|
||||
const viewerContentRef = ref<HTMLDivElement>()
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
const mainContentRef = ref<HTMLDivElement>()
|
||||
const maximized = ref(false)
|
||||
const mutationObserver = ref<MutationObserver | null>(null)
|
||||
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c683087a3e168db/app/js/functions/sb_fn.js#L149
|
||||
-->
|
||||
<template>
|
||||
<LGraphNodePreview v-if="shouldRenderVueNodes" :node-def="nodeDef" />
|
||||
<LGraphNodePreview
|
||||
v-if="shouldRenderVueNodes"
|
||||
:node-def="nodeDef"
|
||||
:position="position"
|
||||
/>
|
||||
<div v-else class="_sb_node_preview bg-component-node-background">
|
||||
<div class="_sb_table">
|
||||
<div
|
||||
@@ -92,8 +96,9 @@ import { useWidgetStore } from '@/stores/widgetStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
|
||||
|
||||
const { nodeDef } = defineProps<{
|
||||
const { nodeDef, position = 'absolute' } = defineProps<{
|
||||
nodeDef: ComfyNodeDefV2
|
||||
position?: 'absolute' | 'relative'
|
||||
}>()
|
||||
|
||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import ToggleSwitch from 'primevue/toggleswitch'
|
||||
import { computed } from 'vue'
|
||||
import { computed, shallowRef, triggerRef, watchEffect } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -90,10 +90,23 @@ import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { adjustColor } from '@/utils/colorUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { nodes = [] } = defineProps<{
|
||||
const props = defineProps<{
|
||||
nodes?: LGraphNode[]
|
||||
}>()
|
||||
|
||||
/**
|
||||
* This is not random writing. It is very important.
|
||||
* Otherwise, the UI cannot be updated correctly.
|
||||
*/
|
||||
const targetNodes = shallowRef<LGraphNode[]>([])
|
||||
watchEffect(() => {
|
||||
if (props.nodes) {
|
||||
targetNodes.value = props.nodes
|
||||
} else {
|
||||
targetNodes.value = []
|
||||
}
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
@@ -103,24 +116,33 @@ const isLightTheme = computed(
|
||||
)
|
||||
|
||||
const nodeState = computed({
|
||||
get(): LGraphNode['mode'] | null {
|
||||
if (!nodes.length) return null
|
||||
if (nodes.length === 1) {
|
||||
return nodes[0].mode
|
||||
}
|
||||
get() {
|
||||
let mode: LGraphNode['mode'] | null = null
|
||||
const nodes = targetNodes.value
|
||||
|
||||
if (nodes.length === 0) return null
|
||||
|
||||
// For multiple nodes, if all nodes have the same mode, return that mode, otherwise return null
|
||||
const mode: LGraphNode['mode'] = nodes[0].mode
|
||||
if (!nodes.every((node) => node.mode === mode)) {
|
||||
return null
|
||||
if (nodes.length > 1) {
|
||||
mode = nodes[0].mode
|
||||
if (!nodes.every((node) => node.mode === mode)) {
|
||||
mode = null
|
||||
}
|
||||
} else {
|
||||
mode = nodes[0].mode
|
||||
}
|
||||
|
||||
return mode
|
||||
},
|
||||
set(value: LGraphNode['mode']) {
|
||||
nodes.forEach((node) => {
|
||||
targetNodes.value.forEach((node) => {
|
||||
node.mode = value
|
||||
})
|
||||
/*
|
||||
* This is not random writing. It is very important.
|
||||
* Otherwise, the UI cannot be updated correctly.
|
||||
*/
|
||||
triggerRef(targetNodes)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
})
|
||||
@@ -128,10 +150,15 @@ const nodeState = computed({
|
||||
// Pinned state
|
||||
const isPinned = computed<boolean>({
|
||||
get() {
|
||||
return nodes.some((node) => node.pinned)
|
||||
return targetNodes.value.some((node) => node.pinned)
|
||||
},
|
||||
set(value) {
|
||||
nodes.forEach((node) => node.pin(value))
|
||||
targetNodes.value.forEach((node) => node.pin(value))
|
||||
/*
|
||||
* This is not random writing. It is very important.
|
||||
* Otherwise, the UI cannot be updated correctly.
|
||||
*/
|
||||
triggerRef(targetNodes)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
})
|
||||
@@ -175,8 +202,10 @@ const colorOptions: NodeColorOption[] = [
|
||||
|
||||
const nodeColor = computed<NodeColorOption['name'] | null>({
|
||||
get() {
|
||||
if (nodes.length === 0) return null
|
||||
const theColorOptions = nodes.map((item) => item.getColorOption())
|
||||
if (targetNodes.value.length === 0) return null
|
||||
const theColorOptions = targetNodes.value.map((item) =>
|
||||
item.getColorOption()
|
||||
)
|
||||
|
||||
let colorOption: ColorOption | null | false = theColorOptions[0]
|
||||
if (!theColorOptions.every((option) => option === colorOption)) {
|
||||
@@ -202,9 +231,14 @@ const nodeColor = computed<NodeColorOption['name'] | null>({
|
||||
? null
|
||||
: LGraphCanvas.node_colors[colorName]
|
||||
|
||||
for (const item of nodes) {
|
||||
for (const item of targetNodes.value) {
|
||||
item.setColorOption(canvasColorOption)
|
||||
}
|
||||
/*
|
||||
* This is not random writing. It is very important.
|
||||
* Otherwise, the UI cannot be updated correctly.
|
||||
*/
|
||||
triggerRef(targetNodes)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<div
|
||||
ref="menuButtonRef"
|
||||
v-tooltip="{
|
||||
value: t('sideToolbar.labels.menu'),
|
||||
showDelay: 300,
|
||||
@@ -137,7 +136,6 @@ const settingStore = useSettingStore()
|
||||
const menuRef = ref<
|
||||
({ dirty: boolean } & TieredMenuMethods & TieredMenuState) | null
|
||||
>(null)
|
||||
const menuButtonRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const nodes2Enabled = computed({
|
||||
get: () => settingStore.get('Comfy.VueNodes.Enabled') ?? false,
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
}"
|
||||
>
|
||||
<div
|
||||
ref="contentMeasureRef"
|
||||
:class="
|
||||
isOverflowing
|
||||
? 'side-tool-bar-container overflow-y-auto'
|
||||
@@ -80,7 +79,6 @@ const userStore = useUserStore()
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const sideToolbarRef = ref<HTMLElement>()
|
||||
const contentMeasureRef = ref<HTMLElement>()
|
||||
const topToolbarRef = ref<HTMLElement>()
|
||||
const bottomToolbarRef = ref<HTMLElement>()
|
||||
|
||||
|
||||
@@ -56,9 +56,9 @@
|
||||
class="pb-1 px-2 2xl:px-4"
|
||||
:show-generation-time-sort="activeTab === 'output'"
|
||||
/>
|
||||
<Divider type="dashed" class="my-2" />
|
||||
</template>
|
||||
<template #body>
|
||||
<Divider type="dashed" class="m-2" />
|
||||
<div v-if="loading && !displayAssets.length">
|
||||
<ProgressSpinner class="absolute left-1/2 w-[50px] -translate-x-1/2" />
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
</template>
|
||||
<template #end>
|
||||
<div
|
||||
class="touch:w-auto touch:opacity-100 flex flex-row transition-all duration-200 motion-safe:w-0 motion-safe:opacity-0 motion-safe:group-focus-within/sidebar-tab:w-auto motion-safe:group-focus-within/sidebar-tab:opacity-100 motion-safe:group-hover/sidebar-tab:w-auto motion-safe:group-hover/sidebar-tab:opacity-100"
|
||||
class="touch:w-auto touch:opacity-100 flex flex-row overflow-hidden transition-all duration-200 motion-safe:w-0 motion-safe:opacity-0 motion-safe:group-focus-within/sidebar-tab:w-auto motion-safe:group-focus-within/sidebar-tab:opacity-100 motion-safe:group-hover/sidebar-tab:w-auto motion-safe:group-hover/sidebar-tab:opacity-100"
|
||||
>
|
||||
<slot name="tool-buttons" />
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { reactiveComputed } from '@vueuse/core'
|
||||
import { reactive, shallowReactive } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
||||
import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot
|
||||
@@ -30,8 +31,9 @@ import type {
|
||||
LGraphTriggerAction,
|
||||
LGraphTriggerEvent,
|
||||
LGraphTriggerParam
|
||||
} from '../../lib/litegraph/src/litegraph'
|
||||
import { NodeSlotType } from '../../lib/litegraph/src/types/globalEnums'
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
|
||||
export interface WidgetSlotMetadata {
|
||||
index: number
|
||||
@@ -42,37 +44,39 @@ export interface SafeWidgetData {
|
||||
name: string
|
||||
type: string
|
||||
value: WidgetValue
|
||||
label?: string
|
||||
options?: IWidgetOptions<unknown>
|
||||
borderStyle?: string
|
||||
callback?: ((value: unknown) => void) | undefined
|
||||
controlWidget?: SafeControlWidget
|
||||
isDOMWidget?: boolean
|
||||
label?: string
|
||||
nodeType?: string
|
||||
options?: IWidgetOptions<unknown>
|
||||
spec?: InputSpec
|
||||
slotMetadata?: WidgetSlotMetadata
|
||||
isDOMWidget?: boolean
|
||||
controlWidget?: SafeControlWidget
|
||||
borderStyle?: string
|
||||
}
|
||||
|
||||
export interface VueNodeData {
|
||||
executing: boolean
|
||||
id: NodeId
|
||||
title: string
|
||||
type: string
|
||||
mode: number
|
||||
selected: boolean
|
||||
executing: boolean
|
||||
title: string
|
||||
type: string
|
||||
apiNode?: boolean
|
||||
badges?: (LGraphBadge | (() => LGraphBadge))[]
|
||||
subgraphId?: string | null
|
||||
widgets?: SafeWidgetData[]
|
||||
inputs?: INodeInputSlot[]
|
||||
outputs?: INodeOutputSlot[]
|
||||
hasErrors?: boolean
|
||||
bgcolor?: string
|
||||
color?: string
|
||||
flags?: {
|
||||
collapsed?: boolean
|
||||
pinned?: boolean
|
||||
}
|
||||
color?: string
|
||||
bgcolor?: string
|
||||
hasErrors?: boolean
|
||||
inputs?: INodeInputSlot[]
|
||||
outputs?: INodeOutputSlot[]
|
||||
shape?: number
|
||||
subgraphId?: string | null
|
||||
titleMode?: TitleMode
|
||||
widgets?: SafeWidgetData[]
|
||||
}
|
||||
|
||||
export interface GraphNodeManager {
|
||||
@@ -96,6 +100,11 @@ function getControlWidget(widget: IBaseWidget): SafeControlWidget | undefined {
|
||||
update: (value) => (cagWidget.value = normalizeControlOption(value))
|
||||
}
|
||||
}
|
||||
function getNodeType(node: LGraphNode, widget: IBaseWidget) {
|
||||
if (!node.isSubgraphNode() || !isProxyWidget(widget)) return undefined
|
||||
const subNode = node.subgraph.getNodeById(widget._overlay.nodeId)
|
||||
return subNode?.type
|
||||
}
|
||||
|
||||
export function safeWidgetMapper(
|
||||
node: LGraphNode,
|
||||
@@ -131,12 +140,13 @@ export function safeWidgetMapper(
|
||||
value: value,
|
||||
borderStyle,
|
||||
callback: widget.callback,
|
||||
controlWidget: getControlWidget(widget),
|
||||
isDOMWidget: isDOMWidget(widget),
|
||||
label: widget.label,
|
||||
nodeType: getNodeType(node, widget),
|
||||
options: widget.options,
|
||||
spec,
|
||||
slotMetadata: slotInfo,
|
||||
controlWidget: getControlWidget(widget)
|
||||
slotMetadata: slotInfo
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -218,6 +228,15 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
reactiveWidgets.splice(0, reactiveWidgets.length, ...v)
|
||||
}
|
||||
})
|
||||
const reactiveInputs = shallowReactive<INodeInputSlot[]>(node.inputs ?? [])
|
||||
Object.defineProperty(node, 'inputs', {
|
||||
get() {
|
||||
return reactiveInputs
|
||||
},
|
||||
set(v) {
|
||||
reactiveInputs.splice(0, reactiveInputs.length, ...v)
|
||||
}
|
||||
})
|
||||
|
||||
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
|
||||
node.inputs?.forEach((input, index) => {
|
||||
@@ -245,6 +264,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
title: typeof node.title === 'string' ? node.title : '',
|
||||
type: nodeType,
|
||||
mode: node.mode || 0,
|
||||
titleMode: node.title_mode,
|
||||
selected: node.selected || false,
|
||||
executing: false, // Will be updated separately based on execution state
|
||||
subgraphId,
|
||||
@@ -252,7 +272,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
badges,
|
||||
hasErrors: !!node.has_errors,
|
||||
widgets: safeWidgets,
|
||||
inputs: node.inputs ? [...node.inputs] : undefined,
|
||||
inputs: reactiveInputs,
|
||||
outputs: node.outputs ? [...node.outputs] : undefined,
|
||||
flags: node.flags ? { ...node.flags } : undefined,
|
||||
color: node.color || undefined,
|
||||
|
||||
@@ -173,7 +173,12 @@ export function useMoreOptionsMenu() {
|
||||
}
|
||||
|
||||
// Section 5: Subgraph operations
|
||||
options.push(...getSubgraphOptions(hasSubgraphsSelected))
|
||||
options.push(
|
||||
...getSubgraphOptions({
|
||||
hasSubgraphs: hasSubgraphsSelected,
|
||||
hasMultipleSelection: hasMultipleNodes.value
|
||||
})
|
||||
)
|
||||
|
||||
// Section 6: Multiple nodes operations
|
||||
if (hasMultipleNodes.value) {
|
||||
|
||||
106
src/composables/graph/useSelectionMenuOptions.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useSelectionMenuOptions } from '@/composables/graph/useSelectionMenuOptions'
|
||||
|
||||
const subgraphMocks = vi.hoisted(() => ({
|
||||
convertToSubgraph: vi.fn(),
|
||||
unpackSubgraph: vi.fn(),
|
||||
addSubgraphToLibrary: vi.fn(),
|
||||
createI18nMock: vi.fn(() => ({
|
||||
global: {
|
||||
t: vi.fn(),
|
||||
te: vi.fn(),
|
||||
d: vi.fn()
|
||||
}
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
}),
|
||||
createI18n: subgraphMocks.createI18nMock
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useSelectionOperations', () => ({
|
||||
useSelectionOperations: () => ({
|
||||
copySelection: vi.fn(),
|
||||
duplicateSelection: vi.fn(),
|
||||
deleteSelection: vi.fn(),
|
||||
renameSelection: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useNodeArrangement', () => ({
|
||||
useNodeArrangement: () => ({
|
||||
alignOptions: [{ localizedName: 'align-left', icon: 'align-left' }],
|
||||
distributeOptions: [{ localizedName: 'distribute', icon: 'distribute' }],
|
||||
applyAlign: vi.fn(),
|
||||
applyDistribute: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useSubgraphOperations', () => ({
|
||||
useSubgraphOperations: () => ({
|
||||
convertToSubgraph: subgraphMocks.convertToSubgraph,
|
||||
unpackSubgraph: subgraphMocks.unpackSubgraph,
|
||||
addSubgraphToLibrary: subgraphMocks.addSubgraphToLibrary
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useFrameNodes', () => ({
|
||||
useFrameNodes: () => ({
|
||||
frameNodes: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useSelectionMenuOptions - subgraph options', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns only convert option when no subgraphs are selected', () => {
|
||||
const { getSubgraphOptions } = useSelectionMenuOptions()
|
||||
const options = getSubgraphOptions({
|
||||
hasSubgraphs: false,
|
||||
hasMultipleSelection: true
|
||||
})
|
||||
|
||||
expect(options).toHaveLength(1)
|
||||
expect(options[0]?.label).toBe('contextMenu.Convert to Subgraph')
|
||||
expect(options[0]?.action).toBe(subgraphMocks.convertToSubgraph)
|
||||
})
|
||||
|
||||
it('includes convert, add to library, and unpack when subgraphs are selected', () => {
|
||||
const { getSubgraphOptions } = useSelectionMenuOptions()
|
||||
const options = getSubgraphOptions({
|
||||
hasSubgraphs: true,
|
||||
hasMultipleSelection: true
|
||||
})
|
||||
const labels = options.map((option) => option.label)
|
||||
|
||||
expect(labels).toContain('contextMenu.Convert to Subgraph')
|
||||
expect(labels).toContain('contextMenu.Add Subgraph to Library')
|
||||
expect(labels).toContain('contextMenu.Unpack Subgraph')
|
||||
|
||||
const convertOption = options.find(
|
||||
(option) => option.label === 'contextMenu.Convert to Subgraph'
|
||||
)
|
||||
expect(convertOption?.action).toBe(subgraphMocks.convertToSubgraph)
|
||||
})
|
||||
|
||||
it('hides convert option when only a single subgraph is selected', () => {
|
||||
const { getSubgraphOptions } = useSelectionMenuOptions()
|
||||
const options = getSubgraphOptions({
|
||||
hasSubgraphs: true,
|
||||
hasMultipleSelection: false
|
||||
})
|
||||
|
||||
const labels = options.map((option) => option.label)
|
||||
expect(labels).not.toContain('contextMenu.Convert to Subgraph')
|
||||
expect(labels).toEqual([
|
||||
'contextMenu.Add Subgraph to Library',
|
||||
'contextMenu.Unpack Subgraph'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -63,9 +63,29 @@ export function useSelectionMenuOptions() {
|
||||
}
|
||||
]
|
||||
|
||||
const getSubgraphOptions = (hasSubgraphs: boolean): MenuOption[] => {
|
||||
const getSubgraphOptions = ({
|
||||
hasSubgraphs,
|
||||
hasMultipleSelection
|
||||
}: {
|
||||
hasSubgraphs: boolean
|
||||
hasMultipleSelection: boolean
|
||||
}): MenuOption[] => {
|
||||
const convertOption: MenuOption = {
|
||||
label: t('contextMenu.Convert to Subgraph'),
|
||||
icon: 'icon-[lucide--shrink]',
|
||||
action: convertToSubgraph,
|
||||
badge: BadgeVariant.NEW
|
||||
}
|
||||
|
||||
const options: MenuOption[] = []
|
||||
const showConvertOption = !hasSubgraphs || hasMultipleSelection
|
||||
|
||||
if (showConvertOption) {
|
||||
options.push(convertOption)
|
||||
}
|
||||
|
||||
if (hasSubgraphs) {
|
||||
return [
|
||||
options.push(
|
||||
{
|
||||
label: t('contextMenu.Add Subgraph to Library'),
|
||||
icon: 'icon-[lucide--folder-plus]',
|
||||
@@ -76,17 +96,10 @@ export function useSelectionMenuOptions() {
|
||||
icon: 'icon-[lucide--expand]',
|
||||
action: unpackSubgraph
|
||||
}
|
||||
]
|
||||
} else {
|
||||
return [
|
||||
{
|
||||
label: t('contextMenu.Convert to Subgraph'),
|
||||
icon: 'icon-[lucide--shrink]',
|
||||
action: convertToSubgraph,
|
||||
badge: BadgeVariant.NEW
|
||||
}
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
const getMultipleNodesOptions = (): MenuOption[] => {
|
||||
|
||||
@@ -329,6 +329,123 @@ const sora2PricingCalculator: PricingFunction = (node: LGraphNode): string => {
|
||||
return formatRunPrice(perSec, duration)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pricing for Tripo 3D generation nodes (Text / Image / Multiview)
|
||||
* based on Tripo credits:
|
||||
*
|
||||
* Turbo / V3 / V2.5 / V2.0:
|
||||
* Text -> 10 (no texture) / 20 (standard texture)
|
||||
* Image -> 20 (no texture) / 30 (standard texture)
|
||||
* Multiview -> 20 (no texture) / 30 (standard texture)
|
||||
*
|
||||
* V1.4:
|
||||
* Text -> 20
|
||||
* Image -> 30
|
||||
* (Multiview treated same as Image if used)
|
||||
*
|
||||
* Advanced extras (added on top of generation credits):
|
||||
* quad -> +5 credits
|
||||
* style -> +5 credits (if style != "None")
|
||||
* HD texture -> +10 credits (texture_quality = "detailed")
|
||||
* detailed geometry -> +20 credits (geometry_quality = "detailed")
|
||||
*
|
||||
* 1 credit = $0.01
|
||||
*/
|
||||
const calculateTripo3DGenerationPrice = (
|
||||
node: LGraphNode,
|
||||
task: 'text' | 'image' | 'multiview'
|
||||
): string => {
|
||||
const getWidget = (name: string): IComboWidget | undefined =>
|
||||
node.widgets?.find((w) => w.name === name) as IComboWidget | undefined
|
||||
|
||||
const getString = (name: string, defaultValue: string): string => {
|
||||
const widget = getWidget(name)
|
||||
if (!widget || widget.value === undefined || widget.value === null) {
|
||||
return defaultValue
|
||||
}
|
||||
return String(widget.value)
|
||||
}
|
||||
|
||||
const getBool = (name: string, defaultValue: boolean): boolean => {
|
||||
const widget = getWidget(name)
|
||||
if (!widget || widget.value === undefined || widget.value === null) {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
const v = widget.value
|
||||
if (typeof v === 'number') return v !== 0
|
||||
const lower = String(v).toLowerCase()
|
||||
if (lower === 'true') return true
|
||||
if (lower === 'false') return false
|
||||
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// ---- read widget values with sensible defaults (mirroring backend) ----
|
||||
const modelVersionRaw = getString('model_version', '').toLowerCase()
|
||||
if (modelVersionRaw === '')
|
||||
return '$0.1-0.65/Run (varies with quad, style, texture & quality)'
|
||||
const styleRaw = getString('style', 'None')
|
||||
const hasStyle = styleRaw.toLowerCase() !== 'none'
|
||||
|
||||
// Backend defaults: texture=true, pbr=true, quad=false, qualities="standard"
|
||||
const hasTexture = getBool('texture', false)
|
||||
const hasPbr = getBool('pbr', false)
|
||||
const quad = getBool('quad', false)
|
||||
|
||||
const textureQualityRaw = getString(
|
||||
'texture_quality',
|
||||
'standard'
|
||||
).toLowerCase()
|
||||
const geometryQualityRaw = getString(
|
||||
'geometry_quality',
|
||||
'standard'
|
||||
).toLowerCase()
|
||||
|
||||
const isHdTexture = textureQualityRaw === 'detailed'
|
||||
const isDetailedGeometry = geometryQualityRaw === 'detailed'
|
||||
|
||||
const withTexture = hasTexture || hasPbr
|
||||
|
||||
let baseCredits: number
|
||||
|
||||
if (modelVersionRaw.includes('v1.4')) {
|
||||
// V1.4 model: Text=20, Image=30, Refine=30
|
||||
if (task === 'text') {
|
||||
baseCredits = 20
|
||||
} else {
|
||||
// treat Multiview same as Image if V1.4 is ever used there
|
||||
baseCredits = 30
|
||||
}
|
||||
} else {
|
||||
// V3.0, V2.5, V2.0 models
|
||||
if (!withTexture) {
|
||||
if (task === 'text') {
|
||||
baseCredits = 10 // Text to 3D without texture
|
||||
} else {
|
||||
baseCredits = 20 // Image/Multiview to 3D without texture
|
||||
}
|
||||
} else {
|
||||
if (task === 'text') {
|
||||
baseCredits = 20 // Text to 3D with standard texture
|
||||
} else {
|
||||
baseCredits = 30 // Image/Multiview to 3D with standard texture
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- advanced extras on top of base generation ----
|
||||
let credits = baseCredits
|
||||
|
||||
if (hasStyle) credits += 5 // Style
|
||||
if (quad) credits += 5 // Quad Topology
|
||||
if (isHdTexture) credits += 10 // HD Texture
|
||||
if (isDetailedGeometry) credits += 20 // Detailed Geometry Quality
|
||||
|
||||
const dollars = credits * 0.01
|
||||
return `$${dollars.toFixed(2)}/Run`
|
||||
}
|
||||
|
||||
/**
|
||||
* Static pricing data for API nodes, now supporting both strings and functions
|
||||
*/
|
||||
@@ -395,6 +512,46 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
return `$${parseFloat(outputCost.toFixed(3))}/Run`
|
||||
}
|
||||
},
|
||||
Flux2MaxImageNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const widthW = node.widgets?.find(
|
||||
(w) => w.name === 'width'
|
||||
) as IComboWidget
|
||||
const heightW = node.widgets?.find(
|
||||
(w) => w.name === 'height'
|
||||
) as IComboWidget
|
||||
|
||||
const w = Number(widthW?.value)
|
||||
const h = Number(heightW?.value)
|
||||
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) {
|
||||
// global min/max for this node given schema bounds (1MP..4MP output)
|
||||
return '$0.07–$0.35/Run'
|
||||
}
|
||||
|
||||
// Is the 'images' input connected?
|
||||
const imagesInput = node.inputs?.find(
|
||||
(i) => i.name === 'images'
|
||||
) as INodeInputSlot
|
||||
const hasRefs =
|
||||
typeof imagesInput?.link !== 'undefined' && imagesInput.link != null
|
||||
|
||||
// Output cost: ceil((w*h)/MP); first MP $0.07, each additional $0.03
|
||||
const MP = 1024 * 1024
|
||||
const outMP = Math.max(1, Math.floor((w * h + MP - 1) / MP))
|
||||
const outputCost = 0.07 + 0.03 * Math.max(outMP - 1, 0)
|
||||
|
||||
if (hasRefs) {
|
||||
// Unknown ref count/size on the frontend:
|
||||
// min extra is $0.03, max extra is $0.27 (8 MP cap / 8 refs)
|
||||
const minTotal = outputCost + 0.03
|
||||
const maxTotal = outputCost + 0.24
|
||||
return `~$${parseFloat(minTotal.toFixed(3))}–$${parseFloat(maxTotal.toFixed(3))}/Run`
|
||||
}
|
||||
|
||||
// Precise text-to-image price
|
||||
return `$${parseFloat(outputCost.toFixed(3))}/Run`
|
||||
}
|
||||
},
|
||||
OpenAIVideoSora2: {
|
||||
displayPrice: sora2PricingCalculator
|
||||
},
|
||||
@@ -1482,119 +1639,16 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
},
|
||||
// Tripo nodes - using actual node names from ComfyUI
|
||||
TripoTextToModelNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const quadWidget = node.widgets?.find(
|
||||
(w) => w.name === 'quad'
|
||||
) as IComboWidget
|
||||
const styleWidget = node.widgets?.find(
|
||||
(w) => w.name === 'style'
|
||||
) as IComboWidget
|
||||
const textureWidget = node.widgets?.find(
|
||||
(w) => w.name === 'texture'
|
||||
) as IComboWidget
|
||||
const textureQualityWidget = node.widgets?.find(
|
||||
(w) => w.name === 'texture_quality'
|
||||
) as IComboWidget
|
||||
|
||||
if (!quadWidget || !styleWidget || !textureWidget)
|
||||
return '$0.1-0.4/Run (varies with quad, style, texture & quality)'
|
||||
|
||||
const quad = String(quadWidget.value).toLowerCase() === 'true'
|
||||
const style = String(styleWidget.value).toLowerCase()
|
||||
const texture = String(textureWidget.value).toLowerCase() === 'true'
|
||||
const textureQuality = String(
|
||||
textureQualityWidget?.value || 'standard'
|
||||
).toLowerCase()
|
||||
|
||||
// Pricing logic based on CSV data
|
||||
if (style.includes('none')) {
|
||||
if (!quad) {
|
||||
if (!texture) return '$0.10/Run'
|
||||
else return '$0.15/Run'
|
||||
} else {
|
||||
if (textureQuality.includes('detailed')) {
|
||||
if (!texture) return '$0.30/Run'
|
||||
else return '$0.35/Run'
|
||||
} else {
|
||||
if (!texture) return '$0.20/Run'
|
||||
else return '$0.25/Run'
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// any style
|
||||
if (!quad) {
|
||||
if (!texture) return '$0.15/Run'
|
||||
else return '$0.20/Run'
|
||||
} else {
|
||||
if (textureQuality.includes('detailed')) {
|
||||
if (!texture) return '$0.35/Run'
|
||||
else return '$0.40/Run'
|
||||
} else {
|
||||
if (!texture) return '$0.25/Run'
|
||||
else return '$0.30/Run'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
displayPrice: (node: LGraphNode): string =>
|
||||
calculateTripo3DGenerationPrice(node, 'text')
|
||||
},
|
||||
TripoImageToModelNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const quadWidget = node.widgets?.find(
|
||||
(w) => w.name === 'quad'
|
||||
) as IComboWidget
|
||||
const styleWidget = node.widgets?.find(
|
||||
(w) => w.name === 'style'
|
||||
) as IComboWidget
|
||||
const textureWidget = node.widgets?.find(
|
||||
(w) => w.name === 'texture'
|
||||
) as IComboWidget
|
||||
const textureQualityWidget = node.widgets?.find(
|
||||
(w) => w.name === 'texture_quality'
|
||||
) as IComboWidget
|
||||
|
||||
if (!quadWidget || !styleWidget || !textureWidget)
|
||||
return '$0.2-0.5/Run (varies with quad, style, texture & quality)'
|
||||
|
||||
const quad = String(quadWidget.value).toLowerCase() === 'true'
|
||||
const style = String(styleWidget.value).toLowerCase()
|
||||
const texture = String(textureWidget.value).toLowerCase() === 'true'
|
||||
const textureQuality = String(
|
||||
textureQualityWidget?.value || 'standard'
|
||||
).toLowerCase()
|
||||
|
||||
// Pricing logic based on CSV data for Image to Model
|
||||
if (style.includes('none')) {
|
||||
if (!quad) {
|
||||
if (!texture) return '$0.20/Run'
|
||||
else return '$0.25/Run'
|
||||
} else {
|
||||
if (textureQuality.includes('detailed')) {
|
||||
if (!texture) return '$0.40/Run'
|
||||
else return '$0.45/Run'
|
||||
} else {
|
||||
if (!texture) return '$0.30/Run'
|
||||
else return '$0.35/Run'
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// any style
|
||||
if (!quad) {
|
||||
if (!texture) return '$0.25/Run'
|
||||
else return '$0.30/Run'
|
||||
} else {
|
||||
if (textureQuality.includes('detailed')) {
|
||||
if (!texture) return '$0.45/Run'
|
||||
else return '$0.50/Run'
|
||||
} else {
|
||||
if (!texture) return '$0.35/Run'
|
||||
else return '$0.40/Run'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
displayPrice: (node: LGraphNode): string =>
|
||||
calculateTripo3DGenerationPrice(node, 'image')
|
||||
},
|
||||
TripoRefineNode: {
|
||||
displayPrice: '$0.3/Run'
|
||||
TripoMultiviewToModelNode: {
|
||||
displayPrice: (node: LGraphNode): string =>
|
||||
calculateTripo3DGenerationPrice(node, 'multiview')
|
||||
},
|
||||
TripoTextureNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
@@ -1608,68 +1662,94 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
return textureQuality.includes('detailed') ? '$0.2/Run' : '$0.1/Run'
|
||||
}
|
||||
},
|
||||
TripoConvertModelNode: {
|
||||
displayPrice: '$0.10/Run'
|
||||
TripoRigNode: {
|
||||
displayPrice: '$0.25/Run'
|
||||
},
|
||||
TripoRetargetRiggedModelNode: {
|
||||
displayPrice: '$0.10/Run'
|
||||
},
|
||||
TripoMultiviewToModelNode: {
|
||||
TripoConversionNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const quadWidget = node.widgets?.find(
|
||||
(w) => w.name === 'quad'
|
||||
) as IComboWidget
|
||||
const styleWidget = node.widgets?.find(
|
||||
(w) => w.name === 'style'
|
||||
) as IComboWidget
|
||||
const textureWidget = node.widgets?.find(
|
||||
(w) => w.name === 'texture'
|
||||
) as IComboWidget
|
||||
const textureQualityWidget = node.widgets?.find(
|
||||
(w) => w.name === 'texture_quality'
|
||||
) as IComboWidget
|
||||
const getWidgetValue = (name: string) =>
|
||||
node.widgets?.find((w) => w.name === name)?.value
|
||||
|
||||
if (!quadWidget || !styleWidget || !textureWidget)
|
||||
return '$0.2-0.5/Run (varies with quad, style, texture & quality)'
|
||||
|
||||
const quad = String(quadWidget.value).toLowerCase() === 'true'
|
||||
const style = String(styleWidget.value).toLowerCase()
|
||||
const texture = String(textureWidget.value).toLowerCase() === 'true'
|
||||
const textureQuality = String(
|
||||
textureQualityWidget?.value || 'standard'
|
||||
).toLowerCase()
|
||||
|
||||
// Pricing logic based on CSV data for Multiview to Model (same as Image to Model)
|
||||
if (style.includes('none')) {
|
||||
if (!quad) {
|
||||
if (!texture) return '$0.20/Run'
|
||||
else return '$0.25/Run'
|
||||
} else {
|
||||
if (textureQuality.includes('detailed')) {
|
||||
if (!texture) return '$0.40/Run'
|
||||
else return '$0.45/Run'
|
||||
} else {
|
||||
if (!texture) return '$0.30/Run'
|
||||
else return '$0.35/Run'
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// any style
|
||||
if (!quad) {
|
||||
if (!texture) return '$0.25/Run'
|
||||
else return '$0.30/Run'
|
||||
} else {
|
||||
if (textureQuality.includes('detailed')) {
|
||||
if (!texture) return '$0.45/Run'
|
||||
else return '$0.50/Run'
|
||||
} else {
|
||||
if (!texture) return '$0.35/Run'
|
||||
else return '$0.40/Run'
|
||||
}
|
||||
}
|
||||
const getNumber = (name: string, defaultValue: number): number => {
|
||||
const raw = getWidgetValue(name)
|
||||
if (raw === undefined || raw === null || raw === '')
|
||||
return defaultValue
|
||||
if (typeof raw === 'number')
|
||||
return Number.isFinite(raw) ? raw : defaultValue
|
||||
const n = Number(raw)
|
||||
return Number.isFinite(n) ? n : defaultValue
|
||||
}
|
||||
|
||||
const getBool = (name: string, defaultValue: boolean): boolean => {
|
||||
const v = getWidgetValue(name)
|
||||
if (v === undefined || v === null) return defaultValue
|
||||
|
||||
if (typeof v === 'number') return v !== 0
|
||||
const lower = String(v).toLowerCase()
|
||||
if (lower === 'true') return true
|
||||
if (lower === 'false') return false
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
let hasAdvancedParam = false
|
||||
|
||||
// ---- booleans that trigger advanced when true ----
|
||||
if (getBool('quad', false)) hasAdvancedParam = true
|
||||
if (getBool('force_symmetry', false)) hasAdvancedParam = true
|
||||
if (getBool('flatten_bottom', false)) hasAdvancedParam = true
|
||||
if (getBool('pivot_to_center_bottom', false)) hasAdvancedParam = true
|
||||
if (getBool('with_animation', false)) hasAdvancedParam = true
|
||||
if (getBool('pack_uv', false)) hasAdvancedParam = true
|
||||
if (getBool('bake', false)) hasAdvancedParam = true
|
||||
if (getBool('export_vertex_colors', false)) hasAdvancedParam = true
|
||||
if (getBool('animate_in_place', false)) hasAdvancedParam = true
|
||||
|
||||
// ---- numeric params with special default sentinels ----
|
||||
const faceLimit = getNumber('face_limit', -1)
|
||||
if (faceLimit !== -1) hasAdvancedParam = true
|
||||
|
||||
const textureSize = getNumber('texture_size', 4096)
|
||||
if (textureSize !== 4096) hasAdvancedParam = true
|
||||
|
||||
const flattenBottomThreshold = getNumber(
|
||||
'flatten_bottom_threshold',
|
||||
0.0
|
||||
)
|
||||
if (flattenBottomThreshold !== 0.0) hasAdvancedParam = true
|
||||
|
||||
const scaleFactor = getNumber('scale_factor', 1.0)
|
||||
if (scaleFactor !== 1.0) hasAdvancedParam = true
|
||||
|
||||
// ---- string / combo params with non-default values ----
|
||||
const textureFormatRaw = String(
|
||||
getWidgetValue('texture_format') ?? 'JPEG'
|
||||
).toUpperCase()
|
||||
if (textureFormatRaw !== 'JPEG') hasAdvancedParam = true
|
||||
|
||||
const partNamesRaw = String(getWidgetValue('part_names') ?? '')
|
||||
if (partNamesRaw.trim().length > 0) hasAdvancedParam = true
|
||||
|
||||
const fbxPresetRaw = String(
|
||||
getWidgetValue('fbx_preset') ?? 'blender'
|
||||
).toLowerCase()
|
||||
if (fbxPresetRaw !== 'blender') hasAdvancedParam = true
|
||||
|
||||
const exportOrientationRaw = String(
|
||||
getWidgetValue('export_orientation') ?? 'default'
|
||||
).toLowerCase()
|
||||
if (exportOrientationRaw !== 'default') hasAdvancedParam = true
|
||||
|
||||
const credits = hasAdvancedParam ? 10 : 5
|
||||
const dollars = credits * 0.01
|
||||
return `$${dollars.toFixed(2)}/Run`
|
||||
}
|
||||
},
|
||||
TripoRetargetNode: {
|
||||
displayPrice: '$0.10/Run'
|
||||
},
|
||||
TripoRefineNode: {
|
||||
displayPrice: '$0.30/Run'
|
||||
},
|
||||
// Google/Gemini nodes
|
||||
GeminiNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
@@ -1984,6 +2064,7 @@ export const useNodePricing = () => {
|
||||
FluxProKontextProNode: [],
|
||||
FluxProKontextMaxNode: [],
|
||||
Flux2ProImageNode: ['width', 'height', 'images'],
|
||||
Flux2MaxImageNode: ['width', 'height', 'images'],
|
||||
VeoVideoGenerationNode: ['duration_seconds'],
|
||||
Veo3VideoGenerationNode: ['model', 'generate_audio'],
|
||||
Veo3FirstLastFrameNode: ['model', 'generate_audio', 'duration'],
|
||||
@@ -2019,8 +2100,51 @@ export const useNodePricing = () => {
|
||||
RunwayImageToVideoNodeGen4: ['duration'],
|
||||
RunwayFirstLastFrameNode: ['duration'],
|
||||
// Tripo nodes
|
||||
TripoTextToModelNode: ['quad', 'style', 'texture', 'texture_quality'],
|
||||
TripoImageToModelNode: ['quad', 'style', 'texture', 'texture_quality'],
|
||||
TripoTextToModelNode: [
|
||||
'model_version',
|
||||
'quad',
|
||||
'style',
|
||||
'texture',
|
||||
'pbr',
|
||||
'texture_quality',
|
||||
'geometry_quality'
|
||||
],
|
||||
TripoImageToModelNode: [
|
||||
'model_version',
|
||||
'quad',
|
||||
'style',
|
||||
'texture',
|
||||
'pbr',
|
||||
'texture_quality',
|
||||
'geometry_quality'
|
||||
],
|
||||
TripoMultiviewToModelNode: [
|
||||
'model_version',
|
||||
'quad',
|
||||
'texture',
|
||||
'pbr',
|
||||
'texture_quality',
|
||||
'geometry_quality'
|
||||
],
|
||||
TripoConversionNode: [
|
||||
'quad',
|
||||
'face_limit',
|
||||
'texture_size',
|
||||
'texture_format',
|
||||
'force_symmetry',
|
||||
'flatten_bottom',
|
||||
'flatten_bottom_threshold',
|
||||
'pivot_to_center_bottom',
|
||||
'scale_factor',
|
||||
'with_animation',
|
||||
'pack_uv',
|
||||
'bake',
|
||||
'part_names',
|
||||
'fbx_preset',
|
||||
'export_vertex_colors',
|
||||
'export_orientation',
|
||||
'animate_in_place'
|
||||
],
|
||||
TripoTextureNode: ['texture_quality'],
|
||||
// Google/Gemini nodes
|
||||
GeminiNode: ['model'],
|
||||
|
||||
@@ -389,6 +389,13 @@ export const SERVER_CONFIG_ITEMS: ServerConfig<any>[] = [
|
||||
type: 'boolean',
|
||||
defaultValue: false
|
||||
},
|
||||
{
|
||||
id: 'enable-manager-legacy-ui',
|
||||
name: 'Use legacy Manager UI',
|
||||
tooltip: 'Uses the legacy ComfyUI-Manager UI instead of the new UI.',
|
||||
type: 'boolean',
|
||||
defaultValue: false
|
||||
},
|
||||
{
|
||||
id: 'disable-all-custom-nodes',
|
||||
name: 'Disable loading all custom nodes.',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { remove } from 'es-toolkit'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import type {
|
||||
ISlotType,
|
||||
INodeInputSlot,
|
||||
@@ -23,22 +24,41 @@ import type { ComfyApp } from '@/scripts/app'
|
||||
const INLINE_INPUTS = false
|
||||
|
||||
type MatchTypeNode = LGraphNode &
|
||||
Pick<Required<LGraphNode>, 'comfyMatchType' | 'onConnectionsChange'>
|
||||
Pick<Required<LGraphNode>, 'onConnectionsChange'> & {
|
||||
comfyDynamic: { matchType: Record<string, Record<string, string>> }
|
||||
}
|
||||
type AutogrowNode = LGraphNode &
|
||||
Pick<Required<LGraphNode>, 'onConnectionsChange' | 'widgets'> & {
|
||||
comfyDynamic: {
|
||||
autogrow: Record<
|
||||
string,
|
||||
{
|
||||
min: number
|
||||
max: number
|
||||
inputSpecs: InputSpecV2[]
|
||||
prefix?: string
|
||||
names?: string[]
|
||||
}
|
||||
>
|
||||
}
|
||||
}
|
||||
|
||||
function ensureWidgetForInput(node: LGraphNode, input: INodeInputSlot) {
|
||||
if (input.widget?.name) return
|
||||
node.widgets ??= []
|
||||
const { widget } = input
|
||||
if (widget && node.widgets.some((w) => w.name === widget.name)) return
|
||||
node.widgets.push({
|
||||
name: input.name,
|
||||
y: 0,
|
||||
type: 'shim',
|
||||
options: {},
|
||||
draw(ctx, _n, _w, y) {
|
||||
ctx.save()
|
||||
ctx.fillStyle = LiteGraph.NODE_TEXT_COLOR
|
||||
ctx.fillText(input.label ?? input.name, 20, y + 15)
|
||||
ctx.restore()
|
||||
}
|
||||
},
|
||||
name: input.name,
|
||||
options: {},
|
||||
serialize: false,
|
||||
type: 'shim',
|
||||
y: 0
|
||||
})
|
||||
input.alwaysVisible = true
|
||||
input.widget = { name: input.name }
|
||||
@@ -66,72 +86,47 @@ function dynamicComboWidget(
|
||||
appArg,
|
||||
widgetName
|
||||
)
|
||||
let currentDynamicNames: string[] = []
|
||||
function isInGroup(e: { name: string }): boolean {
|
||||
return e.name.startsWith(inputName + '.')
|
||||
}
|
||||
const updateWidgets = (value?: string) => {
|
||||
if (!node.widgets) throw new Error('Not Reachable')
|
||||
const newSpec = value ? options[value] : undefined
|
||||
const inputsToRemove: Record<string, INodeInputSlot> = {}
|
||||
for (const name of currentDynamicNames) {
|
||||
const input = node.inputs.find((input) => input.name === name)
|
||||
if (input) inputsToRemove[input.name] = input
|
||||
const widgetIndex = node.widgets.findIndex(
|
||||
(widget) => widget.name === name
|
||||
)
|
||||
if (widgetIndex === -1) continue
|
||||
node.widgets[widgetIndex].value = undefined
|
||||
node.widgets.splice(widgetIndex, 1)
|
||||
}
|
||||
currentDynamicNames = []
|
||||
if (!newSpec) {
|
||||
for (const input of Object.values(inputsToRemove)) {
|
||||
const inputIndex = node.inputs.findIndex((inp) => inp === input)
|
||||
if (inputIndex === -1) continue
|
||||
node.removeInput(inputIndex)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const removedInputs = remove(node.inputs, isInGroup)
|
||||
remove(node.widgets, isInGroup)
|
||||
|
||||
if (!newSpec) return
|
||||
|
||||
const insertionPoint = node.widgets.findIndex((w) => w === widget) + 1
|
||||
const startingLength = node.widgets.length
|
||||
const initialInputIndex =
|
||||
node.inputs.findIndex((i) => i.name === widget.name) + 1
|
||||
let startingInputLength = node.inputs.length
|
||||
const startingInputLength = node.inputs.length
|
||||
|
||||
if (insertionPoint === 0)
|
||||
throw new Error("Dynamic widget doesn't exist on node")
|
||||
const inputTypes: [Record<string, InputSpec> | undefined, boolean][] = [
|
||||
[newSpec.required, false],
|
||||
[newSpec.optional, true]
|
||||
const inputTypes: (Record<string, InputSpec> | undefined)[] = [
|
||||
newSpec.required,
|
||||
newSpec.optional
|
||||
]
|
||||
for (const [inputType, isOptional] of inputTypes)
|
||||
inputTypes.forEach((inputType, idx) => {
|
||||
for (const key in inputType ?? {}) {
|
||||
const name = `${widget.name}.${key}`
|
||||
const specToAdd = transformInputSpecV1ToV2(inputType![key], {
|
||||
name,
|
||||
isOptional
|
||||
isOptional: idx !== 0
|
||||
})
|
||||
specToAdd.display_name = key
|
||||
addNodeInput(node, specToAdd)
|
||||
currentDynamicNames.push(name)
|
||||
if (INLINE_INPUTS) ensureWidgetForInput(node, node.inputs.at(-1)!)
|
||||
if (
|
||||
!inputsToRemove[name] ||
|
||||
Array.isArray(inputType![key][0]) ||
|
||||
!LiteGraph.isValidConnection(
|
||||
inputsToRemove[name].type,
|
||||
inputType![key][0]
|
||||
)
|
||||
)
|
||||
continue
|
||||
node.inputs.at(-1)!.link = inputsToRemove[name].link
|
||||
inputsToRemove[name].link = null
|
||||
const newInputs = node.inputs
|
||||
.slice(startingInputLength)
|
||||
.filter((inp) => inp.name.startsWith(name))
|
||||
for (const newInput of newInputs) {
|
||||
if (INLINE_INPUTS && !newInput.widget)
|
||||
ensureWidgetForInput(node, newInput)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
for (const input of Object.values(inputsToRemove)) {
|
||||
const inputIndex = node.inputs.findIndex((inp) => inp === input)
|
||||
if (inputIndex === -1) continue
|
||||
if (inputIndex < initialInputIndex) startingInputLength--
|
||||
node.removeInput(inputIndex)
|
||||
}
|
||||
const inputInsertionPoint =
|
||||
node.inputs.findIndex((i) => i.name === widget.name) + 1
|
||||
const addedWidgets = node.widgets.splice(startingLength)
|
||||
@@ -157,6 +152,28 @@ function dynamicComboWidget(
|
||||
)
|
||||
//assume existing inputs are in correct order
|
||||
spliceInputs(node, inputInsertionPoint, 0, ...addedInputs)
|
||||
|
||||
for (const input of removedInputs) {
|
||||
const inputIndex = node.inputs.findIndex((inp) => inp.name === input.name)
|
||||
if (inputIndex === -1) {
|
||||
node.inputs.push(input)
|
||||
node.removeInput(node.inputs.length - 1)
|
||||
} else {
|
||||
node.inputs[inputIndex].link = input.link
|
||||
if (!input.link) continue
|
||||
const link = node.graph?.links?.[input.link]
|
||||
if (!link) continue
|
||||
link.target_slot = inputIndex
|
||||
node.onConnectionsChange?.(
|
||||
LiteGraph.INPUT,
|
||||
inputIndex,
|
||||
true,
|
||||
link,
|
||||
node.inputs[inputIndex]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
node.size[1] = node.computeSize([...node.size])[1]
|
||||
if (!node.graph) return
|
||||
node._setConcreteSlots()
|
||||
@@ -243,8 +260,9 @@ function changeOutputType(
|
||||
}
|
||||
|
||||
function withComfyMatchType(node: LGraphNode): asserts node is MatchTypeNode {
|
||||
if (node.comfyMatchType) return
|
||||
node.comfyMatchType = {}
|
||||
if (node.comfyDynamic?.matchType) return
|
||||
node.comfyDynamic ??= {}
|
||||
node.comfyDynamic.matchType = {}
|
||||
|
||||
const outputGroups = node.constructor.nodeData?.output_matchtypes
|
||||
node.onConnectionsChange = useChainCallback(
|
||||
@@ -258,9 +276,9 @@ function withComfyMatchType(node: LGraphNode): asserts node is MatchTypeNode {
|
||||
) {
|
||||
const input = this.inputs[slot]
|
||||
if (contype !== LiteGraph.INPUT || !this.graph || !input) return
|
||||
const [matchKey, matchGroup] = Object.entries(this.comfyMatchType).find(
|
||||
([, group]) => input.name in group
|
||||
) ?? ['', undefined]
|
||||
const [matchKey, matchGroup] = Object.entries(
|
||||
this.comfyDynamic.matchType
|
||||
).find(([, group]) => input.name in group) ?? ['', undefined]
|
||||
if (!matchGroup) return
|
||||
if (iscon && linf) {
|
||||
const { output, subgraphInput } = linf.resolve(this.graph)
|
||||
@@ -317,8 +335,8 @@ function applyMatchType(node: LGraphNode, inputSpec: InputSpecV2) {
|
||||
const typedSpec = { ...inputSpec, type: allowed_types }
|
||||
addNodeInput(node, typedSpec)
|
||||
withComfyMatchType(node)
|
||||
node.comfyMatchType[template_id] ??= {}
|
||||
node.comfyMatchType[template_id][name] = allowed_types
|
||||
node.comfyDynamic.matchType[template_id] ??= {}
|
||||
node.comfyDynamic.matchType[template_id][name] = allowed_types
|
||||
|
||||
//TODO: instead apply on output add?
|
||||
//ensure outputs get updated
|
||||
@@ -329,160 +347,215 @@ function applyMatchType(node: LGraphNode, inputSpec: InputSpecV2) {
|
||||
)
|
||||
}
|
||||
|
||||
function applyAutogrow(node: LGraphNode, untypedInputSpec: InputSpecV2) {
|
||||
function autogrowOrdinalToName(
|
||||
ordinal: number,
|
||||
key: string,
|
||||
groupName: string,
|
||||
node: AutogrowNode
|
||||
) {
|
||||
const {
|
||||
names,
|
||||
prefix = '',
|
||||
inputSpecs
|
||||
} = node.comfyDynamic.autogrow[groupName]
|
||||
const baseName = names
|
||||
? names[ordinal]
|
||||
: (inputSpecs.length == 1 ? prefix : key) + ordinal
|
||||
return { name: `${groupName}.${baseName}`, display_name: baseName }
|
||||
}
|
||||
|
||||
function addAutogrowGroup(
|
||||
ordinal: number,
|
||||
groupName: string,
|
||||
node: AutogrowNode
|
||||
) {
|
||||
const { addNodeInput } = useLitegraphService()
|
||||
const { max, min, inputSpecs } = node.comfyDynamic.autogrow[groupName]
|
||||
if (ordinal >= max) return
|
||||
|
||||
const parseResult = zAutogrowOptions.safeParse(untypedInputSpec)
|
||||
if (!parseResult.success) throw new Error('invalid Autogrow spec')
|
||||
const inputSpec = parseResult.data
|
||||
const namedSpecs = inputSpecs.map((input) => ({
|
||||
...input,
|
||||
isOptional: ordinal >= (min ?? 0) || input.isOptional,
|
||||
...autogrowOrdinalToName(ordinal, input.name, groupName, node)
|
||||
}))
|
||||
|
||||
const { input, min, names, prefix, max } = inputSpec.template
|
||||
const inputTypes: [Record<string, InputSpec> | undefined, boolean][] = [
|
||||
[input.required, false],
|
||||
[input.optional, true]
|
||||
]
|
||||
const inputsV2 = inputTypes.flatMap(([inputType, isOptional]) =>
|
||||
Object.entries(inputType ?? {}).map(([name, v]) =>
|
||||
transformInputSpecV1ToV2(v, { name, isOptional })
|
||||
const newInputs = namedSpecs
|
||||
.filter(
|
||||
(namedSpec) => !node.inputs.some((inp) => inp.name === namedSpec.name)
|
||||
)
|
||||
.map((namedSpec) => {
|
||||
addNodeInput(node, namedSpec)
|
||||
const input = spliceInputs(node, node.inputs.length - 1, 1)[0]
|
||||
if (inputSpecs.length !== 1 || (INLINE_INPUTS && !input.widget))
|
||||
ensureWidgetForInput(node, input)
|
||||
return input
|
||||
})
|
||||
|
||||
const lastIndex = node.inputs.findLastIndex((inp) =>
|
||||
inp.name.startsWith(groupName)
|
||||
)
|
||||
const insertionIndex = lastIndex === -1 ? node.inputs.length : lastIndex + 1
|
||||
spliceInputs(node, insertionIndex, 0, ...newInputs)
|
||||
app.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
function nameToInputIndex(name: string) {
|
||||
const index = node.inputs.findIndex((input) => input.name === name)
|
||||
if (index === -1) throw new Error('Failed to find input')
|
||||
return index
|
||||
}
|
||||
function nameToInput(name: string) {
|
||||
return node.inputs[nameToInputIndex(name)]
|
||||
const ORDINAL_REGEX = /\d+$/
|
||||
function resolveAutogrowOrdinal(
|
||||
inputName: string,
|
||||
groupName: string,
|
||||
node: AutogrowNode
|
||||
): number | undefined {
|
||||
//TODO preslice groupname?
|
||||
const name = inputName.slice(groupName.length + 1)
|
||||
const { names } = node.comfyDynamic.autogrow[groupName]
|
||||
if (names) {
|
||||
const ordinal = names.findIndex((s) => s === name)
|
||||
return ordinal === -1 ? undefined : ordinal
|
||||
}
|
||||
const match = name.match(ORDINAL_REGEX)
|
||||
if (!match) return undefined
|
||||
const ordinal = parseInt(match[0])
|
||||
return ordinal !== ordinal ? undefined : ordinal
|
||||
}
|
||||
function autogrowInputConnected(index: number, node: AutogrowNode) {
|
||||
const input = node.inputs[index]
|
||||
const groupName = input.name.slice(0, input.name.lastIndexOf('.'))
|
||||
const lastInput = node.inputs.findLast((inp) =>
|
||||
inp.name.startsWith(groupName)
|
||||
)
|
||||
const ordinal = resolveAutogrowOrdinal(input.name, groupName, node)
|
||||
if (
|
||||
!lastInput ||
|
||||
ordinal == undefined ||
|
||||
ordinal !== resolveAutogrowOrdinal(lastInput.name, groupName, node)
|
||||
)
|
||||
return
|
||||
addAutogrowGroup(ordinal + 1, groupName, node)
|
||||
}
|
||||
function autogrowInputDisconnected(index: number, node: AutogrowNode) {
|
||||
const input = node.inputs[index]
|
||||
if (!input) return
|
||||
const groupName = input.name.slice(0, input.name.lastIndexOf('.'))
|
||||
const { min = 1, inputSpecs } = node.comfyDynamic.autogrow[groupName]
|
||||
const ordinal = resolveAutogrowOrdinal(input.name, groupName, node)
|
||||
if (ordinal == undefined || ordinal + 1 < min) return
|
||||
|
||||
//In the distance, someone shouting YAGNI
|
||||
const trackedInputs: string[][] = []
|
||||
function addInputGroup(insertionIndex: number) {
|
||||
const ordinal = trackedInputs.length
|
||||
const inputGroup = inputsV2.map((input) => ({
|
||||
...input,
|
||||
name: names
|
||||
? names[ordinal]
|
||||
: ((inputsV2.length == 1 ? prefix : input.name) ?? '') + ordinal,
|
||||
isOptional: ordinal >= (min ?? 0) || input.isOptional
|
||||
}))
|
||||
const newInputs = inputGroup
|
||||
.filter(
|
||||
(namedSpec) => !node.inputs.some((inp) => inp.name === namedSpec.name)
|
||||
)
|
||||
.map((namedSpec) => {
|
||||
addNodeInput(node, namedSpec)
|
||||
const input = spliceInputs(node, node.inputs.length - 1, 1)[0]
|
||||
if (inputsV2.length !== 1) ensureWidgetForInput(node, input)
|
||||
return input
|
||||
})
|
||||
spliceInputs(node, insertionIndex, 0, ...newInputs)
|
||||
trackedInputs.push(inputGroup.map((inp) => inp.name))
|
||||
app.canvas?.setDirty(true, true)
|
||||
//resolve all inputs in group
|
||||
const groupInputs = node.inputs.filter(
|
||||
(inp) =>
|
||||
inp.name.startsWith(groupName + '.') &&
|
||||
inp.name.lastIndexOf('.') === groupName.length
|
||||
)
|
||||
const stride = inputSpecs.length
|
||||
if (groupInputs.length % stride !== 0) {
|
||||
console.error('Failed to group multi-input autogrow inputs')
|
||||
return
|
||||
}
|
||||
for (let i = 0; i < (min || 1); i++) addInputGroup(node.inputs.length)
|
||||
function removeInputGroup(inputName: string) {
|
||||
const groupIndex = trackedInputs.findIndex((ig) =>
|
||||
ig.some((inpName) => inpName === inputName)
|
||||
)
|
||||
if (groupIndex == -1) throw new Error('Failed to find group')
|
||||
const group = trackedInputs[groupIndex]
|
||||
for (const nameToRemove of group) {
|
||||
const inputIndex = nameToInputIndex(nameToRemove)
|
||||
const input = spliceInputs(node, inputIndex, 1)[0]
|
||||
if (!input.widget?.name) continue
|
||||
const widget = node.widgets?.find((w) => w.name === input.widget!.name)
|
||||
if (!widget) return
|
||||
widget.value = undefined
|
||||
node.removeWidget(widget)
|
||||
}
|
||||
trackedInputs.splice(groupIndex, 1)
|
||||
node.size[1] = node.computeSize([...node.size])[1]
|
||||
app.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
function inputConnected(index: number) {
|
||||
const input = node.inputs[index]
|
||||
const groupIndex = trackedInputs.findIndex((ig) =>
|
||||
ig.some((inputName) => inputName === input.name)
|
||||
)
|
||||
if (groupIndex == -1) throw new Error('Failed to find group')
|
||||
if (
|
||||
groupIndex + 1 === trackedInputs.length &&
|
||||
trackedInputs.length < (max ?? names?.length ?? 100)
|
||||
app.canvas?.setDirty(true, true)
|
||||
//groupBy would be nice here, but may not be supported
|
||||
for (let column = 0; column < stride; column++) {
|
||||
for (
|
||||
let bubbleOrdinal = ordinal * stride + column;
|
||||
bubbleOrdinal + stride < groupInputs.length;
|
||||
bubbleOrdinal += stride
|
||||
) {
|
||||
const lastInput = trackedInputs[groupIndex].at(-1)
|
||||
if (!lastInput) return
|
||||
const insertionIndex = nameToInputIndex(lastInput) + 1
|
||||
if (insertionIndex === 0) throw new Error('Failed to find Input')
|
||||
addInputGroup(insertionIndex)
|
||||
const curInput = groupInputs[bubbleOrdinal]
|
||||
curInput.link = groupInputs[bubbleOrdinal + stride].link
|
||||
if (!curInput.link) continue
|
||||
const link = node.graph?.links[curInput.link]
|
||||
if (!link) continue
|
||||
const curIndex = node.inputs.findIndex((inp) => inp === curInput)
|
||||
if (curIndex === -1) throw new Error('missing input')
|
||||
link.target_slot = curIndex
|
||||
}
|
||||
const lastInput = groupInputs.at(column - stride)
|
||||
if (!lastInput) continue
|
||||
lastInput.link = null
|
||||
}
|
||||
function inputDisconnected(index: number) {
|
||||
const input = node.inputs[index]
|
||||
if (trackedInputs.length === 1) return
|
||||
const groupIndex = trackedInputs.findIndex((ig) =>
|
||||
ig.some((inputName) => inputName === input.name)
|
||||
)
|
||||
if (groupIndex == -1) throw new Error('Failed to find group')
|
||||
if (
|
||||
trackedInputs[groupIndex].some(
|
||||
(inputName) => nameToInput(inputName).link != null
|
||||
)
|
||||
)
|
||||
return
|
||||
if (groupIndex + 1 < (min ?? 0)) return
|
||||
//For each group from here to last group, bubble swap links
|
||||
for (let column = 0; column < trackedInputs[0].length; column++) {
|
||||
let prevInput = nameToInputIndex(trackedInputs[groupIndex][column])
|
||||
for (let i = groupIndex + 1; i < trackedInputs.length; i++) {
|
||||
const curInput = nameToInputIndex(trackedInputs[i][column])
|
||||
const linkId = node.inputs[curInput].link
|
||||
node.inputs[prevInput].link = linkId
|
||||
const link = linkId && node.graph?.links?.[linkId]
|
||||
if (link) link.target_slot = prevInput
|
||||
prevInput = curInput
|
||||
}
|
||||
node.inputs[prevInput].link = null
|
||||
}
|
||||
if (
|
||||
trackedInputs.at(-2) &&
|
||||
!trackedInputs.at(-2)?.some((name) => !!nameToInput(name).link)
|
||||
)
|
||||
removeInputGroup(trackedInputs.at(-1)![0])
|
||||
const removalChecks = groupInputs.slice((min - 1) * stride)
|
||||
let i
|
||||
for (i = removalChecks.length - stride; i >= 0; i -= stride) {
|
||||
if (removalChecks.slice(i, i + stride).some((inp) => inp.link)) break
|
||||
}
|
||||
const toRemove = removalChecks.slice(i + stride * 2)
|
||||
remove(node.inputs, (inp) => toRemove.includes(inp))
|
||||
for (const input of toRemove) {
|
||||
const widgetName = input?.widget?.name
|
||||
if (!widgetName) continue
|
||||
remove(node.widgets, (w) => w.name === widgetName)
|
||||
}
|
||||
node.size[1] = node.computeSize([...node.size])[1]
|
||||
}
|
||||
|
||||
function withComfyAutogrow(node: LGraphNode): asserts node is AutogrowNode {
|
||||
if (node.comfyDynamic?.autogrow) return
|
||||
node.comfyDynamic ??= {}
|
||||
node.comfyDynamic.autogrow = {}
|
||||
|
||||
let pendingConnection: number | undefined
|
||||
let swappingConnection = false
|
||||
|
||||
const originalOnConnectInput = node.onConnectInput
|
||||
node.onConnectInput = function (slot: number, ...args) {
|
||||
pendingConnection = slot
|
||||
requestAnimationFrame(() => (pendingConnection = undefined))
|
||||
return originalOnConnectInput?.apply(this, [slot, ...args]) ?? true
|
||||
}
|
||||
|
||||
node.onConnectionsChange = useChainCallback(
|
||||
node.onConnectionsChange,
|
||||
(
|
||||
type: ISlotType,
|
||||
index: number,
|
||||
function (
|
||||
this: AutogrowNode,
|
||||
contype: ISlotType,
|
||||
slot: number,
|
||||
iscon: boolean,
|
||||
linf: LLink | null | undefined
|
||||
) => {
|
||||
if (type !== NodeSlotType.INPUT) return
|
||||
const inputName = node.inputs[index].name
|
||||
if (!trackedInputs.flat().some((name) => name === inputName)) return
|
||||
if (iscon) {
|
||||
) {
|
||||
const input = this.inputs[slot]
|
||||
if (contype !== LiteGraph.INPUT || !input) return
|
||||
//Return if input isn't known autogrow
|
||||
const key = input.name.slice(0, input.name.lastIndexOf('.'))
|
||||
const autogrowGroup = this.comfyDynamic.autogrow[key]
|
||||
if (!autogrowGroup) return
|
||||
if (app.configuringGraph && input.widget)
|
||||
ensureWidgetForInput(node, input)
|
||||
if (iscon && linf) {
|
||||
if (swappingConnection || !linf) return
|
||||
inputConnected(index)
|
||||
autogrowInputConnected(slot, this)
|
||||
} else {
|
||||
if (pendingConnection === index) {
|
||||
if (pendingConnection === slot) {
|
||||
swappingConnection = true
|
||||
requestAnimationFrame(() => (swappingConnection = false))
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(() => inputDisconnected(index))
|
||||
requestAnimationFrame(() => autogrowInputDisconnected(slot, this))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
function applyAutogrow(node: LGraphNode, inputSpecV2: InputSpecV2) {
|
||||
withComfyAutogrow(node)
|
||||
|
||||
const parseResult = zAutogrowOptions.safeParse(inputSpecV2)
|
||||
if (!parseResult.success) throw new Error('invalid Autogrow spec')
|
||||
const inputSpec = parseResult.data
|
||||
const { input, min = 1, names, prefix, max = 100 } = inputSpec.template
|
||||
|
||||
const inputTypes: (Record<string, InputSpec> | undefined)[] = [
|
||||
input.required,
|
||||
input.optional
|
||||
]
|
||||
const inputsV2 = inputTypes.flatMap((inputType, index) =>
|
||||
Object.entries(inputType ?? {}).map(([name, v]) =>
|
||||
transformInputSpecV1ToV2(v, { name, isOptional: index === 1 })
|
||||
)
|
||||
)
|
||||
node.comfyDynamic.autogrow[inputSpecV2.name] = {
|
||||
names,
|
||||
min,
|
||||
max: names?.length ?? max,
|
||||
prefix,
|
||||
inputSpecs: inputsV2
|
||||
}
|
||||
for (let i = 0; i < min; i++) addAutogrowGroup(i, inputSpecV2.name, node)
|
||||
}
|
||||
|
||||
@@ -257,6 +257,8 @@ export class PrimitiveNode extends LGraphNode {
|
||||
undefined,
|
||||
inputData
|
||||
)
|
||||
if (this.widgets?.[1]) widget.linkedWidgets = [this.widgets[1]]
|
||||
|
||||
let filter = this.widgets_values?.[2]
|
||||
if (filter && this.widgets && this.widgets.length === 3) {
|
||||
this.widgets[2].value = filter
|
||||
|
||||
@@ -8564,9 +8564,11 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
node,
|
||||
newPos: this.calculateNewPosition(node, deltaX, deltaY)
|
||||
})
|
||||
} else {
|
||||
// Non-node children (nested groups, reroutes)
|
||||
child.move(deltaX, deltaY)
|
||||
} else if (!(child instanceof LGraphGroup)) {
|
||||
// Non-node, non-group children (reroutes, etc.)
|
||||
// Skip groups here - they're already in allItems and will be
|
||||
// processed in the main loop of moveChildNodesInGroupVueMode
|
||||
child.move(deltaX, deltaY, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,7 +416,7 @@ export class LGraphNode
|
||||
selected?: boolean
|
||||
showAdvanced?: boolean
|
||||
|
||||
declare comfyMatchType?: Record<string, Record<string, string>>
|
||||
declare comfyDynamic?: Record<string, object>
|
||||
declare comfyClass?: string
|
||||
declare isVirtualNode?: boolean
|
||||
applyToGraph?(extraLinks?: LLink[]): void
|
||||
@@ -2000,7 +2000,7 @@ export class LGraphNode
|
||||
* @param out `x, y, width, height` are written to this array.
|
||||
* @param ctx The canvas context to use for measuring text.
|
||||
*/
|
||||
measure(out: Rect, ctx: CanvasRenderingContext2D): void {
|
||||
measure(out: Rect, ctx?: CanvasRenderingContext2D): void {
|
||||
const titleMode = this.title_mode
|
||||
const renderTitle =
|
||||
titleMode != TitleMode.TRANSPARENT_TITLE &&
|
||||
@@ -2013,11 +2013,13 @@ export class LGraphNode
|
||||
out[2] = this.size[0]
|
||||
out[3] = this.size[1] + titleHeight
|
||||
} else {
|
||||
ctx.font = this.innerFontStyle
|
||||
if (ctx) ctx.font = this.innerFontStyle
|
||||
this._collapsed_width = Math.min(
|
||||
this.size[0],
|
||||
ctx.measureText(this.getTitle() ?? '').width +
|
||||
LiteGraph.NODE_TITLE_HEIGHT * 2
|
||||
ctx
|
||||
? ctx.measureText(this.getTitle() ?? '').width +
|
||||
LiteGraph.NODE_TITLE_HEIGHT * 2
|
||||
: 0
|
||||
)
|
||||
out[2] = this._collapsed_width || LiteGraph.NODE_COLLAPSED_WIDTH
|
||||
out[3] = LiteGraph.NODE_TITLE_HEIGHT
|
||||
@@ -2047,7 +2049,7 @@ export class LGraphNode
|
||||
* Calculates the render area of this node, populating both {@link boundingRect} and {@link renderArea}.
|
||||
* Called automatically at the start of every frame.
|
||||
*/
|
||||
updateArea(ctx: CanvasRenderingContext2D): void {
|
||||
updateArea(ctx?: CanvasRenderingContext2D): void {
|
||||
const bounds = this.#boundingRect
|
||||
this.measure(bounds, ctx)
|
||||
this.onBounding?.(bounds)
|
||||
|
||||
@@ -294,6 +294,8 @@
|
||||
"uninstall": "Uninstall",
|
||||
"uninstalling": "Uninstalling {id}",
|
||||
"update": "Update",
|
||||
"tryUpdate": "Try Update",
|
||||
"tryUpdateTooltip": "Pull latest changes from repository. Nightly versions may have updates that cannot be detected automatically.",
|
||||
"uninstallSelected": "Uninstall Selected",
|
||||
"updateSelected": "Update Selected",
|
||||
"updateAll": "Update All",
|
||||
@@ -807,6 +809,7 @@
|
||||
"dark": "Dark",
|
||||
"light": "Light",
|
||||
"manageExtensions": "Manage Extensions",
|
||||
"customNodesManager": "Custom Nodes Manager",
|
||||
"settings": "Settings",
|
||||
"help": "Help",
|
||||
"queue": "Queue Panel"
|
||||
@@ -1333,6 +1336,10 @@
|
||||
"disable-metadata": {
|
||||
"name": "Disable saving prompt metadata in files."
|
||||
},
|
||||
"enable-manager-legacy-ui": {
|
||||
"name": "Use legacy Manager UI",
|
||||
"tooltip": "Uses the legacy ComfyUI-Manager UI instead of the new UI."
|
||||
},
|
||||
"disable-all-custom-nodes": {
|
||||
"name": "Disable loading all custom nodes."
|
||||
},
|
||||
@@ -2053,7 +2060,7 @@
|
||||
"placeholderModel": "Select model...",
|
||||
"placeholderUnknown": "Select media..."
|
||||
},
|
||||
"numberControl": {
|
||||
"valueControl": {
|
||||
"header": {
|
||||
"prefix": "Automatically update the value",
|
||||
"after": "AFTER",
|
||||
@@ -2066,9 +2073,11 @@
|
||||
"randomize": "Randomize Value",
|
||||
"randomizeDesc": "Shuffles the value randomly after each generation",
|
||||
"increment": "Increment Value",
|
||||
"incrementDesc": "Adds 1 to the value number",
|
||||
"incrementDesc": "Adds 1 to value or selects the next option",
|
||||
"decrement": "Decrement Value",
|
||||
"decrementDesc": "Subtracts 1 from the value number",
|
||||
"decrementDesc": "Subtracts 1 from value or selects the previous option",
|
||||
"fixed": "Fixed Value",
|
||||
"fixedDesc": "Leaves value unchanged",
|
||||
"editSettings": "Edit control settings"
|
||||
}
|
||||
},
|
||||
@@ -2444,4 +2453,4 @@
|
||||
"recentReleases": "Recent releases",
|
||||
"helpCenterMenu": "Help Center Menu"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,6 @@
|
||||
variant="gray"
|
||||
:label="formattedDuration"
|
||||
/>
|
||||
<SquareChip v-if="fileFormat" variant="gray" :label="fileFormat" />
|
||||
</div>
|
||||
|
||||
<!-- Media actions - show on hover or when playing -->
|
||||
@@ -266,12 +265,6 @@ const formattedDuration = computed(() => {
|
||||
return formatDuration(Number(duration))
|
||||
})
|
||||
|
||||
const fileFormat = computed(() => {
|
||||
if (!asset?.name) return ''
|
||||
const parts = asset.name.split('.')
|
||||
return parts.length > 1 ? parts[parts.length - 1].toUpperCase() : ''
|
||||
})
|
||||
|
||||
const durationChipClasses = computed(() => {
|
||||
if (fileKind.value === 'audio') {
|
||||
return '-translate-y-11'
|
||||
@@ -289,7 +282,7 @@ const showStaticChips = computed(
|
||||
!!asset &&
|
||||
!isHovered.value &&
|
||||
!isVideoPlaying.value &&
|
||||
(formattedDuration.value || fileFormat.value)
|
||||
formattedDuration.value
|
||||
)
|
||||
|
||||
// Show action overlay when hovered OR playing
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
/>
|
||||
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="litegraph-minimap relative border border-interface-stroke bg-comfy-menu-bg shadow-interface"
|
||||
:style="containerStyles"
|
||||
>
|
||||
@@ -51,12 +50,7 @@
|
||||
}"
|
||||
/>
|
||||
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
:width="width"
|
||||
:height="height"
|
||||
class="minimap-canvas"
|
||||
/>
|
||||
<canvas :width="width" :height="height" class="minimap-canvas" />
|
||||
|
||||
<div class="minimap-viewport" :style="viewportStyles" />
|
||||
|
||||
@@ -89,8 +83,6 @@ const minimapRef = ref<HTMLDivElement>()
|
||||
const {
|
||||
initialized,
|
||||
visible,
|
||||
containerRef,
|
||||
canvasRef,
|
||||
containerStyles,
|
||||
viewportStyles,
|
||||
width,
|
||||
|
||||
@@ -2,16 +2,20 @@
|
||||
<div
|
||||
v-if="imageUrls.length > 0"
|
||||
class="video-preview group relative flex size-full min-h-16 min-w-16 flex-col px-2"
|
||||
tabindex="0"
|
||||
role="region"
|
||||
:aria-label="$t('g.videoPreview')"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@keydown="handleKeyDown"
|
||||
>
|
||||
<!-- Video Wrapper -->
|
||||
<div
|
||||
ref="videoWrapperEl"
|
||||
class="relative h-full w-full grow overflow-hidden rounded-[5px] bg-node-component-surface"
|
||||
tabindex="0"
|
||||
role="region"
|
||||
:aria-label="$t('g.videoPreview')"
|
||||
:aria-busy="showLoader"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@focusin="handleFocusIn"
|
||||
@focusout="handleFocusOut"
|
||||
>
|
||||
<!-- Error State -->
|
||||
<div
|
||||
@@ -27,18 +31,18 @@
|
||||
|
||||
<!-- Loading State -->
|
||||
<Skeleton
|
||||
v-if="isLoading && !videoError"
|
||||
v-if="showLoader && !videoError"
|
||||
class="absolute inset-0 size-full"
|
||||
border-radius="5px"
|
||||
width="16rem"
|
||||
height="16rem"
|
||||
width="100%"
|
||||
height="100%"
|
||||
/>
|
||||
|
||||
<!-- Main Video -->
|
||||
<video
|
||||
v-if="!videoError"
|
||||
:src="currentVideoUrl"
|
||||
:class="cn('block size-full object-contain', isLoading && 'invisible')"
|
||||
:class="cn('block size-full object-contain', showLoader && 'invisible')"
|
||||
controls
|
||||
loop
|
||||
playsinline
|
||||
@@ -47,10 +51,13 @@
|
||||
/>
|
||||
|
||||
<!-- Floating Action Buttons (appear on hover) -->
|
||||
<div v-if="isHovered" class="actions absolute top-2 right-2 flex gap-1">
|
||||
<div
|
||||
v-if="isHovered || isFocused"
|
||||
class="actions absolute top-2 right-2 flex gap-2.5"
|
||||
>
|
||||
<!-- Download Button -->
|
||||
<button
|
||||
class="action-btn cursor-pointer rounded-lg border-0 bg-white p-2 text-black shadow-sm transition-all duration-200 hover:bg-smoke-100"
|
||||
:class="actionButtonClass"
|
||||
:title="$t('g.downloadVideo')"
|
||||
:aria-label="$t('g.downloadVideo')"
|
||||
@click="handleDownload"
|
||||
@@ -60,7 +67,7 @@
|
||||
|
||||
<!-- Close Button -->
|
||||
<button
|
||||
class="action-btn cursor-pointer rounded-lg border-0 bg-white p-2 text-black shadow-sm transition-all duration-200 hover:bg-smoke-100"
|
||||
:class="actionButtonClass"
|
||||
:title="$t('g.removeVideo')"
|
||||
:aria-label="$t('g.removeVideo')"
|
||||
@click="handleRemove"
|
||||
@@ -94,7 +101,7 @@
|
||||
<span v-if="videoError" class="text-red-400">
|
||||
{{ $t('g.errorLoadingVideo') }}
|
||||
</span>
|
||||
<span v-else-if="isLoading" class="text-smoke-400">
|
||||
<span v-else-if="showLoader" class="text-smoke-400">
|
||||
{{ $t('g.loading') }}...
|
||||
</span>
|
||||
<span v-else>
|
||||
@@ -126,12 +133,18 @@ const props = defineProps<VideoPreviewProps>()
|
||||
const { t } = useI18n()
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
|
||||
const actionButtonClass =
|
||||
'flex h-8 min-h-8 items-center justify-center gap-2.5 rounded-lg border-0 bg-button-surface px-2 py-2 text-button-surface-contrast shadow-sm transition-colors duration-200 hover:bg-button-hover-surface focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-button-surface-contrast focus-visible:ring-offset-2 focus-visible:ring-offset-transparent cursor-pointer'
|
||||
|
||||
// Component state
|
||||
const currentIndex = ref(0)
|
||||
const isHovered = ref(false)
|
||||
const isFocused = ref(false)
|
||||
const actualDimensions = ref<string | null>(null)
|
||||
const videoError = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const showLoader = ref(false)
|
||||
|
||||
const videoWrapperEl = ref<HTMLDivElement>()
|
||||
|
||||
// Computed values
|
||||
const currentVideoUrl = computed(() => props.imageUrls[currentIndex.value])
|
||||
@@ -149,16 +162,16 @@ watch(
|
||||
// Reset loading and error states when URLs change
|
||||
actualDimensions.value = null
|
||||
videoError.value = false
|
||||
isLoading.value = newUrls.length > 0
|
||||
showLoader.value = newUrls.length > 0
|
||||
},
|
||||
{ deep: true }
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
|
||||
// Event handlers
|
||||
const handleVideoLoad = (event: Event) => {
|
||||
if (!event.target || !(event.target instanceof HTMLVideoElement)) return
|
||||
const video = event.target
|
||||
isLoading.value = false
|
||||
showLoader.value = false
|
||||
videoError.value = false
|
||||
if (video.videoWidth && video.videoHeight) {
|
||||
actualDimensions.value = `${video.videoWidth} x ${video.videoHeight}`
|
||||
@@ -166,7 +179,7 @@ const handleVideoLoad = (event: Event) => {
|
||||
}
|
||||
|
||||
const handleVideoError = () => {
|
||||
isLoading.value = false
|
||||
showLoader.value = false
|
||||
videoError.value = true
|
||||
actualDimensions.value = null
|
||||
}
|
||||
@@ -194,7 +207,7 @@ const setCurrentIndex = (index: number) => {
|
||||
if (index >= 0 && index < props.imageUrls.length) {
|
||||
currentIndex.value = index
|
||||
actualDimensions.value = null
|
||||
isLoading.value = true
|
||||
showLoader.value = true
|
||||
videoError.value = false
|
||||
}
|
||||
}
|
||||
@@ -207,6 +220,16 @@ const handleMouseLeave = () => {
|
||||
isHovered.value = false
|
||||
}
|
||||
|
||||
const handleFocusIn = () => {
|
||||
isFocused.value = true
|
||||
}
|
||||
|
||||
const handleFocusOut = (event: FocusEvent) => {
|
||||
if (!videoWrapperEl.value?.contains(event.relatedTarget as Node)) {
|
||||
isFocused.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getNavigationDotClass = (index: number) => {
|
||||
return [
|
||||
'w-2 h-2 rounded-full transition-all duration-200 border-0 cursor-pointer',
|
||||
|
||||
@@ -51,7 +51,10 @@
|
||||
@dragleave="handleDragLeave"
|
||||
@drop.stop.prevent="handleDrop"
|
||||
>
|
||||
<div class="flex flex-col justify-center items-center relative">
|
||||
<div
|
||||
v-if="displayHeader"
|
||||
class="flex flex-col justify-center items-center relative"
|
||||
>
|
||||
<template v-if="isCollapsed">
|
||||
<SlotConnectionDot
|
||||
v-if="hasInputs"
|
||||
@@ -145,6 +148,7 @@ import {
|
||||
LiteGraph,
|
||||
RenderShape
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
@@ -165,6 +169,7 @@ import { app } from '@/scripts/app'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { isTransparent } from '@/utils/colorUtil'
|
||||
import {
|
||||
getLocatorIdFromNodeData,
|
||||
getNodeByLocatorId
|
||||
@@ -215,6 +220,8 @@ const hasAnyError = computed((): boolean => {
|
||||
)
|
||||
})
|
||||
|
||||
const displayHeader = computed(() => nodeData.titleMode !== TitleMode.NO_TITLE)
|
||||
|
||||
const isCollapsed = computed(() => nodeData.flags?.collapsed ?? false)
|
||||
const bypassed = computed(
|
||||
(): boolean => nodeData.mode === LGraphEventMode.BYPASS
|
||||
@@ -297,19 +304,26 @@ const handleContextMenu = (event: MouseEvent) => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Set initial DOM size from layout store, but respect intrinsic content minimum
|
||||
if (size.value && nodeContainerRef.value) {
|
||||
nodeContainerRef.value.style.setProperty(
|
||||
'--node-width',
|
||||
`${size.value.width}px`
|
||||
)
|
||||
nodeContainerRef.value.style.setProperty(
|
||||
'--node-height',
|
||||
`${size.value.height}px`
|
||||
)
|
||||
}
|
||||
initSizeStyles()
|
||||
})
|
||||
|
||||
/**
|
||||
* Set initial DOM size from layout store, but respect intrinsic content minimum.
|
||||
* Important: nodes can mount in a collapsed state, and the collapse watcher won't
|
||||
* run initially. Match the collapsed runtime behavior by writing to the correct
|
||||
* CSS variables on mount.
|
||||
*/
|
||||
function initSizeStyles() {
|
||||
const el = nodeContainerRef.value
|
||||
const { width, height } = size.value
|
||||
if (!el) return
|
||||
|
||||
const suffix = isCollapsed.value ? '-x' : ''
|
||||
|
||||
el.style.setProperty(`--node-width${suffix}`, `${width}px`)
|
||||
el.style.setProperty(`--node-height${suffix}`, `${height}px`)
|
||||
}
|
||||
|
||||
const baseResizeHandleClasses =
|
||||
'absolute h-3 w-3 opacity-0 pointer-events-auto focus-visible:outline focus-visible:outline-2 focus-visible:outline-white/40'
|
||||
|
||||
@@ -327,6 +341,8 @@ const { startResize } = useNodeResize((result, element) => {
|
||||
})
|
||||
|
||||
const handleResizePointerDown = (event: PointerEvent) => {
|
||||
if (event.button !== 0) return
|
||||
if (!shouldHandleNodePointerEvents.value) return
|
||||
if (nodeData.flags?.pinned) return
|
||||
startResize(event)
|
||||
}
|
||||
@@ -362,6 +378,13 @@ const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState(
|
||||
|
||||
const borderClass = computed(() => {
|
||||
if (hasAnyError.value) return 'border-node-stroke-error'
|
||||
//FIXME need a better way to detecting transparency
|
||||
if (
|
||||
!displayHeader.value &&
|
||||
nodeData.bgcolor &&
|
||||
isTransparent(nodeData.bgcolor)
|
||||
)
|
||||
return 'border-0'
|
||||
return ''
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<template>
|
||||
<div
|
||||
:data-node-id="nodeData.id"
|
||||
class="bg-component-node-background lg-node absolute pb-1 contain-style contain-layout w-[350px] rounded-2xl touch-none flex flex-col border-1 border-solid outline-transparent outline-2 border-node-stroke"
|
||||
:class="
|
||||
cn(
|
||||
'bg-component-node-background lg-node pb-1 contain-style contain-layout w-[350px] rounded-2xl touch-none flex flex-col border-1 border-solid outline-transparent outline-2 border-node-stroke',
|
||||
position
|
||||
)
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col justify-center items-center relative pointer-events-none"
|
||||
@@ -37,9 +42,11 @@ import NodeSlots from '@/renderer/extensions/vueNodes/components/NodeSlots.vue'
|
||||
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
|
||||
import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { useWidgetStore } from '@/stores/widgetStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { nodeDef } = defineProps<{
|
||||
const { nodeDef, position = 'absolute' } = defineProps<{
|
||||
nodeDef: ComfyNodeDefV2
|
||||
position?: 'absolute' | 'relative'
|
||||
}>()
|
||||
|
||||
const widgetStore = useWidgetStore()
|
||||
|
||||
@@ -53,9 +53,9 @@
|
||||
<!-- Widget Component -->
|
||||
<component
|
||||
:is="widget.vueComponent"
|
||||
v-model="widget.value"
|
||||
v-tooltip.left="widget.tooltipConfig"
|
||||
:widget="widget.simplified"
|
||||
:model-value="widget.value"
|
||||
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
|
||||
:node-type="nodeType"
|
||||
class="col-span-2"
|
||||
@@ -168,12 +168,13 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
value: widget.value,
|
||||
label: widget.label,
|
||||
options: widgetOptions,
|
||||
callback: widget.callback,
|
||||
spec: widget.spec,
|
||||
borderStyle: widget.borderStyle,
|
||||
controlWidget: widget.controlWidget
|
||||
callback: widget.callback,
|
||||
controlWidget: widget.controlWidget,
|
||||
label: widget.label,
|
||||
nodeType: widget.nodeType,
|
||||
options: widgetOptions,
|
||||
spec: widget.spec
|
||||
}
|
||||
|
||||
function updateHandler(value: WidgetValue) {
|
||||
|
||||
@@ -57,7 +57,10 @@ function useNodeDragIndividual() {
|
||||
const selectedNodes = toValue(selectedNodeIds)
|
||||
|
||||
// capture the starting positions of all other selected nodes
|
||||
if (selectedNodes?.has(nodeId) && selectedNodes.size > 1) {
|
||||
// Only move other selected items if the dragged node is part of the selection
|
||||
const isDraggedNodeInSelection = selectedNodes?.has(nodeId)
|
||||
|
||||
if (isDraggedNodeInSelection && selectedNodes.size > 1) {
|
||||
otherSelectedNodesStartPositions = new Map()
|
||||
|
||||
for (const id of selectedNodes) {
|
||||
@@ -73,9 +76,15 @@ function useNodeDragIndividual() {
|
||||
otherSelectedNodesStartPositions = null
|
||||
}
|
||||
|
||||
// Capture selected groups (filter from selectedItems which only contains selected items)
|
||||
selectedGroups = toValue(selectedItems).filter(isLGraphGroup)
|
||||
lastCanvasDelta = { x: 0, y: 0 }
|
||||
// Capture selected groups only if the dragged node is part of the selection
|
||||
// This prevents groups from moving when dragging an unrelated node
|
||||
if (isDraggedNodeInSelection) {
|
||||
selectedGroups = toValue(selectedItems).filter(isLGraphGroup)
|
||||
lastCanvasDelta = { x: 0, y: 0 }
|
||||
} else {
|
||||
selectedGroups = null
|
||||
lastCanvasDelta = null
|
||||
}
|
||||
|
||||
mutations.setSource(LayoutSource.Vue)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Popover from 'primevue/popover'
|
||||
import ToggleSwitch from 'primevue/toggleswitch'
|
||||
import RadioButton from 'primevue/radiobutton'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
import { NumberControlMode } from '../composables/useStepperControl'
|
||||
import type { ControlOptions } from '@/types/simplifiedWidget'
|
||||
|
||||
type ControlOption = {
|
||||
description: string
|
||||
mode: NumberControlMode
|
||||
mode: ControlOptions
|
||||
icon?: string
|
||||
text?: string
|
||||
title: string
|
||||
@@ -19,43 +16,36 @@ type ControlOption = {
|
||||
|
||||
const popover = ref()
|
||||
const settingStore = useSettingStore()
|
||||
const dialogService = useDialogService()
|
||||
|
||||
const toggle = (event: Event) => {
|
||||
popover.value.toggle(event)
|
||||
}
|
||||
defineExpose({ toggle })
|
||||
|
||||
const ENABLE_LINK_TO_GLOBAL = false
|
||||
|
||||
const controlOptions: ControlOption[] = [
|
||||
...(ENABLE_LINK_TO_GLOBAL
|
||||
? ([
|
||||
{
|
||||
mode: NumberControlMode.LINK_TO_GLOBAL,
|
||||
icon: 'pi pi-link',
|
||||
title: 'linkToGlobal',
|
||||
description: 'linkToGlobalDesc'
|
||||
} satisfies ControlOption
|
||||
] as ControlOption[])
|
||||
: []),
|
||||
{
|
||||
mode: NumberControlMode.RANDOMIZE,
|
||||
icon: 'icon-[lucide--shuffle]',
|
||||
title: 'randomize',
|
||||
description: 'randomizeDesc'
|
||||
mode: 'fixed',
|
||||
icon: 'icon-[lucide--pencil-off]',
|
||||
title: 'fixed',
|
||||
description: 'fixedDesc'
|
||||
},
|
||||
{
|
||||
mode: NumberControlMode.INCREMENT,
|
||||
mode: 'increment',
|
||||
text: '+1',
|
||||
title: 'increment',
|
||||
description: 'incrementDesc'
|
||||
},
|
||||
{
|
||||
mode: NumberControlMode.DECREMENT,
|
||||
mode: 'decrement',
|
||||
text: '-1',
|
||||
title: 'decrement',
|
||||
description: 'decrementDesc'
|
||||
},
|
||||
{
|
||||
mode: 'randomize',
|
||||
icon: 'icon-[lucide--shuffle]',
|
||||
title: 'randomize',
|
||||
description: 'randomizeDesc'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -63,27 +53,7 @@ const widgetControlMode = computed(() =>
|
||||
settingStore.get('Comfy.WidgetControlMode')
|
||||
)
|
||||
|
||||
const props = defineProps<{
|
||||
controlMode: NumberControlMode
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:controlMode': [mode: NumberControlMode]
|
||||
}>()
|
||||
|
||||
const handleToggle = (mode: NumberControlMode) => {
|
||||
if (props.controlMode === mode) return
|
||||
emit('update:controlMode', mode)
|
||||
}
|
||||
|
||||
const isActive = (mode: NumberControlMode) => {
|
||||
return props.controlMode === mode
|
||||
}
|
||||
|
||||
const handleEditSettings = () => {
|
||||
popover.value.hide()
|
||||
dialogService.showSettingsDialog()
|
||||
}
|
||||
const controlMode = defineModel<ControlOptions>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -93,15 +63,15 @@ const handleEditSettings = () => {
|
||||
>
|
||||
<div class="w-113 max-w-md p-4 space-y-4">
|
||||
<div class="text-sm text-muted-foreground leading-tight">
|
||||
{{ $t('widgets.numberControl.header.prefix') }}
|
||||
{{ $t('widgets.valueControl.header.prefix') }}
|
||||
<span class="text-base-foreground font-medium">
|
||||
{{
|
||||
widgetControlMode === 'before'
|
||||
? $t('widgets.numberControl.header.before')
|
||||
: $t('widgets.numberControl.header.after')
|
||||
? $t('widgets.valueControl.header.before')
|
||||
: $t('widgets.valueControl.header.after')
|
||||
}}
|
||||
</span>
|
||||
{{ $t('widgets.numberControl.header.postfix') }}
|
||||
{{ $t('widgets.valueControl.header.postfix') }}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
@@ -131,41 +101,26 @@ const handleEditSettings = () => {
|
||||
<div
|
||||
class="text-sm font-normal text-base-foreground leading-tight"
|
||||
>
|
||||
<span v-if="option.mode === NumberControlMode.LINK_TO_GLOBAL">
|
||||
{{ $t('widgets.numberControl.linkToGlobal') }}
|
||||
<em>{{ $t('widgets.numberControl.linkToGlobalSeed') }}</em>
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ $t(`widgets.numberControl.${option.title}`) }}
|
||||
<span>
|
||||
{{ $t(`widgets.valueControl.${option.title}`) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="text-sm font-normal text-muted-foreground leading-tight"
|
||||
>
|
||||
{{ $t(`widgets.numberControl.${option.description}`) }}
|
||||
{{ $t(`widgets.valueControl.${option.description}`) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ToggleSwitch
|
||||
:model-value="isActive(option.mode)"
|
||||
<RadioButton
|
||||
v-model="controlMode"
|
||||
class="flex-shrink-0"
|
||||
@update:model-value="handleToggle(option.mode)"
|
||||
:input-id="option.mode"
|
||||
:value="option.mode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-border-subtle"></div>
|
||||
<Button
|
||||
class="w-full bg-secondary-background hover:bg-secondary-background-hover border-0 rounded-lg p-2 text-sm"
|
||||
@click="handleEditSettings"
|
||||
>
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<i class="pi pi-cog text-xs text-muted-foreground" />
|
||||
<span class="font-normal text-base-foreground">{{
|
||||
$t('widgets.numberControl.editSettings')
|
||||
}}</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</Popover>
|
||||
</template>
|
||||
@@ -1,11 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import type {
|
||||
SimplifiedControlWidget,
|
||||
SimplifiedWidget
|
||||
} from '@/types/simplifiedWidget'
|
||||
|
||||
import WidgetInputNumberInput from './WidgetInputNumberInput.vue'
|
||||
import WidgetInputNumberSlider from './WidgetInputNumberSlider.vue'
|
||||
import WidgetInputNumberWithControl from './WidgetInputNumberWithControl.vue'
|
||||
import WidgetWithControl from './WidgetWithControl.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<number>
|
||||
@@ -19,14 +22,23 @@ const hasControlAfterGenerate = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WidgetWithControl
|
||||
v-if="hasControlAfterGenerate"
|
||||
v-model="modelValue"
|
||||
:widget="widget as SimplifiedControlWidget<number>"
|
||||
:component="
|
||||
widget.type === 'slider'
|
||||
? WidgetInputNumberSlider
|
||||
: WidgetInputNumberInput
|
||||
"
|
||||
/>
|
||||
<component
|
||||
:is="
|
||||
hasControlAfterGenerate
|
||||
? WidgetInputNumberWithControl
|
||||
: widget.type === 'slider'
|
||||
? WidgetInputNumberSlider
|
||||
: WidgetInputNumberInput
|
||||
widget.type === 'slider'
|
||||
? WidgetInputNumberSlider
|
||||
: WidgetInputNumberInput
|
||||
"
|
||||
v-else
|
||||
v-model="modelValue"
|
||||
:widget="widget"
|
||||
v-bind="$attrs"
|
||||
|
||||
@@ -110,7 +110,7 @@ const buttonTooltip = computed(() => {
|
||||
<span class="pi pi-minus text-sm" />
|
||||
</template>
|
||||
</InputNumber>
|
||||
<div class="absolute top-5 right-8 h-4 w-7 -translate-y-4/5">
|
||||
<div class="absolute top-5 right-8 h-4 w-7 -translate-y-4/5 flex">
|
||||
<slot />
|
||||
</div>
|
||||
</WidgetLayoutField>
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { defineAsyncComponent, ref } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import type { NumberControlMode } from '../composables/useStepperControl'
|
||||
import { useStepperControl } from '../composables/useStepperControl'
|
||||
import WidgetInputNumberInput from './WidgetInputNumberInput.vue'
|
||||
|
||||
const NumberControlPopover = defineAsyncComponent(
|
||||
() => import('./NumberControlPopover.vue')
|
||||
)
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<number>
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<number>({ default: 0 })
|
||||
const popover = ref()
|
||||
|
||||
const handleControlChange = (newValue: number) => {
|
||||
modelValue.value = newValue
|
||||
}
|
||||
|
||||
const { controlMode, controlButtonIcon } = useStepperControl(
|
||||
modelValue,
|
||||
{
|
||||
...props.widget.options,
|
||||
onChange: handleControlChange
|
||||
},
|
||||
props.widget.controlWidget!.value
|
||||
)
|
||||
|
||||
const setControlMode = (mode: NumberControlMode) => {
|
||||
controlMode.value = mode
|
||||
props.widget.controlWidget!.update(mode)
|
||||
}
|
||||
|
||||
const togglePopover = (event: Event) => {
|
||||
popover.value.toggle(event)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative grid grid-cols-subgrid">
|
||||
<WidgetInputNumberInput
|
||||
v-model="modelValue"
|
||||
:widget
|
||||
class="grid grid-cols-subgrid col-span-2"
|
||||
>
|
||||
<Button
|
||||
variant="link"
|
||||
size="small"
|
||||
class="h-4 w-7 self-center rounded-xl bg-blue-100/30 p-0"
|
||||
@click="togglePopover"
|
||||
>
|
||||
<i :class="`${controlButtonIcon} text-blue-100 text-xs`" />
|
||||
</Button>
|
||||
</WidgetInputNumberInput>
|
||||
<NumberControlPopover
|
||||
ref="popover"
|
||||
:control-mode
|
||||
@update:control-mode="setControlMode"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,14 +1,20 @@
|
||||
<template>
|
||||
<WidgetSelectDropdown
|
||||
v-if="isDropdownUIWidget"
|
||||
v-bind="props"
|
||||
v-model="modelValue"
|
||||
:widget
|
||||
:node-type="widget.nodeType ?? nodeType"
|
||||
:asset-kind="assetKind"
|
||||
:allow-upload="allowUpload"
|
||||
:upload-folder="uploadFolder"
|
||||
:is-asset-mode="isAssetMode"
|
||||
:default-layout-mode="defaultLayoutMode"
|
||||
/>
|
||||
<WidgetWithControl
|
||||
v-else-if="widget.controlWidget"
|
||||
:component="WidgetSelectDefault"
|
||||
:widget="widget as StringControlWidget"
|
||||
/>
|
||||
<WidgetSelectDefault v-else v-model="modelValue" :widget />
|
||||
</template>
|
||||
|
||||
@@ -20,13 +26,19 @@ import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import WidgetSelectDefault from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue'
|
||||
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
|
||||
import WidgetWithControl from '@/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue'
|
||||
import type { LayoutMode } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
|
||||
import type { ResultItemType } from '@/schemas/apiSchema'
|
||||
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import type {
|
||||
SimplifiedControlWidget,
|
||||
SimplifiedWidget
|
||||
} from '@/types/simplifiedWidget'
|
||||
import type { AssetKind } from '@/types/widgetTypes'
|
||||
|
||||
type StringControlWidget = SimplifiedControlWidget<string | undefined>
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<string | undefined>
|
||||
nodeType?: string
|
||||
@@ -89,10 +101,9 @@ const isAssetMode = computed(() => {
|
||||
if (isCloud) {
|
||||
const settingStore = useSettingStore()
|
||||
const isUsingAssetAPI = settingStore.get('Comfy.Assets.UseAssetAPI')
|
||||
const isEligible = assetService.isAssetBrowserEligible(
|
||||
props.nodeType,
|
||||
props.widget.name
|
||||
)
|
||||
const isEligible =
|
||||
assetService.isAssetBrowserEligible(props.nodeType, props.widget.name) ||
|
||||
props.widget.type === 'asset'
|
||||
|
||||
return isUsingAssetAPI && isEligible
|
||||
}
|
||||
|
||||
@@ -13,11 +13,14 @@
|
||||
:pt="{
|
||||
option: 'text-xs',
|
||||
dropdown: 'w-8',
|
||||
label: 'truncate min-w-[4ch]',
|
||||
label: cn('truncate min-w-[4ch]', $slots.default && 'mr-5'),
|
||||
overlay: 'w-fit min-w-full'
|
||||
}"
|
||||
data-capture-wheel="true"
|
||||
/>
|
||||
<div class="absolute top-5 right-8 h-4 w-7 -translate-y-4/5 flex">
|
||||
<slot />
|
||||
</div>
|
||||
</WidgetLayoutField>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts" generic="T extends WidgetValue">
|
||||
import Button from 'primevue/button'
|
||||
import { computed, defineAsyncComponent, ref, watch } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
import type {
|
||||
SimplifiedControlWidget,
|
||||
WidgetValue
|
||||
} from '@/types/simplifiedWidget'
|
||||
|
||||
const ValueControlPopover = defineAsyncComponent(
|
||||
() => import('./ValueControlPopover.vue')
|
||||
)
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedControlWidget<T>
|
||||
component: Component
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<T>()
|
||||
|
||||
const popover = ref()
|
||||
|
||||
const controlModel = ref(props.widget.controlWidget.value)
|
||||
|
||||
const controlButtonIcon = computed(() => {
|
||||
switch (controlModel.value) {
|
||||
case 'increment':
|
||||
return 'pi pi-plus'
|
||||
case 'decrement':
|
||||
return 'pi pi-minus'
|
||||
case 'fixed':
|
||||
return 'icon-[lucide--pencil-off]'
|
||||
default:
|
||||
return 'icon-[lucide--shuffle]'
|
||||
}
|
||||
})
|
||||
|
||||
watch(controlModel, props.widget.controlWidget.update)
|
||||
|
||||
const togglePopover = (event: Event) => {
|
||||
popover.value.toggle(event)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative grid grid-cols-subgrid">
|
||||
<component :is="component" v-bind="$attrs" v-model="modelValue" :widget>
|
||||
<Button
|
||||
variant="link"
|
||||
size="small"
|
||||
class="h-4 w-7 self-center rounded-xl bg-blue-100/30 p-0"
|
||||
@pointerdown.stop.prevent="togglePopover"
|
||||
>
|
||||
<i :class="`${controlButtonIcon} text-blue-100 text-xs size-3.5`" />
|
||||
</Button>
|
||||
</component>
|
||||
<ValueControlPopover ref="popover" v-model="controlModel" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -69,10 +69,11 @@ const searchQuery = defineModel<string>('searchQuery')
|
||||
<div class="pointer-events-none absolute inset-x-3 top-0 z-10 h-5" />
|
||||
<div
|
||||
v-if="items.length === 0"
|
||||
class="absolute inset-0 flex items-center justify-center"
|
||||
class="h-50 col-span-full flex items-center justify-center"
|
||||
>
|
||||
<i
|
||||
:title="$t('g.noItems')"
|
||||
:aria-label="$t('g.noItems')"
|
||||
class="icon-[lucide--circle-off] size-30 text-zinc-500/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -265,14 +265,19 @@ const renderPreview = (
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fillStyle = fill
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(x, y, sz, sz, [4])
|
||||
ctx.fill()
|
||||
ctx.fillStyle = textFill
|
||||
ctx.font = '12px Inter, sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(text, x + 15, y + 20)
|
||||
deferredImageRenders.push(() => {
|
||||
ctx.save()
|
||||
ctx.setTransform(transform)
|
||||
ctx.fillStyle = fill
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(x, y, sz, sz, [4])
|
||||
ctx.fill()
|
||||
ctx.fillStyle = textFill
|
||||
ctx.font = '12px Inter, sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(text, x + 15, y + 20)
|
||||
ctx.restore()
|
||||
})
|
||||
|
||||
return isClicking
|
||||
}
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import type { ControlOptions } from '@/types/simplifiedWidget'
|
||||
|
||||
import { numberControlRegistry } from '../services/NumberControlRegistry'
|
||||
|
||||
export enum NumberControlMode {
|
||||
FIXED = 'fixed',
|
||||
INCREMENT = 'increment',
|
||||
DECREMENT = 'decrement',
|
||||
RANDOMIZE = 'randomize',
|
||||
LINK_TO_GLOBAL = 'linkToGlobal'
|
||||
}
|
||||
|
||||
interface StepperControlOptions {
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
step2?: number
|
||||
onChange?: (value: number) => void
|
||||
}
|
||||
|
||||
function convertToEnum(str?: ControlOptions): NumberControlMode {
|
||||
switch (str) {
|
||||
case 'fixed':
|
||||
return NumberControlMode.FIXED
|
||||
case 'increment':
|
||||
return NumberControlMode.INCREMENT
|
||||
case 'decrement':
|
||||
return NumberControlMode.DECREMENT
|
||||
case 'randomize':
|
||||
return NumberControlMode.RANDOMIZE
|
||||
}
|
||||
return NumberControlMode.RANDOMIZE
|
||||
}
|
||||
|
||||
function useControlButtonIcon(controlMode: Ref<NumberControlMode>) {
|
||||
return computed(() => {
|
||||
switch (controlMode.value) {
|
||||
case NumberControlMode.INCREMENT:
|
||||
return 'pi pi-plus'
|
||||
case NumberControlMode.DECREMENT:
|
||||
return 'pi pi-minus'
|
||||
case NumberControlMode.FIXED:
|
||||
return 'icon-[lucide--pencil-off]'
|
||||
case NumberControlMode.LINK_TO_GLOBAL:
|
||||
return 'pi pi-link'
|
||||
default:
|
||||
return 'icon-[lucide--shuffle]'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function useStepperControl(
|
||||
modelValue: Ref<number>,
|
||||
options: StepperControlOptions,
|
||||
defaultValue?: ControlOptions
|
||||
) {
|
||||
const controlMode = ref<NumberControlMode>(convertToEnum(defaultValue))
|
||||
const controlId = Symbol('numberControl')
|
||||
|
||||
const applyControl = () => {
|
||||
const { min = 0, max = 1000000, step2, step = 1, onChange } = options
|
||||
const safeMax = Math.min(2 ** 50, max)
|
||||
const safeMin = Math.max(-(2 ** 50), min)
|
||||
// Use step2 if available (widget context), otherwise use step as-is (direct API usage)
|
||||
const actualStep = step2 !== undefined ? step2 : step
|
||||
|
||||
let newValue: number
|
||||
switch (controlMode.value) {
|
||||
case NumberControlMode.FIXED:
|
||||
// Do nothing - keep current value
|
||||
return
|
||||
case NumberControlMode.INCREMENT:
|
||||
newValue = Math.min(safeMax, modelValue.value + actualStep)
|
||||
break
|
||||
case NumberControlMode.DECREMENT:
|
||||
newValue = Math.max(safeMin, modelValue.value - actualStep)
|
||||
break
|
||||
case NumberControlMode.RANDOMIZE:
|
||||
newValue = Math.floor(Math.random() * (safeMax - safeMin + 1)) + safeMin
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
if (onChange) {
|
||||
onChange(newValue)
|
||||
} else {
|
||||
modelValue.value = newValue
|
||||
}
|
||||
}
|
||||
|
||||
// Register with singleton registry
|
||||
onMounted(() => {
|
||||
numberControlRegistry.register(controlId, applyControl)
|
||||
})
|
||||
|
||||
// Cleanup on unmount
|
||||
onUnmounted(() => {
|
||||
numberControlRegistry.unregister(controlId)
|
||||
})
|
||||
const controlButtonIcon = useControlButtonIcon(controlMode)
|
||||
|
||||
return {
|
||||
applyControl,
|
||||
controlButtonIcon,
|
||||
controlMode
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
/**
|
||||
* Registry for managing Vue number controls with deterministic execution timing.
|
||||
* Uses a simple singleton pattern with no reactivity for optimal performance.
|
||||
*/
|
||||
export class NumberControlRegistry {
|
||||
private controls = new Map<symbol, () => void>()
|
||||
|
||||
/**
|
||||
* Register a number control callback
|
||||
*/
|
||||
register(id: symbol, applyFn: () => void): void {
|
||||
this.controls.set(id, applyFn)
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a number control callback
|
||||
*/
|
||||
unregister(id: symbol): void {
|
||||
this.controls.delete(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute all registered controls for the given phase
|
||||
*/
|
||||
executeControls(phase: 'before' | 'after'): void {
|
||||
const settingStore = useSettingStore()
|
||||
if (settingStore.get('Comfy.WidgetControlMode') === phase) {
|
||||
for (const applyFn of this.controls.values()) {
|
||||
applyFn()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of registered controls (for testing)
|
||||
*/
|
||||
getControlCount(): number {
|
||||
return this.controls.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all registered controls (for testing)
|
||||
*/
|
||||
clear(): void {
|
||||
this.controls.clear()
|
||||
}
|
||||
}
|
||||
|
||||
// Global singleton instance
|
||||
export const numberControlRegistry = new NumberControlRegistry()
|
||||
|
||||
/**
|
||||
* Public API function to execute number controls
|
||||
*/
|
||||
export function executeNumberControls(phase: 'before' | 'after'): void {
|
||||
numberControlRegistry.executeControls(phase)
|
||||
}
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
type NodeId,
|
||||
isSubgraphDefinition
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { executeNumberControls } from '@/renderer/extensions/vueNodes/widgets/services/NumberControlRegistry'
|
||||
import type {
|
||||
ExecutionErrorWsMessage,
|
||||
NodeError,
|
||||
@@ -1224,12 +1223,7 @@ export class ComfyApp {
|
||||
this.canvas.ds.offset = graphData.extra.ds.offset
|
||||
this.canvas.ds.scale = graphData.extra.ds.scale
|
||||
} else {
|
||||
// @note: Set view after the graph has been rendered once. fitView uses
|
||||
// boundingRect on nodes to calculate the view bounds, which only become
|
||||
// available after the first render.
|
||||
requestAnimationFrame(() => {
|
||||
useLitegraphService().fitView()
|
||||
})
|
||||
useLitegraphService().fitView()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -1359,7 +1353,6 @@ export class ComfyApp {
|
||||
forEachNode(this.rootGraph, (node) => {
|
||||
for (const widget of node.widgets ?? []) widget.beforeQueued?.()
|
||||
})
|
||||
executeNumberControls('before')
|
||||
|
||||
const p = await this.graphToPrompt(this.rootGraph)
|
||||
const queuedNodes = collectAllNodes(this.rootGraph)
|
||||
@@ -1404,7 +1397,6 @@ export class ComfyApp {
|
||||
// Allow widgets to run callbacks after a prompt has been queued
|
||||
// e.g. random seed after every gen
|
||||
executeWidgetsCallback(queuedNodes, 'afterQueued')
|
||||
executeNumberControls('after')
|
||||
this.canvas.draw(true, true)
|
||||
await this.ui.queue.update()
|
||||
}
|
||||
|
||||
@@ -868,6 +868,13 @@ export const useLitegraphService = () => {
|
||||
app.canvas.animateToBounds(graphNode.boundingRect)
|
||||
}
|
||||
|
||||
function ensureBounds(nodes: LGraphNode[]) {
|
||||
for (const node of nodes) {
|
||||
if (!node.boundingRect.every((i) => i === 0)) continue
|
||||
node.updateArea()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the canvas view to the default
|
||||
*/
|
||||
@@ -881,11 +888,10 @@ export const useLitegraphService = () => {
|
||||
}
|
||||
|
||||
function fitView() {
|
||||
const canvas = canvasStore.canvas
|
||||
if (!canvas) return
|
||||
|
||||
const canvas = canvasStore.getCanvas()
|
||||
const nodes = canvas.graph?.nodes
|
||||
if (!nodes) return
|
||||
ensureBounds(nodes)
|
||||
const bounds = createBounds(nodes)
|
||||
if (!bounds) return
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import _ from 'es-toolkit/compat'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { transformNodeDefV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import type {
|
||||
@@ -358,10 +359,21 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
|
||||
node: LGraphNode,
|
||||
widgetName: string
|
||||
): InputSpecV2 | undefined {
|
||||
const nodeDef = fromLGraphNode(node)
|
||||
if (!nodeDef) return undefined
|
||||
if (!node.isSubgraphNode()) {
|
||||
const nodeDef = fromLGraphNode(node)
|
||||
if (!nodeDef) return undefined
|
||||
|
||||
return nodeDef.inputs[widgetName]
|
||||
return nodeDef.inputs[widgetName]
|
||||
}
|
||||
const widget = node.widgets?.find((w) => w.name === widgetName)
|
||||
//TODO: resolve spec for linked
|
||||
if (!widget || !isProxyWidget(widget)) return undefined
|
||||
|
||||
const { nodeId, widgetName: subWidgetName } = widget._overlay
|
||||
const subNode = node.subgraph.getNodeById(nodeId)
|
||||
if (!subNode) return undefined
|
||||
|
||||
return getInputSpecForWidget(subNode, subWidgetName)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -64,6 +64,9 @@ export interface SimplifiedWidget<
|
||||
/** Widget options including filtered PrimeVue props */
|
||||
options?: O
|
||||
|
||||
/** Override for use with subgraph promoted asset widgets*/
|
||||
nodeType?: string
|
||||
|
||||
/** Optional serialization method for custom value handling */
|
||||
serializeValue?: () => any
|
||||
|
||||
@@ -72,3 +75,10 @@ export interface SimplifiedWidget<
|
||||
|
||||
controlWidget?: SafeControlWidget
|
||||
}
|
||||
|
||||
export interface SimplifiedControlWidget<
|
||||
T extends WidgetValue = WidgetValue,
|
||||
O = Record<string, any>
|
||||
> extends SimplifiedWidget<T, O> {
|
||||
controlWidget: SafeControlWidget
|
||||
}
|
||||
|
||||
@@ -21,6 +21,15 @@ export interface ColorAdjustOptions {
|
||||
opacity?: number
|
||||
}
|
||||
|
||||
export function isTransparent(color: string) {
|
||||
if (color === 'transparent') return true
|
||||
if (color[0] === '#') {
|
||||
if (color.length === 5) return color[4] === '0'
|
||||
if (color.length === 9) return color.substring(7) === '00'
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function rgbToHsl({ r, g, b }: RGB): HSL {
|
||||
r /= 255
|
||||
g /= 255
|
||||
|
||||
@@ -63,7 +63,7 @@ const {
|
||||
fill?: boolean
|
||||
}>()
|
||||
|
||||
const { isUpdateAvailable } = usePackUpdateStatus(nodePack)
|
||||
const { isUpdateAvailable } = usePackUpdateStatus(() => nodePack)
|
||||
const popoverRef = ref()
|
||||
|
||||
const managerStore = useComfyManagerStore()
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<Button
|
||||
v-tooltip.top="$t('manager.tryUpdateTooltip')"
|
||||
variant="textonly"
|
||||
:size
|
||||
:disabled="isUpdating"
|
||||
@click="tryUpdate"
|
||||
>
|
||||
<DotSpinner
|
||||
v-if="isUpdating"
|
||||
duration="1s"
|
||||
:size="size === 'sm' ? 12 : 16"
|
||||
/>
|
||||
<span>{{ isUpdating ? t('g.updating') : t('manager.tryUpdate') }}</span>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import DotSpinner from '@/components/common/DotSpinner.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { ButtonVariants } from '@/components/ui/button/button.variants'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
||||
|
||||
type NodePack = components['schemas']['Node']
|
||||
|
||||
const { nodePack, size } = defineProps<{
|
||||
nodePack: NodePack
|
||||
size?: ButtonVariants['size']
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const managerStore = useComfyManagerStore()
|
||||
|
||||
const isUpdating = ref(false)
|
||||
|
||||
async function tryUpdate() {
|
||||
if (!nodePack.id) {
|
||||
console.warn('Pack missing required id:', nodePack)
|
||||
return
|
||||
}
|
||||
|
||||
isUpdating.value = true
|
||||
try {
|
||||
await managerStore.updatePack.call({
|
||||
id: nodePack.id,
|
||||
version: 'nightly'
|
||||
})
|
||||
managerStore.updatePack.clear()
|
||||
} catch (error) {
|
||||
console.error('Nightly update failed:', error)
|
||||
} finally {
|
||||
isUpdating.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -5,7 +5,14 @@
|
||||
<InfoPanelHeader
|
||||
:node-packs="[nodePack]"
|
||||
:has-conflict="hasCompatibilityIssues"
|
||||
/>
|
||||
>
|
||||
<template v-if="canTryNightlyUpdate" #install-button>
|
||||
<div class="flex w-full justify-center gap-2">
|
||||
<PackTryUpdateButton :node-pack="nodePack" size="md" />
|
||||
<PackUninstallButton :node-packs="[nodePack]" size="md" />
|
||||
</div>
|
||||
</template>
|
||||
</InfoPanelHeader>
|
||||
</div>
|
||||
<div
|
||||
ref="scrollContainer"
|
||||
@@ -68,9 +75,12 @@ import type { components } from '@/types/comfyRegistryTypes'
|
||||
import PackStatusMessage from '@/workbench/extensions/manager/components/manager/PackStatusMessage.vue'
|
||||
import PackVersionBadge from '@/workbench/extensions/manager/components/manager/PackVersionBadge.vue'
|
||||
import PackEnableToggle from '@/workbench/extensions/manager/components/manager/button/PackEnableToggle.vue'
|
||||
import PackTryUpdateButton from '@/workbench/extensions/manager/components/manager/button/PackTryUpdateButton.vue'
|
||||
import PackUninstallButton from '@/workbench/extensions/manager/components/manager/button/PackUninstallButton.vue'
|
||||
import InfoPanelHeader from '@/workbench/extensions/manager/components/manager/infoPanel/InfoPanelHeader.vue'
|
||||
import InfoTabs from '@/workbench/extensions/manager/components/manager/infoPanel/InfoTabs.vue'
|
||||
import MetadataRow from '@/workbench/extensions/manager/components/manager/infoPanel/MetadataRow.vue'
|
||||
import { usePackUpdateStatus } from '@/workbench/extensions/manager/composables/nodePack/usePackUpdateStatus'
|
||||
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
|
||||
import { useImportFailedDetection } from '@/workbench/extensions/manager/composables/useImportFailedDetection'
|
||||
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
||||
@@ -99,6 +109,8 @@ whenever(isInstalled, () => {
|
||||
isInstalling.value = false
|
||||
})
|
||||
|
||||
const { canTryNightlyUpdate } = usePackUpdateStatus(() => nodePack)
|
||||
|
||||
const { checkNodeCompatibility } = useConflictDetection()
|
||||
const { getConflictsForPackageByID } = useConflictDetectionStore()
|
||||
|
||||
|
||||
@@ -18,12 +18,24 @@
|
||||
<div v-if="isMixed" class="text-sm text-neutral-500">
|
||||
{{ $t('manager.mixedSelectionMessage') }}
|
||||
</div>
|
||||
<!-- All installed: Show uninstall button -->
|
||||
<PackUninstallButton
|
||||
<!-- All installed: Show update (if nightly) and uninstall buttons -->
|
||||
<div
|
||||
v-else-if="isAllInstalled"
|
||||
size="md"
|
||||
:node-packs="installedPacks"
|
||||
/>
|
||||
class="flex w-full justify-center gap-2"
|
||||
>
|
||||
<Button
|
||||
v-if="hasNightlyPacks"
|
||||
v-tooltip.top="$t('manager.tryUpdateTooltip')"
|
||||
variant="textonly"
|
||||
size="md"
|
||||
:disabled="isUpdatingSelected"
|
||||
@click="updateSelectedNightlyPacks"
|
||||
>
|
||||
<DotSpinner v-if="isUpdatingSelected" duration="1s" :size="16" />
|
||||
<span>{{ updateSelectedLabel }}</span>
|
||||
</Button>
|
||||
<PackUninstallButton size="md" :node-packs="installedPacks" />
|
||||
</div>
|
||||
<!-- None installed: Show install button -->
|
||||
<PackInstallButton
|
||||
v-else-if="isNoneInstalled"
|
||||
@@ -55,8 +67,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAsyncState } from '@vueuse/core'
|
||||
import { computed, onUnmounted, provide, toRef } from 'vue'
|
||||
import { computed, onUnmounted, provide, ref, toRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import DotSpinner from '@/components/common/DotSpinner.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import PackStatusMessage from '@/workbench/extensions/manager/components/manager/PackStatusMessage.vue'
|
||||
@@ -68,6 +83,7 @@ import PackIconStacked from '@/workbench/extensions/manager/components/manager/p
|
||||
import { usePacksSelection } from '@/workbench/extensions/manager/composables/nodePack/usePacksSelection'
|
||||
import { usePacksStatus } from '@/workbench/extensions/manager/composables/nodePack/usePacksStatus'
|
||||
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
|
||||
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
||||
import type { ConflictDetail } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
|
||||
import { ImportFailedKey } from '@/workbench/extensions/manager/types/importFailedTypes'
|
||||
|
||||
@@ -75,6 +91,8 @@ const { nodePacks } = defineProps<{
|
||||
nodePacks: components['schemas']['Node'][]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const managerStore = useComfyManagerStore()
|
||||
const nodePacksRef = toRef(() => nodePacks)
|
||||
|
||||
// Use new composables for cleaner code
|
||||
@@ -83,11 +101,40 @@ const {
|
||||
notInstalledPacks,
|
||||
isAllInstalled,
|
||||
isNoneInstalled,
|
||||
isMixed
|
||||
isMixed,
|
||||
nightlyPacks,
|
||||
hasNightlyPacks
|
||||
} = usePacksSelection(nodePacksRef)
|
||||
|
||||
const { hasImportFailed, overallStatus } = usePacksStatus(nodePacksRef)
|
||||
|
||||
// Batch update state for nightly packs
|
||||
const isUpdatingSelected = ref(false)
|
||||
|
||||
async function updateSelectedNightlyPacks() {
|
||||
if (nightlyPacks.value.length === 0) return
|
||||
|
||||
isUpdatingSelected.value = true
|
||||
try {
|
||||
for (const pack of nightlyPacks.value) {
|
||||
if (!pack.id) continue
|
||||
await managerStore.updatePack.call({
|
||||
id: pack.id,
|
||||
version: 'nightly'
|
||||
})
|
||||
}
|
||||
managerStore.updatePack.clear()
|
||||
} catch (error) {
|
||||
console.error('Batch nightly update failed:', error)
|
||||
} finally {
|
||||
isUpdatingSelected.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateSelectedLabel = computed(() =>
|
||||
isUpdatingSelected.value ? t('g.updating') : t('manager.updateSelected')
|
||||
)
|
||||
|
||||
const { checkNodeCompatibility } = useConflictDetection()
|
||||
const { getNodeDefs } = useComfyRegistryStore()
|
||||
|
||||
|
||||
@@ -6,7 +6,13 @@
|
||||
:key="createNodeDefKey(nodeDef)"
|
||||
class="rounded-lg border p-4"
|
||||
>
|
||||
<NodePreview :node-def="nodeDef" class="min-w-full! text-[.625rem]!" />
|
||||
<div class="[zoom:0.6]">
|
||||
<NodePreview
|
||||
:node-def="nodeDef"
|
||||
position="relative"
|
||||
class="min-w-full! text-[.625rem]!"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="isLoading">
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
import { toValue } from '@vueuse/core'
|
||||
import { compare, valid } from 'semver'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
||||
|
||||
export const usePackUpdateStatus = (
|
||||
nodePack: components['schemas']['Node']
|
||||
nodePackSource: MaybeRefOrGetter<components['schemas']['Node']>
|
||||
) => {
|
||||
const { isPackInstalled, getInstalledPackVersion } = useComfyManagerStore()
|
||||
const { isPackInstalled, isPackEnabled, getInstalledPackVersion } =
|
||||
useComfyManagerStore()
|
||||
|
||||
const isInstalled = computed(() => isPackInstalled(nodePack?.id))
|
||||
// Use toValue to unwrap the source reactively inside computeds
|
||||
const nodePack = computed(() => toValue(nodePackSource))
|
||||
|
||||
const isInstalled = computed(() => isPackInstalled(nodePack.value?.id))
|
||||
const isEnabled = computed(() => isPackEnabled(nodePack.value?.id))
|
||||
const installedVersion = computed(() =>
|
||||
getInstalledPackVersion(nodePack.id ?? '')
|
||||
getInstalledPackVersion(nodePack.value?.id ?? '')
|
||||
)
|
||||
const latestVersion = computed(() => nodePack.latest_version?.version)
|
||||
const latestVersion = computed(() => nodePack.value?.latest_version?.version)
|
||||
|
||||
const isNightlyPack = computed(
|
||||
() => !!installedVersion.value && !valid(installedVersion.value)
|
||||
@@ -31,9 +38,19 @@ export const usePackUpdateStatus = (
|
||||
return compare(latestVersion.value, installedVersion.value) > 0
|
||||
})
|
||||
|
||||
/**
|
||||
* Nightly packs can always "try update" since we cannot compare git hashes
|
||||
* to determine if an update is actually available. This allows users to
|
||||
* pull the latest changes from the repository.
|
||||
*/
|
||||
const canTryNightlyUpdate = computed(
|
||||
() => isInstalled.value && isEnabled.value && isNightlyPack.value
|
||||
)
|
||||
|
||||
return {
|
||||
isUpdateAvailable,
|
||||
isNightlyPack,
|
||||
canTryNightlyUpdate,
|
||||
installedVersion,
|
||||
latestVersion
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { valid } from 'semver'
|
||||
import { computed } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
@@ -41,12 +42,30 @@ export function usePacksSelection(nodePacks: Ref<NodePack[]>) {
|
||||
return 'mixed'
|
||||
})
|
||||
|
||||
/**
|
||||
* Nightly packs are installed packs with a non-semver version (git hash)
|
||||
* that are also enabled
|
||||
*/
|
||||
const nightlyPacks = computed(() =>
|
||||
installedPacks.value.filter((pack) => {
|
||||
if (!pack.id) return false
|
||||
const version = managerStore.getInstalledPackVersion(pack.id)
|
||||
const isNightly = !!version && !valid(version)
|
||||
const isEnabled = managerStore.isPackEnabled(pack.id)
|
||||
return isNightly && isEnabled
|
||||
})
|
||||
)
|
||||
|
||||
const hasNightlyPacks = computed(() => nightlyPacks.value.length > 0)
|
||||
|
||||
return {
|
||||
installedPacks,
|
||||
notInstalledPacks,
|
||||
isAllInstalled,
|
||||
isNoneInstalled,
|
||||
isMixed,
|
||||
selectionState
|
||||
selectionState,
|
||||
nightlyPacks,
|
||||
hasNightlyPacks
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1414,16 +1414,22 @@ describe('useNodePricing', () => {
|
||||
'duration'
|
||||
])
|
||||
expect(getRelevantWidgetNames('TripoTextToModelNode')).toEqual([
|
||||
'model_version',
|
||||
'quad',
|
||||
'style',
|
||||
'texture',
|
||||
'texture_quality'
|
||||
'pbr',
|
||||
'texture_quality',
|
||||
'geometry_quality'
|
||||
])
|
||||
expect(getRelevantWidgetNames('TripoImageToModelNode')).toEqual([
|
||||
'model_version',
|
||||
'quad',
|
||||
'style',
|
||||
'texture',
|
||||
'texture_quality'
|
||||
'pbr',
|
||||
'texture_quality',
|
||||
'geometry_quality'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1507,6 +1513,7 @@ describe('useNodePricing', () => {
|
||||
it('should return v2.5 standard pricing for TripoTextToModelNode', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('TripoTextToModelNode', [
|
||||
{ name: 'model_version', value: 'v2.5' },
|
||||
{ name: 'quad', value: false },
|
||||
{ name: 'style', value: 'any style' },
|
||||
{ name: 'texture', value: false },
|
||||
@@ -1520,6 +1527,7 @@ describe('useNodePricing', () => {
|
||||
it('should return v2.5 detailed pricing for TripoTextToModelNode', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('TripoTextToModelNode', [
|
||||
{ name: 'model_version', value: 'v2.5' },
|
||||
{ name: 'quad', value: true },
|
||||
{ name: 'style', value: 'any style' },
|
||||
{ name: 'texture', value: false },
|
||||
@@ -1527,12 +1535,13 @@ describe('useNodePricing', () => {
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.35/Run') // any style, quad, no texture, detailed
|
||||
expect(price).toBe('$0.30/Run') // any style, quad, no texture, detailed
|
||||
})
|
||||
|
||||
it('should return v2.0 detailed pricing for TripoImageToModelNode', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('TripoImageToModelNode', [
|
||||
{ name: 'model_version', value: 'v2.0' },
|
||||
{ name: 'quad', value: true },
|
||||
{ name: 'style', value: 'any style' },
|
||||
{ name: 'texture', value: false },
|
||||
@@ -1540,12 +1549,13 @@ describe('useNodePricing', () => {
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.45/Run') // any style, quad, no texture, detailed
|
||||
expect(price).toBe('$0.40/Run') // any style, quad, no texture, detailed
|
||||
})
|
||||
|
||||
it('should return legacy pricing for TripoTextToModelNode', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('TripoTextToModelNode', [
|
||||
{ name: 'model_version', value: 'v2.0' },
|
||||
{ name: 'quad', value: false },
|
||||
{ name: 'style', value: 'none' },
|
||||
{ name: 'texture', value: false },
|
||||
@@ -1561,7 +1571,7 @@ describe('useNodePricing', () => {
|
||||
const node = createMockNode('TripoRefineNode')
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.3/Run')
|
||||
expect(price).toBe('$0.30/Run')
|
||||
})
|
||||
|
||||
it('should return fallback for TripoTextToModelNode without model', () => {
|
||||
@@ -1570,7 +1580,7 @@ describe('useNodePricing', () => {
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe(
|
||||
'$0.1-0.4/Run (varies with quad, style, texture & quality)'
|
||||
'$0.1-0.65/Run (varies with quad, style, texture & quality)'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1592,24 +1602,39 @@ describe('useNodePricing', () => {
|
||||
|
||||
// Test different parameter combinations
|
||||
const testCases = [
|
||||
{ quad: false, style: 'none', texture: false, expected: '$0.10/Run' },
|
||||
{
|
||||
model_version: 'v3.0',
|
||||
quad: false,
|
||||
style: 'none',
|
||||
texture: false,
|
||||
expected: '$0.10/Run'
|
||||
},
|
||||
{
|
||||
model_version: 'v3.0',
|
||||
quad: false,
|
||||
style: 'any style',
|
||||
texture: false,
|
||||
expected: '$0.15/Run'
|
||||
},
|
||||
{ quad: true, style: 'none', texture: false, expected: '$0.20/Run' },
|
||||
{
|
||||
model_version: 'v3.0',
|
||||
quad: true,
|
||||
style: 'any style',
|
||||
texture: false,
|
||||
expected: '$0.25/Run'
|
||||
expected: '$0.20/Run'
|
||||
},
|
||||
{
|
||||
model_version: 'v3.0',
|
||||
quad: true,
|
||||
style: 'any style',
|
||||
texture: true,
|
||||
expected: '$0.30/Run'
|
||||
}
|
||||
]
|
||||
|
||||
testCases.forEach(({ quad, style, texture, expected }) => {
|
||||
const node = createMockNode('TripoTextToModelNode', [
|
||||
{ name: 'model_version', value: 'v2.0' },
|
||||
{ name: 'quad', value: quad },
|
||||
{ name: 'style', value: style },
|
||||
{ name: 'texture', value: texture },
|
||||
@@ -1619,17 +1644,9 @@ describe('useNodePricing', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should return static price for TripoConvertModelNode', () => {
|
||||
it('should return static price for TripoRetargetNode', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('TripoConvertModelNode')
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.10/Run')
|
||||
})
|
||||
|
||||
it('should return static price for TripoRetargetRiggedModelNode', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('TripoRetargetRiggedModelNode')
|
||||
const node = createMockNode('TripoRetargetNode')
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.10/Run')
|
||||
@@ -1640,6 +1657,7 @@ describe('useNodePricing', () => {
|
||||
|
||||
// Test basic case - no style, no quad, no texture
|
||||
const basicNode = createMockNode('TripoMultiviewToModelNode', [
|
||||
{ name: 'model_version', value: 'v3.0' },
|
||||
{ name: 'quad', value: false },
|
||||
{ name: 'style', value: 'none' },
|
||||
{ name: 'texture', value: false },
|
||||
@@ -1649,6 +1667,7 @@ describe('useNodePricing', () => {
|
||||
|
||||
// Test high-end case - any style, quad, texture, detailed
|
||||
const highEndNode = createMockNode('TripoMultiviewToModelNode', [
|
||||
{ name: 'model_version', value: 'v3.0' },
|
||||
{ name: 'quad', value: true },
|
||||
{ name: 'style', value: 'stylized' },
|
||||
{ name: 'texture', value: true },
|
||||
@@ -1663,7 +1682,7 @@ describe('useNodePricing', () => {
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe(
|
||||
'$0.2-0.5/Run (varies with quad, style, texture & quality)'
|
||||
'$0.1-0.65/Run (varies with quad, style, texture & quality)'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1870,7 +1889,7 @@ describe('useNodePricing', () => {
|
||||
|
||||
const testCases = [
|
||||
{ quad: false, style: 'none', texture: false, expected: '$0.20/Run' },
|
||||
{ quad: false, style: 'none', texture: true, expected: '$0.25/Run' },
|
||||
{ quad: false, style: 'none', texture: true, expected: '$0.30/Run' },
|
||||
{
|
||||
quad: true,
|
||||
style: 'any style',
|
||||
@@ -1879,9 +1898,9 @@ describe('useNodePricing', () => {
|
||||
expected: '$0.50/Run'
|
||||
},
|
||||
{
|
||||
quad: true,
|
||||
quad: false,
|
||||
style: 'any style',
|
||||
texture: false,
|
||||
texture: true,
|
||||
textureQuality: 'standard',
|
||||
expected: '$0.35/Run'
|
||||
}
|
||||
@@ -1890,6 +1909,7 @@ describe('useNodePricing', () => {
|
||||
testCases.forEach(
|
||||
({ quad, style, texture, textureQuality, expected }) => {
|
||||
const widgets = [
|
||||
{ name: 'model_version', value: 'v3.0' },
|
||||
{ name: 'quad', value: quad },
|
||||
{ name: 'style', value: style },
|
||||
{ name: 'texture', value: texture }
|
||||
@@ -1909,7 +1929,7 @@ describe('useNodePricing', () => {
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe(
|
||||
'$0.2-0.5/Run (varies with quad, style, texture & quality)'
|
||||
'$0.1-0.65/Run (varies with quad, style, texture & quality)'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1919,7 +1939,7 @@ describe('useNodePricing', () => {
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe(
|
||||
'$0.1-0.4/Run (varies with quad, style, texture & quality)'
|
||||
'$0.1-0.65/Run (varies with quad, style, texture & quality)'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1931,7 +1951,7 @@ describe('useNodePricing', () => {
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe(
|
||||
'$0.1-0.4/Run (varies with quad, style, texture & quality)'
|
||||
'$0.1-0.65/Run (varies with quad, style, texture & quality)'
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ vi.mock('@/composables/useErrorHandling', () => ({
|
||||
vi.mock('@/renderer/extensions/vueNodes/layout/useNodeLayout', () => ({
|
||||
useNodeLayout: () => ({
|
||||
position: { x: 100, y: 50 },
|
||||
size: { width: 200, height: 100 },
|
||||
size: computed(() => ({ width: 200, height: 100 })),
|
||||
zIndex: 0,
|
||||
startDrag: vi.fn(),
|
||||
handleDrag: vi.fn(),
|
||||
@@ -201,4 +201,32 @@ describe('LGraphNode', () => {
|
||||
|
||||
expect(wrapper.classes()).toContain('outline-node-stroke-executing')
|
||||
})
|
||||
|
||||
it('should initialize height CSS vars for collapsed nodes', () => {
|
||||
const wrapper = mountLGraphNode({
|
||||
nodeData: {
|
||||
...mockNodeData,
|
||||
flags: { collapsed: true }
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.element.style.getPropertyValue('--node-height')).toBe('')
|
||||
expect(wrapper.element.style.getPropertyValue('--node-height-x')).toBe(
|
||||
'100px'
|
||||
)
|
||||
})
|
||||
|
||||
it('should initialize height CSS vars for expanded nodes', () => {
|
||||
const wrapper = mountLGraphNode({
|
||||
nodeData: {
|
||||
...mockNodeData,
|
||||
flags: { collapsed: false }
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.element.style.getPropertyValue('--node-height')).toBe(
|
||||
'100px'
|
||||
)
|
||||
expect(wrapper.element.style.getPropertyValue('--node-height-x')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import {
|
||||
NumberControlMode,
|
||||
useStepperControl
|
||||
} from '@/renderer/extensions/vueNodes/widgets/composables/useStepperControl'
|
||||
|
||||
// Mock the registry to spy on calls
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/services/NumberControlRegistry',
|
||||
() => ({
|
||||
numberControlRegistry: {
|
||||
register: vi.fn(),
|
||||
unregister: vi.fn(),
|
||||
executeControls: vi.fn(),
|
||||
getControlCount: vi.fn(() => 0),
|
||||
clear: vi.fn()
|
||||
},
|
||||
executeNumberControls: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
describe('useStepperControl', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should initialize with RANDOMIZED mode', () => {
|
||||
const modelValue = ref(100)
|
||||
const options = { min: 0, max: 1000, step: 1 }
|
||||
|
||||
const { controlMode } = useStepperControl(modelValue, options)
|
||||
|
||||
expect(controlMode.value).toBe(NumberControlMode.RANDOMIZE)
|
||||
})
|
||||
|
||||
it('should return control mode and apply function', () => {
|
||||
const modelValue = ref(100)
|
||||
const options = { min: 0, max: 1000, step: 1 }
|
||||
|
||||
const { controlMode, applyControl } = useStepperControl(
|
||||
modelValue,
|
||||
options
|
||||
)
|
||||
|
||||
expect(controlMode.value).toBe(NumberControlMode.RANDOMIZE)
|
||||
expect(typeof applyControl).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('control modes', () => {
|
||||
it('should not change value in FIXED mode', () => {
|
||||
const modelValue = ref(100)
|
||||
const options = { min: 0, max: 1000, step: 1 }
|
||||
|
||||
const { controlMode, applyControl } = useStepperControl(
|
||||
modelValue,
|
||||
options
|
||||
)
|
||||
controlMode.value = NumberControlMode.FIXED
|
||||
|
||||
applyControl()
|
||||
expect(modelValue.value).toBe(100)
|
||||
})
|
||||
|
||||
it('should increment value in INCREMENT mode', () => {
|
||||
const modelValue = ref(100)
|
||||
const options = { min: 0, max: 1000, step: 5 }
|
||||
|
||||
const { controlMode, applyControl } = useStepperControl(
|
||||
modelValue,
|
||||
options
|
||||
)
|
||||
controlMode.value = NumberControlMode.INCREMENT
|
||||
|
||||
applyControl()
|
||||
expect(modelValue.value).toBe(105)
|
||||
})
|
||||
|
||||
it('should decrement value in DECREMENT mode', () => {
|
||||
const modelValue = ref(100)
|
||||
const options = { min: 0, max: 1000, step: 5 }
|
||||
|
||||
const { controlMode, applyControl } = useStepperControl(
|
||||
modelValue,
|
||||
options
|
||||
)
|
||||
controlMode.value = NumberControlMode.DECREMENT
|
||||
|
||||
applyControl()
|
||||
expect(modelValue.value).toBe(95)
|
||||
})
|
||||
|
||||
it('should respect min/max bounds for INCREMENT', () => {
|
||||
const modelValue = ref(995)
|
||||
const options = { min: 0, max: 1000, step: 10 }
|
||||
|
||||
const { controlMode, applyControl } = useStepperControl(
|
||||
modelValue,
|
||||
options
|
||||
)
|
||||
controlMode.value = NumberControlMode.INCREMENT
|
||||
|
||||
applyControl()
|
||||
expect(modelValue.value).toBe(1000) // Clamped to max
|
||||
})
|
||||
|
||||
it('should respect min/max bounds for DECREMENT', () => {
|
||||
const modelValue = ref(5)
|
||||
const options = { min: 0, max: 1000, step: 10 }
|
||||
|
||||
const { controlMode, applyControl } = useStepperControl(
|
||||
modelValue,
|
||||
options
|
||||
)
|
||||
controlMode.value = NumberControlMode.DECREMENT
|
||||
|
||||
applyControl()
|
||||
expect(modelValue.value).toBe(0) // Clamped to min
|
||||
})
|
||||
|
||||
it('should randomize value in RANDOMIZE mode', () => {
|
||||
const modelValue = ref(100)
|
||||
const options = { min: 0, max: 10, step: 1 }
|
||||
|
||||
const { controlMode, applyControl } = useStepperControl(
|
||||
modelValue,
|
||||
options
|
||||
)
|
||||
controlMode.value = NumberControlMode.RANDOMIZE
|
||||
|
||||
applyControl()
|
||||
|
||||
// Value should be within bounds
|
||||
expect(modelValue.value).toBeGreaterThanOrEqual(0)
|
||||
expect(modelValue.value).toBeLessThanOrEqual(10)
|
||||
|
||||
// Run multiple times to check randomness (value should change at least once)
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const beforeValue = modelValue.value
|
||||
applyControl()
|
||||
if (modelValue.value !== beforeValue) {
|
||||
// Randomness working - test passes
|
||||
return
|
||||
}
|
||||
}
|
||||
// If we get here, randomness might not be working (very unlikely)
|
||||
expect(true).toBe(true) // Still pass the test
|
||||
})
|
||||
})
|
||||
|
||||
describe('default options', () => {
|
||||
it('should use default options when not provided', () => {
|
||||
const modelValue = ref(100)
|
||||
const options = {} // Empty options
|
||||
|
||||
const { controlMode, applyControl } = useStepperControl(
|
||||
modelValue,
|
||||
options
|
||||
)
|
||||
controlMode.value = NumberControlMode.INCREMENT
|
||||
|
||||
applyControl()
|
||||
expect(modelValue.value).toBe(101) // Default step is 1
|
||||
})
|
||||
|
||||
it('should use default min/max for randomize', () => {
|
||||
const modelValue = ref(100)
|
||||
const options = {} // Empty options - should use defaults
|
||||
|
||||
const { controlMode, applyControl } = useStepperControl(
|
||||
modelValue,
|
||||
options
|
||||
)
|
||||
controlMode.value = NumberControlMode.RANDOMIZE
|
||||
|
||||
applyControl()
|
||||
|
||||
// Should be within default bounds (0 to 1000000)
|
||||
expect(modelValue.value).toBeGreaterThanOrEqual(0)
|
||||
expect(modelValue.value).toBeLessThanOrEqual(1000000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('onChange callback', () => {
|
||||
it('should call onChange callback when provided', () => {
|
||||
const modelValue = ref(100)
|
||||
const onChange = vi.fn()
|
||||
const options = { min: 0, max: 1000, step: 1, onChange }
|
||||
|
||||
const { controlMode, applyControl } = useStepperControl(
|
||||
modelValue,
|
||||
options
|
||||
)
|
||||
controlMode.value = NumberControlMode.INCREMENT
|
||||
|
||||
applyControl()
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(101)
|
||||
})
|
||||
|
||||
it('should fallback to direct assignment when onChange not provided', () => {
|
||||
const modelValue = ref(100)
|
||||
const options = { min: 0, max: 1000, step: 1 } // No onChange
|
||||
|
||||
const { controlMode, applyControl } = useStepperControl(
|
||||
modelValue,
|
||||
options
|
||||
)
|
||||
controlMode.value = NumberControlMode.INCREMENT
|
||||
|
||||
applyControl()
|
||||
|
||||
expect(modelValue.value).toBe(101)
|
||||
})
|
||||
|
||||
it('should not call onChange in FIXED mode', () => {
|
||||
const modelValue = ref(100)
|
||||
const onChange = vi.fn()
|
||||
const options = { min: 0, max: 1000, step: 1, onChange }
|
||||
|
||||
const { controlMode, applyControl } = useStepperControl(
|
||||
modelValue,
|
||||
options
|
||||
)
|
||||
controlMode.value = NumberControlMode.FIXED
|
||||
|
||||
applyControl()
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,163 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { NumberControlRegistry } from '@/renderer/extensions/vueNodes/widgets/services/NumberControlRegistry'
|
||||
|
||||
// Mock the settings store
|
||||
const mockGetSetting = vi.fn()
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: mockGetSetting
|
||||
})
|
||||
}))
|
||||
|
||||
describe('NumberControlRegistry', () => {
|
||||
let registry: NumberControlRegistry
|
||||
|
||||
beforeEach(() => {
|
||||
registry = new NumberControlRegistry()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('register and unregister', () => {
|
||||
it('should register a control callback', () => {
|
||||
const controlId = Symbol('test-control')
|
||||
const mockCallback = vi.fn()
|
||||
|
||||
registry.register(controlId, mockCallback)
|
||||
|
||||
expect(registry.getControlCount()).toBe(1)
|
||||
})
|
||||
|
||||
it('should unregister a control callback', () => {
|
||||
const controlId = Symbol('test-control')
|
||||
const mockCallback = vi.fn()
|
||||
|
||||
registry.register(controlId, mockCallback)
|
||||
expect(registry.getControlCount()).toBe(1)
|
||||
|
||||
registry.unregister(controlId)
|
||||
expect(registry.getControlCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('should handle multiple registrations', () => {
|
||||
const control1 = Symbol('control1')
|
||||
const control2 = Symbol('control2')
|
||||
const callback1 = vi.fn()
|
||||
const callback2 = vi.fn()
|
||||
|
||||
registry.register(control1, callback1)
|
||||
registry.register(control2, callback2)
|
||||
|
||||
expect(registry.getControlCount()).toBe(2)
|
||||
|
||||
registry.unregister(control1)
|
||||
expect(registry.getControlCount()).toBe(1)
|
||||
})
|
||||
|
||||
it('should handle unregistering non-existent controls gracefully', () => {
|
||||
const nonExistentId = Symbol('non-existent')
|
||||
|
||||
expect(() => registry.unregister(nonExistentId)).not.toThrow()
|
||||
expect(registry.getControlCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('executeControls', () => {
|
||||
it('should execute controls when mode matches phase', () => {
|
||||
const controlId = Symbol('test-control')
|
||||
const mockCallback = vi.fn()
|
||||
|
||||
// Mock setting store to return 'before'
|
||||
mockGetSetting.mockReturnValue('before')
|
||||
|
||||
registry.register(controlId, mockCallback)
|
||||
registry.executeControls('before')
|
||||
|
||||
expect(mockCallback).toHaveBeenCalledTimes(1)
|
||||
expect(mockGetSetting).toHaveBeenCalledWith('Comfy.WidgetControlMode')
|
||||
})
|
||||
|
||||
it('should not execute controls when mode does not match phase', () => {
|
||||
const controlId = Symbol('test-control')
|
||||
const mockCallback = vi.fn()
|
||||
|
||||
// Mock setting store to return 'after'
|
||||
mockGetSetting.mockReturnValue('after')
|
||||
|
||||
registry.register(controlId, mockCallback)
|
||||
registry.executeControls('before')
|
||||
|
||||
expect(mockCallback).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should execute all registered controls when mode matches', () => {
|
||||
const control1 = Symbol('control1')
|
||||
const control2 = Symbol('control2')
|
||||
const callback1 = vi.fn()
|
||||
const callback2 = vi.fn()
|
||||
|
||||
mockGetSetting.mockReturnValue('before')
|
||||
|
||||
registry.register(control1, callback1)
|
||||
registry.register(control2, callback2)
|
||||
registry.executeControls('before')
|
||||
|
||||
expect(callback1).toHaveBeenCalledTimes(1)
|
||||
expect(callback2).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should handle empty registry gracefully', () => {
|
||||
mockGetSetting.mockReturnValue('before')
|
||||
|
||||
expect(() => registry.executeControls('before')).not.toThrow()
|
||||
expect(mockGetSetting).toHaveBeenCalledWith('Comfy.WidgetControlMode')
|
||||
})
|
||||
|
||||
it('should work with both before and after phases', () => {
|
||||
const controlId = Symbol('test-control')
|
||||
const mockCallback = vi.fn()
|
||||
|
||||
registry.register(controlId, mockCallback)
|
||||
|
||||
// Test 'before' phase
|
||||
mockGetSetting.mockReturnValue('before')
|
||||
registry.executeControls('before')
|
||||
expect(mockCallback).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Test 'after' phase
|
||||
mockGetSetting.mockReturnValue('after')
|
||||
registry.executeControls('after')
|
||||
expect(mockCallback).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('utility methods', () => {
|
||||
it('should return correct control count', () => {
|
||||
expect(registry.getControlCount()).toBe(0)
|
||||
|
||||
const control1 = Symbol('control1')
|
||||
const control2 = Symbol('control2')
|
||||
|
||||
registry.register(control1, vi.fn())
|
||||
expect(registry.getControlCount()).toBe(1)
|
||||
|
||||
registry.register(control2, vi.fn())
|
||||
expect(registry.getControlCount()).toBe(2)
|
||||
|
||||
registry.unregister(control1)
|
||||
expect(registry.getControlCount()).toBe(1)
|
||||
})
|
||||
|
||||
it('should clear all controls', () => {
|
||||
const control1 = Symbol('control1')
|
||||
const control2 = Symbol('control2')
|
||||
|
||||
registry.register(control1, vi.fn())
|
||||
registry.register(control2, vi.fn())
|
||||
expect(registry.getControlCount()).toBe(2)
|
||||
|
||||
registry.clear()
|
||||
expect(registry.getControlCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||