Compare commits

...

58 Commits

Author SHA1 Message Date
filtered
db11580668 Create native reroutes from node search menu 2025-03-22 10:09:55 +11:00
Comfy Org PR Bot
22a1200bdf [chore] Update litegraph to 0.11.0-0 (#3183)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-03-22 10:08:41 +11:00
Chenlei Hu
ee20b63bc1 Remove unused plugin vite-plugin-static-copy (#3182) 2025-03-21 14:11:01 -04:00
Comfy Org PR Bot
0752e8b986 1.14.5 (#3181)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-21 11:07:21 -04:00
Christian Byrne
b234a68cf8 [Manager] Get node pack info on select (#3177) 2025-03-21 11:05:09 -04:00
Terry Jia
0863fda6a4 [3d] add support to export different formats (#3176)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-21 11:04:39 -04:00
Christian Byrne
8530406c3e [Manager] Set max size on custom node pack icons (#3178) 2025-03-21 11:04:23 -04:00
Christian Byrne
f7bfb6ec57 Fix Workflow Validation error when node pack 'unknown' version (#3179) 2025-03-21 11:04:06 -04:00
Comfy Org PR Bot
830933e78f Update locales for node definitions (#3174)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-20 21:44:46 -04:00
Comfy Org PR Bot
65693ed2be 1.14.4 (#3173)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-20 21:36:22 -04:00
Chenlei Hu
ed153dccd9 [Test] Fix flaky playwright tests (#3170) 2025-03-20 21:33:05 -04:00
Christian Byrne
fc7ed1bf09 Add Hunyuan3d template workflow titles i18 fields (#3171)
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Chenlei Hu <huchenlei@proton.me>
2025-03-20 21:31:42 -04:00
Christian Byrne
4dad89369a Load workflows from GLTF files (#3169)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-20 20:55:51 -04:00
Chenlei Hu
5b730517a3 [Test] Sync workflow instead of full page reload (#3168) 2025-03-20 20:42:12 -04:00
Chenlei Hu
af0bf05883 [TS] Add null checks to TreeExplorer.vue (#3166) 2025-03-20 13:08:22 -04:00
Chenlei Hu
d9e62ff860 [Vue] Use Vue 3.5 syntax for prop default value (#3165) 2025-03-20 12:40:49 -04:00
Chenlei Hu
d9ae6cb395 [TS] Use custom TreeNode type (#3164) 2025-03-20 12:03:47 -04:00
Chenlei Hu
b162963593 [TS] Fix TreeExplorerNode types (#3163) 2025-03-20 11:47:04 -04:00
Christian Byrne
c34cc301f1 [Manager] Allow cancelling registry requests by route (#3158) 2025-03-20 10:30:00 -04:00
Terry Jia
afdb94f12f [3d] refactor legacy code by using new vue style (#3161) 2025-03-20 10:28:35 -04:00
Dr.Lt.Data
cc8dc3dbfb refine locales/ko (#3162) 2025-03-20 10:27:55 -04:00
filtered
42d99fc37e Track floating link changes in undo/redo history (#3160) 2025-03-20 22:19:31 +11:00
Comfy Org PR Bot
1f03984d12 1.14.3 (#3155)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-19 22:01:08 -04:00
Terry Jia
d49815fcb4 [3d] add preview 3d for saveGlb (#3156) 2025-03-19 22:00:59 -04:00
Christian Byrne
4899a1d8f6 [Manager] Keep node previews inside info panel bounds (#3140) 2025-03-19 21:22:16 -04:00
Christian Byrne
71444d8c69 [Manager] Allow searching while in 'Missing' node packs tab (#3153) 2025-03-19 20:58:55 -04:00
Chenlei Hu
867ed4c1d7 [Schema] Update zod schema on zVector2 (#3152) 2025-03-19 20:58:13 -04:00
Christian Byrne
0c6957bfd8 [Manager] Use skeleton placeholder when loading (#3150) 2025-03-19 20:18:44 -04:00
Christian Byrne
fffce30e91 [Manager] Allow scrolling info panel while keeping the icon pinned at top (#3139) 2025-03-19 20:05:49 -04:00
Chenlei Hu
c554138887 [Develop] Remove duplicated run of tsc typecheck (#3149) 2025-03-19 14:26:22 -04:00
Chenlei Hu
5bbceea76c Downgrade vitest to 2.0.0 (#3148) 2025-03-19 14:22:44 -04:00
Christian Byrne
0f0601100f [Manager] Re-evaluate search results in installed tab when node packs change (#3144) 2025-03-19 10:34:24 -04:00
Christian Byrne
ff59245a7f [Manager] Use inject for installing button state (#3143) 2025-03-19 10:33:47 -04:00
Christian Byrne
c5af11d1ea [Manager] Remove title hover (#3142) 2025-03-19 10:30:42 -04:00
Christian Byrne
bc3e2e1597 [Manager] Auto scroll logs terminal to bottom (#3141)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-19 10:30:11 -04:00
filtered
e2a8456ff0 Remove yarn packageManager claim from package.json (#3145) 2025-03-19 19:26:35 +11:00
Christian Byrne
361c5ba930 [Manager] Fallback text on info panel (#3138)
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
2025-03-19 00:49:04 -07:00
Comfy Org PR Bot
bae47b80b3 1.14.2 (#3137)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-18 22:57:26 -04:00
Chenlei Hu
a049e9ae2d [TS] Enable strict mode (#3136) 2025-03-18 22:57:17 -04:00
Terry Jia
44edec7ad2 [TS] ts-strict for 3D components (#3135) 2025-03-18 22:40:13 -04:00
Chenlei Hu
db43f587a6 [TS] Fix ts-strict errors in Vue components (Part 4) (#3134) 2025-03-18 20:42:32 -04:00
Christian Byrne
8997ff4b2a [Manager] Add 'Missing' and 'In Workflow' tabs (#3133)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-18 20:21:03 -04:00
Chenlei Hu
91a8591249 Add support for webm video from SaveWEBM node (#3132) 2025-03-18 17:46:18 -04:00
Christian Byrne
ef74d7cb01 [Manager] Show node pack's dependencies in info panel (#3130)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-18 16:39:43 -04:00
Christian Byrne
8ab2334270 [Manager] Reset Manager state on reboot (#3129) 2025-03-18 16:38:17 -04:00
Chenlei Hu
59dbcc5261 [TS] Type component ref for 3D components (#3127) 2025-03-18 16:38:04 -04:00
Terry Jia
06488cc811 [3d] performance improve (#3131) 2025-03-18 16:23:32 -04:00
filtered
5a12bf33f3 Add graph ID and revision to schema (#3096) 2025-03-19 07:00:25 +11:00
Chenlei Hu
96ff8a7785 [TS] Fix ts-strict errors in Vue components (Part 3) (#3126) 2025-03-18 11:38:43 -04:00
Christian Byrne
a85a1bf794 [Manager] Add infinite scroll to search results (#3124) 2025-03-18 10:52:32 -04:00
Terry Jia
52bad3d0d1 [3d] support output normal and lineart at once (#3122) 2025-03-18 10:51:53 -04:00
Chenlei Hu
e8997a7653 [TS] Fix ts-strict errors in Vue components (Part 2) (#3123)
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-03-18 10:51:23 -04:00
Comfy Org PR Bot
0a6d3c0231 [chore] Update Comfy Registry API types from comfy-api@e40500f (#3121)
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-03-17 19:07:10 -07:00
Chenlei Hu
2db29fc2af [TS] Fix ts-strict errors in Vue components (Part 1) (#3119) 2025-03-17 21:15:00 -04:00
Robin Huang
329bdff677 [Desktop] Add install path validation error messages (#3059)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-17 20:37:11 -04:00
Comfy Org PR Bot
906eb750ad 1.14.1 (#3118)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-17 20:35:26 -04:00
Christian Byrne
1a120adaea [Manager] Adjust node pack card style according to installing or disabled state (#3103) 2025-03-17 17:17:36 -07:00
Christian Byrne
26a7ebdd77 Fix uploaded image not forcing re-render (#3115)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-17 20:13:05 -04:00
204 changed files with 4818 additions and 1906 deletions

Binary file not shown.

View File

@@ -214,6 +214,10 @@ export class ComfyPage {
`Failed to setup workflows directory: ${await resp.text()}`
)
}
await this.page.evaluate(async () => {
await window['app'].extensionManager.workflow.syncWorkflows()
})
}
async setupUser(username: string) {
@@ -459,7 +463,14 @@ export class ComfyPage {
await this.nextFrame()
}
async dragAndDropFile(fileName: string) {
async dragAndDropFile(
fileName: string,
options: {
dropPosition?: Position
} = {}
) {
const { dropPosition = { x: 100, y: 100 } } = options
const filePath = this.assetPath(fileName)
// Read the file content
@@ -471,38 +482,63 @@ export class ComfyPage {
if (fileName.endsWith('.webp')) return 'image/webp'
if (fileName.endsWith('.webm')) return 'video/webm'
if (fileName.endsWith('.json')) return 'application/json'
if (fileName.endsWith('.glb')) return 'model/gltf-binary'
return 'application/octet-stream'
}
const fileType = getFileType(fileName)
await this.page.evaluate(
async ({ buffer, fileName, fileType }) => {
async ({ buffer, fileName, fileType, dropPosition }) => {
const file = new File([new Uint8Array(buffer)], fileName, {
type: fileType
})
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
const dropEvent = new DragEvent('drop', {
const targetElement = document.elementFromPoint(
dropPosition.x,
dropPosition.y
)
if (!targetElement) {
console.error('No element found at drop position:', dropPosition)
return { success: false, error: 'No element at position' }
}
const eventOptions = {
bubbles: true,
cancelable: true,
dataTransfer
})
dataTransfer,
clientX: dropPosition.x,
clientY: dropPosition.y
}
const dragOverEvent = new DragEvent('dragover', eventOptions)
const dropEvent = new DragEvent('drop', eventOptions)
Object.defineProperty(dropEvent, 'preventDefault', {
value: () => {},
writable: false
})
Object.defineProperty(dropEvent, 'stopPropagation', {
value: () => {},
writable: false
})
document.dispatchEvent(dropEvent)
targetElement.dispatchEvent(dragOverEvent)
targetElement.dispatchEvent(dropEvent)
return {
success: true,
targetInfo: {
tagName: targetElement.tagName,
id: targetElement.id,
classList: Array.from(targetElement.classList)
}
}
},
{ buffer: [...new Uint8Array(buffer)], fileName, fileType }
{ buffer: [...new Uint8Array(buffer)], fileName, fileType, dropPosition }
)
await this.nextFrame()

View File

@@ -115,8 +115,20 @@ export class NodeWidgetReference {
}
)
}
}
async getValue() {
return await this.node.comfyPage.page.evaluate(
([id, index]) => {
const node = window['app'].graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
const widget = node.widgets[index]
if (!widget) throw new Error(`Widget ${index} not found.`)
return widget.value
},
[this.node.id, this.index] as const
)
}
}
export class NodeReference {
constructor(
readonly id: NodeId,

View File

@@ -5,8 +5,8 @@ import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('DOM Widget', () => {
test('Collapsed multiline textarea is not visible', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('collapsed_multiline')
expect(comfyPage.page.locator('.comfy-multiline-input')).not.toBeVisible()
const textareaWidget = comfyPage.page.locator('.comfy-multiline-input')
await expect(textareaWidget).not.toBeVisible()
})
test('Multiline textarea correctly collapses', async ({ comfyPage }) => {

View File

@@ -8,7 +8,8 @@ test.describe('Load Workflow in Media', () => {
'edited_workflow.webp',
'no_workflow.webp',
'large_workflow.webp',
'workflow.webm'
'workflow.webm',
'workflow.glb'
].forEach(async (fileName) => {
test(`Load workflow in ${fileName}`, async ({ comfyPage }) => {
await comfyPage.dragAndDropFile(fileName)

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 103 KiB

View File

@@ -321,6 +321,7 @@ test.describe('Remote COMBO Widget', () => {
// Click refresh button
await clickRefreshButton(comfyPage, nodeName)
await comfyPage.page.waitForTimeout(200)
// Verify the selected value of the widget is the first option in the refreshed list
const refreshedValue = await getWidgetValue(comfyPage, nodeName)

View File

@@ -34,7 +34,6 @@ test.describe('Workflows sidebar', () => {
'workflow1.json': 'default.json',
'workflow2.json': 'default.json'
})
await comfyPage.setup()
const tab = comfyPage.menu.workflowsTab
await tab.open()
@@ -77,7 +76,6 @@ test.describe('Workflows sidebar', () => {
await comfyPage.setupWorkflowsDirectory({
'workflow1.json': 'single_ksampler.json'
})
await comfyPage.setup()
const tab = comfyPage.menu.workflowsTab
await tab.open()
@@ -101,7 +99,6 @@ test.describe('Workflows sidebar', () => {
'bar.json': 'default.json'
}
})
await comfyPage.setup()
const tab = comfyPage.menu.workflowsTab
await tab.open()
@@ -230,6 +227,7 @@ test.describe('Workflows sidebar', () => {
await topbar.saveWorkflowAs('workflow1.json')
await comfyPage.confirmDialog.click('overwrite')
await comfyPage.page.waitForTimeout(200)
// The old workflow1.json should be deleted and the new one should be saved.
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
'workflow2.json',
@@ -323,7 +321,7 @@ test.describe('Workflows sidebar', () => {
await comfyPage.setupWorkflowsDirectory({
'workflow1.json': 'default.json'
})
await comfyPage.setup()
await comfyPage.menu.workflowsTab.open()
const nodeCount = await comfyPage.getGraphNodesCount()

View File

@@ -128,11 +128,62 @@ test.describe('Dynamic widget manipulation', () => {
})
})
test.describe('Load image widget', () => {
test.describe('Image widget', () => {
test('Can load image', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('widgets/load_image_widget')
await expect(comfyPage.canvas).toHaveScreenshot('load_image_widget.png')
})
test('Can drag and drop image', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('widgets/load_image_widget')
// Get position of the load image node
const nodes = await comfyPage.getNodeRefsByType('LoadImage')
const loadImageNode = nodes[0]
const { x, y } = await loadImageNode.getPosition()
// Drag and drop image file onto the load image node
await comfyPage.dragAndDropFile('image32x32.webp', {
dropPosition: { x, y }
})
// Expect the image preview to change automatically
await expect(comfyPage.canvas).toHaveScreenshot(
'image_preview_drag_and_dropped.png'
)
// Expect the filename combo value to be updated
const fileComboWidget = await loadImageNode.getWidget(0)
const filename = await fileComboWidget.getValue()
expect(filename).toBe('image32x32.webp')
})
test('Can change image by changing the filename combo value', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('widgets/load_image_widget')
const nodes = await comfyPage.getNodeRefsByType('LoadImage')
const loadImageNode = nodes[0]
// Click the combo widget used to select the image filename
const fileComboWidget = await loadImageNode.getWidget(0)
await fileComboWidget.click()
// Select a new image filename value from the combo context menu
const comboEntry = comfyPage.page.getByRole('menuitem', {
name: 'image32x32.webp'
})
await comboEntry.click({ noWaitAfter: true })
// Expect the image preview to change automatically
await expect(comfyPage.canvas).toHaveScreenshot(
'image_preview_changed_by_combo_value.png'
)
// Expect the filename combo value to be updated
const filename = await fileComboWidget.getValue()
expect(filename).toBe('image32x32.webp')
})
})
test.describe('Load audio widget', () => {

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

@@ -3,9 +3,7 @@ export default {
'./**/*.{ts,tsx,vue,mts}': (stagedFiles) => [
...formatAndEslint(stagedFiles),
'vue-tsc --noEmit',
'tsc --noEmit',
'tsc-strict'
'vue-tsc --noEmit'
]
}

1266
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.14.0",
"version": "1.14.5",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -13,7 +13,7 @@
"build": "npm run typecheck && vite build",
"build:types": "vite build --config vite.types.config.mts && node scripts/prepare-types.js",
"zipdist": "node scripts/zipdist.js",
"typecheck": "vue-tsc --noEmit && tsc --noEmit && tsc-strict",
"typecheck": "vue-tsc --noEmit",
"format": "prettier --write './**/*.{js,ts,tsx,vue,mts}'",
"format:check": "prettier --check './**/*.{js,ts,tsx,vue,mts}'",
"test:browser": "npx playwright test",
@@ -58,13 +58,11 @@
"tsx": "^4.15.6",
"typescript": "^5.4.5",
"typescript-eslint": "^8.0.0",
"typescript-strict-plugin": "^2.4.4",
"unplugin-icons": "^0.19.3",
"unplugin-vue-components": "^0.27.4",
"vite": "^5.4.14",
"vite-plugin-dts": "^4.3.0",
"vite-plugin-static-copy": "^1.0.5",
"vitest": "^2.1.9",
"vitest": "^2.0.0",
"vue-tsc": "^2.1.10",
"zip-dir": "^2.0.0",
"zod-to-json-schema": "^3.24.1"
@@ -72,8 +70,8 @@
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.20",
"@comfyorg/litegraph": "^0.10.9",
"@comfyorg/comfyui-electron-types": "^0.4.31",
"@comfyorg/litegraph": "^0.11.0-0",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",

View File

@@ -28,7 +28,7 @@ const handleKey = (e: KeyboardEvent) => {
useEventListener(window, 'keydown', handleKey)
useEventListener(window, 'keyup', handleKey)
const showContextMenu = (event: PointerEvent) => {
const showContextMenu = (event: MouseEvent) => {
const { target } = event
switch (true) {
case target instanceof HTMLTextAreaElement:
@@ -40,6 +40,7 @@ const showContextMenu = (event: PointerEvent) => {
}
onMounted(() => {
// @ts-expect-error fixme ts strict error
window['__COMFYUI_FRONTEND_VERSION__'] = config.app_version
console.log('ComfyUI Front-end version:', config.app_version)

View File

@@ -2,8 +2,8 @@
<Splitter
class="splitter-overlay-root splitter-overlay"
:pt:gutter="sidebarPanelVisible ? '' : 'hidden'"
:key="activeSidebarTabId"
:stateKey="activeSidebarTabId"
:key="activeSidebarTabId ?? undefined"
:stateKey="activeSidebarTabId ?? undefined"
stateStorage="local"
>
<SplitterPanel

View File

@@ -1,7 +1,6 @@
<template>
<div
class="batch-count"
:class="props.class"
v-tooltip.bottom="{
value: $t('menu.batchCount'),
showDelay: 600
@@ -41,14 +40,6 @@ import { computed } from 'vue'
import { useQueueSettingsStore } from '@/stores/queueStore'
import { useSettingStore } from '@/stores/settingStore'
interface Props {
class?: string
}
const props = withDefaults(defineProps<Props>(), {
class: ''
})
const queueSettingsStore = useQueueSettingsStore()
const { batchCount } = storeToRefs(queueSettingsStore)
const minQueueCount = 1

View File

@@ -135,8 +135,11 @@ const hasPendingTasks = computed(
)
const commandStore = useCommandStore()
const queuePrompt = (e: MouseEvent) => {
const commandId = e.shiftKey ? 'Comfy.QueuePromptFront' : 'Comfy.QueuePrompt'
const queuePrompt = (e: Event) => {
const commandId =
'shiftKey' in e && e.shiftKey
? 'Comfy.QueuePromptFront'
: 'Comfy.QueuePrompt'
commandStore.execute(commandId)
}
</script>

View File

@@ -12,11 +12,11 @@ import { Ref, onUnmounted, ref } from 'vue'
import { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
const emit = defineEmits<{
created: [ReturnType<typeof useTerminal>, Ref<HTMLElement>]
created: [ReturnType<typeof useTerminal>, Ref<HTMLElement | undefined>]
unmounted: []
}>()
const terminalEl = ref<HTMLElement>()
const rootEl = ref<HTMLElement>()
const terminalEl = ref<HTMLElement | undefined>()
const rootEl = ref<HTMLElement | undefined>()
emit('created', useTerminal(terminalEl), rootEl)
onUnmounted(() => emit('unmounted'))

View File

@@ -13,7 +13,7 @@ import BaseTerminal from './BaseTerminal.vue'
const terminalCreated = (
{ terminal, useAutoSize }: ReturnType<typeof useTerminal>,
root: Ref<HTMLElement>
root: Ref<HTMLElement | undefined>
) => {
const terminalApi = electronAPI().Terminal

View File

@@ -27,7 +27,7 @@ const loading = ref(true)
const terminalCreated = (
{ terminal, useAutoSize }: ReturnType<typeof useTerminal>,
root: Ref<HTMLElement>
root: Ref<HTMLElement | undefined>
) => {
// `autoCols` is false because we don't want the progress bar in the terminal
// to render incorrectly as the progress bar is rendered based on the

View File

@@ -18,7 +18,7 @@
:src="src"
@error="handleImageError"
class="comfy-image-main"
:class="[...classArray]"
:class="classProp"
:alt="alt"
/>
</span>
@@ -29,36 +29,24 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ref } from 'vue'
const props = withDefaults(
defineProps<{
src: string
class?: string | string[] | object
contain: boolean
alt?: string
}>(),
{
contain: false,
alt: 'Image content'
}
)
const {
src,
class: classProp,
contain = false,
alt = 'Image content'
} = defineProps<{
src: string
class?: any
contain?: boolean
alt?: string
}>()
const imageBroken = ref(false)
const handleImageError = () => {
imageBroken.value = true
}
const classArray = computed(() => {
if (Array.isArray(props.class)) {
return props.class
} else if (typeof props.class === 'string') {
return props.class.split(' ')
} else if (typeof props.class === 'object') {
return Object.keys(props.class).filter((key) => props.class[key])
}
return []
})
</script>
<style scoped>

View File

@@ -98,12 +98,14 @@ const defaultIcon = iconOptions.find(
(option) => option.value === nodeBookmarkStore.defaultBookmarkIcon
)
// @ts-expect-error fixme ts strict error
const selectedIcon = ref<{ name: string; value: string }>(defaultIcon)
const finalColor = ref(
props.initialColor || nodeBookmarkStore.defaultBookmarkColor
)
const resetCustomization = () => {
// @ts-expect-error fixme ts strict error
selectedIcon.value =
iconOptions.find((option) => option.value === props.initialIcon) ||
defaultIcon

View File

@@ -2,7 +2,9 @@
<div class="grid grid-cols-2 gap-2">
<template v-for="col in deviceColumns" :key="col.field">
<div class="font-medium">{{ col.header }}</div>
<div>{{ formatValue(props.device[col.field], col.field) }}</div>
<div>
{{ formatValue(props.device[col.field], col.field) }}
</div>
</template>
</div>
</template>
@@ -15,7 +17,7 @@ const props = defineProps<{
device: DeviceStats
}>()
const deviceColumns = [
const deviceColumns: { field: keyof DeviceStats; header: string }[] = [
{ field: 'name', header: 'Name' },
{ field: 'type', header: 'Type' },
{ field: 'vram_total', header: 'VRAM Total' },

View File

@@ -1,6 +1,6 @@
<template>
<div class="editable-text">
<span v-if="!props.isEditing">
<span v-if="!isEditing">
{{ modelValue }}
</span>
<!-- Avoid double triggering finishEditing event when keyup.enter is triggered -->
@@ -27,30 +27,27 @@
import InputText from 'primevue/inputtext'
import { nextTick, ref, watch } from 'vue'
interface EditableTextProps {
const { modelValue, isEditing = false } = defineProps<{
modelValue: string
isEditing?: boolean
}
const props = withDefaults(defineProps<EditableTextProps>(), {
isEditing: false
})
}>()
const emit = defineEmits(['update:modelValue', 'edit'])
const inputValue = ref<string>(props.modelValue)
const inputRef = ref(null)
const inputValue = ref<string>(modelValue)
const inputRef = ref<InstanceType<typeof InputText> | undefined>()
const blurInputElement = () => {
// @ts-expect-error - $el is an internal property of the InputText component
inputRef.value?.$el.blur()
}
const finishEditing = () => {
emit('edit', inputValue.value)
}
watch(
() => props.isEditing,
() => isEditing,
(newVal) => {
if (newVal) {
inputValue.value = props.modelValue
inputValue.value = modelValue
nextTick(() => {
if (!inputRef.value) return
const fileName = inputValue.value.includes('.')
@@ -58,6 +55,7 @@ watch(
: inputValue.value
const start = 0
const end = fileName.length
// @ts-expect-error - $el is an internal property of the InputText component
const inputElement = inputRef.value.$el
inputElement.setSelectionRange?.(start, end)
})

View File

@@ -101,13 +101,16 @@ const fileSize = computed(() =>
download.fileSize.value ? formatSize(download.fileSize.value) : '?'
)
const electronDownloadStore = useElectronDownloadStore()
// @ts-expect-error fixme ts strict error
const [savePath, filename] = props.label.split('/')
electronDownloadStore.$subscribe((_, { downloads }) => {
const download = downloads.find((download) => props.url === download.url)
if (download) {
// @ts-expect-error fixme ts strict error
downloadProgress.value = Number((download.progress * 100).toFixed(1))
// @ts-expect-error fixme ts strict error
status.value = download.status
}
})

View File

@@ -72,7 +72,7 @@ function getFormAttrs(item: FormItem) {
item.options(formValue.value)
: item.options
if (typeof item.options[0] !== 'string') {
if (typeof item.options?.[0] !== 'string') {
attrs['optionLabel'] = 'text'
attrs['optionValue'] = 'value'
}

View File

@@ -76,7 +76,7 @@ const updateValue = (newValue: number | null) => {
const displayValue = (value: number): string => {
updateValue(value)
const stepString = props.step.toString()
const stepString = (props.step ?? 1).toString()
const resolution = stepString.includes('.')
? stepString.split('.')[1].length
: 0

View File

@@ -2,7 +2,7 @@
<div class="input-slider flex flex-row items-center gap-2">
<Slider
:modelValue="modelValue"
@update:modelValue="updateValue"
@update:modelValue="(value) => updateValue(value as number)"
class="slider-part"
:class="sliderClass"
:min="min"

View File

@@ -13,9 +13,9 @@
<template>
<Button
class="relative p-button-icon-only"
:outlined="props.outlined"
:severity="props.severity"
:disabled="active || props.disabled"
:outlined="outlined"
:severity="severity"
:disabled="active || disabled"
@click="(event) => $emit('refresh', event)"
>
<span
@@ -34,16 +34,15 @@ import ProgressSpinner from 'primevue/progressspinner'
import { VueSeverity } from '@/types/primeVueTypes'
// Properties
interface Props {
outlined?: boolean
const {
disabled,
outlined = true,
severity = 'secondary'
} = defineProps<{
disabled?: boolean
outlined?: boolean
severity?: VueSeverity
}
const props = withDefaults(defineProps<Props>(), {
outlined: true,
severity: 'secondary'
})
}>()
// Model
const active = defineModel<boolean>({ required: true })

View File

@@ -48,15 +48,16 @@ const systemInfo = computed(() => ({
argv: props.stats.system.argv.join(' ')
}))
const systemColumns = [
{ field: 'os', header: 'OS' },
{ field: 'python_version', header: 'Python Version' },
{ field: 'embedded_python', header: 'Embedded Python' },
{ field: 'pytorch_version', header: 'Pytorch Version' },
{ field: 'argv', header: 'Arguments' },
{ field: 'ram_total', header: 'RAM Total' },
{ field: 'ram_free', header: 'RAM Free' }
]
const systemColumns: { field: keyof SystemStats['system']; header: string }[] =
[
{ field: 'os', header: 'OS' },
{ field: 'python_version', header: 'Python Version' },
{ field: 'embedded_python', header: 'Embedded Python' },
{ field: 'pytorch_version', header: 'Pytorch Version' },
{ field: 'argv', header: 'Arguments' },
{ field: 'ram_total', header: 'RAM Total' },
{ field: 'ram_free', header: 'RAM Free' }
]
const formatValue = (value: any, field: string) => {
if (['ram_total', 'ram_free'].includes(field)) {

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex items-center" :class="props.class">
<div class="flex items-center">
<span v-if="position === 'left'" class="mr-2 shrink-0">{{ text }}</span>
<Divider :align="align" :type="type" :layout="layout" class="flex-grow" />
<span v-if="position === 'right'" class="ml-2 shrink-0">{{ text }}</span>
@@ -9,19 +9,17 @@
<script setup lang="ts">
import Divider from 'primevue/divider'
interface Props {
const {
text,
position = 'left',
align = 'center',
type = 'solid',
layout = 'horizontal'
} = defineProps<{
text: string
class?: string
position?: 'left' | 'right'
align?: 'left' | 'center' | 'right' | 'top' | 'bottom'
type?: 'solid' | 'dashed' | 'dotted'
layout?: 'horizontal' | 'vertical'
}
const props = withDefaults(defineProps<Props>(), {
position: 'left',
align: 'center',
type: 'solid',
layout: 'horizontal'
})
}>()
</script>

View File

@@ -53,7 +53,9 @@ import {
} from '@/types/treeExplorerTypes'
import { combineTrees, findNodeByKey } from '@/utils/treeUtil'
const expandedKeys = defineModel<Record<string, boolean>>('expandedKeys')
const expandedKeys = defineModel<Record<string, boolean>>('expandedKeys', {
required: true
})
provide(InjectKeyExpandedKeys, expandedKeys)
const selectionKeys = defineModel<Record<string, boolean>>('selectionKeys')
// Tracks whether the caller has set the selectionKeys model.
@@ -103,7 +105,7 @@ const getTreeNodeIcon = (node: TreeExplorerNode) => {
return isExpanded ? 'pi pi-folder-open' : 'pi pi-folder'
}
const fillNodeInfo = (node: TreeExplorerNode): RenderedTreeExplorerNode => {
const children = node.children?.map(fillNodeInfo)
const children = node.children?.map(fillNodeInfo) ?? []
const totalLeaves = node.leaf
? 1
: children.reduce((acc, child) => acc + child.totalLeaves, 0)
@@ -113,7 +115,7 @@ const fillNodeInfo = (node: TreeExplorerNode): RenderedTreeExplorerNode => {
children,
type: node.leaf ? 'node' : 'folder',
totalLeaves,
badgeText: node.getBadgeText ? node.getBadgeText() : null,
badgeText: node.getBadgeText ? node.getBadgeText() : undefined,
isEditingLabel: node.key === renameEditingNode.value?.key
}
}
@@ -129,7 +131,7 @@ const onNodeContentClick = async (
}
emit('nodeClick', node, e)
}
const menu = ref(null)
const menu = ref<InstanceType<typeof ContextMenu> | null>(null)
const menuTargetNode = ref<RenderedTreeExplorerNode | null>(null)
const extraMenuItems = computed(() => {
return menuTargetNode.value?.contextMenuItems
@@ -149,7 +151,7 @@ const handleNodeLabelEdit = async (
if (node.key === newFolderNode.value?.key) {
await handleFolderCreation(newName)
} else {
await node.handleRename(newName)
await node.handleRename?.(newName)
}
},
node.handleError,
@@ -174,22 +176,32 @@ const menuItems = computed<MenuItem[]>(() =>
{
label: t('g.rename'),
icon: 'pi pi-file-edit',
command: () => renameCommand(menuTargetNode.value),
command: () => {
if (menuTargetNode.value) {
renameCommand(menuTargetNode.value)
}
},
visible: menuTargetNode.value?.handleRename !== undefined
},
{
label: t('g.delete'),
icon: 'pi pi-trash',
command: () => deleteCommand(menuTargetNode.value),
command: () => {
if (menuTargetNode.value) {
deleteCommand(menuTargetNode.value)
}
},
visible: menuTargetNode.value?.handleDelete !== undefined,
isAsync: true // The delete command can be async
},
...extraMenuItems.value
].map((menuItem) => ({
].map((menuItem: MenuItem) => ({
...menuItem,
command: wrapCommandWithErrorHandler(menuItem.command, {
isAsync: menuItem.isAsync ?? false
})
command: menuItem.command
? wrapCommandWithErrorHandler(menuItem.command, {
isAsync: menuItem.isAsync ?? false
})
: undefined
}))
)
@@ -217,14 +229,15 @@ const wrapCommandWithErrorHandler = (
}
defineExpose({
renameCommand,
deleteCommand,
/**
* The command to add a folder to a node via the context menu
* @param targetNodeKey - The key of the node where the folder will be added under
*/
addFolderCommand: (targetNodeKey: string) => {
addFolderCommand(findNodeByKey(renderedRoot.value, targetNodeKey))
const targetNode = findNodeByKey(renderedRoot.value, targetNodeKey)
if (targetNode) {
addFolderCommand(targetNode)
}
}
})
</script>

View File

@@ -76,10 +76,10 @@ const nodeBadgeText = computed<string>(() => {
})
const showNodeBadgeText = computed<boolean>(() => nodeBadgeText.value !== '')
const isEditing = computed<boolean>(() => props.node.isEditingLabel)
const isEditing = computed<boolean>(() => props.node.isEditingLabel ?? false)
const handleEditLabel = inject(InjectKeyHandleEditLabelFunction)
const handleRename = (newName: string) => {
handleEditLabel(props.node, newName)
handleEditLabel?.(props.node, newName)
}
const container = ref<HTMLElement | null>(null)
@@ -102,7 +102,7 @@ if (props.node.draggable) {
? ({ nativeSetDragImage }) => {
setCustomNativeDragPreview({
render: ({ container }) => {
return props.node.renderDragPreview(container)
return props.node.renderDragPreview?.(container)
},
nativeSetDragImage
})

View File

@@ -68,9 +68,9 @@ onMounted(async () => {
await validateUrl(props.modelValue)
})
const handleInput = (value: string) => {
const handleInput = (value: string | undefined) => {
// Update internal value without emitting
internalValue.value = cleanInput(value)
internalValue.value = cleanInput(value ?? '')
// Reset validation state when user types
validationState.value = ValidationState.IDLE
}

View File

@@ -15,10 +15,16 @@
</template>
<script setup lang="ts" generic="T">
import { useElementSize, useScroll } from '@vueuse/core'
import { useElementSize, useScroll, whenever } from '@vueuse/core'
import { clamp, debounce } from 'lodash'
import { type CSSProperties, computed, onBeforeUnmount, ref, watch } from 'vue'
type GridState = {
start: number
end: number
isNearEnd: boolean
}
const {
items,
bufferRows = 1,
@@ -36,6 +42,13 @@ const {
defaultItemWidth?: number
}>()
const emit = defineEmits<{
/**
* Emitted when `bufferRows` (or fewer) rows remaining between scrollY and grid bottom.
*/
'approach-end': []
}>()
const itemHeight = ref(defaultItemHeight)
const itemWidth = ref(defaultItemWidth)
const container = ref<HTMLElement | null>(null)
@@ -50,22 +63,31 @@ const viewRows = computed(() => Math.ceil(height.value / itemHeight.value))
const offsetRows = computed(() => Math.floor(scrollY.value / itemHeight.value))
const isValidGrid = computed(() => height.value && width.value && items?.length)
const state = computed<{ start: number; end: number }>(() => {
const state = computed<GridState>(() => {
const fromRow = offsetRows.value - bufferRows
const toRow = offsetRows.value + bufferRows + viewRows.value
const fromCol = fromRow * cols.value
const toCol = toRow * cols.value
const remainingCol = items.length - toCol
return {
start: clamp(fromCol, 0, items?.length),
end: clamp(toCol, fromCol, items?.length)
end: clamp(toCol, fromCol, items?.length),
isNearEnd: remainingCol <= cols.value * bufferRows
}
})
const renderedItems = computed(() =>
isValidGrid.value ? items.slice(state.value.start, state.value.end) : []
)
whenever(
() => state.value.isNearEnd,
() => {
emit('approach-end')
}
)
const updateItemSize = () => {
if (container.value) {
const firstItem = container.value.querySelector('[data-virtual-grid-item]')

View File

@@ -1,4 +1,3 @@
// @ts-strict-ignore
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'
@@ -14,6 +13,7 @@ describe('EditableText', () => {
app.use(PrimeVue)
})
// @ts-expect-error fixme ts strict error
const mountComponent = (props, options = {}) => {
return mount(EditableText, {
global: {
@@ -65,6 +65,7 @@ describe('EditableText', () => {
})
await wrapper.findComponent(InputText).trigger('blur')
expect(wrapper.emitted('edit')).toBeTruthy()
// @ts-expect-error fixme ts strict error
expect(wrapper.emitted('edit')[0]).toEqual(['Test Text'])
})
})

View File

@@ -1,4 +1,3 @@
// @ts-strict-ignore
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import Badge from 'primevue/badge'
@@ -59,6 +58,7 @@ describe('TreeExplorerTreeNode', () => {
expect(wrapper.findComponent(EditableText).props('modelValue')).toBe(
'Test Node'
)
// @ts-expect-error fixme ts strict error
expect(wrapper.findComponent(Badge).props()['value'].toString()).toBe('3')
})

View File

@@ -20,7 +20,7 @@
>
<div v-for="(panel, index) in taskPanels" :key="index">
<Panel
:expanded="expandedPanels[index] || false"
:expanded="collapsedPanels[index] || false"
toggleable
class="shadow-elevation-1 rounded-lg mt-2 dark-theme:bg-black dark-theme:border-black"
>
@@ -28,11 +28,11 @@
<div class="flex items-center justify-between w-full py-2">
<div class="flex flex-col text-sm font-medium leading-normal">
<span>{{ panel.taskName }}</span>
<span v-show="expandedPanels[index]" class="text-muted">
<span class="text-muted">
{{
index === taskPanels.length - 1
? 'In progress'
: 'Completed '
isInProgress(index)
? $t('g.inProgress')
: $t('g.completed') + ' ✓'
}}
</span>
</div>
@@ -41,9 +41,9 @@
<template #toggleicon>
<Button
:icon="
expandedPanels[index]
? 'pi pi-chevron-down'
: 'pi pi-chevron-right'
collapsedPanels[index]
? 'pi pi-chevron-right'
: 'pi pi-chevron-down'
"
text
class="text-neutral-300"
@@ -51,11 +51,17 @@
/>
</template>
<div
:ref="
index === taskPanels.length - 1
? (el) => (lastPanelRef = el as HTMLElement)
: undefined
"
class="overflow-y-auto h-64 rounded-lg bg-black"
:class="{
'h-64': index !== taskPanels.length - 1,
'flex-grow': index === taskPanels.length - 1
}"
@scroll="handleScroll"
>
<div class="h-full">
<div
@@ -86,26 +92,68 @@ import {
const { taskLogs } = useComfyManagerStore()
const progressDialogContent = useManagerProgressDialogStore()
const managerStore = useComfyManagerStore()
const isInProgress = (index: number) =>
index === taskPanels.value.length - 1 && managerStore.uncompletedCount > 0
const taskPanels = computed(() => taskLogs)
const isExpanded = computed(() => progressDialogContent.isExpanded)
const isCollapsed = computed(() => !isExpanded.value)
const expandedPanels = ref<Record<number, boolean>>({})
const collapsedPanels = ref<Record<number, boolean>>({})
const togglePanel = (index: number) => {
expandedPanels.value[index] = !expandedPanels.value[index]
collapsedPanels.value[index] = !collapsedPanels.value[index]
}
const sectionsContainerRef = ref<HTMLElement | null>(null)
const { y: scrollY } = useScroll(sectionsContainerRef)
const { y: scrollY } = useScroll(sectionsContainerRef, {
eventListenerOptions: {
passive: true
}
})
const scrollToBottom = () => {
const lastPanelRef = ref<HTMLElement | null>(null)
const isUserScrolling = ref(false)
const lastPanelLogs = computed(() => taskPanels.value?.at(-1)?.logs)
const isAtBottom = (el: HTMLElement | null) => {
if (!el) return false
const threshold = 20
return Math.abs(el.scrollHeight - el.scrollTop - el.clientHeight) < threshold
}
const scrollLastPanelToBottom = () => {
if (!lastPanelRef.value || isUserScrolling.value) return
lastPanelRef.value.scrollTop = lastPanelRef.value.scrollHeight
}
const scrollContentToBottom = () => {
scrollY.value = sectionsContainerRef.value?.scrollHeight ?? 0
}
whenever(() => isExpanded.value, scrollToBottom)
const resetUserScrolling = () => {
isUserScrolling.value = false
}
const handleScroll = (e: Event) => {
const target = e.target as HTMLElement
if (target !== lastPanelRef.value) return
isUserScrolling.value = !isAtBottom(target)
}
const onLogsAdded = () => {
// If user is scrolling manually, don't automatically scroll to bottom
if (isUserScrolling.value) return
scrollLastPanelToBottom()
}
whenever(lastPanelLogs, onLogsAdded, { flush: 'post', deep: true })
whenever(() => isExpanded.value, scrollContentToBottom)
whenever(isCollapsed, resetUserScrolling)
onMounted(() => {
expandedPanels.value = {}
scrollToBottom()
scrollContentToBottom()
})
onBeforeUnmount(() => {

View File

@@ -35,9 +35,10 @@ const onConfirm = () => {
useDialogStore().closeDialog()
}
const inputRef = ref(null)
const inputRef = ref<InstanceType<typeof InputText> | undefined>()
const selectAllText = () => {
if (!inputRef.value) return
// @ts-expect-error - $el is an internal property of the InputText component
const inputElement = inputRef.value.$el
inputElement.setSelectionRange(0, inputElement.value.length)
}

View File

@@ -15,7 +15,7 @@
scrollHeight="100%"
:optionDisabled="
(option: SettingTreeNode) =>
!queryIsEmpty && !searchResultsCategories.has(option.label)
!queryIsEmpty && !searchResultsCategories.has(option.label ?? '')
"
class="border-none w-full"
/>
@@ -31,7 +31,7 @@
<PanelTemplate
v-for="category in settingCategories"
:key="category.key"
:value="category.label"
:value="category.label ?? ''"
>
<template #header>
<CurrentUserMessage v-if="tabValue === 'Comfy'" />
@@ -266,8 +266,8 @@ const handleSearch = (query: string) => {
const queryIsEmpty = computed(() => searchQuery.value.length === 0)
const inSearch = computed(() => !queryIsEmpty.value && !searchInProgress.value)
const tabValue = computed(() =>
inSearch.value ? 'Search Results' : activeCategory.value?.label
const tabValue = computed<string>(() =>
inSearch.value ? 'Search Results' : activeCategory.value?.label ?? ''
)
// Don't allow null category to be set outside of search.
// In search mode, the active category can be null to show all search results.

View File

@@ -0,0 +1,176 @@
import { VueWrapper, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import Button from 'primevue/button'
import PrimeVue from 'primevue/config'
import Panel from 'primevue/panel'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import ManagerProgressDialogContent from '../ManagerProgressDialogContent.vue'
type ComponentInstance = InstanceType<typeof ManagerProgressDialogContent> & {
lastPanelRef: HTMLElement | null
onLogsAdded: () => void
handleScroll: (e: { target: HTMLElement }) => void
isUserScrolling: boolean
resetUserScrolling: () => void
collapsedPanels: Record<number, boolean>
togglePanel: (index: number) => void
}
const mockCollapse = vi.fn()
const defaultMockTaskLogs = [
{ taskName: 'Task 1', logs: ['Log 1', 'Log 2'] },
{ taskName: 'Task 2', logs: ['Log 3', 'Log 4'] }
]
vi.mock('@/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn(() => ({
taskLogs: [...defaultMockTaskLogs]
})),
useManagerProgressDialogStore: vi.fn(() => ({
isExpanded: true,
collapse: mockCollapse
}))
}))
describe('ManagerProgressDialogContent', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCollapse.mockReset()
})
const mountComponent = ({
props = {}
}: Record<string, any> = {}): VueWrapper<ComponentInstance> => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return mount(ManagerProgressDialogContent, {
props: {
...props
},
global: {
plugins: [PrimeVue, createPinia(), i18n],
components: {
Panel,
Button
}
}
}) as VueWrapper<ComponentInstance>
}
it('renders the correct number of panels', async () => {
const wrapper = mountComponent()
await nextTick()
expect(wrapper.findAllComponents(Panel).length).toBe(2)
})
it('expands the last panel by default', async () => {
const wrapper = mountComponent()
await nextTick()
expect(wrapper.vm.collapsedPanels[1]).toBeFalsy()
})
it('toggles panel expansion when toggle method is called', async () => {
const wrapper = mountComponent()
await nextTick()
// Initial state - first panel should be collapsed
expect(wrapper.vm.collapsedPanels[0]).toBeFalsy()
wrapper.vm.togglePanel(0)
await nextTick()
// After toggle - first panel should be expanded
expect(wrapper.vm.collapsedPanels[0]).toBe(true)
wrapper.vm.togglePanel(0)
await nextTick()
expect(wrapper.vm.collapsedPanels[0]).toBeFalsy()
})
it('displays the correct status for each panel', async () => {
const wrapper = mountComponent()
await nextTick()
// Expand all panels to see status text
const panels = wrapper.findAllComponents(Panel)
for (let i = 0; i < panels.length; i++) {
if (!wrapper.vm.collapsedPanels[i]) {
wrapper.vm.togglePanel(i)
await nextTick()
}
}
const panelsText = wrapper
.findAllComponents(Panel)
.map((panel) => panel.text())
expect(panelsText[0]).toContain('Completed ✓')
expect(panelsText[1]).toContain('Completed ✓')
})
it('auto-scrolls to bottom when new logs are added', async () => {
const wrapper = mountComponent()
await nextTick()
const mockScrollElement = document.createElement('div')
Object.defineProperty(mockScrollElement, 'scrollHeight', { value: 200 })
Object.defineProperty(mockScrollElement, 'clientHeight', { value: 100 })
Object.defineProperty(mockScrollElement, 'scrollTop', {
value: 0,
writable: true
})
wrapper.vm.lastPanelRef = mockScrollElement
wrapper.vm.onLogsAdded()
await nextTick()
// Check if scrollTop is set to scrollHeight (scrolled to bottom)
expect(mockScrollElement.scrollTop).toBe(200)
})
it('does not auto-scroll when user is manually scrolling', async () => {
const wrapper = mountComponent()
await nextTick()
const mockScrollElement = document.createElement('div')
Object.defineProperty(mockScrollElement, 'scrollHeight', { value: 200 })
Object.defineProperty(mockScrollElement, 'clientHeight', { value: 100 })
Object.defineProperty(mockScrollElement, 'scrollTop', {
value: 50,
writable: true
})
wrapper.vm.lastPanelRef = mockScrollElement
wrapper.vm.handleScroll({ target: mockScrollElement })
await nextTick()
expect(wrapper.vm.isUserScrolling).toBe(true)
// Now trigger the log update
wrapper.vm.onLogsAdded()
await nextTick()
// Check that scrollTop is not changed (should still be 50)
expect(mockScrollElement.scrollTop).toBe(50)
})
it('calls collapse method when component is unmounted', async () => {
const wrapper = mountComponent()
await nextTick()
wrapper.unmount()
expect(mockCollapse).toHaveBeenCalled()
})
})

View File

@@ -4,7 +4,7 @@
@submit="submit"
:resolver="zodResolver(issueReportSchema)"
>
<Panel :pt="$attrs.pt">
<Panel :pt="$attrs.pt as any">
<template #header>
<div class="flex items-center gap-2">
<span class="font-bold">{{ title }}</span>
@@ -187,7 +187,7 @@ const createUser = (formData: IssueReportFormData): User => ({
})
const createExtraData = async (formData: IssueReportFormData) => {
const result = {}
const result: Record<string, unknown> = {}
const isChecked = (fieldValue: string) => formData[fieldValue]
await Promise.all(
@@ -243,7 +243,7 @@ const submit = async (event: FormSubmitEvent) => {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: error.message,
detail: error instanceof Error ? error.message : String(error),
life: 3000
})
}

View File

@@ -1,4 +1,3 @@
// @ts-strict-ignore
import { Form } from '@primevue/forms'
import { mount } from '@vue/test-utils'
import Checkbox from 'primevue/checkbox'
@@ -95,12 +94,15 @@ vi.mock('@primevue/forms', () => ({
},
methods: {
onSubmit() {
// @ts-expect-error fixme ts strict error
this.$emit('submit', {
valid: true,
// @ts-expect-error fixme ts strict error
values: this.formValues
})
},
updateFieldValue(name: string, value: any) {
// @ts-expect-error fixme ts strict error
this.formValues[name] = value
}
}
@@ -116,13 +118,17 @@ vi.mock('@primevue/forms', () => ({
}
},
methods: {
// @ts-expect-error fixme ts strict error
updateValue(value) {
// @ts-expect-error fixme ts strict error
this.modelValue = value
// @ts-expect-error fixme ts strict error
let parent = this.$parent
while (parent && parent.$options.name !== 'Form') {
parent = parent.$parent
}
if (parent) {
// @ts-expect-error fixme ts strict error
parent.updateFieldValue(this.name, value)
}
}
@@ -163,6 +169,7 @@ describe('ReportIssuePanel', () => {
for (const field of DEFAULT_FIELDS) {
const checkbox = checkboxes.find(
// @ts-expect-error fixme ts strict error
(checkbox) => checkbox.props('value') === field
)
expect(checkbox).toBeDefined()
@@ -218,12 +225,11 @@ describe('ReportIssuePanel', () => {
})
// Filter out the contact preferences checkboxes
const fieldCheckboxes = wrapper
.findAllComponents(Checkbox)
.filter(
(checkbox) =>
!['followUp', 'notifyOnResolution'].includes(checkbox.props('value'))
)
const fieldCheckboxes = wrapper.findAllComponents(Checkbox).filter(
// @ts-expect-error fixme ts strict error
(checkbox) =>
!['followUp', 'notifyOnResolution'].includes(checkbox.props('value'))
)
expect(fieldCheckboxes.length).toBe(1)
expect(fieldCheckboxes.at(0)?.props('value')).toBe('Settings')
})
@@ -235,6 +241,7 @@ describe('ReportIssuePanel', () => {
})
const customCheckbox = wrapper
.findAllComponents(Checkbox)
// @ts-expect-error fixme ts strict error
.find((checkbox) => checkbox.props('value') === 'CustomField')
expect(customCheckbox).toBeDefined()
})

View File

@@ -34,10 +34,10 @@
/>
<div class="flex-1 overflow-auto">
<div
v-if="isLoading || isInitialLoad"
class="flex justify-center items-center h-full"
v-if="isLoading"
class="w-full h-full overflow-auto scrollbar-hide"
>
<ProgressSpinner />
<GridSkeleton :grid-style="GRID_STYLE" :skeleton-card-count />
</div>
<NoResultsPlaceholder
v-else-if="searchResults.length === 0"
@@ -55,13 +55,9 @@
<div v-else class="h-full" @click="handleGridContainerClick">
<VirtualGrid
:items="resultsWithKeys"
:buffer-rows="5"
:gridStyle="{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(22rem, 1fr))',
padding: '0.5rem',
gap: '1.5rem'
}"
:buffer-rows="3"
:gridStyle="GRID_STYLE"
@approach-end="onApproachEnd"
>
<template #item="{ item }">
<PackCard
@@ -83,7 +79,7 @@
<ContentDivider orientation="vertical" :width="0.2" />
<div class="flex-1 flex flex-col isolate">
<InfoPanel
v-if="!hasMultipleSelections"
v-if="!hasMultipleSelections && selectedNodePack"
:node-pack="selectedNodePack"
/>
<InfoPanelMultiItem v-else :node-packs="selectedNodePacks" />
@@ -94,9 +90,10 @@
</template>
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import { merge } from 'lodash'
import Button from 'primevue/button'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, ref, watchEffect } from 'vue'
import { computed, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import ContentDivider from '@/components/common/ContentDivider.vue'
@@ -107,15 +104,33 @@ import InfoPanel from '@/components/dialog/content/manager/infoPanel/InfoPanel.v
import InfoPanelMultiItem from '@/components/dialog/content/manager/infoPanel/InfoPanelMultiItem.vue'
import PackCard from '@/components/dialog/content/manager/packCard/PackCard.vue'
import RegistrySearchBar from '@/components/dialog/content/manager/registrySearchBar/RegistrySearchBar.vue'
import GridSkeleton from '@/components/dialog/content/manager/skeleton/GridSkeleton.vue'
import { useResponsiveCollapse } from '@/composables/element/useResponsiveCollapse'
import { useInstalledPacks } from '@/composables/useInstalledPacks'
import { useInstalledPacks } from '@/composables/nodePack/useInstalledPacks'
import { useWorkflowPacks } from '@/composables/nodePack/useWorkflowPacks'
import { useRegistrySearch } from '@/composables/useRegistrySearch'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import type { TabItem } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
enum ManagerTab {
All = 'all',
Installed = 'installed',
Workflow = 'workflow',
Missing = 'missing'
}
const { t } = useI18n()
const comfyManagerStore = useComfyManagerStore()
const { getPackById } = useComfyRegistryStore()
const GRID_STYLE = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(19rem, 1fr))',
padding: '0.5rem',
gap: '1.5rem'
} as const
const {
isSmallScreen,
@@ -124,56 +139,179 @@ const {
} = useResponsiveCollapse()
const tabs = ref<TabItem[]>([
{ id: 'all', label: t('g.all'), icon: 'pi-list' },
{ id: 'installed', label: t('g.installed'), icon: 'pi-box' }
{ id: ManagerTab.All, label: t('g.all'), icon: 'pi-list' },
{ id: ManagerTab.Installed, label: t('g.installed'), icon: 'pi-box' },
{
id: ManagerTab.Workflow,
label: t('manager.inWorkflow'),
icon: 'pi-folder'
},
{
id: ManagerTab.Missing,
label: t('g.missing'),
icon: 'pi-exclamation-circle'
}
])
const selectedTab = ref<TabItem>(tabs.value[0])
const {
searchQuery,
pageNumber,
isLoading,
isLoading: isSearchLoading,
searchResults,
searchMode,
suggestions
} = useRegistrySearch()
pageNumber.value = 0
const onApproachEnd = () => {
pageNumber.value++
}
const isInitialLoad = computed(
() => searchResults.value.length === 0 && searchQuery.value === ''
)
const { getInstalledPacks } = useInstalledPacks()
const displayPacks = ref<components['schemas']['Node'][]>([])
const isEmptySearch = computed(() => searchQuery.value === '')
const displayPacks = ref<components['schemas']['Node'][]>([])
const getInstalledSearchResults = async () => {
if (isEmptySearch.value) return getInstalledPacks()
return searchResults.value.filter((pack) =>
comfyManagerStore.installedPacksIds.has(pack.name)
)
const {
startFetchInstalled,
filterInstalledPack,
installedPacks,
isLoading: isLoadingInstalled
} = useInstalledPacks()
const {
startFetchWorkflowPacks,
filterWorkflowPack,
workflowPacks,
isLoading: isLoadingWorkflow
} = useWorkflowPacks()
const getInstalledResults = () => {
if (isEmptySearch.value) {
startFetchInstalled()
return installedPacks.value
} else {
return filterInstalledPack(searchResults.value)
}
}
watchEffect(async () => {
if (selectedTab.value.id === 'installed') {
displayPacks.value = await getInstalledSearchResults()
const getInWorkflowResults = () => {
if (isEmptySearch.value) {
startFetchWorkflowPacks()
return workflowPacks.value
} else {
displayPacks.value = searchResults.value
return filterWorkflowPack(searchResults.value)
}
}
const filterMissingPacks = (packs: components['schemas']['Node'][]) =>
packs.filter((pack) => !comfyManagerStore.isPackInstalled(pack.id))
const setMissingPacks = () => {
displayPacks.value = filterMissingPacks(workflowPacks.value)
}
const getMissingPacks = () => {
if (isEmptySearch.value) {
startFetchWorkflowPacks()
whenever(() => workflowPacks.value.length, setMissingPacks, {
immediate: true,
once: true
})
return filterMissingPacks(workflowPacks.value)
} else {
return filterMissingPacks(filterWorkflowPack(searchResults.value))
}
}
const onTabChange = () => {
switch (selectedTab.value?.id) {
case ManagerTab.Installed:
displayPacks.value = getInstalledResults()
break
case ManagerTab.Workflow:
displayPacks.value = getInWorkflowResults()
break
case ManagerTab.Missing:
displayPacks.value = getMissingPacks()
break
default:
displayPacks.value = searchResults.value
}
}
const onResultsChange = () => {
switch (selectedTab.value?.id) {
case ManagerTab.Installed:
displayPacks.value = filterInstalledPack(searchResults.value)
break
case ManagerTab.Workflow:
displayPacks.value = filterWorkflowPack(searchResults.value)
break
case ManagerTab.Missing:
displayPacks.value = filterMissingPacks(
filterWorkflowPack(searchResults.value)
)
break
default:
displayPacks.value = searchResults.value
}
}
whenever(selectedTab, onTabChange)
watch(searchResults, onResultsChange, { flush: 'pre' })
watch(() => comfyManagerStore.installedPacksIds, onResultsChange)
const isLoading = computed(() => {
if (isSearchLoading.value) return searchResults.value.length === 0
if (selectedTab.value?.id === ManagerTab.Installed) {
return isLoadingInstalled.value
}
if (
selectedTab.value?.id === ManagerTab.Workflow ||
selectedTab.value?.id === ManagerTab.Missing
) {
return isLoadingWorkflow.value
}
return isInitialLoad.value
})
const resultsWithKeys = computed(() =>
displayPacks.value.map((item) => ({
...item,
key: item.id || item.name
}))
const resultsWithKeys = computed(
() =>
displayPacks.value.map((item) => ({
...item,
key: item.id || item.name
})) as (components['schemas']['Node'] & { key: string })[]
)
const selectedNodePacks = ref<components['schemas']['Node'][]>([])
const selectedNodePack = computed(() =>
const selectedNodePack = computed<components['schemas']['Node'] | null>(() =>
selectedNodePacks.value.length === 1 ? selectedNodePacks.value[0] : null
)
const getLoadingCount = () => {
switch (selectedTab.value?.id) {
case ManagerTab.Installed:
return comfyManagerStore.installedPacksIds?.size
case ManagerTab.Workflow:
return workflowPacks.value?.length
case ManagerTab.Missing:
return workflowPacks.value?.filter?.(
(pack) => !comfyManagerStore.isPackInstalled(pack.id)
)?.length
default:
return searchResults.value.length
}
}
const skeletonCardCount = computed(() => {
const loadingCount = getLoadingCount()
if (loadingCount) return loadingCount
return isSmallScreen.value ? 12 : 16
})
const selectNodePack = (
nodePack: components['schemas']['Node'],
event: MouseEvent
@@ -208,4 +346,24 @@ const handleGridContainerClick = (event: MouseEvent) => {
}
const hasMultipleSelections = computed(() => selectedNodePacks.value.length > 1)
whenever(selectedNodePack, async () => {
// Cancel any in-flight requests from previously selected node pack
getPackById.cancel()
if (!selectedNodePack.value?.id) return
// If only a single node pack is selected, fetch full node pack info from registry
if (hasMultipleSelections.value) return
const data = await getPackById.call(selectedNodePack.value.id)
if (data?.id === selectedNodePack.value?.id) {
// If selected node hasn't changed since request, merge registry & Algolia data
selectedNodePacks.value = [merge(selectedNodePack.value, data)]
}
})
onUnmounted(() => {
getPackById.cancel()
})
</script>

View File

@@ -6,12 +6,12 @@
'w-full': fullWidth,
'w-min-content': !fullWidth
}"
:disabled="isExecuted"
:disabled="isInstalling"
v-bind="$attrs"
@click="onClick"
>
<span class="py-2.5 px-3">
<template v-if="isExecuted">
<template v-if="isInstalling">
{{ loadingMessage ?? $t('g.loading') }}
</template>
<template v-else>
@@ -23,7 +23,9 @@
<script setup lang="ts">
import Button from 'primevue/button'
import { ref } from 'vue'
import { inject, ref } from 'vue'
import { IsInstallingKey } from '@/types/comfyManagerTypes'
const {
label,
@@ -43,10 +45,10 @@ defineOptions({
inheritAttrs: false
})
const isExecuted = ref(false)
const isInstalling = inject(IsInstallingKey, ref(false))
const onClick = (): void => {
isExecuted.value = true
isInstalling.value = true
emit('action')
}
</script>

View File

@@ -11,9 +11,12 @@
</template>
<script setup lang="ts">
import { inject, ref } from 'vue'
import PackActionButton from '@/components/dialog/content/manager/button/PackActionButton.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import {
IsInstallingKey,
ManagerChannel,
ManagerDatabaseSource,
SelectedVersion
@@ -26,6 +29,8 @@ const { nodePacks } = defineProps<{
nodePacks: NodePack[]
}>()
const isInstalling = inject(IsInstallingKey, ref(false))
const managerStore = useComfyManagerStore()
const createPayload = (installItem: NodePack) => {
@@ -50,6 +55,8 @@ const installPack = (item: NodePack) =>
const installAllPacks = async () => {
if (!nodePacks?.length) return
isInstalling.value = true
const uninstalledPacks = nodePacks.filter(
(pack) => !managerStore.isPackInstalled(pack.id)
)

View File

@@ -1,8 +1,10 @@
<template>
<template v-if="nodePack">
<div class="flex flex-col h-full z-40 hidden-scrollbar w-80">
<div class="p-6 flex-1 overflow-hidden text-sm">
<div class="flex flex-col h-full z-40 w-80 overflow-hidden relative">
<div class="top-0 z-10 px-6 pt-6 w-full">
<InfoPanelHeader :node-packs="[nodePack]" />
</div>
<div class="p-6 pt-2 overflow-y-auto flex-1 text-sm hidden-scrollbar">
<div class="mb-6">
<MetadataRow
v-if="isPackInstalled(nodePack.id)"
@@ -44,7 +46,8 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { whenever } from '@vueuse/core'
import { computed, provide, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import PackStatusMessage from '@/components/dialog/content/manager/PackStatusMessage.vue'
@@ -54,6 +57,7 @@ import InfoPanelHeader from '@/components/dialog/content/manager/infoPanel/InfoP
import InfoTabs from '@/components/dialog/content/manager/infoPanel/InfoTabs.vue'
import MetadataRow from '@/components/dialog/content/manager/infoPanel/MetadataRow.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { IsInstallingKey } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
interface InfoItem {
@@ -66,6 +70,14 @@ const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
}>()
const managerStore = useComfyManagerStore()
const isInstalled = computed(() => managerStore.isPackInstalled(nodePack.id))
const isInstalling = ref(false)
provide(IsInstallingKey, isInstalling)
whenever(isInstalled, () => {
isInstalling.value = false
})
const { isPackInstalled } = useComfyManagerStore()
const { t, d, n } = useI18n()
@@ -94,9 +106,6 @@ const infoItems = computed<InfoItem[]>(() => [
</script>
<style scoped>
.hidden-scrollbar {
height: 100%;
overflow-y: auto;
/* Firefox */
scrollbar-width: none;

View File

@@ -1,7 +1,7 @@
<template>
<div class="flex flex-col h-full">
<div v-if="nodePacks?.length" class="flex flex-col h-full">
<div class="p-6 flex-1 overflow-auto">
<InfoPanelHeader :node-packs="nodePacks">
<InfoPanelHeader :node-packs>
<template #thumbnail>
<PackIconStacked :node-packs="nodePacks" />
</template>
@@ -24,6 +24,9 @@
</div>
</div>
</div>
<div v-else class="mt-4 mx-8 flex-1 overflow-hidden text-sm">
{{ $t('manager.infoPanelEmpty') }}
</div>
</template>
<script setup lang="ts">
@@ -48,7 +51,7 @@ const getPackNodes = async (pack: components['schemas']['Node']) => {
if (!comfyRegistryService.packNodesAvailable(pack)) return []
return comfyRegistryService.getNodeDefs({
packId: pack.id,
versionId: pack.latest_version.id
versionId: pack.latest_version?.id
})
}

View File

@@ -1,12 +1,24 @@
<template>
<div class="mt-4 overflow-hidden">
<InfoTextSection
v-if="nodePack.description"
v-if="nodePack?.description"
:sections="descriptionSections"
/>
<p v-else class="text-muted italic text-sm">
{{ $t('manager.noDescription') }}
</p>
<div v-if="nodePack?.latest_version?.dependencies?.length">
<p class="mb-1">
{{ $t('manager.dependencies') }}
</p>
<div
v-for="(dep, index) in nodePack.latest_version.dependencies"
:key="index"
class="text-muted break-words"
>
{{ dep }}
</div>
</div>
</div>
</template>

View File

@@ -9,13 +9,17 @@
:key="i"
class="border border-surface-border rounded-lg p-4"
>
<NodePreview :node-def="placeholderNodeDef" />
<NodePreview
:node-def="placeholderNodeDef"
class="!text-[.625rem] !min-w-full"
/>
</div>
</div>
</template>
<script setup lang="ts">
import NodePreview from '@/components/node/NodePreview.vue'
import { ComfyNodeDef } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { components } from '@/types/comfyRegistryTypes'
defineProps<{
@@ -24,7 +28,8 @@ defineProps<{
}>()
// TODO: when registry returns node defs, use them here
const placeholderNodeDef = {
const placeholderNodeDef: ComfyNodeDef = {
name: 'Sample Node',
display_name: 'Sample Node',
description: 'This is a sample node for preview purposes',
inputs: {
@@ -32,8 +37,11 @@ const placeholderNodeDef = {
input2: { name: 'Input 2', type: 'CONDITIONING' }
},
outputs: [
{ name: 'Output 1', type: 'IMAGE', index: 0 },
{ name: 'Output 2', type: 'MASK', index: 1 }
]
{ name: 'Output 1', type: 'IMAGE', index: 0, is_list: false },
{ name: 'Output 2', type: 'MASK', index: 1, is_list: false }
],
category: 'Utility',
output_node: false,
python_module: 'nodes'
}
</script>

View File

@@ -2,7 +2,8 @@
<Card
class="w-full h-full inline-flex flex-col justify-between items-start overflow-hidden rounded-2xl shadow-elevation-3 dark-theme:bg-dark-elevation-2 transition-all duration-200"
:class="{
'outline outline-[6px] outline-[var(--p-primary-color)]': isSelected
'outline outline-[6px] outline-[var(--p-primary-color)]': isSelected,
'opacity-60': isDisabled
}"
:pt="{
body: { class: 'p-0 flex flex-col w-full h-full rounded-2xl gap-0' },
@@ -19,53 +20,65 @@
</template>
<template #content>
<ContentDivider />
<div
class="self-stretch px-4 py-3 inline-flex justify-start items-start cursor-pointer"
>
<PackIcon :node-pack="nodePack" />
<template v-if="isInstalling">
<div
class="px-4 inline-flex flex-col justify-start items-start overflow-hidden"
class="self-stretch inline-flex flex-col justify-center items-center gap-2 h-full"
>
<span
class="text-sm font-bold truncate overflow-hidden text-ellipsis"
:title="nodePack.name"
>
{{ nodePack.name }}
</span>
<ProgressSpinner />
<div
class="self-stretch inline-flex justify-center items-center gap-2.5"
class="self-stretch text-center justify-start text-sm font-medium leading-none"
>
<p
v-if="nodePack.description"
class="flex-1 justify-start text-muted text-sm font-medium leading-3 break-words overflow-hidden min-h-12 line-clamp-3"
:title="nodePack.description"
>
{{ nodePack.description }}
</p>
{{ $t('g.installing') }}...
</div>
</div>
</template>
<template v-else>
<div
class="self-stretch px-4 py-3 inline-flex justify-start items-start cursor-pointer"
>
<PackIcon :node-pack="nodePack" />
<div
class="self-stretch inline-flex justify-start items-center gap-2"
class="px-4 inline-flex flex-col justify-start items-start overflow-hidden"
>
<div
v-if="nodesCount"
class="px-2 py-1 flex justify-center text-sm items-center gap-1"
<span
class="text-sm font-bold truncate overflow-hidden text-ellipsis"
>
<div class="text-center justify-center font-medium leading-3">
{{ nodesCount }} {{ $t('g.nodes') }}
</div>
</div>
<div class="px-2 py-1 flex justify-center items-center gap-1">
<div
v-if="isUpdateAvailable"
class="w-4 h-4 relative overflow-hidden"
{{ nodePack.name }}
</span>
<div
class="self-stretch inline-flex justify-center items-center gap-2.5"
>
<p
v-if="nodePack.description"
class="flex-1 justify-start text-muted text-sm font-medium leading-3 break-words overflow-hidden min-h-12 line-clamp-3"
>
<i class="pi pi-arrow-circle-up text-blue-600" />
{{ nodePack.description }}
</p>
</div>
<div
class="self-stretch inline-flex justify-start items-center gap-2"
>
<div
v-if="nodesCount"
class="px-2 py-1 flex justify-center text-sm items-center gap-1"
>
<div class="text-center justify-center font-medium leading-3">
{{ nodesCount }} {{ $t('g.nodes') }}
</div>
</div>
<div class="px-2 py-1 flex justify-center items-center gap-1">
<div
v-if="isUpdateAvailable"
class="w-4 h-4 relative overflow-hidden"
>
<i class="pi pi-arrow-circle-up text-blue-600" />
</div>
<PackVersionBadge :node-pack="nodePack" />
</div>
<PackVersionBadge :node-pack="nodePack" />
</div>
</div>
</div>
</div>
</template>
</template>
<template #footer>
<ContentDivider :width="0.1" />
@@ -75,14 +88,17 @@
</template>
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import Card from 'primevue/card'
import { computed } from 'vue'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, provide, ref } from 'vue'
import ContentDivider from '@/components/common/ContentDivider.vue'
import PackVersionBadge from '@/components/dialog/content/manager/PackVersionBadge.vue'
import PackCardFooter from '@/components/dialog/content/manager/packCard/PackCardFooter.vue'
import PackIcon from '@/components/dialog/content/manager/packIcon/PackIcon.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { IsInstallingKey } from '@/types/comfyManagerTypes'
import type { components } from '@/types/comfyRegistryTypes'
import { compareVersions, isSemVer } from '@/utils/formatUtil'
@@ -91,16 +107,26 @@ const { nodePack, isSelected = false } = defineProps<{
isSelected?: boolean
}>()
const { isPackInstalled, getInstalledPackVersion } = useComfyManagerStore()
const isInstalling = ref(false)
provide(IsInstallingKey, isInstalling)
const { isPackInstalled, isPackEnabled, getInstalledPackVersion } =
useComfyManagerStore()
const isInstalled = computed(() => isPackInstalled(nodePack?.id))
const isDisabled = computed(
() => isInstalled.value && !isPackEnabled(nodePack?.id)
)
whenever(isInstalled, () => (isInstalling.value = false))
const isUpdateAvailable = computed(() => {
if (!isInstalled.value) return false
const latestVersion = nodePack.latest_version?.version
if (!latestVersion) return false
const installedVersion = getInstalledPackVersion(nodePack.id)
const installedVersion = getInstalledPackVersion(nodePack.id ?? '')
// Don't attempt to show update available for nightly GitHub packs
if (installedVersion && !isSemVer(installedVersion)) return false

View File

@@ -2,7 +2,7 @@
<img
:src="isImageError ? DEFAULT_ICON : imgSrc"
:alt="nodePack.name + ' icon'"
class="object-contain rounded-lg"
class="object-contain rounded-lg max-h-72 max-w-72"
:style="{ width: cssWidth, height: cssHeight }"
@error="isImageError = true"
/>

View File

@@ -0,0 +1,19 @@
<template>
<div :style="gridStyle">
<PackCardSkeleton v-for="n in skeletonCardCount" :key="n" />
</div>
</template>
<script setup lang="ts">
import PackCardSkeleton from '@/components/dialog/content/manager/skeleton/PackCardSkeleton.vue'
const { skeletonCardCount = 12, gridStyle } = defineProps<{
skeletonCardCount?: number
gridStyle: {
display: string
gridTemplateColumns: string
padding: string
gap: string
}
}>()
</script>

View File

@@ -0,0 +1,62 @@
<template>
<div
class="rounded-lg border shadow-sm h-full overflow-hidden flex flex-col"
data-virtual-grid-item
>
<!-- Card header - flush with top, approximately 15% of height -->
<div class="w-full px-4 py-3 flex justify-between items-center border-b">
<div class="flex items-center">
<div class="w-6 h-6 flex items-center justify-center">
<Skeleton shape="circle" width="1.5rem" height="1.5rem"></Skeleton>
</div>
<Skeleton width="5rem" height="1rem" class="ml-2"></Skeleton>
</div>
<Skeleton width="4rem" height="1.75rem" borderRadius="0.75rem"></Skeleton>
</div>
<!-- Card content with icon on left and text on right -->
<div class="flex-1 p-4 flex">
<!-- Left icon - 64x64 -->
<div class="flex-shrink-0 mr-4">
<Skeleton width="4rem" height="4rem" borderRadius="0.5rem"></Skeleton>
</div>
<!-- Right content -->
<div class="flex-1 flex flex-col overflow-hidden">
<!-- Title -->
<Skeleton width="80%" height="1rem" class="mb-2"></Skeleton>
<!-- Description -->
<div class="mb-3">
<Skeleton width="100%" height="0.75rem" class="mb-1"></Skeleton>
<Skeleton width="95%" height="0.75rem" class="mb-1"></Skeleton>
<Skeleton width="90%" height="0.75rem"></Skeleton>
</div>
<!-- Tags/Badges -->
<div class="flex gap-2">
<Skeleton
width="4rem"
height="1.5rem"
borderRadius="0.75rem"
></Skeleton>
<Skeleton
width="5rem"
height="1.5rem"
borderRadius="0.75rem"
></Skeleton>
</div>
</div>
</div>
<!-- Card footer - similar to header -->
<div class="w-full px-5 py-4 flex justify-between items-center border-t">
<Skeleton width="4rem" height="0.8rem"></Skeleton>
<Skeleton width="6rem" height="0.8rem"></Skeleton>
</div>
</div>
</template>
<script setup lang="ts">
import Skeleton from 'primevue/skeleton'
</script>

View File

@@ -0,0 +1,79 @@
import { VueWrapper, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import GridSkeleton from '../GridSkeleton.vue'
import PackCardSkeleton from '../PackCardSkeleton.vue'
describe('GridSkeleton', () => {
const mountComponent = ({
props = {}
}: Record<string, any> = {}): VueWrapper => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return mount(GridSkeleton, {
props: {
gridStyle: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(19rem, 1fr))',
padding: '0.5rem',
gap: '1.5rem'
},
...props
},
global: {
plugins: [PrimeVue, createPinia(), i18n],
stubs: {
PackCardSkeleton: true
}
}
})
}
it('renders with default props', () => {
const wrapper = mountComponent()
expect(wrapper.exists()).toBe(true)
})
it('applies the provided grid style', () => {
const customGridStyle = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(15rem, 1fr))',
padding: '1rem',
gap: '1rem'
}
const wrapper = mountComponent({
props: { gridStyle: customGridStyle }
})
const gridElement = wrapper.element
expect(gridElement.style.display).toBe('grid')
expect(gridElement.style.gridTemplateColumns).toBe(
'repeat(auto-fill, minmax(15rem, 1fr))'
)
expect(gridElement.style.padding).toBe('1rem')
expect(gridElement.style.gap).toBe('1rem')
})
it('renders the specified number of skeleton cards', async () => {
const cardCount = 5
const wrapper = mountComponent({
props: { skeletonCardCount: cardCount }
})
await nextTick()
const skeletonCards = wrapper.findAllComponents(PackCardSkeleton)
expect(skeletonCards.length).toBe(5)
})
})

View File

@@ -55,7 +55,7 @@
icon="pi pi-ellipsis-h"
text
severity="secondary"
@click="menu.show($event)"
@click="menu?.show($event)"
/>
<ContextMenu ref="menu" :model="contextMenuItems" />
</template>

View File

@@ -157,7 +157,10 @@ interface ICommandData {
const commandsData = computed<ICommandData[]>(() => {
return Object.values(commandStore.commands).map((command) => ({
id: command.id,
label: t(`commands.${normalizeI18nKey(command.id)}.label`, command.label),
label: t(
`commands.${normalizeI18nKey(command.id)}.label`,
command.label ?? ''
),
keybinding: keybindingStore.getKeybindingByCommandId(command.id)
}))
})
@@ -166,7 +169,7 @@ const selectedCommandData = ref<ICommandData | null>(null)
const editDialogVisible = ref(false)
const newBindingKeyCombo = ref<KeyComboImpl | null>(null)
const currentEditingCommand = ref<ICommandData | null>(null)
const keybindingInput = ref(null)
const keybindingInput = ref<InstanceType<typeof InputText> | null>(null)
const existingKeybindingOnCombo = computed<KeybindingImpl | null>(() => {
if (!currentEditingCommand.value) {
@@ -201,6 +204,7 @@ watchEffect(() => {
if (editDialogVisible.value) {
// nextTick doesn't work here, so we use a timeout instead
setTimeout(() => {
// @ts-expect-error - $el is an internal property of the InputText component
keybindingInput.value?.$el?.focus()
}, 300)
}

View File

@@ -1,4 +1,3 @@
// @ts-strict-ignore
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'

View File

@@ -15,15 +15,10 @@ import { computed } from 'vue'
import { KeyComboImpl } from '@/stores/keybindingStore'
const props = withDefaults(
defineProps<{
keyCombo: KeyComboImpl
isModified: boolean
}>(),
{
isModified: false
}
)
const { keyCombo, isModified = false } = defineProps<{
keyCombo: KeyComboImpl
isModified?: boolean
}>()
const keySequences = computed(() => props.keyCombo.getKeySequences())
const keySequences = computed(() => keyCombo.getKeySequences())
</script>

View File

@@ -101,6 +101,8 @@ const handleRestart = async () => {
const onReconnect = () => {
useCommandStore().execute('Comfy.RefreshNodeDefinitions')
comfyManagerStore.clearLogs()
comfyManagerStore.setStale()
}
useEventListener(api, 'reconnected', onReconnect, { once: true })
}

View File

@@ -5,7 +5,7 @@
v-for="widget in widgets"
:key="widget.id"
:widget="widget"
:widget-state="domWidgetStore.widgetStates.get(widget.id)"
:widget-state="domWidgetStore.widgetStates.get(widget.id)!"
@update:widget-value="widget.value = $event"
/>
</div>
@@ -56,7 +56,7 @@ const updateWidgets = () => {
(widget.computedHeight ?? 50) - margin * 2
]
// TODO: optimize this logic as it's O(n), where n is the number of nodes
widgetState.zIndex = lgCanvas.graph.nodes.indexOf(node)
widgetState.zIndex = lgCanvas.graph?.nodes.indexOf(node) ?? -1
widgetState.readonly = lgCanvas.read_only
}
}

View File

@@ -106,7 +106,9 @@ watchEffect(() => {
watchEffect(() => {
const spellcheckEnabled = settingStore.get('Comfy.TextareaWidget.Spellcheck')
const textareas = document.querySelectorAll('textarea.comfy-multiline-input')
const textareas = document.querySelectorAll<HTMLTextAreaElement>(
'textarea.comfy-multiline-input'
)
textareas.forEach((textarea: HTMLTextAreaElement) => {
textarea.spellcheck = spellcheckEnabled
@@ -124,6 +126,7 @@ watch(
for (const n of comfyApp.graph.nodes) {
if (!n.widgets) continue
for (const w of n.widgets) {
// @ts-expect-error fixme ts strict error
if (w[IS_CONTROL_WIDGET]) {
updateControlWidgetLabel(w)
if (w.linkedWidgets) {
@@ -168,6 +171,7 @@ const loadCustomNodesI18n = async () => {
const comfyAppReady = ref(false)
const workflowPersistence = useWorkflowPersistence()
// @ts-expect-error fixme ts strict error
useCanvasDrop(canvasRef)
useLitegraphSettings()
@@ -188,12 +192,15 @@ onMounted(async () => {
CORE_SETTINGS.forEach((setting) => {
settingStore.addSetting(setting)
})
// @ts-expect-error fixme ts strict error
await comfyApp.setup(canvasRef.value)
canvasStore.canvas = comfyApp.canvas
canvasStore.canvas.render_canvas_border = false
workspaceStore.spinner = false
// @ts-expect-error fixme ts strict error
window['app'] = comfyApp
// @ts-expect-error fixme ts strict error
window['graph'] = comfyApp.graph
comfyAppReady.value = true

View File

@@ -28,12 +28,12 @@ import { normalizeI18nKey } from '@/utils/formatUtil'
let idleTimeout: number
const nodeDefStore = useNodeDefStore()
const settingStore = useSettingStore()
const tooltipRef = ref<HTMLDivElement>()
const tooltipRef = ref<HTMLDivElement | undefined>()
const tooltipText = ref('')
const left = ref<string>()
const top = ref<string>()
const hideTooltip = () => (tooltipText.value = null)
const hideTooltip = () => (tooltipText.value = '')
const showTooltip = async (tooltip: string | null | undefined) => {
if (!tooltip) return
@@ -44,7 +44,9 @@ const showTooltip = async (tooltip: string | null | undefined) => {
await nextTick()
const rect = tooltipRef.value.getBoundingClientRect()
const rect = tooltipRef.value?.getBoundingClientRect()
if (!rect) return
if (rect.right > window.innerWidth) {
left.value = comfyApp.canvas.mouse[0] - rect.width + 'px'
}
@@ -60,7 +62,7 @@ const onIdle = () => {
if (!node) return
const ctor = node.constructor as { title_mode?: 0 | 1 | 2 | 3 }
const nodeDef = nodeDefStore.nodeDefsByName[node.type]
const nodeDef = nodeDefStore.nodeDefsByName[node.type ?? '']
if (
ctor.title_mode !== LiteGraph.NO_TITLE &&
@@ -80,8 +82,8 @@ const onIdle = () => {
if (inputSlot !== -1) {
const inputName = node.inputs[inputSlot].name
const translatedTooltip = st(
`nodeDefs.${normalizeI18nKey(node.type)}.inputs.${normalizeI18nKey(inputName)}.tooltip`,
nodeDef.inputs[inputName]?.tooltip
`nodeDefs.${normalizeI18nKey(node.type ?? '')}.inputs.${normalizeI18nKey(inputName)}.tooltip`,
nodeDef.inputs[inputName]?.tooltip ?? ''
)
return showTooltip(translatedTooltip)
}
@@ -94,8 +96,8 @@ const onIdle = () => {
)
if (outputSlot !== -1) {
const translatedTooltip = st(
`nodeDefs.${normalizeI18nKey(node.type)}.outputs.${outputSlot}.tooltip`,
nodeDef.outputs[outputSlot]?.tooltip
`nodeDefs.${normalizeI18nKey(node.type ?? '')}.outputs.${outputSlot}.tooltip`,
nodeDef.outputs[outputSlot]?.tooltip ?? ''
)
return showTooltip(translatedTooltip)
}
@@ -104,8 +106,8 @@ const onIdle = () => {
// Dont show for DOM widgets, these use native browser tooltips as we dont get proper mouse events on these
if (widget && !isDOMWidget(widget)) {
const translatedTooltip = st(
`nodeDefs.${normalizeI18nKey(node.type)}.inputs.${normalizeI18nKey(widget.name)}.tooltip`,
nodeDef.inputs[widget.name]?.tooltip
`nodeDefs.${normalizeI18nKey(node.type ?? '')}.inputs.${normalizeI18nKey(widget.name)}.tooltip`,
nodeDef.inputs[widget.name]?.tooltip ?? ''
)
// Widget tooltip can be set dynamically, current translation collection does not support this.
return showTooltip(widget.tooltip ?? translatedTooltip)

View File

@@ -48,7 +48,7 @@ const positionSelectionOverlay = (canvas: LGraphCanvas) => {
// Register listener on canvas creation.
watch(
() => canvasStore.canvas,
() => canvasStore.canvas as LGraphCanvas | null,
(canvas: LGraphCanvas | null) => {
if (!canvas) return

View File

@@ -7,7 +7,7 @@
>
<template #icon>
<div class="flex items-center gap-1">
<i class="pi pi-circle-fill" :style="{ color: currentColor }" />
<i class="pi pi-circle-fill" :style="{ color: currentColor ?? '' }" />
<i class="pi pi-chevron-down" :style="{ fontSize: '0.5rem' }" />
</div>
</template>

View File

@@ -41,7 +41,7 @@ const emit = defineEmits<{
(e: 'update:widgetValue', value: string | object): void
}>()
const widgetElement = ref<HTMLElement>()
const widgetElement = ref<HTMLElement | undefined>()
const { style: positionStyle, updatePositionWithTransform } =
useAbsolutePosition()
@@ -61,6 +61,8 @@ const enableDomClipping = computed(() =>
const updateDomClipping = () => {
const lgCanvas = canvasStore.canvas
if (!lgCanvas || !widgetElement.value) return
const selectedNode = Object.values(
lgCanvas.selected_nodes ?? {}
)[0] as LGraphNode
@@ -130,7 +132,7 @@ const inputSpec = widget.node.constructor.nodeData
const tooltip = inputSpec?.inputs?.[widget.name]?.tooltip
onMounted(() => {
if (isDOMWidget(widget)) {
if (isDOMWidget(widget) && widgetElement.value) {
widgetElement.value.appendChild(widget.element)
}
})

View File

@@ -146,7 +146,7 @@ const cpuMode = computed({
selected.value = value ? 'cpu' : null
}
})
const selected = defineModel<TorchDeviceType>('device', {
const selected = defineModel<TorchDeviceType | null>('device', {
required: true
})

View File

@@ -33,6 +33,9 @@
<Message v-if="pathExists" severity="warn">
{{ $t('install.pathExists') }}
</Message>
<Message v-if="nonDefaultDrive" severity="warn">
{{ $t('install.nonDefaultDrive') }}
</Message>
</div>
<!-- System Paths Info -->
@@ -80,6 +83,7 @@ const { t } = useI18n()
const installPath = defineModel<string>('installPath', { required: true })
const pathError = defineModel<string>('pathError', { required: true })
const pathExists = ref(false)
const nonDefaultDrive = ref(false)
const appData = ref('')
const appPath = ref('')
const inputTouched = ref(false)
@@ -96,11 +100,12 @@ onMounted(async () => {
await validatePath(paths.defaultInstallPath)
})
const validatePath = async (path: string) => {
const validatePath = async (path: string | undefined) => {
try {
pathError.value = ''
pathExists.value = false
const validation = await electron.validateInstallPath(path)
nonDefaultDrive.value = false
const validation = await electron.validateInstallPath(path ?? '')
// Create a pre-formatted list of errors
if (!validation.isValid) {
@@ -111,12 +116,14 @@ const validatePath = async (path: string) => {
errors.push(`${t('install.insufficientFreeSpace')}: ${requiredGB} GB`)
}
if (validation.parentMissing) errors.push(t('install.parentMissing'))
if (validation.isOneDrive) errors.push(t('install.isOneDrive'))
if (validation.error)
errors.push(`${t('install.unhandledError')}: ${validation.error}`)
pathError.value = errors.join('\n')
}
// Display the path exists warning
if (validation.isNonDefaultDrive) nonDefaultDrive.value = true
if (validation.exists) pathExists.value = true
} catch (error) {
pathError.value = t('install.pathValidationFailed')

View File

@@ -99,7 +99,7 @@ const isValidSource = computed(
() => sourcePath.value !== '' && pathError.value === ''
)
const validateSource = async (sourcePath: string) => {
const validateSource = async (sourcePath: string | undefined) => {
if (!sourcePath) {
pathError.value = ''
return
@@ -109,7 +109,7 @@ const validateSource = async (sourcePath: string) => {
pathError.value = ''
const validation = await electron.validateComfyUISource(sourcePath)
if (!validation.isValid) pathError.value = validation.error
if (!validation.isValid) pathError.value = validation.error ?? 'ERROR'
} catch (error) {
console.error(error)
pathError.value = t('install.pathValidationFailed')

View File

@@ -50,7 +50,7 @@ import { isInChina } from '@/utils/networkUtil'
import { ValidationState, mergeValidationStates } from '@/utils/validationUtil'
const showMirrorInputs = ref(false)
const { device } = defineProps<{ device: TorchDeviceType }>()
const { device } = defineProps<{ device: TorchDeviceType | null }>()
const pythonMirror = defineModel<string>('pythonMirror', { required: true })
const pypiMirror = defineModel<string>('pypiMirror', { required: true })
const torchMirror = defineModel<string>('torchMirror', { required: true })
@@ -95,7 +95,7 @@ const mirrors = computed<[UVMirror, ModelRef<string>][]>(() =>
[
[PYTHON_MIRROR, pythonMirror],
[PYPI_MIRROR, pypiMirror],
[getTorchMirrorItem(device), torchMirror]
[getTorchMirrorItem(device ?? 'cpu'), torchMirror]
] as [UVMirror, ModelRef<string>][]
).map(([item, modelValue]) => [
userIsInChina.value ? useFallbackMirror(item) : item,

View File

@@ -1,8 +1,13 @@
<template>
<div class="relative w-full h-full">
<div
class="relative w-full h-full"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<Load3DScene
ref="load3DSceneRef"
:node="node"
:type="type"
:inputSpec="inputSpec"
:backgroundColor="backgroundColor"
:showGrid="showGrid"
:lightIntensity="lightIntensity"
@@ -25,6 +30,7 @@
@edgeThresholdChange="listenEdgeThresholdChange"
/>
<Load3DControls
:inputSpec="inputSpec"
:backgroundColor="backgroundColor"
:showGrid="showGrid"
:showPreview="showPreview"
@@ -37,7 +43,6 @@
:hasBackgroundImage="hasBackgroundImage"
:upDirection="upDirection"
:materialMode="materialMode"
:isAnimation="false"
:edgeThreshold="edgeThreshold"
@updateBackgroundImage="handleBackgroundImageUpdate"
@switchCamera="switchCamera"
@@ -49,6 +54,7 @@
@updateUpDirection="handleUpdateUpDirection"
@updateMaterialMode="handleUpdateMaterialMode"
@updateEdgeThreshold="handleUpdateEdgeThreshold"
@exportModel="handleExportModel"
/>
</div>
</template>
@@ -67,6 +73,7 @@ import {
} from '@/extensions/core/load3d/interfaces'
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComponentWidget } from '@/scripts/domWidget'
import { useToastStore } from '@/stores/toastStore'
const { widget } = defineProps<{
widget: ComponentWidget<string[]>
@@ -90,11 +97,24 @@ const backgroundImage = ref('')
const upDirection = ref<UpDirection>('original')
const materialMode = ref<MaterialMode>('original')
const edgeThreshold = ref(85)
const load3DSceneRef = ref<InstanceType<typeof Load3DScene> | null>(null)
const showPreviewButton = computed(() => {
return !type.includes('Preview')
})
const handleMouseEnter = () => {
if (load3DSceneRef.value?.load3d) {
load3DSceneRef.value.load3d.updateStatusMouseOnScene(true)
}
}
const handleMouseLeave = () => {
if (load3DSceneRef.value?.load3d) {
load3DSceneRef.value.load3d.updateStatusMouseOnScene(false)
}
}
const switchCamera = () => {
cameraType.value =
cameraType.value === 'perspective' ? 'orthographic' : 'perspective'
@@ -165,6 +185,22 @@ const handleUpdateMaterialMode = (value: MaterialMode) => {
node.properties['Material Mode'] = value
}
const handleExportModel = async (format: string) => {
if (!load3DSceneRef.value?.load3d) {
useToastStore().addAlert('No 3D scene to export')
return
}
try {
await load3DSceneRef.value.load3d.exportModel(format)
} catch (error) {
console.error('Error exporting model:', error)
useToastStore().addAlert(
`Failed to export model as ${format.toUpperCase()}`
)
}
}
const listenMaterialModeChange = (mode: MaterialMode) => {
materialMode.value = mode

View File

@@ -1,8 +1,13 @@
<template>
<div class="relative w-full h-full">
<div
class="relative w-full h-full"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<Load3DAnimationScene
ref="load3DAnimationSceneRef"
:node="node"
:type="type"
:inputSpec="inputSpec"
:backgroundColor="backgroundColor"
:showGrid="showGrid"
:lightIntensity="lightIntensity"
@@ -30,6 +35,7 @@
/>
<div class="absolute top-0 left-0 w-full h-full pointer-events-none">
<Load3DControls
:inputSpec="inputSpec"
:backgroundColor="backgroundColor"
:showGrid="showGrid"
:showPreview="showPreview"
@@ -42,7 +48,6 @@
:hasBackgroundImage="hasBackgroundImage"
:upDirection="upDirection"
:materialMode="materialMode"
:isAnimation="true"
@updateBackgroundImage="handleBackgroundImageUpdate"
@switchCamera="switchCamera"
@toggleGrid="toggleGrid"
@@ -110,6 +115,24 @@ const showPreviewButton = computed(() => {
return !type.includes('Preview')
})
const load3DAnimationSceneRef = ref<InstanceType<
typeof Load3DAnimationScene
> | null>(null)
const handleMouseEnter = () => {
const sceneRef = load3DAnimationSceneRef.value?.load3DSceneRef
if (sceneRef?.load3d) {
sceneRef.load3d.updateStatusMouseOnScene(true)
}
}
const handleMouseLeave = () => {
const sceneRef = load3DAnimationSceneRef.value?.load3DSceneRef
if (sceneRef?.load3d) {
sceneRef.load3d.updateStatusMouseOnScene(false)
}
}
const switchCamera = () => {
cameraType.value =
cameraType.value === 'perspective' ? 'orthographic' : 'perspective'

View File

@@ -1,7 +1,7 @@
<template>
<Load3DScene
:node="node"
:type="type"
:inputSpec="inputSpec"
:backgroundColor="backgroundColor"
:showGrid="showGrid"
:lightIntensity="lightIntensity"
@@ -26,16 +26,17 @@
import { ref, watch } from 'vue'
import Load3DScene from '@/components/load3d/Load3DScene.vue'
import Load3dAnimation from '@/extensions/core/load3d/Load3dAnimation'
import {
CameraType,
Load3DAnimationNodeType,
MaterialMode,
UpDirection
} from '@/extensions/core/load3d/interfaces'
import { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
const props = defineProps<{
node: any
type: Load3DAnimationNodeType
inputSpec: CustomInputSpec
backgroundColor: string
showGrid: boolean
lightIntensity: number
@@ -63,7 +64,7 @@ const upDirection = ref(props.upDirection)
const materialMode = ref(props.materialMode)
const showFOVButton = ref(props.showFOVButton)
const showLightIntensityButton = ref(props.showLightIntensityButton)
const load3DSceneRef = ref(null)
const load3DSceneRef = ref<InstanceType<typeof Load3DScene> | null>(null)
watch(
() => props.cameraType,
@@ -124,21 +125,24 @@ watch(
watch(
() => props.playing,
(newValue) => {
load3DSceneRef.value.load3d.toggleAnimation(newValue)
const load3d = load3DSceneRef.value?.load3d as Load3dAnimation | null
load3d?.toggleAnimation(newValue)
}
)
watch(
() => props.selectedSpeed,
(newValue) => {
load3DSceneRef.value.load3d.setAnimationSpeed(newValue)
const load3d = load3DSceneRef.value?.load3d as Load3dAnimation | null
load3d?.setAnimationSpeed(newValue)
}
)
watch(
() => props.selectedAnimation,
(newValue) => {
load3DSceneRef.value.load3d.updateSelectedAnimation(newValue)
const load3d = load3DSceneRef.value?.load3d as Load3dAnimation | null
load3d?.updateSelectedAnimation(newValue)
}
)
@@ -183,4 +187,8 @@ const animationListeners = {
emit('animationListChange', newValue)
}
}
defineExpose({
load3DSceneRef
})
</script>

View File

@@ -16,14 +16,14 @@
>
<div class="flex flex-col">
<Button
v-for="(label, category) in categories"
v-for="category in availableCategories"
:key="category"
class="p-button-text w-full flex items-center justify-start"
:class="{ 'bg-gray-600': activeCategory === category }"
@click="selectCategory(category)"
>
<i :class="getCategoryIcon(category)"></i>
<span class="text-white">{{ t(label) }}</span>
<span class="text-white">{{ t(categoryLabels[category]) }}</span>
</Button>
</div>
</div>
@@ -43,9 +43,9 @@
<ModelControls
v-if="activeCategory === 'model'"
:inputSpec="inputSpec"
:upDirection="upDirection"
:materialMode="materialMode"
:isAnimation="isAnimation"
:edgeThreshold="edgeThreshold"
@updateUpDirection="handleUpdateUpDirection"
@updateMaterialMode="handleUpdateMaterialMode"
@@ -70,6 +70,12 @@
@updateLightIntensity="handleUpdateLightIntensity"
ref="lightControlsRef"
/>
<ExportControls
v-if="activeCategory === 'export'"
@exportModel="handleExportModel"
ref="exportControlsRef"
/>
</div>
<div v-if="showPreviewButton">
<Button class="p-button-rounded p-button-text" @click="togglePreview">
@@ -89,9 +95,10 @@
<script setup lang="ts">
import { Tooltip } from 'primevue'
import Button from 'primevue/button'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import CameraControls from '@/components/load3d/controls/CameraControls.vue'
import ExportControls from '@/components/load3d/controls/ExportControls.vue'
import LightControls from '@/components/load3d/controls/LightControls.vue'
import ModelControls from '@/components/load3d/controls/ModelControls.vue'
import SceneControls from '@/components/load3d/controls/SceneControls.vue'
@@ -101,10 +108,12 @@ import {
UpDirection
} from '@/extensions/core/load3d/interfaces'
import { t } from '@/i18n'
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
const vTooltip = Tooltip
const props = defineProps<{
inputSpec: CustomInputSpec
backgroundColor: string
showGrid: boolean
showPreview: boolean
@@ -117,24 +126,34 @@ const props = defineProps<{
hasBackgroundImage?: boolean
upDirection: UpDirection
materialMode: MaterialMode
isAnimation: boolean
edgeThreshold?: number
}>()
const isMenuOpen = ref(false)
const activeCategory = ref<'scene' | 'model' | 'camera' | 'light'>('scene')
const categories = {
const activeCategory = ref<string>('scene')
const categoryLabels: Record<string, string> = {
scene: 'load3d.scene',
model: 'load3d.model',
camera: 'load3d.camera',
light: 'load3d.light'
light: 'load3d.light',
export: 'load3d.export'
}
const availableCategories = computed(() => {
const baseCategories = ['scene', 'model', 'camera', 'light']
if (!props.inputSpec.isAnimation) {
return [...baseCategories, 'export']
}
return baseCategories
})
const toggleMenu = () => {
isMenuOpen.value = !isMenuOpen.value
}
const selectCategory = (category: 'scene' | 'model' | 'camera' | 'light') => {
const selectCategory = (category: string) => {
activeCategory.value = category
isMenuOpen.value = false
}
@@ -144,8 +163,10 @@ const getCategoryIcon = (category: string) => {
scene: 'pi pi-image',
model: 'pi pi-box',
camera: 'pi pi-camera',
light: 'pi pi-sun'
light: 'pi pi-sun',
export: 'pi pi-download'
}
// @ts-expect-error fixme ts strict error
return `${icons[category]} text-white text-lg`
}
@@ -160,6 +181,7 @@ const emit = defineEmits<{
(e: 'updateUpDirection', direction: UpDirection): void
(e: 'updateMaterialMode', mode: MaterialMode): void
(e: 'updateEdgeThreshold', value: number): void
(e: 'exportModel', format: string): void
}>()
const backgroundColor = ref(props.backgroundColor)
@@ -216,6 +238,10 @@ const handleUpdateFOV = (value: number) => {
emit('updateFOV', value)
}
const handleExportModel = (format: string) => {
emit('exportModel', format)
}
const closeSlider = (e: MouseEvent) => {
const target = e.target as HTMLElement

View File

@@ -13,17 +13,16 @@ import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dAnimation from '@/extensions/core/load3d/Load3dAnimation'
import {
CameraType,
Load3DAnimationNodeType,
Load3DNodeType,
MaterialMode,
UpDirection
} from '@/extensions/core/load3d/interfaces'
import { t } from '@/i18n'
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useLoad3dService } from '@/services/load3dService'
const props = defineProps<{
node: LGraphNode
type: Load3DNodeType | Load3DAnimationNodeType
inputSpec: CustomInputSpec
backgroundColor: string
showGrid: boolean
lightIntensity: number
@@ -60,7 +59,13 @@ const eventConfig = {
modelLoadingEnd: () => loadingOverlayRef.value?.endLoading(),
materialLoadingStart: () =>
loadingOverlayRef.value?.startLoading(t('load3d.switchingMaterialMode')),
materialLoadingEnd: () => loadingOverlayRef.value?.endLoading()
materialLoadingEnd: () => loadingOverlayRef.value?.endLoading(),
exportLoadingStart: (message: string) => {
loadingOverlayRef.value?.startLoading(message || t('load3d.exportingModel'))
},
exportLoadingEnd: () => {
loadingOverlayRef.value?.endLoading()
}
} as const
watchEffect(() => {
@@ -95,6 +100,7 @@ watch(
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value)
// @ts-expect-error fixme ts strict error
rawLoad3d.setEdgeThreshold(newValue)
}
}
@@ -132,8 +138,9 @@ const handleEvents = (action: 'add' | 'remove') => {
onMounted(() => {
load3d.value = useLoad3dService().registerLoad3d(
node.value as LGraphNode,
// @ts-expect-error fixme ts strict error
container.value,
props.type
props.inputSpec
)
handleEvents('add')
})

View File

@@ -15,19 +15,22 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { nextTick, ref } from 'vue'
import { t } from '@/i18n'
const modelLoading = ref(false)
const loadingMessage = ref('')
const startLoading = (message?: string) => {
modelLoading.value = true
const startLoading = async (message?: string) => {
loadingMessage.value = message || t('load3d.loadingModel')
modelLoading.value = true
await nextTick()
}
const endLoading = () => {
const endLoading = async () => {
await new Promise((resolve) => setTimeout(resolve, 100))
modelLoading.value = false
}

View File

@@ -0,0 +1,81 @@
<template>
<div class="flex flex-col">
<div class="relative show-export-formats">
<Button
class="p-button-rounded p-button-text"
@click="toggleExportFormats"
>
<i
class="pi pi-download text-white text-lg"
v-tooltip.right="{
value: t('load3d.exportModel'),
showDelay: 300
}"
></i>
</Button>
<div
v-show="showExportFormats"
class="absolute left-12 top-0 bg-black bg-opacity-50 rounded-lg shadow-lg"
>
<div class="flex flex-col">
<Button
v-for="format in exportFormats"
:key="format.value"
class="p-button-text text-white"
@click="exportModel(format.value)"
>
{{ format.label }}
</Button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Tooltip } from 'primevue'
import Button from 'primevue/button'
import { onMounted, onUnmounted, ref } from 'vue'
import { t } from '@/i18n'
const vTooltip = Tooltip
const emit = defineEmits<{
(e: 'exportModel', format: string): void
}>()
const showExportFormats = ref(false)
const exportFormats = [
{ label: 'GLB', value: 'glb' },
{ label: 'OBJ', value: 'obj' },
{ label: 'STL', value: 'stl' }
]
const toggleExportFormats = () => {
showExportFormats.value = !showExportFormats.value
}
const exportModel = (format: string) => {
emit('exportModel', format)
showExportFormats.value = false
}
const closeExportFormatsList = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (!target.closest('.show-export-formats')) {
showExportFormats.value = false
}
}
onMounted(() => {
document.addEventListener('click', closeExportFormatsList)
})
onUnmounted(() => {
document.removeEventListener('click', closeExportFormatsList)
})
</script>

View File

@@ -100,13 +100,14 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { MaterialMode, UpDirection } from '@/extensions/core/load3d/interfaces'
import { t } from '@/i18n'
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
const vTooltip = Tooltip
const props = defineProps<{
inputSpec: CustomInputSpec
upDirection: UpDirection
materialMode: MaterialMode
isAnimation: boolean
edgeThreshold?: number
}>()
@@ -141,7 +142,7 @@ const materialModes = computed(() => {
//'depth' disable for now
]
if (!props.isAnimation) {
if (!props.inputSpec.isAnimation && !props.inputSpec.isPreview) {
modes.push('lineart')
}
@@ -169,6 +170,7 @@ watch(
watch(
() => props.edgeThreshold,
(newValue) => {
// @ts-expect-error fixme ts strict error
edgeThreshold.value = newValue
}
)

View File

@@ -74,8 +74,8 @@ const description = computed(() =>
)
// Use a minimum run time to ensure tasks "feel" like they have run
const reactiveLoading = computed(() => runner.value.refreshing)
const reactiveExecuting = computed(() => runner.value.executing)
const reactiveLoading = computed(() => !!runner.value.refreshing)
const reactiveExecuting = computed(() => !!runner.value.executing)
const isLoading = useMinLoadingDurationRef(reactiveLoading, 250)
const isExecuting = useMinLoadingDurationRef(reactiveExecuting, 250)

View File

@@ -71,16 +71,16 @@ const severity = computed<VueSeverity>(() =>
)
// Use a minimum run time to ensure tasks "feel" like they have run
const reactiveLoading = computed(() => runner.value.refreshing)
const reactiveExecuting = computed(() => runner.value.executing)
const reactiveLoading = computed(() => !!runner.value.refreshing)
const reactiveExecuting = computed(() => !!runner.value.executing)
const isLoading = useMinLoadingDurationRef(reactiveLoading, 250)
const isExecuting = useMinLoadingDurationRef(reactiveExecuting, 250)
// Popover
const infoPopover = ref()
const infoPopover = ref<InstanceType<typeof Popover> | null>(null)
const toggle = (event: Event) => {
infoPopover.value.toggle(event)
infoPopover.value?.toggle(event)
}
</script>

View File

@@ -35,7 +35,7 @@ let xterm: Terminal | null = null
// Created and destroyed with the Drawer - contents copied from hidden buffer
const terminalCreated = (
{ terminal, useAutoSize }: ReturnType<typeof useTerminal>,
root: Ref<HTMLElement>
root: Ref<HTMLElement | undefined>
) => {
xterm = terminal
useAutoSize({ root, autoRows: true, autoCols: true })

View File

@@ -35,7 +35,7 @@
</Dialog>
<AutoCompletePlus
:model-value="props.filters"
:model-value="filters"
class="comfy-vue-node-search-box z-10 flex-grow"
scrollHeight="40vh"
:placeholder="placeholder"
@@ -99,15 +99,10 @@ const enableNodePreview = computed(() =>
settingStore.get('Comfy.NodeSearchBoxImpl.NodePreview')
)
const props = withDefaults(
defineProps<{
filters: FilterAndValue[]
searchLimit?: number
}>(),
{
searchLimit: 64
}
)
const { filters, searchLimit = 64 } = defineProps<{
filters: FilterAndValue[]
searchLimit?: number
}>()
const nodeSearchFilterVisible = ref(false)
const inputId = `comfy-vue-node-search-box-input-${Math.random()}`
@@ -115,19 +110,19 @@ const suggestions = ref<ComfyNodeDefImpl[]>([])
const hoveredSuggestion = ref<ComfyNodeDefImpl | null>(null)
const currentQuery = ref('')
const placeholder = computed(() => {
return props.filters.length === 0 ? t('g.searchNodes') + '...' : ''
return filters.length === 0 ? t('g.searchNodes') + '...' : ''
})
const nodeDefStore = useNodeDefStore()
const nodeFrequencyStore = useNodeFrequencyStore()
const search = (query: string) => {
const queryIsEmpty = query === '' && props.filters.length === 0
const queryIsEmpty = query === '' && filters.length === 0
currentQuery.value = query
suggestions.value = queryIsEmpty
? nodeFrequencyStore.topNodeDefs
: [
...nodeDefStore.nodeSearchService.searchNode(query, props.filters, {
limit: props.searchLimit
...nodeDefStore.nodeSearchService.searchNode(query, filters, {
limit: searchLimit
})
]
}

View File

@@ -35,7 +35,8 @@
<script setup lang="ts">
import { LiteGraph } from '@comfyorg/litegraph'
import type {
ConnectingLink,
INodeInputSlot,
INodeOutputSlot,
LiteGraphCanvasEvent,
Vector2
} from '@comfyorg/litegraph'
@@ -88,15 +89,33 @@ const closeDialog = () => {
}
const addNode = (nodeDef: ComfyNodeDefImpl) => {
const node = litegraphService.addNodeOnGraph(nodeDef, {
pos: getNewNodeLocation()
})
type InputOrOutputSlot = INodeInputSlot | INodeOutputSlot | undefined
const eventDetail = triggerEvent.value?.detail
if (eventDetail && eventDetail.subType === 'empty-release') {
eventDetail.linkReleaseContext.links.forEach((link: ConnectingLink) => {
ConnectingLinkImpl.createFromPlainObject(link).connectTo(node)
if (nodeDef.name === 'Reroute') {
if (eventDetail && eventDetail.subType === 'empty-release') {
for (const link of eventDetail.linkReleaseContext.links) {
const slot = (link.output ?? link.input) as InputOrOutputSlot
if (!slot) continue
link.node.connectFloatingReroute(getNewNodeLocation(), slot)
link.node.setDirtyCanvas(false, true)
}
} else {
// TODO: Should hide the reroute from the search menu, if it's not an "empty release" (dragging new link)
}
} else {
const node = litegraphService.addNodeOnGraph(nodeDef, {
pos: getNewNodeLocation()
})
const eventDetail = triggerEvent.value?.detail
if (eventDetail && eventDetail.subType === 'empty-release') {
eventDetail.linkReleaseContext.links.forEach((link) => {
// @ts-expect-error Type overwritten by computed public interface
ConnectingLinkImpl.createFromPlainObject(link).connectTo(node)
})
}
}
// TODO: This is not robust timing-wise.
@@ -121,6 +140,7 @@ const showSearchBox = (e: LiteGraphCanvasEvent) => {
showNewSearchBox(e)
}
} else {
// @ts-expect-error fixme ts strict error
canvasStore.canvas.showSearchBox(detail.originalEvent)
}
}
@@ -137,7 +157,9 @@ const showNewSearchBox = (e: LiteGraphCanvasEvent) => {
const filter = nodeDefStore.nodeSearchService.getFilterById(
firstLink.releaseSlotType
)
// @ts-expect-error fixme ts strict error
const dataType = firstLink.type.toString()
// @ts-expect-error fixme ts strict error
addFilter([filter, dataType])
}
@@ -180,6 +202,7 @@ const showContextMenu = (e: LiteGraphCanvasEvent) => {
slotTo: firstLink.input,
afterRerouteId: firstLink.afterRerouteId
}
// @ts-expect-error fixme ts strict error
canvasStore.canvas.showConnectionMenu({
...connectionOptions,
...commonOptions

View File

@@ -46,7 +46,6 @@
<script setup lang="ts">
import Button from 'primevue/button'
import type { TreeNode } from 'primevue/treenode'
import { computed, nextTick, onMounted, ref, toRef, watch } from 'vue'
import SearchBox from '@/components/common/SearchBox.vue'
@@ -64,6 +63,7 @@ import {
} from '@/stores/modelStore'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import { useSettingStore } from '@/stores/settingStore'
import type { TreeNode } from '@/types/treeExplorerTypes'
import type { TreeExplorerNode } from '@/types/treeExplorerTypes'
import { isElectron } from '@/utils/envUtil'
import { buildTree } from '@/utils/treeUtil'
@@ -135,24 +135,27 @@ const renderedRoot = computed<TreeExplorerNode<ModelOrFolder>>(() => {
return 'pi pi-folder'
},
getBadgeText() {
// Return null to apply default badge text
// Return undefined to apply default badge text
// Return empty string to hide badge
if (!folder) {
return null
return
}
return folder.state === ResourceState.Loaded ? null : ''
return folder.state === ResourceState.Loaded ? undefined : ''
},
children,
draggable: node.leaf,
handleClick(e: MouseEvent) {
if (this.leaf) {
// @ts-expect-error fixme ts strict error
const provider = modelToNodeStore.getNodeProvider(model.directory)
if (provider) {
const node = useLitegraphService().addNodeOnGraph(provider.nodeDef)
// @ts-expect-error fixme ts strict error
const widget = node.widgets.find(
(widget) => widget.name === provider.key
)
if (widget) {
// @ts-expect-error fixme ts strict error
widget.value = model.file_name
}
}

View File

@@ -26,7 +26,7 @@
class="node-lib-search-box p-2 2xl:p-4"
v-model:modelValue="searchQuery"
@search="handleSearch"
@show-filter="($event) => searchFilter.toggle($event)"
@show-filter="($event) => searchFilter?.toggle($event)"
@remove-filter="onRemoveFilter"
:placeholder="$t('g.searchNodes') + '...'"
filter-icon="pi pi-filter"
@@ -65,7 +65,6 @@
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import Popover from 'primevue/popover'
import type { TreeNode } from 'primevue/treenode'
import { Ref, computed, h, nextTick, ref, render } from 'vue'
import SearchBox from '@/components/common/SearchBox.vue'
@@ -84,6 +83,7 @@ import {
buildNodeDefTree,
useNodeDefStore
} from '@/stores/nodeDefStore'
import type { TreeNode } from '@/types/treeExplorerTypes'
import type { TreeExplorerNode } from '@/types/treeExplorerTypes'
import { sortedTree } from '@/utils/treeUtil'
@@ -97,7 +97,7 @@ const { expandNode, toggleNodeOnEvent } = useTreeExpansion(expandedKeys)
const nodeBookmarkTreeExplorerRef = ref<InstanceType<
typeof NodeBookmarkTreeExplorer
> | null>(null)
const searchFilter = ref(null)
const searchFilter = ref<InstanceType<typeof Popover> | null>(null)
const alphabeticalSort = ref(false)
const searchQuery = ref<string>('')
@@ -132,6 +132,7 @@ const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
},
handleClick(e: MouseEvent) {
if (this.leaf) {
// @ts-expect-error fixme ts strict error
useLitegraphService().addNodeOnGraph(this.data)
} else {
toggleNodeOnEvent(e, this)
@@ -173,6 +174,7 @@ const handleSearch = (query: string) => {
)
nextTick(() => {
// @ts-expect-error fixme ts strict error
expandNode(filteredRoot.value)
})
}
@@ -189,6 +191,7 @@ const onAddFilter = (filterAndValue: FilterAndValue) => {
handleSearch(searchQuery.value)
}
// @ts-expect-error fixme ts strict error
const onRemoveFilter = (filterAndValue) => {
const index = filters.value.findIndex((f) => f === filterAndValue)
if (index !== -1) {

View File

@@ -194,7 +194,7 @@ const confirmRemoveAll = (event: Event) => {
})
}
const menu = ref(null)
const menu = ref<InstanceType<typeof ContextMenu> | null>(null)
const menuTargetTask = ref<TaskItemImpl | null>(null)
const menuTargetNode = ref<ComfyNode | null>(null)
const menuItems = computed<MenuItem[]>(() => [
@@ -213,7 +213,11 @@ const menuItems = computed<MenuItem[]>(() => [
{
label: t('g.goToNode'),
icon: 'pi pi-arrow-circle-right',
command: () => useLitegraphService().goToNode(menuTargetNode.value?.id),
command: () => {
if (!menuTargetNode.value) return
useLitegraphService().goToNode(menuTargetNode.value.id)
},
visible: !!menuTargetNode.value
}
])
@@ -225,7 +229,7 @@ const handleContextMenu = ({
}: {
task: TaskItemImpl
event: Event
node?: ComfyNode
node: ComfyNode | null
}) => {
menuTargetTask.value = task
menuTargetNode.value = node

View File

@@ -34,6 +34,7 @@
<TreeExplorer
:root="renderTreeNode(openWorkflowsTree, WorkflowTreeType.Open)"
:selectionKeys="selectionKeys"
v-model:expandedKeys="dummyExpandedKeys"
>
<template #node="{ node }">
<TreeExplorerTreeNode :node="node">
@@ -75,6 +76,7 @@
)
"
:selectionKeys="selectionKeys"
v-model:expandedKeys="dummyExpandedKeys"
>
<template #node="{ node }">
<WorkflowTreeLeaf :node="node" />
@@ -123,7 +125,6 @@
<script setup lang="ts">
import Button from 'primevue/button'
import ConfirmDialog from 'primevue/confirmdialog'
import type { TreeNode } from 'primevue/treenode'
import { computed, nextTick, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -143,6 +144,7 @@ import {
} from '@/stores/workflowStore'
import { ComfyWorkflow } from '@/stores/workflowStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { TreeNode } from '@/types/treeExplorerTypes'
import { TreeExplorerNode } from '@/types/treeExplorerTypes'
import { appendJsonExt } from '@/utils/formatUtil'
import { buildTree, sortedTree } from '@/utils/treeUtil'
@@ -179,6 +181,7 @@ const workspaceStore = useWorkspaceStore()
const { t } = useI18n()
const expandedKeys = ref<Record<string, boolean>>({})
const { expandNode, toggleNodeOnEvent } = useTreeExpansion(expandedKeys)
const dummyExpandedKeys = ref<Record<string, boolean>>({})
const handleCloseWorkflow = (workflow?: ComfyWorkflow) => {
if (workflow) {

View File

@@ -1,9 +1,9 @@
<template>
<div class="flex flex-col">
<div>
{{ getDownloadLabel(download.savePath) }}
{{ getDownloadLabel(download.savePath ?? '') }}
</div>
<div v-if="['cancelled', 'error'].includes(download.status)">
<div v-if="['cancelled', 'error'].includes(download.status ?? '')">
<Chip
class="h-6 text-sm font-light bg-red-700 mt-2"
removable
@@ -14,15 +14,17 @@
</div>
<div
class="mt-2 flex flex-row items-center gap-2"
v-if="['in_progress', 'paused', 'completed'].includes(download.status)"
v-if="
['in_progress', 'paused', 'completed'].includes(download.status ?? '')
"
>
<!-- Temporary fix for issue when % only comes into view only if the progress bar is large enough
https://comfy-organization.slack.com/archives/C07H3GLKDPF/p1731551013385499
-->
<ProgressBar
class="flex-1"
:value="Number((download.progress * 100).toFixed(1))"
:show-value="download.progress > 0.1"
:value="Number(((download.progress ?? 0) * 100).toFixed(1))"
:show-value="(download.progress ?? 0) > 0.1"
/>
<Button
@@ -51,7 +53,7 @@
rounded
severity="danger"
@click="triggerCancelDownload"
v-if="['in_progress', 'paused'].includes(download.status)"
v-if="['in_progress', 'paused'].includes(download.status ?? '')"
icon="pi pi-times-circle"
v-tooltip.top="t('electronFileDownload.cancel')"
/>
@@ -79,7 +81,7 @@ const props = defineProps<{
}>()
const getDownloadLabel = (savePath: string) => {
let parts = (savePath ?? '').split('/')
let parts = savePath.split('/')
parts = parts.length === 1 ? parts[0].split('\\') : parts
const name = parts.pop()
const dir = parts.pop()

View File

@@ -41,10 +41,11 @@ const props = defineProps<{
node: RenderedTreeExplorerNode<ComfyModelDef>
}>()
const modelDef = computed(() => props.node.data)
// Note: The leaf node should always have a model definition on node.data.
const modelDef = computed<ComfyModelDef>(() => props.node.data!)
const modelPreviewUrl = computed(() => {
if (modelDef.value?.image) {
if (modelDef.value.image) {
return modelDef.value.image
}
const folder = modelDef.value.directory
@@ -69,6 +70,8 @@ const sidebarLocation = computed<'left' | 'right'>(() =>
const handleModelHover = async () => {
const hoverTarget = modelContentElement.value
if (!hoverTarget) return
const targetRect = hoverTarget.getBoundingClientRect()
const previewHeight = previewRef.value?.$el.offsetHeight || 0
@@ -87,8 +90,8 @@ const handleModelHover = async () => {
modelDef.value.load()
}
const container = ref<HTMLElement | null>(null)
const modelContentElement = ref<HTMLElement | null>(null)
const container = ref<HTMLElement | undefined>()
const modelContentElement = ref<HTMLElement | undefined>()
const isHovered = ref(false)
const showPreview = computed(() => {
@@ -114,7 +117,8 @@ const handleMouseLeave = () => {
isHovered.value = false
}
onMounted(() => {
modelContentElement.value = container.value?.closest('.p-tree-node-content')
modelContentElement.value =
container.value?.closest('.p-tree-node-content') ?? undefined
modelContentElement.value?.addEventListener('mouseenter', handleMouseEnter)
modelContentElement.value?.addEventListener('mouseleave', handleMouseLeave)
modelDef.value.load()

View File

@@ -22,7 +22,6 @@
</template>
<script setup lang="ts">
import type { TreeNode } from 'primevue/treenode'
import { computed, h, nextTick, ref, render, watch } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -35,6 +34,7 @@ import { useTreeExpansion } from '@/composables/useTreeExpansion'
import { useLitegraphService } from '@/services/litegraphService'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { TreeNode } from '@/types/treeExplorerTypes'
import type {
RenderedTreeExplorerNode,
TreeExplorerDragAndDropData,
@@ -97,6 +97,8 @@ const extraMenuItems = (
label: t('g.customize'),
icon: 'pi pi-palette',
command: () => {
if (!menuTargetNode.data) return
const customization =
nodeBookmarkStore.bookmarksCustomization[menuTargetNode.data.nodePath]
initialIcon.value =
@@ -159,15 +161,19 @@ const renderedBookmarkedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(
handleDrop(data: TreeExplorerDragAndDropData<ComfyNodeDefImpl>) {
const nodeDefToAdd = data.data.data
// Remove bookmark if the source is the top level bookmarked node.
// @ts-expect-error fixme ts strict error
if (nodeBookmarkStore.isBookmarked(nodeDefToAdd)) {
// @ts-expect-error fixme ts strict error
nodeBookmarkStore.toggleBookmark(nodeDefToAdd)
}
const folderNodeDef = node.data as ComfyNodeDefImpl
// @ts-expect-error fixme ts strict error
const nodePath = folderNodeDef.category + '/' + nodeDefToAdd.name
nodeBookmarkStore.addBookmark(nodePath)
},
handleClick(e: MouseEvent) {
if (this.leaf) {
// @ts-expect-error fixme ts strict error
useLitegraphService().addNodeOnGraph(this.data)
} else {
toggleNodeOnEvent(e, node)
@@ -183,6 +189,7 @@ const renderedBookmarkedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(
}
},
handleDelete() {
// @ts-expect-error fixme ts strict error
nodeBookmarkStore.deleteBookmarkFolder(this.data)
}
})

View File

@@ -52,7 +52,7 @@ onMounted(() => {
const updateIconColor = () => {
if (iconElement.value && customization.value) {
iconElement.value.style.color = customization.value.color
iconElement.value.style.color = customization.value.color ?? ''
}
}
@@ -64,6 +64,7 @@ onUnmounted(() => {
const expandedKeys = inject(InjectKeyExpandedKeys)
const handleItemDrop = (node: RenderedTreeExplorerNode) => {
if (!expandedKeys) return
expandedKeys.value[node.key] = true
}
</script>

View File

@@ -56,7 +56,8 @@ const props = defineProps<{
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>
}>()
const nodeDef = computed(() => props.node.data)
// Note: node.data should be present for leaf nodes.
const nodeDef = computed(() => props.node.data!)
const nodeBookmarkStore = useNodeBookmarkStore()
const isBookmarked = computed(() =>
nodeBookmarkStore.isBookmarked(nodeDef.value)
@@ -79,6 +80,8 @@ const nodePreviewStyle = ref<CSSProperties>({
const handleNodeHover = async () => {
const hoverTarget = nodeContentElement.value
if (!hoverTarget) return
const targetRect = hoverTarget.getBoundingClientRect()
const previewHeight = previewRef.value?.$el.offsetHeight || 0
@@ -107,7 +110,8 @@ const handleMouseLeave = () => {
isHovered.value = false
}
onMounted(() => {
nodeContentElement.value = container.value?.closest('.p-tree-node-content')
nodeContentElement.value =
container.value?.closest('.p-tree-node-content') ?? null
nodeContentElement.value?.addEventListener('mouseenter', handleMouseEnter)
nodeContentElement.value?.addEventListener('mouseleave', handleMouseLeave)
})

View File

@@ -8,7 +8,7 @@
"
>
<ResultItem
v-if="flatOutputs.length"
v-if="flatOutputs.length && coverResult"
:result="coverResult"
@preview="handlePreview"
/>
@@ -42,7 +42,12 @@
:label="`${node?.type} (#${node?.id})`"
link
size="small"
@click="litegraphService.goToNode(node?.id)"
@click="
() => {
if (!node) return
litegraphService.goToNode(node.id)
}
"
/>
</Tag>
<Tag :severity="taskTagSeverity(task.displayStatus)">
@@ -95,7 +100,7 @@ const coverResult = flatOutputs.length
const node: ComfyNode | null =
flatOutputs.length && props.task.workflow
? props.task.workflow.nodes.find(
(n: ComfyNode) => n.id == coverResult.nodeId
(n: ComfyNode) => n.id == coverResult?.nodeId
) ?? null
: null
const progressPreviewBlobUrl = ref('')
@@ -103,7 +108,7 @@ const progressPreviewBlobUrl = ref('')
const emit = defineEmits<{
(
e: 'contextmenu',
value: { task: TaskItemImpl; event: MouseEvent; node?: ComfyNode }
value: { task: TaskItemImpl; event: MouseEvent; node: ComfyNode | null }
): void
(e: 'preview', value: TaskItemImpl): void
(e: 'task-output-length-clicked', value: TaskItemImpl): void

View File

@@ -1,82 +0,0 @@
// @ts-strict-ignore
// Disabled because of https://github.com/Comfy-Org/ComfyUI_frontend/issues/1184
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import Galleria from 'primevue/galleria'
import { beforeEach, describe, expect, it } from 'vitest'
import ComfyImage from '@/components/common/ComfyImage.vue'
import { ResultItemImpl } from '@/stores/queueStore'
import ResultGallery from '../ResultGallery.vue'
type ResultGalleryProps = typeof ResultGallery.__props
describe('ResultGallery', () => {
let mockResultItem: ResultItemImpl
beforeEach(() => {
mockResultItem = new ResultItemImpl({
filename: 'test.jpg',
type: 'images',
nodeId: 1,
mediaType: 'images'
})
})
const mountResultGallery = (props: ResultGalleryProps, options = {}) => {
return mount(ResultGallery, {
global: {
plugins: [PrimeVue],
components: { Galleria, ComfyImage }
},
props,
...options
})
}
const clickElement = async (element: Element) => {
element.dispatchEvent(new MouseEvent('mousedown'))
element.dispatchEvent(new MouseEvent('mouseup'))
}
it('is dismissed when overlay mask is clicked', async () => {
const wrapper = mountResultGallery({
activeIndex: 0,
allGalleryItems: [mockResultItem]
})
wrapper.vm.galleryVisible = true
await wrapper.vm.$nextTick()
expect(wrapper.findComponent(Galleria).exists()).toBe(true)
expect(wrapper.vm.galleryVisible).toBe(true)
// Since Galleria uses teleport, we need to query the mask in the global document
const mask = document.querySelector('[data-mask]')
expect(mask).not.toBeNull()
// Click the overlay mask to dismiss the gallery
await clickElement(mask)
await wrapper.vm.$nextTick()
expect(wrapper.vm.galleryVisible).toBe(false)
})
it('is not dismissed when gallery is clicked', async () => {
const wrapper = mountResultGallery({
activeIndex: 0,
allGalleryItems: [mockResultItem]
})
wrapper.vm.galleryVisible = true
await wrapper.vm.$nextTick()
expect(wrapper.findComponent(Galleria).exists()).toBe(true)
expect(wrapper.vm.galleryVisible).toBe(true)
// Since Galleria uses teleport, we need to query the mask in the global document
const gallery = document.querySelector('.p-galleria-content')
expect(gallery).not.toBeNull()
// The gallery should not be dismissed when the gallery itself is clicked
await clickElement(gallery)
await wrapper.vm.$nextTick()
expect(wrapper.vm.galleryVisible).toBe(true)
})
})

View File

@@ -20,12 +20,12 @@ import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
import { ComfyWorkflow, useWorkflowBookmarkStore } from '@/stores/workflowStore'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
const props = defineProps<{
const { node } = defineProps<{
node: RenderedTreeExplorerNode<ComfyWorkflow>
}>()
const workflowBookmarkStore = useWorkflowBookmarkStore()
const isBookmarked = computed(() =>
workflowBookmarkStore.isBookmarked(props.node.data.path)
const isBookmarked = computed(
() => node.data && workflowBookmarkStore.isBookmarked(node.data.path)
)
</script>

View File

@@ -90,7 +90,7 @@ const { isReady } = useAsyncState(
null
)
const selectedTab = ref<WorkflowTemplates | null>()
const selectedTab = ref<WorkflowTemplates | null>(null)
const selectFirstTab = () => {
const firstTab = workflowTemplatesStore.groupedTemplates[0].modules[0]
handleTabSelection(firstTab)
@@ -118,7 +118,7 @@ const loadWorkflow = async (id: string) => {
workflowLoading.value = id
let json
if (selectedTab.value.moduleName === 'default') {
if (selectedTab.value?.moduleName === 'default') {
// Default templates provided by frontend are served on this separate endpoint
json = await fetch(api.fileURL(`/templates/${id}.json`)).then((r) =>
r.json()
@@ -126,13 +126,13 @@ const loadWorkflow = async (id: string) => {
} else {
json = await fetch(
api.apiURL(
`/workflow_templates/${selectedTab.value.moduleName}/${id}.json`
`/workflow_templates/${selectedTab.value?.moduleName}/${id}.json`
)
).then((r) => r.json())
}
useDialogStore().closeDialog()
const workflowName =
selectedTab.value.moduleName === 'default'
selectedTab.value?.moduleName === 'default'
? t(`templateWorkflows.template.${id}`, id)
: id
await app.loadGraphData(json, true, true, workflowName)

View File

@@ -20,7 +20,7 @@ import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
defineProps<{
src: string
alt: string
hoverZoom?: number
hoverZoom: number
isHovered?: boolean
}>()
</script>

View File

@@ -59,7 +59,9 @@ function updateToastPosition() {
document.getElementById('dynamic-toast-style') || createStyleElement()
const rect = document
.querySelector('.graph-canvas-container')
.getBoundingClientRect()
?.getBoundingClientRect()
if (!rect) return
styleElement.textContent = `
.p-toast.p-component.p-toast-top-right {
top: ${rect.top + 20}px !important;

Some files were not shown because too many files have changed in this diff Show More