mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-03 03:44:07 +00:00
Compare commits
58 Commits
manager/re
...
native-rer
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db11580668 | ||
|
|
22a1200bdf | ||
|
|
ee20b63bc1 | ||
|
|
0752e8b986 | ||
|
|
b234a68cf8 | ||
|
|
0863fda6a4 | ||
|
|
8530406c3e | ||
|
|
f7bfb6ec57 | ||
|
|
830933e78f | ||
|
|
65693ed2be | ||
|
|
ed153dccd9 | ||
|
|
fc7ed1bf09 | ||
|
|
4dad89369a | ||
|
|
5b730517a3 | ||
|
|
af0bf05883 | ||
|
|
d9e62ff860 | ||
|
|
d9ae6cb395 | ||
|
|
b162963593 | ||
|
|
c34cc301f1 | ||
|
|
afdb94f12f | ||
|
|
cc8dc3dbfb | ||
|
|
42d99fc37e | ||
|
|
1f03984d12 | ||
|
|
d49815fcb4 | ||
|
|
4899a1d8f6 | ||
|
|
71444d8c69 | ||
|
|
867ed4c1d7 | ||
|
|
0c6957bfd8 | ||
|
|
fffce30e91 | ||
|
|
c554138887 | ||
|
|
5bbceea76c | ||
|
|
0f0601100f | ||
|
|
ff59245a7f | ||
|
|
c5af11d1ea | ||
|
|
bc3e2e1597 | ||
|
|
e2a8456ff0 | ||
|
|
361c5ba930 | ||
|
|
bae47b80b3 | ||
|
|
a049e9ae2d | ||
|
|
44edec7ad2 | ||
|
|
db43f587a6 | ||
|
|
8997ff4b2a | ||
|
|
91a8591249 | ||
|
|
ef74d7cb01 | ||
|
|
8ab2334270 | ||
|
|
59dbcc5261 | ||
|
|
06488cc811 | ||
|
|
5a12bf33f3 | ||
|
|
96ff8a7785 | ||
|
|
a85a1bf794 | ||
|
|
52bad3d0d1 | ||
|
|
e8997a7653 | ||
|
|
0a6d3c0231 | ||
|
|
2db29fc2af | ||
|
|
329bdff677 | ||
|
|
906eb750ad | ||
|
|
1a120adaea | ||
|
|
26a7ebdd77 |
BIN
browser_tests/assets/workflow.glb
Normal file
BIN
browser_tests/assets/workflow.glb
Normal file
Binary file not shown.
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 |
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 |
@@ -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
1266
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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]')
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// @ts-strict-ignore
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -101,6 +101,8 @@ const handleRestart = async () => {
|
||||
|
||||
const onReconnect = () => {
|
||||
useCommandStore().execute('Comfy.RefreshNodeDefinitions')
|
||||
comfyManagerStore.clearLogs()
|
||||
comfyManagerStore.setStale()
|
||||
}
|
||||
useEventListener(api, 'reconnected', onReconnect, { once: true })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
81
src/components/load3d/controls/ExportControls.vue
Normal file
81
src/components/load3d/controls/ExportControls.vue
Normal 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>
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -20,7 +20,7 @@ import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
|
||||
defineProps<{
|
||||
src: string
|
||||
alt: string
|
||||
hoverZoom?: number
|
||||
hoverZoom: number
|
||||
isHovered?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user