Compare commits
37 Commits
fix/load-a
...
core/1.32
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f40afbafb | ||
|
|
e0e8f1535d | ||
|
|
e132eafe1b | ||
|
|
b879cbaaf8 | ||
|
|
50254d16f3 | ||
|
|
de7d7f5775 | ||
|
|
d8efb2b646 | ||
|
|
5226de2963 | ||
|
|
8f527e846f | ||
|
|
487ce3da98 | ||
|
|
7db8ae3f79 | ||
|
|
a096113292 | ||
|
|
01e2fbc04c | ||
|
|
5172e6f8f0 | ||
|
|
d78d07bc11 | ||
|
|
a2a695393c | ||
|
|
0641400a7c | ||
|
|
d64c18b06c | ||
|
|
c26438bd0c | ||
|
|
d4d6ed0bb5 | ||
|
|
d7f11dd852 | ||
|
|
7a212522fe | ||
|
|
d3044fe765 | ||
|
|
b66a181879 | ||
|
|
0507d333fe | ||
|
|
55f842f4cb | ||
|
|
0d42b62d4f | ||
|
|
8c2fe715bd | ||
|
|
e94a74f167 | ||
|
|
662f79edf4 | ||
|
|
07e4004c2d | ||
|
|
15794a83e3 | ||
|
|
689634e4d3 | ||
|
|
fe1daa2c29 | ||
|
|
6600a8a13b | ||
|
|
5e5bf8248f | ||
|
|
7b6fdce0f2 |
@@ -91,7 +91,7 @@
|
||||
"build-storybook": "storybook build -o dist/storybook"
|
||||
},
|
||||
"dependencies": {
|
||||
"@comfyorg/comfyui-electron-types": "0.4.73-0",
|
||||
"@comfyorg/comfyui-electron-types": "catalog:",
|
||||
"@comfyorg/shared-frontend-utils": "workspace:*",
|
||||
"@primevue/core": "catalog:",
|
||||
"@primevue/themes": "catalog:",
|
||||
|
||||
@@ -115,19 +115,18 @@ import Button from 'primevue/button'
|
||||
import Divider from 'primevue/divider'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Message from 'primevue/message'
|
||||
import { type ModelRef, computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import type { ModelRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import MigrationPicker from '@/components/install/MigrationPicker.vue'
|
||||
import MirrorItem from '@/components/install/mirror/MirrorItem.vue'
|
||||
import {
|
||||
PYPI_MIRROR,
|
||||
PYTHON_MIRROR,
|
||||
type UVMirror
|
||||
} from '@/constants/uvMirrors'
|
||||
import { PYPI_MIRROR, PYTHON_MIRROR } from '@/constants/uvMirrors'
|
||||
import type { UVMirror } from '@/constants/uvMirrors'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
import { ValidationState } from '@/utils/validationUtil'
|
||||
|
||||
import MigrationPicker from './MigrationPicker.vue'
|
||||
import MirrorItem from './mirror/MirrorItem.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const installPath = defineModel<string>('installPath', { required: true })
|
||||
@@ -229,6 +228,10 @@ const validatePath = async (path: string | undefined) => {
|
||||
}
|
||||
if (validation.parentMissing) errors.push(t('install.parentMissing'))
|
||||
if (validation.isOneDrive) errors.push(t('install.isOneDrive'))
|
||||
if (validation.isInsideAppInstallDir)
|
||||
errors.push(t('install.insideAppInstallDir'))
|
||||
if (validation.isInsideUpdaterCache)
|
||||
errors.push(t('install.insideUpdaterCache'))
|
||||
|
||||
if (validation.error)
|
||||
errors.push(`${t('install.unhandledError')}: ${validation.error}`)
|
||||
|
||||
@@ -16,7 +16,8 @@ export const DESKTOP_MAINTENANCE_TASKS: Readonly<MaintenanceTask>[] = [
|
||||
execute: async () => await electron.setBasePath(),
|
||||
name: 'Base path',
|
||||
shortDescription: 'Change the application base path.',
|
||||
errorDescription: 'Unable to open the base path. Please select a new one.',
|
||||
errorDescription:
|
||||
'The current base path is invalid or unsafe. Please select a new location.',
|
||||
description:
|
||||
'The base path is the default location where ComfyUI stores data. It is the location for the python environment, and may also contain models, custom nodes, and other extensions.',
|
||||
isInstallationFix: true,
|
||||
|
||||
@@ -85,6 +85,7 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
|
||||
const electron = electronAPI()
|
||||
|
||||
// Reactive state
|
||||
const lastUpdate = ref<InstallValidation | null>(null)
|
||||
const isRefreshing = ref(false)
|
||||
const isRunningTerminalCommand = computed(() =>
|
||||
tasks.value
|
||||
@@ -97,6 +98,13 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
|
||||
.some((task) => getRunner(task)?.executing)
|
||||
)
|
||||
|
||||
const unsafeBasePath = computed(
|
||||
() => lastUpdate.value?.unsafeBasePath === true
|
||||
)
|
||||
const unsafeBasePathReason = computed(
|
||||
() => lastUpdate.value?.unsafeBasePathReason
|
||||
)
|
||||
|
||||
// Task list
|
||||
const tasks = ref(DESKTOP_MAINTENANCE_TASKS)
|
||||
|
||||
@@ -123,6 +131,7 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
|
||||
* @param validationUpdate Update details passed in by electron
|
||||
*/
|
||||
const processUpdate = (validationUpdate: InstallValidation) => {
|
||||
lastUpdate.value = validationUpdate
|
||||
const update = validationUpdate as IndexedUpdate
|
||||
isRefreshing.value = true
|
||||
|
||||
@@ -155,7 +164,11 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
|
||||
}
|
||||
|
||||
const execute = async (task: MaintenanceTask) => {
|
||||
return getRunner(task).execute(task)
|
||||
const success = await getRunner(task).execute(task)
|
||||
if (success && task.isInstallationFix) {
|
||||
await refreshDesktopTasks()
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -163,6 +176,8 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
|
||||
isRefreshing,
|
||||
isRunningTerminalCommand,
|
||||
isRunningInstallationFix,
|
||||
unsafeBasePath,
|
||||
unsafeBasePathReason,
|
||||
execute,
|
||||
getRunner,
|
||||
processUpdate,
|
||||
|
||||
159
apps/desktop-ui/src/views/MaintenanceView.stories.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
// eslint-disable-next-line storybook/no-renderer-packages
|
||||
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
type UnsafeReason = 'appInstallDir' | 'updaterCache' | 'oneDrive' | null
|
||||
type ValidationIssueState = 'OK' | 'warning' | 'error' | 'skipped'
|
||||
|
||||
type ValidationState = {
|
||||
inProgress: boolean
|
||||
installState: string
|
||||
basePath?: ValidationIssueState
|
||||
unsafeBasePath: boolean
|
||||
unsafeBasePathReason: UnsafeReason
|
||||
venvDirectory?: ValidationIssueState
|
||||
pythonInterpreter?: ValidationIssueState
|
||||
pythonPackages?: ValidationIssueState
|
||||
uv?: ValidationIssueState
|
||||
git?: ValidationIssueState
|
||||
vcRedist?: ValidationIssueState
|
||||
upgradePackages?: ValidationIssueState
|
||||
}
|
||||
|
||||
const validationState: ValidationState = {
|
||||
inProgress: false,
|
||||
installState: 'installed',
|
||||
basePath: 'OK',
|
||||
unsafeBasePath: false,
|
||||
unsafeBasePathReason: null,
|
||||
venvDirectory: 'OK',
|
||||
pythonInterpreter: 'OK',
|
||||
pythonPackages: 'OK',
|
||||
uv: 'OK',
|
||||
git: 'OK',
|
||||
vcRedist: 'OK',
|
||||
upgradePackages: 'OK'
|
||||
}
|
||||
|
||||
const createMockElectronAPI = () => {
|
||||
const logListeners: Array<(message: string) => void> = []
|
||||
|
||||
const getValidationUpdate = () => ({
|
||||
...validationState
|
||||
})
|
||||
|
||||
return {
|
||||
getPlatform: () => 'darwin',
|
||||
changeTheme: (_theme: unknown) => {},
|
||||
onLogMessage: (listener: (message: string) => void) => {
|
||||
logListeners.push(listener)
|
||||
},
|
||||
showContextMenu: (_options: unknown) => {},
|
||||
Events: {
|
||||
trackEvent: (_eventName: string, _data?: unknown) => {}
|
||||
},
|
||||
Validation: {
|
||||
onUpdate: (_callback: (update: unknown) => void) => {},
|
||||
async getStatus() {
|
||||
return getValidationUpdate()
|
||||
},
|
||||
async validateInstallation(callback: (update: unknown) => void) {
|
||||
callback(getValidationUpdate())
|
||||
},
|
||||
async complete() {
|
||||
// Only allow completion when the base path is safe
|
||||
return !validationState.unsafeBasePath
|
||||
},
|
||||
dispose: () => {}
|
||||
},
|
||||
setBasePath: () => Promise.resolve(true),
|
||||
reinstall: () => Promise.resolve(),
|
||||
uv: {
|
||||
installRequirements: () => Promise.resolve(),
|
||||
clearCache: () => Promise.resolve(),
|
||||
resetVenv: () => Promise.resolve()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ensureElectronAPI = () => {
|
||||
const globalWindow = window as unknown as { electronAPI?: unknown }
|
||||
if (!globalWindow.electronAPI) {
|
||||
globalWindow.electronAPI = createMockElectronAPI()
|
||||
}
|
||||
|
||||
return globalWindow.electronAPI
|
||||
}
|
||||
|
||||
const MaintenanceView = defineAsyncComponent(async () => {
|
||||
ensureElectronAPI()
|
||||
const module = await import('./MaintenanceView.vue')
|
||||
return module.default
|
||||
})
|
||||
|
||||
const meta: Meta<typeof MaintenanceView> = {
|
||||
title: 'Desktop/Views/MaintenanceView',
|
||||
component: MaintenanceView,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
backgrounds: {
|
||||
default: 'dark',
|
||||
values: [
|
||||
{ name: 'dark', value: '#0a0a0a' },
|
||||
{ name: 'neutral-900', value: '#171717' },
|
||||
{ name: 'neutral-950', value: '#0a0a0a' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
name: 'All tasks OK',
|
||||
render: () => ({
|
||||
components: { MaintenanceView },
|
||||
setup() {
|
||||
validationState.inProgress = false
|
||||
validationState.installState = 'installed'
|
||||
validationState.basePath = 'OK'
|
||||
validationState.unsafeBasePath = false
|
||||
validationState.unsafeBasePathReason = null
|
||||
validationState.venvDirectory = 'OK'
|
||||
validationState.pythonInterpreter = 'OK'
|
||||
validationState.pythonPackages = 'OK'
|
||||
validationState.uv = 'OK'
|
||||
validationState.git = 'OK'
|
||||
validationState.vcRedist = 'OK'
|
||||
validationState.upgradePackages = 'OK'
|
||||
ensureElectronAPI()
|
||||
return {}
|
||||
},
|
||||
template: '<MaintenanceView />'
|
||||
})
|
||||
}
|
||||
|
||||
export const UnsafeBasePathOneDrive: Story = {
|
||||
name: 'Unsafe base path (OneDrive)',
|
||||
render: () => ({
|
||||
components: { MaintenanceView },
|
||||
setup() {
|
||||
validationState.inProgress = false
|
||||
validationState.installState = 'installed'
|
||||
validationState.basePath = 'error'
|
||||
validationState.unsafeBasePath = true
|
||||
validationState.unsafeBasePathReason = 'oneDrive'
|
||||
validationState.venvDirectory = 'OK'
|
||||
validationState.pythonInterpreter = 'OK'
|
||||
validationState.pythonPackages = 'OK'
|
||||
validationState.uv = 'OK'
|
||||
validationState.git = 'OK'
|
||||
validationState.vcRedist = 'OK'
|
||||
validationState.upgradePackages = 'OK'
|
||||
ensureElectronAPI()
|
||||
return {}
|
||||
},
|
||||
template: '<MaintenanceView />'
|
||||
})
|
||||
}
|
||||
@@ -47,6 +47,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unsafe migration warning -->
|
||||
<div v-if="taskStore.unsafeBasePath" class="my-4">
|
||||
<p class="flex items-start gap-3 text-neutral-300">
|
||||
<Tag
|
||||
icon="pi pi-exclamation-triangle"
|
||||
severity="warn"
|
||||
:value="t('icon.exclamation-triangle')"
|
||||
/>
|
||||
<span>
|
||||
<strong class="block mb-1">
|
||||
{{ t('maintenance.unsafeMigration.title') }}
|
||||
</strong>
|
||||
<span class="block mb-1">
|
||||
{{ unsafeReasonText }}
|
||||
</span>
|
||||
<span class="block text-sm text-neutral-400">
|
||||
{{ t('maintenance.unsafeMigration.action') }}
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Tasks -->
|
||||
<TaskListPanel
|
||||
class="border-neutral-700 border-solid border-x-0 border-y"
|
||||
@@ -89,10 +111,10 @@
|
||||
import { PrimeIcons } from '@primevue/core/api'
|
||||
import Button from 'primevue/button'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import Tag from 'primevue/tag'
|
||||
import Toast from 'primevue/toast'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import RefreshButton from '@/components/common/RefreshButton.vue'
|
||||
import StatusTag from '@/components/maintenance/StatusTag.vue'
|
||||
@@ -139,6 +161,27 @@ const filterOptions = ref([
|
||||
/** Filter binding; can be set to show all tasks, or only errors. */
|
||||
const filter = ref<MaintenanceFilter>(filterOptions.value[0])
|
||||
|
||||
const unsafeReasonText = computed(() => {
|
||||
const reason = taskStore.unsafeBasePathReason
|
||||
if (!reason) {
|
||||
return t('maintenance.unsafeMigration.generic')
|
||||
}
|
||||
|
||||
if (reason === 'appInstallDir') {
|
||||
return t('maintenance.unsafeMigration.appInstallDir')
|
||||
}
|
||||
|
||||
if (reason === 'updaterCache') {
|
||||
return t('maintenance.unsafeMigration.updaterCache')
|
||||
}
|
||||
|
||||
if (reason === 'oneDrive') {
|
||||
return t('maintenance.unsafeMigration.oneDrive')
|
||||
}
|
||||
|
||||
return t('maintenance.unsafeMigration.generic')
|
||||
})
|
||||
|
||||
/** If valid, leave the validation window. */
|
||||
const completeValidation = async () => {
|
||||
const isValid = await electron.Validation.complete()
|
||||
|
||||
@@ -564,7 +564,7 @@ export class ComfyPage {
|
||||
async dragAndDrop(source: Position, target: Position) {
|
||||
await this.page.mouse.move(source.x, source.y)
|
||||
await this.page.mouse.down()
|
||||
await this.page.mouse.move(target.x, target.y)
|
||||
await this.page.mouse.move(target.x, target.y, { steps: 100 })
|
||||
await this.page.mouse.up()
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
@@ -65,7 +65,9 @@ export class VueNodeHelpers {
|
||||
* Select a specific Vue node by ID
|
||||
*/
|
||||
async selectNode(nodeId: string): Promise<void> {
|
||||
await this.page.locator(`[data-node-id="${nodeId}"]`).click()
|
||||
await this.page
|
||||
.locator(`[data-node-id="${nodeId}"] .lg-node-header`)
|
||||
.click()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,11 +79,13 @@ export class VueNodeHelpers {
|
||||
// Select first node normally
|
||||
await this.selectNode(nodeIds[0])
|
||||
|
||||
// Add additional nodes with Ctrl+click
|
||||
// Add additional nodes with Ctrl+click on header
|
||||
for (let i = 1; i < nodeIds.length; i++) {
|
||||
await this.page.locator(`[data-node-id="${nodeIds[i]}"]`).click({
|
||||
modifiers: ['Control']
|
||||
})
|
||||
await this.page
|
||||
.locator(`[data-node-id="${nodeIds[i]}"] .lg-node-header`)
|
||||
.click({
|
||||
modifiers: ['Control']
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |
@@ -6,6 +6,7 @@ import {
|
||||
test.describe('Vue Nodes Zoom', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.setSetting('LiteGraph.Canvas.MinFontSizeForLOD', 8)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 112 KiB |
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Vue Node Resizing', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test('should resize node without position drift after selecting', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Get a Vue node fixture
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint')
|
||||
const initialBox = await node.boundingBox()
|
||||
if (!initialBox) throw new Error('Node bounding box not found')
|
||||
|
||||
// Select the node first (this was causing the bug)
|
||||
await node.header.click()
|
||||
await comfyPage.page.waitForTimeout(100) // Brief pause after selection
|
||||
|
||||
// Get position after selection
|
||||
const selectedBox = await node.boundingBox()
|
||||
if (!selectedBox)
|
||||
throw new Error('Node bounding box not found after select')
|
||||
|
||||
// Verify position unchanged after selection
|
||||
expect(selectedBox.x).toBeCloseTo(initialBox.x, 1)
|
||||
expect(selectedBox.y).toBeCloseTo(initialBox.y, 1)
|
||||
|
||||
// Now resize from bottom-right corner
|
||||
const resizeStartX = selectedBox.x + selectedBox.width - 5
|
||||
const resizeStartY = selectedBox.y + selectedBox.height - 5
|
||||
|
||||
await comfyPage.page.mouse.move(resizeStartX, resizeStartY)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(resizeStartX + 50, resizeStartY + 30)
|
||||
await comfyPage.page.mouse.up()
|
||||
|
||||
// Get final position and size
|
||||
const finalBox = await node.boundingBox()
|
||||
if (!finalBox) throw new Error('Node bounding box not found after resize')
|
||||
|
||||
// Position should NOT have changed (the bug was position drift)
|
||||
expect(finalBox.x).toBeCloseTo(initialBox.x, 1)
|
||||
expect(finalBox.y).toBeCloseTo(initialBox.y, 1)
|
||||
|
||||
// Size should have increased
|
||||
expect(finalBox.width).toBeGreaterThan(initialBox.width)
|
||||
expect(finalBox.height).toBeGreaterThan(initialBox.height)
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 142 KiB |
@@ -1,48 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Vue Nodes - LOD', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.setup()
|
||||
await comfyPage.loadWorkflow('default')
|
||||
})
|
||||
|
||||
test('should toggle LOD based on zoom threshold', async ({ comfyPage }) => {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const initialNodeCount = await comfyPage.vueNodes.getNodeCount()
|
||||
expect(initialNodeCount).toBeGreaterThan(0)
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('vue-nodes-default.png')
|
||||
|
||||
const vueNodesContainer = comfyPage.vueNodes.nodes
|
||||
const textboxesInNodes = vueNodesContainer.getByRole('textbox')
|
||||
const comboboxesInNodes = vueNodesContainer.getByRole('combobox')
|
||||
|
||||
await expect(textboxesInNodes.first()).toBeVisible()
|
||||
await expect(comboboxesInNodes.first()).toBeVisible()
|
||||
|
||||
await comfyPage.zoom(120, 10)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('vue-nodes-lod-active.png')
|
||||
|
||||
await expect(textboxesInNodes.first()).toBeHidden()
|
||||
await expect(comboboxesInNodes.first()).toBeHidden()
|
||||
|
||||
await comfyPage.zoom(-120, 10)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-nodes-lod-inactive.png'
|
||||
)
|
||||
await expect(textboxesInNodes.first()).toBeVisible()
|
||||
await expect(comboboxesInNodes.first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 82 KiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.32.6",
|
||||
"version": "1.32.10",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -128,7 +128,7 @@
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "catalog:",
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
|
||||
"@comfyorg/comfyui-electron-types": "0.4.73-0",
|
||||
"@comfyorg/comfyui-electron-types": "catalog:",
|
||||
"@comfyorg/design-system": "workspace:*",
|
||||
"@comfyorg/registry-types": "workspace:*",
|
||||
"@comfyorg/shared-frontend-utils": "workspace:*",
|
||||
|
||||
@@ -1329,57 +1329,6 @@ audio.comfy-audio.empty-audio-widget {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* START LOD specific styles */
|
||||
/* LOD styles - Custom CSS avoids 100+ Tailwind selectors that would slow style recalculation when .isLOD toggles */
|
||||
|
||||
.isLOD .lg-node {
|
||||
box-shadow: none;
|
||||
filter: none;
|
||||
backdrop-filter: none;
|
||||
text-shadow: none;
|
||||
mask-image: none;
|
||||
clip-path: none;
|
||||
background-image: none;
|
||||
text-rendering: optimizeSpeed;
|
||||
border-radius: 0;
|
||||
contain: layout style;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.isLOD .lg-node-header {
|
||||
border-radius: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.isLOD .lg-node-widgets {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.lod-toggle {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.isLOD .lod-toggle {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.lod-fallback {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.isLOD .lod-fallback {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.isLOD .image-preview img {
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.isLOD .slot-dot {
|
||||
border-radius: 0;
|
||||
}
|
||||
/* END LOD specific styles */
|
||||
|
||||
/* ===================== Mask Editor Styles ===================== */
|
||||
/* To be migrated to Tailwind later */
|
||||
#maskEditor_brush {
|
||||
|
||||
55
pnpm-lock.yaml
generated
@@ -9,6 +9,9 @@ catalogs:
|
||||
'@alloc/quick-lru':
|
||||
specifier: ^5.2.0
|
||||
version: 5.2.0
|
||||
'@comfyorg/comfyui-electron-types':
|
||||
specifier: 0.5.5
|
||||
version: 0.5.5
|
||||
'@eslint/js':
|
||||
specifier: ^9.35.0
|
||||
version: 9.35.0
|
||||
@@ -318,8 +321,8 @@ importers:
|
||||
specifier: ^1.3.1
|
||||
version: 1.3.1
|
||||
'@comfyorg/comfyui-electron-types':
|
||||
specifier: 0.4.73-0
|
||||
version: 0.4.73-0
|
||||
specifier: 'catalog:'
|
||||
version: 0.5.5
|
||||
'@comfyorg/design-system':
|
||||
specifier: workspace:*
|
||||
version: link:packages/design-system
|
||||
@@ -709,8 +712,8 @@ importers:
|
||||
apps/desktop-ui:
|
||||
dependencies:
|
||||
'@comfyorg/comfyui-electron-types':
|
||||
specifier: 0.4.73-0
|
||||
version: 0.4.73-0
|
||||
specifier: 'catalog:'
|
||||
version: 0.5.5
|
||||
'@comfyorg/shared-frontend-utils':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/shared-frontend-utils
|
||||
@@ -1453,8 +1456,8 @@ packages:
|
||||
'@cacheable/utils@2.0.3':
|
||||
resolution: {integrity: sha512-m7Rce68cMHlAUjvWBy9Ru1Nmw5gU0SjGGtQDdhpe6E0xnbcvrIY0Epy//JU1VYYBUTzrG9jvgmTauULGKzOkWA==}
|
||||
|
||||
'@comfyorg/comfyui-electron-types@0.4.73-0':
|
||||
resolution: {integrity: sha512-WlItGJQx9ZWShNG9wypx3kq+19pSig/U+s5sD2SAeEcMph4u8A/TS+lnRgdKhT58VT1uD7cMcj2SJpfdBPNWvw==}
|
||||
'@comfyorg/comfyui-electron-types@0.5.5':
|
||||
resolution: {integrity: sha512-f3XOXpMsALIwHakz7FekVPm4/Fh2pvJPEi8tRe8jYGBt8edsd4Mkkq31Yjs2Weem3BP7yNwbdNuSiQdP/pxJyg==}
|
||||
|
||||
'@csstools/color-helpers@5.1.0':
|
||||
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
|
||||
@@ -4413,6 +4416,9 @@ packages:
|
||||
csstype@3.1.3:
|
||||
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
|
||||
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
data-urls@5.0.0:
|
||||
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -7000,6 +7006,11 @@ packages:
|
||||
engines: {node: '>= 0.4'}
|
||||
hasBin: true
|
||||
|
||||
resolve@1.22.11:
|
||||
resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
hasBin: true
|
||||
|
||||
restore-cursor@3.1.0:
|
||||
resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -7095,6 +7106,11 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
semver@7.7.3:
|
||||
resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
set-function-length@1.2.2:
|
||||
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -7815,8 +7831,8 @@ packages:
|
||||
vue-component-type-helpers@3.1.1:
|
||||
resolution: {integrity: sha512-B0kHv7qX6E7+kdc5nsaqjdGZ1KwNKSUQDWGy7XkTYT7wFsOpkEyaJ1Vq79TjwrrtuLRgizrTV7PPuC4rRQo+vw==}
|
||||
|
||||
vue-component-type-helpers@3.1.3:
|
||||
resolution: {integrity: sha512-V1dOD8XYfstOKCnXbWyEJIrhTBMwSyNjv271L1Jlx9ExpNlCSuqOs3OdWrGJ0V544zXufKbcYabi/o+gK8lyfQ==}
|
||||
vue-component-type-helpers@3.1.4:
|
||||
resolution: {integrity: sha512-Uws7Ew1OzTTqHW8ZVl/qLl/HB+jf08M0NdFONbVWAx0N4gMLK8yfZDgeB77hDnBmaigWWEn5qP8T9BG59jIeyQ==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
@@ -8992,7 +9008,7 @@ snapshots:
|
||||
|
||||
'@cacheable/utils@2.0.3': {}
|
||||
|
||||
'@comfyorg/comfyui-electron-types@0.4.73-0': {}
|
||||
'@comfyorg/comfyui-electron-types@0.5.5': {}
|
||||
|
||||
'@csstools/color-helpers@5.1.0': {}
|
||||
|
||||
@@ -10617,7 +10633,7 @@ snapshots:
|
||||
storybook: 9.1.6(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
|
||||
type-fest: 2.19.0
|
||||
vue: 3.5.13(typescript@5.9.2)
|
||||
vue-component-type-helpers: 3.1.3
|
||||
vue-component-type-helpers: 3.1.4
|
||||
|
||||
'@swc/helpers@0.5.17':
|
||||
dependencies:
|
||||
@@ -10989,7 +11005,7 @@ snapshots:
|
||||
|
||||
'@types/react@19.1.9':
|
||||
dependencies:
|
||||
csstype: 3.1.3
|
||||
csstype: 3.2.3
|
||||
|
||||
'@types/semver@7.7.0': {}
|
||||
|
||||
@@ -12168,6 +12184,8 @@ snapshots:
|
||||
|
||||
csstype@3.1.3: {}
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
data-urls@5.0.0:
|
||||
dependencies:
|
||||
whatwg-mimetype: 4.0.0
|
||||
@@ -12594,7 +12612,7 @@ snapshots:
|
||||
dependencies:
|
||||
debug: 3.2.7
|
||||
is-core-module: 2.16.1
|
||||
resolve: 1.22.10
|
||||
resolve: 1.22.11
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
optional: true
|
||||
@@ -13740,7 +13758,7 @@ snapshots:
|
||||
acorn: 8.15.0
|
||||
eslint-visitor-keys: 3.4.3
|
||||
espree: 9.6.1
|
||||
semver: 7.7.2
|
||||
semver: 7.7.3
|
||||
|
||||
jsonc-parser@3.2.0: {}
|
||||
|
||||
@@ -15345,6 +15363,13 @@ snapshots:
|
||||
path-parse: 1.0.7
|
||||
supports-preserve-symlinks-flag: 1.0.0
|
||||
|
||||
resolve@1.22.11:
|
||||
dependencies:
|
||||
is-core-module: 2.16.1
|
||||
path-parse: 1.0.7
|
||||
supports-preserve-symlinks-flag: 1.0.0
|
||||
optional: true
|
||||
|
||||
restore-cursor@3.1.0:
|
||||
dependencies:
|
||||
onetime: 5.1.2
|
||||
@@ -15449,6 +15474,8 @@ snapshots:
|
||||
|
||||
semver@7.7.2: {}
|
||||
|
||||
semver@7.7.3: {}
|
||||
|
||||
set-function-length@1.2.2:
|
||||
dependencies:
|
||||
define-data-property: 1.1.4
|
||||
@@ -16343,7 +16370,7 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@3.1.1: {}
|
||||
|
||||
vue-component-type-helpers@3.1.3: {}
|
||||
vue-component-type-helpers@3.1.4: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.2)):
|
||||
dependencies:
|
||||
|
||||
@@ -4,6 +4,7 @@ packages:
|
||||
|
||||
catalog:
|
||||
'@alloc/quick-lru': ^5.2.0
|
||||
'@comfyorg/comfyui-electron-types': 0.5.5
|
||||
'@eslint/js': ^9.35.0
|
||||
'@iconify-json/lucide': ^1.1.178
|
||||
'@iconify/json': ^2.2.380
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
severity="primary"
|
||||
size="small"
|
||||
:model="queueModeMenuItems"
|
||||
:disabled="hasMissingNodes"
|
||||
data-testid="queue-button"
|
||||
@click="queuePrompt"
|
||||
>
|
||||
@@ -79,13 +78,15 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import {
|
||||
useQueuePendingTaskCountStore,
|
||||
useQueueSettingsStore
|
||||
} from '@/stores/queueStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
|
||||
|
||||
import BatchCountEdit from '../BatchCountEdit.vue'
|
||||
|
||||
@@ -93,7 +94,10 @@ const workspaceStore = useWorkspaceStore()
|
||||
const queueCountStore = storeToRefs(useQueuePendingTaskCountStore())
|
||||
const { mode: queueMode, batchCount } = storeToRefs(useQueueSettingsStore())
|
||||
|
||||
const { hasMissingNodes } = useMissingNodes()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const hasMissingNodes = computed(() =>
|
||||
graphHasMissingNodes(app.graph, nodeDefStore.nodeDefsByName)
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
const queueModeMenuItemLookup = computed(() => {
|
||||
|
||||
@@ -64,11 +64,13 @@ import {
|
||||
ComfyWorkflow,
|
||||
useWorkflowStore
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { appendJsonExt } from '@/utils/formatUtil'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
|
||||
|
||||
interface Props {
|
||||
item: MenuItem
|
||||
@@ -79,7 +81,10 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
isActive: false
|
||||
})
|
||||
|
||||
const { hasMissingNodes } = useMissingNodes()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const hasMissingNodes = computed(() =>
|
||||
graphHasMissingNodes(app.graph, nodeDefStore.nodeDefsByName)
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
const menu = ref<InstanceType<typeof Menu> & MenuState>()
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
interface IconButtonProps extends BaseButtonProps {
|
||||
onClick: (event: Event) => void
|
||||
onClick?: (event: MouseEvent) => void
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
|
||||
@@ -47,7 +47,7 @@ const {
|
||||
} = defineProps<IconTextButtonProps>()
|
||||
|
||||
const buttonStyle = computed(() => {
|
||||
const baseClasses = `${getBaseButtonClasses()} justify-start! gap-2`
|
||||
const baseClasses = `${getBaseButtonClasses()} justify-start gap-2`
|
||||
const sizeClasses = getButtonSizeClasses(size)
|
||||
const typeClasses = border
|
||||
? getBorderButtonTypeClasses(type)
|
||||
|
||||
@@ -35,6 +35,7 @@ import { ValidationState } from '@/utils/validationUtil'
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
validateUrlFn?: (url: string) => Promise<boolean>
|
||||
disableValidation?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -101,6 +102,8 @@ const defaultValidateUrl = async (url: string): Promise<boolean> => {
|
||||
}
|
||||
|
||||
const validateUrl = async (value: string) => {
|
||||
if (props.disableValidation) return
|
||||
|
||||
if (validationState.value === ValidationState.LOADING) return
|
||||
|
||||
const url = cleanInput(value)
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
class="w-62.5"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--arrow-up-down]" />
|
||||
<i class="icon-[lucide--arrow-up-down] text-muted-foreground" />
|
||||
</template>
|
||||
</SingleSelect>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-[490px] flex-col border-t-1 border-b-1 border-border-default"
|
||||
class="flex w-[490px] flex-col border-t-1 border-border-default"
|
||||
:class="isCloud ? 'border-b-1' : ''"
|
||||
>
|
||||
<div class="flex h-full w-full flex-col gap-4 p-4">
|
||||
<!-- Description -->
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({
|
||||
class: cn(
|
||||
'h-10 relative inline-flex cursor-pointer select-none',
|
||||
'rounded-lg bg-base-background text-base-foreground',
|
||||
'rounded-lg bg-secondary-background text-base-foreground',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
'border-[2.5px] border-solid',
|
||||
selectedCount > 0
|
||||
@@ -127,7 +127,7 @@
|
||||
|
||||
<!-- Trigger value (keep text scale identical) -->
|
||||
<template #value>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
<span class="text-sm">
|
||||
{{ label }}
|
||||
</span>
|
||||
<span
|
||||
@@ -140,7 +140,7 @@
|
||||
|
||||
<!-- Chevron size identical to current -->
|
||||
<template #dropdownicon>
|
||||
<i class="icon-[lucide--chevron-down] text-lg text-neutral-400" />
|
||||
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
|
||||
</template>
|
||||
|
||||
<!-- Custom option row: square checkbox + label (unchanged layout/colors) -->
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div :class="wrapperStyle" @click="focusInput">
|
||||
<i class="icon-[lucide--search] text-muted" />
|
||||
<i class="icon-[lucide--search] text-muted-foreground" />
|
||||
<InputText
|
||||
ref="input"
|
||||
v-model="internalSearchQuery"
|
||||
@@ -73,7 +73,7 @@ onMounted(() => autofocus && focusInput())
|
||||
|
||||
const wrapperStyle = computed(() => {
|
||||
const baseClasses =
|
||||
'relative flex w-full items-center gap-2 bg-base-background cursor-text'
|
||||
'relative flex w-full items-center gap-2 bg-secondary-background cursor-text'
|
||||
|
||||
if (showBorder) {
|
||||
return cn(
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
'h-10 relative inline-flex cursor-pointer select-none items-center',
|
||||
// trigger surface
|
||||
'rounded-lg',
|
||||
'bg-base-background text-base-foreground',
|
||||
'bg-secondary-background text-base-foreground',
|
||||
'border-[2.5px] border-solid border-transparent',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
'focus-within:border-node-component-border',
|
||||
@@ -84,7 +84,7 @@
|
||||
>
|
||||
<!-- Trigger value -->
|
||||
<template #value="slotProps">
|
||||
<div class="flex items-center gap-2 text-sm text-neutral-500">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<slot name="icon" />
|
||||
<span
|
||||
v-if="slotProps.value !== null && slotProps.value !== undefined"
|
||||
@@ -100,7 +100,7 @@
|
||||
|
||||
<!-- Trigger caret -->
|
||||
<template #dropdownicon>
|
||||
<i class="icon-[lucide--chevron-down] text-base text-neutral-500" />
|
||||
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
|
||||
</template>
|
||||
|
||||
<!-- Option row -->
|
||||
|
||||
@@ -3,7 +3,7 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
|
||||
-->
|
||||
<template>
|
||||
<LGraphNodePreview v-if="shouldRenderVueNodes" :node-def="nodeDef" />
|
||||
<div v-else class="_sb_node_preview">
|
||||
<div v-else class="_sb_node_preview bg-component-node-background">
|
||||
<div class="_sb_table">
|
||||
<div
|
||||
class="node_header mr-4 text-ellipsis"
|
||||
@@ -200,7 +200,6 @@ const truncateDefaultValue = (value: any, charLimit: number = 32): string => {
|
||||
}
|
||||
|
||||
._sb_node_preview {
|
||||
background-color: var(--comfy-menu-bg);
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
color: var(--descrip-text);
|
||||
border: 1px solid var(--descrip-text);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="group flex w-full items-center justify-between gap-3 rounded-lg border-0 bg-secondary-background p-1 text-left transition-colors duration-200 ease-in-out hover:cursor-pointer hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
|
||||
<IconButton
|
||||
type="secondary"
|
||||
size="fit-content"
|
||||
class="group w-full justify-between gap-3 rounded-lg p-1 text-left font-normal hover:cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
|
||||
:aria-label="props.ariaLabel"
|
||||
@click="emit('click', $event)"
|
||||
>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<span v-if="props.mode === 'allFailed'" class="inline-flex items-center">
|
||||
@@ -76,10 +78,11 @@
|
||||
>
|
||||
<i class="icon-[lucide--chevron-down] block size-4 leading-none" />
|
||||
</span>
|
||||
</button>
|
||||
</IconButton>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import type {
|
||||
CompletionSummary,
|
||||
CompletionSummaryMode
|
||||
@@ -96,4 +99,8 @@ type Props = {
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
thumbnailUrls: () => []
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click', event: MouseEvent): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -42,17 +42,19 @@
|
||||
t('sideToolbar.queueProgressOverlay.running')
|
||||
}}</span>
|
||||
</span>
|
||||
<button
|
||||
<IconButton
|
||||
v-if="runningCount > 0"
|
||||
v-tooltip.top="cancelJobTooltip"
|
||||
class="inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background p-0 transition-colors hover:bg-destructive-background"
|
||||
type="secondary"
|
||||
size="sm"
|
||||
class="size-6 bg-secondary-background hover:bg-destructive-background"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.interruptAll')"
|
||||
@click="$emit('interruptAll')"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--x] block size-4 leading-none text-text-primary"
|
||||
/>
|
||||
</button>
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -62,26 +64,28 @@
|
||||
t('sideToolbar.queueProgressOverlay.queuedSuffix')
|
||||
}}</span>
|
||||
</span>
|
||||
<button
|
||||
<IconButton
|
||||
v-if="queuedCount > 0"
|
||||
v-tooltip.top="clearQueueTooltip"
|
||||
class="inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background p-0 transition-colors hover:bg-destructive-background"
|
||||
type="secondary"
|
||||
size="sm"
|
||||
class="size-6 bg-secondary-background hover:bg-destructive-background"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
|
||||
@click="$emit('clearQueued')"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--list-x] block size-4 leading-none text-text-primary"
|
||||
/>
|
||||
</button>
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="inline-flex h-6 min-w-[120px] flex-1 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background px-2 py-0 text-[12px] text-text-primary hover:bg-secondary-background-hover hover:opacity-90"
|
||||
<TextButton
|
||||
class="h-6 min-w-[120px] flex-1 px-2 py-0 text-[12px]"
|
||||
type="secondary"
|
||||
:label="t('sideToolbar.queueProgressOverlay.viewAllJobs')"
|
||||
@click="$emit('viewAllJobs')"
|
||||
>
|
||||
{{ t('sideToolbar.queueProgressOverlay.viewAllJobs') }}
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -90,6 +94,8 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import TextButton from '@/components/button/TextButton.vue'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
|
||||
defineProps<{
|
||||
|
||||
@@ -8,17 +8,20 @@
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-between px-3">
|
||||
<button
|
||||
class="inline-flex grow cursor-pointer items-center justify-center gap-1 rounded border-0 bg-secondary-background p-2 text-center font-inter text-[12px] leading-none text-text-primary hover:bg-secondary-background-hover hover:opacity-90"
|
||||
<IconTextButton
|
||||
class="grow gap-1 p-2 text-center font-inter text-[12px] leading-none hover:opacity-90 justify-center"
|
||||
type="secondary"
|
||||
:label="t('sideToolbar.queueProgressOverlay.showAssets')"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.showAssets')"
|
||||
@click="$emit('showAssets')"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-none block size-4 shrink-0 leading-none icon-[comfy--image-ai-edit]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{{ t('sideToolbar.queueProgressOverlay.showAssets') }}</span>
|
||||
</button>
|
||||
<template #icon>
|
||||
<div
|
||||
class="pointer-events-none block size-4 shrink-0 leading-none icon-[comfy--image-ai-edit]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<div class="ml-4 inline-flex items-center">
|
||||
<div
|
||||
class="inline-flex h-6 items-center text-[12px] leading-none text-text-primary opacity-90"
|
||||
@@ -28,16 +31,18 @@
|
||||
t('sideToolbar.queueProgressOverlay.queuedSuffix')
|
||||
}}</span>
|
||||
</div>
|
||||
<button
|
||||
<IconButton
|
||||
v-if="queuedCount > 0"
|
||||
class="group ml-2 inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background p-0 transition-colors hover:bg-destructive-background"
|
||||
class="group ml-2 size-6 bg-secondary-background hover:bg-destructive-background"
|
||||
type="secondary"
|
||||
size="sm"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
|
||||
@click="$emit('clearQueued')"
|
||||
>
|
||||
<i
|
||||
class="pointer-events-none icon-[lucide--list-x] block size-4 leading-none text-text-primary transition-colors group-hover:text-base-background"
|
||||
/>
|
||||
</button>
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -75,6 +80,8 @@
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import type {
|
||||
JobGroup,
|
||||
JobListItem,
|
||||
|
||||
@@ -18,16 +18,18 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
<IconButton
|
||||
v-tooltip.top="moreTooltipConfig"
|
||||
class="inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-transparent p-0 hover:bg-secondary-background hover:opacity-100"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="size-6 bg-transparent hover:bg-secondary-background hover:opacity-100"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.moreOptions')"
|
||||
@click="onMoreClick"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--more-horizontal] block size-4 leading-none text-text-secondary"
|
||||
/>
|
||||
</button>
|
||||
</IconButton>
|
||||
<Popover
|
||||
ref="morePopoverRef"
|
||||
:dismissable="true"
|
||||
@@ -45,18 +47,19 @@
|
||||
<div
|
||||
class="flex flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3 font-inter"
|
||||
>
|
||||
<button
|
||||
class="inline-flex w-full cursor-pointer items-center justify-start gap-2 rounded-lg border-0 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
|
||||
<IconTextButton
|
||||
class="w-full justify-start gap-2 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
|
||||
type="transparent"
|
||||
:label="t('sideToolbar.queueProgressOverlay.clearHistory')"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.clearHistory')"
|
||||
@click="onClearHistoryFromMenu"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--file-x-2] block size-4 leading-none text-text-secondary"
|
||||
/>
|
||||
<span>{{
|
||||
t('sideToolbar.queueProgressOverlay.clearHistory')
|
||||
}}</span>
|
||||
</button>
|
||||
<template #icon>
|
||||
<i
|
||||
class="icon-[lucide--file-x-2] block size-4 leading-none text-text-secondary"
|
||||
/>
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
@@ -69,6 +72,8 @@ import type { PopoverMethods } from 'primevue/popover'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
|
||||
defineProps<{
|
||||
|
||||
@@ -8,13 +8,15 @@
|
||||
<p class="m-0 text-[14px] font-normal leading-none">
|
||||
{{ t('sideToolbar.queueProgressOverlay.clearHistoryDialogTitle') }}
|
||||
</p>
|
||||
<button
|
||||
class="inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-transparent p-0 text-text-secondary transition hover:bg-secondary-background hover:opacity-100"
|
||||
<IconButton
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="size-6 bg-transparent text-text-secondary hover:bg-secondary-background hover:opacity-100"
|
||||
:aria-label="t('g.close')"
|
||||
@click="onCancel"
|
||||
>
|
||||
<i class="icon-[lucide--x] block size-4 leading-none" />
|
||||
</button>
|
||||
</IconButton>
|
||||
</header>
|
||||
|
||||
<div class="flex flex-col gap-4 px-4 py-4 text-[14px] text-text-secondary">
|
||||
@@ -30,21 +32,19 @@
|
||||
|
||||
<footer class="flex items-center justify-end px-4 py-4">
|
||||
<div class="flex items-center gap-4 text-[14px] leading-none">
|
||||
<button
|
||||
class="inline-flex min-h-[24px] cursor-pointer items-center rounded-md border-0 bg-transparent px-1 py-1 text-[14px] leading-[1] text-text-secondary transition hover:text-text-primary"
|
||||
:aria-label="t('g.cancel')"
|
||||
<TextButton
|
||||
class="min-h-[24px] px-1 py-1 text-[14px] leading-[1] text-text-secondary hover:text-text-primary"
|
||||
type="transparent"
|
||||
:label="t('g.cancel')"
|
||||
@click="onCancel"
|
||||
>
|
||||
{{ t('g.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex min-h-[32px] items-center rounded-lg border-0 bg-secondary-background px-4 py-2 text-[12px] font-normal leading-[1] text-text-primary transition hover:bg-secondary-background-hover hover:text-text-primary disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:aria-label="t('g.clear')"
|
||||
/>
|
||||
<TextButton
|
||||
class="min-h-[32px] px-4 py-2 text-[12px] font-normal leading-[1]"
|
||||
type="secondary"
|
||||
:label="t('g.clear')"
|
||||
:disabled="isClearing"
|
||||
@click="onConfirm"
|
||||
>
|
||||
{{ t('g.clear') }}
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
@@ -54,6 +54,8 @@
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import TextButton from '@/components/button/TextButton.vue'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
|
||||
@@ -20,21 +20,24 @@
|
||||
<div v-if="entry.kind === 'divider'" class="px-2 py-1">
|
||||
<div class="h-px bg-interface-stroke" />
|
||||
</div>
|
||||
<button
|
||||
<IconTextButton
|
||||
v-else
|
||||
class="inline-flex w-full cursor-pointer items-center justify-start gap-2 rounded-lg border-0 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary transition-colors duration-150 hover:bg-interface-panel-hover-surface"
|
||||
class="w-full justify-start gap-2 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-interface-panel-hover-surface"
|
||||
type="transparent"
|
||||
:label="entry.label"
|
||||
:aria-label="entry.label"
|
||||
@click="onEntry(entry)"
|
||||
>
|
||||
<i
|
||||
v-if="entry.icon"
|
||||
:class="[
|
||||
entry.icon,
|
||||
'block size-4 shrink-0 leading-none text-text-secondary'
|
||||
]"
|
||||
/>
|
||||
<span>{{ entry.label }}</span>
|
||||
</button>
|
||||
<template #icon>
|
||||
<i
|
||||
v-if="entry.icon"
|
||||
:class="[
|
||||
entry.icon,
|
||||
'block size-4 shrink-0 leading-none text-text-secondary'
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</template>
|
||||
</div>
|
||||
</Popover>
|
||||
@@ -44,6 +47,7 @@
|
||||
import Popover from 'primevue/popover'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import type { MenuEntry } from '@/composables/queue/useJobMenu'
|
||||
|
||||
defineProps<{ entries: MenuEntry[] }>()
|
||||
|
||||
@@ -20,17 +20,18 @@
|
||||
class="flex min-w-0 items-center text-[0.75rem] leading-normal font-normal text-text-secondary"
|
||||
>
|
||||
<span class="block min-w-0 truncate">{{ row.value }}</span>
|
||||
<button
|
||||
<IconButton
|
||||
v-if="row.canCopy"
|
||||
type="button"
|
||||
class="ml-2 inline-flex size-6 items-center justify-center rounded border-0 bg-transparent p-0 hover:opacity-90"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="ml-2 size-6 bg-transparent hover:opacity-90"
|
||||
:aria-label="copyAriaLabel"
|
||||
@click.stop="copyJobId"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--copy] block size-4 leading-none text-text-secondary"
|
||||
/>
|
||||
</button>
|
||||
</IconButton>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -60,25 +61,31 @@
|
||||
{{ t('queue.jobDetails.errorMessage') }}
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-6 items-center justify-center gap-2 rounded border-none bg-transparent px-0 text-[0.75rem] leading-none text-text-secondary hover:opacity-90"
|
||||
<IconTextButton
|
||||
class="h-6 justify-start gap-2 bg-transparent px-0 text-[0.75rem] leading-none text-text-secondary hover:opacity-90"
|
||||
type="transparent"
|
||||
:label="copyAriaLabel"
|
||||
:aria-label="copyAriaLabel"
|
||||
icon-position="right"
|
||||
@click.stop="copyErrorMessage"
|
||||
>
|
||||
<span>{{ copyAriaLabel }}</span>
|
||||
<i class="icon-[lucide--copy] block size-3.5 leading-none" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-6 items-center justify-center gap-2 rounded border-none bg-transparent px-0 text-[0.75rem] leading-none text-text-secondary hover:opacity-90"
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--copy] block size-3.5 leading-none" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<IconTextButton
|
||||
class="h-6 justify-start gap-2 bg-transparent px-0 text-[0.75rem] leading-none text-text-secondary hover:opacity-90"
|
||||
type="transparent"
|
||||
:label="t('queue.jobDetails.report')"
|
||||
icon-position="right"
|
||||
@click.stop="reportJobError"
|
||||
>
|
||||
<span>{{ t('queue.jobDetails.report') }}</span>
|
||||
<i
|
||||
class="icon-[lucide--message-circle-warning] block size-3.5 leading-none"
|
||||
/>
|
||||
</button>
|
||||
<template #icon>
|
||||
<i
|
||||
class="icon-[lucide--message-circle-warning] block size-3.5 leading-none"
|
||||
/>
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</div>
|
||||
<div
|
||||
class="col-span-2 mt-2 rounded bg-interface-panel-hover-surface px-4 py-2 text-[0.75rem] leading-normal text-text-secondary"
|
||||
@@ -94,6 +101,8 @@
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
|
||||
@@ -2,26 +2,26 @@
|
||||
<div class="flex items-center justify-between gap-2 px-3">
|
||||
<div class="min-w-0 flex-1 overflow-x-auto">
|
||||
<div class="inline-flex items-center gap-1 whitespace-nowrap">
|
||||
<button
|
||||
<TextButton
|
||||
v-for="tab in visibleJobTabs"
|
||||
:key="tab"
|
||||
class="h-6 cursor-pointer rounded border-0 px-3 py-1 text-[12px] leading-none hover:opacity-90"
|
||||
class="h-6 px-3 py-1 text-[12px] leading-none hover:opacity-90"
|
||||
:type="selectedJobTab === tab ? 'secondary' : 'transparent'"
|
||||
:class="[
|
||||
selectedJobTab === tab
|
||||
? 'bg-secondary-background text-text-primary'
|
||||
: 'bg-transparent text-text-secondary'
|
||||
selectedJobTab === tab ? 'text-text-primary' : 'text-text-secondary'
|
||||
]"
|
||||
:label="tabLabel(tab)"
|
||||
@click="$emit('update:selectedJobTab', tab)"
|
||||
>
|
||||
{{ tabLabel(tab) }}
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-2 flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
<IconButton
|
||||
v-if="showWorkflowFilter"
|
||||
v-tooltip.top="filterTooltipConfig"
|
||||
class="relative inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background p-0 hover:bg-secondary-background-hover hover:opacity-90"
|
||||
type="secondary"
|
||||
size="sm"
|
||||
class="relative size-6 bg-secondary-background hover:bg-secondary-background-hover hover:opacity-90"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.filterJobs')"
|
||||
@click="onFilterClick"
|
||||
>
|
||||
@@ -32,7 +32,7 @@
|
||||
v-if="selectedWorkflowFilter !== 'all'"
|
||||
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
|
||||
/>
|
||||
</button>
|
||||
</IconButton>
|
||||
<Popover
|
||||
v-if="showWorkflowFilter"
|
||||
ref="filterPopoverRef"
|
||||
@@ -51,46 +51,48 @@
|
||||
<div
|
||||
class="flex min-w-[12rem] flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3"
|
||||
>
|
||||
<button
|
||||
class="inline-flex w-full cursor-pointer items-center justify-start gap-1 rounded-lg border-0 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
|
||||
<IconTextButton
|
||||
class="w-full justify-between gap-1 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
|
||||
type="transparent"
|
||||
icon-position="right"
|
||||
:label="t('sideToolbar.queueProgressOverlay.filterAllWorkflows')"
|
||||
:aria-label="
|
||||
t('sideToolbar.queueProgressOverlay.filterAllWorkflows')
|
||||
"
|
||||
@click="selectWorkflowFilter('all')"
|
||||
>
|
||||
<span>{{
|
||||
t('sideToolbar.queueProgressOverlay.filterAllWorkflows')
|
||||
}}</span>
|
||||
<span class="ml-auto inline-flex items-center">
|
||||
<template #icon>
|
||||
<i
|
||||
v-if="selectedWorkflowFilter === 'all'"
|
||||
class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<div class="mx-2 mt-1 h-px" />
|
||||
<button
|
||||
class="inline-flex w-full cursor-pointer items-center justify-start gap-1 rounded-lg border-0 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
|
||||
<IconTextButton
|
||||
class="w-full justify-between gap-1 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
|
||||
type="transparent"
|
||||
icon-position="right"
|
||||
:label="t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow')"
|
||||
:aria-label="
|
||||
t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow')
|
||||
"
|
||||
@click="selectWorkflowFilter('current')"
|
||||
>
|
||||
<span>{{
|
||||
t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow')
|
||||
}}</span>
|
||||
<span class="ml-auto inline-flex items-center">
|
||||
<template #icon>
|
||||
<i
|
||||
v-if="selectedWorkflowFilter === 'current'"
|
||||
class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</div>
|
||||
</Popover>
|
||||
<button
|
||||
<IconButton
|
||||
v-tooltip.top="sortTooltipConfig"
|
||||
class="relative inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background p-0 hover:bg-secondary-background-hover hover:opacity-90"
|
||||
type="secondary"
|
||||
size="sm"
|
||||
class="relative size-6 bg-secondary-background hover:bg-secondary-background-hover hover:opacity-90"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.sortJobs')"
|
||||
@click="onSortClick"
|
||||
>
|
||||
@@ -101,7 +103,7 @@
|
||||
v-if="selectedSortMode !== 'mostRecent'"
|
||||
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
|
||||
/>
|
||||
</button>
|
||||
</IconButton>
|
||||
<Popover
|
||||
ref="sortPopoverRef"
|
||||
:dismissable="true"
|
||||
@@ -120,19 +122,21 @@
|
||||
class="flex min-w-[12rem] flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3"
|
||||
>
|
||||
<template v-for="(mode, index) in jobSortModes" :key="mode">
|
||||
<button
|
||||
class="inline-flex w-full cursor-pointer items-center justify-start gap-1 rounded-lg border-0 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
|
||||
<IconTextButton
|
||||
class="w-full justify-between gap-1 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
|
||||
type="transparent"
|
||||
icon-position="right"
|
||||
:label="sortLabel(mode)"
|
||||
:aria-label="sortLabel(mode)"
|
||||
@click="selectSortMode(mode)"
|
||||
>
|
||||
<span>{{ sortLabel(mode) }}</span>
|
||||
<span class="ml-auto inline-flex items-center">
|
||||
<template #icon>
|
||||
<i
|
||||
v-if="selectedSortMode === mode"
|
||||
class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<div
|
||||
v-if="index < jobSortModes.length - 1"
|
||||
class="mx-2 mt-1 h-px"
|
||||
@@ -149,6 +153,9 @@ import Popover from 'primevue/popover'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import TextButton from '@/components/button/TextButton.vue'
|
||||
import { jobSortModes, jobTabs } from '@/composables/queue/useJobList'
|
||||
import type { JobSortMode, JobTab } from '@/composables/queue/useJobList'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
|
||||
@@ -108,45 +108,47 @@
|
||||
key="actions"
|
||||
class="inline-flex items-center gap-2 pr-1"
|
||||
>
|
||||
<button
|
||||
<IconButton
|
||||
v-if="props.state === 'failed' && computedShowClear"
|
||||
v-tooltip.top="deleteTooltipConfig"
|
||||
type="button"
|
||||
class="inline-flex h-6 transform cursor-pointer items-center gap-1 rounded border-0 bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background hover:opacity-95"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background hover:opacity-95"
|
||||
:aria-label="t('g.delete')"
|
||||
@click.stop="emit('delete')"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</button>
|
||||
<button
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-else-if="props.state !== 'completed' && computedShowClear"
|
||||
v-tooltip.top="cancelTooltipConfig"
|
||||
type="button"
|
||||
class="inline-flex h-6 transform cursor-pointer items-center gap-1 rounded border-0 bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background hover:opacity-95"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background hover:opacity-95"
|
||||
:aria-label="t('g.cancel')"
|
||||
@click.stop="emit('cancel')"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</button>
|
||||
<button
|
||||
</IconButton>
|
||||
<TextButton
|
||||
v-else-if="props.state === 'completed'"
|
||||
type="button"
|
||||
class="inline-flex h-6 transform cursor-pointer items-center gap-1 rounded border-0 bg-modal-card-button-surface px-2 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95"
|
||||
class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-2 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95"
|
||||
type="transparent"
|
||||
:label="t('menuLabels.View')"
|
||||
:aria-label="t('menuLabels.View')"
|
||||
@click.stop="emit('view')"
|
||||
>
|
||||
<span>{{ t('menuLabels.View') }}</span>
|
||||
</button>
|
||||
<button
|
||||
/>
|
||||
<IconButton
|
||||
v-if="props.showMenu !== undefined ? props.showMenu : true"
|
||||
v-tooltip.top="moreTooltipConfig"
|
||||
type="button"
|
||||
class="inline-flex h-6 transform cursor-pointer items-center gap-1 rounded border-0 bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95"
|
||||
:aria-label="t('g.more')"
|
||||
@click.stop="emit('menu', $event)"
|
||||
>
|
||||
<i class="icon-[lucide--more-horizontal] size-4" />
|
||||
</button>
|
||||
</IconButton>
|
||||
</div>
|
||||
<div v-else key="secondary" class="pr-2">
|
||||
<slot name="secondary">{{ props.rightText }}</slot>
|
||||
@@ -161,6 +163,8 @@
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import TextButton from '@/components/button/TextButton.vue'
|
||||
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
|
||||
import QueueAssetPreview from '@/components/queue/job/QueueAssetPreview.vue'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
@click.stop="handleNodes2ToggleClick"
|
||||
>
|
||||
<span class="p-menubar-item-label text-nowrap">{{ item.label }}</span>
|
||||
<Tag severity="info" class="ml-2 text-xs">{{ $t('g.beta') }}</Tag>
|
||||
<ToggleSwitch
|
||||
v-model="nodes2Enabled"
|
||||
class="ml-4"
|
||||
@@ -101,6 +102,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import Tag from 'primevue/tag'
|
||||
import TieredMenu from 'primevue/tieredmenu'
|
||||
import type { TieredMenuMethods, TieredMenuState } from 'primevue/tieredmenu'
|
||||
import ToggleSwitch from 'primevue/toggleswitch'
|
||||
|
||||
@@ -208,7 +208,7 @@ const shouldShowDeleteButton = computed(() => {
|
||||
|
||||
const getOutputCount = (item: AssetItem): number => {
|
||||
const count = item.user_metadata?.outputCount
|
||||
return typeof count === 'number' && count > 0 ? count : 0
|
||||
return typeof count === 'number' && count > 0 ? count : 1
|
||||
}
|
||||
|
||||
const shouldShowOutputCount = (item: AssetItem): boolean => {
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
v-if="showVueNodesBanner"
|
||||
class="pointer-events-auto relative w-full h-10 bg-gradient-to-r from-blue-600 to-blue-700 flex items-center justify-center px-4"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center text-sm">
|
||||
<i class="icon-[lucide--rocket]"></i>
|
||||
<span class="pl-2 text-sm">{{ $t('vueNodesBanner.message') }}</span>
|
||||
<span class="pl-2">{{ $t('vueNodesBanner.title') }}</span>
|
||||
<span class="pl-1.5 hidden md:inline">{{
|
||||
$t('vueNodesBanner.desc')
|
||||
}}</span>
|
||||
<Button
|
||||
class="cursor-pointer bg-transparent rounded h-7 px-3 border border-white text-white ml-4 text-xs"
|
||||
@click="handleTryItOut"
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type { NodeId } from '@/renderer/core/layout/types'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { isDOMWidget } from '@/scripts/domWidget'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
@@ -46,7 +47,7 @@ export interface SafeWidgetData {
|
||||
}
|
||||
|
||||
export interface VueNodeData {
|
||||
id: string
|
||||
id: NodeId
|
||||
title: string
|
||||
type: string
|
||||
mode: number
|
||||
@@ -78,10 +79,64 @@ export interface GraphNodeManager {
|
||||
cleanup(): void
|
||||
}
|
||||
|
||||
export function safeWidgetMapper(
|
||||
node: LGraphNode,
|
||||
slotMetadata: Map<string, WidgetSlotMetadata>
|
||||
): (widget: IBaseWidget) => SafeWidgetData {
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
return function (widget) {
|
||||
try {
|
||||
// TODO: Use widget.getReactiveData() once TypeScript types are updated
|
||||
let value = widget.value
|
||||
|
||||
// For combo widgets, if value is undefined, use the first option as default
|
||||
if (
|
||||
value === undefined &&
|
||||
widget.type === 'combo' &&
|
||||
widget.options?.values &&
|
||||
Array.isArray(widget.options.values) &&
|
||||
widget.options.values.length > 0
|
||||
) {
|
||||
value = widget.options.values[0]
|
||||
}
|
||||
const spec = nodeDefStore.getInputSpecForWidget(node, widget.name)
|
||||
const slotInfo = slotMetadata.get(widget.name)
|
||||
|
||||
return {
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
value: value,
|
||||
label: widget.label,
|
||||
options: widget.options ? { ...widget.options } : undefined,
|
||||
callback: widget.callback,
|
||||
spec,
|
||||
slotMetadata: slotInfo,
|
||||
isDOMWidget: isDOMWidget(widget)
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
name: widget.name || 'unknown',
|
||||
type: widget.type || 'text',
|
||||
value: undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidWidgetValue(value: unknown): value is WidgetValue {
|
||||
return (
|
||||
value === null ||
|
||||
value === undefined ||
|
||||
typeof value === 'string' ||
|
||||
typeof value === 'number' ||
|
||||
typeof value === 'boolean' ||
|
||||
typeof value === 'object'
|
||||
)
|
||||
}
|
||||
|
||||
export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
// Get layout mutations composable
|
||||
const { createNode, deleteNode, setSource } = useLayoutMutations()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
// Safe reactive data extracted from LiteGraph nodes
|
||||
const vueNodeData = reactive(new Map<string, VueNodeData>())
|
||||
|
||||
@@ -147,45 +202,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
linked: input.link != null
|
||||
})
|
||||
})
|
||||
return (
|
||||
node.widgets?.map((widget) => {
|
||||
try {
|
||||
// TODO: Use widget.getReactiveData() once TypeScript types are updated
|
||||
let value = widget.value
|
||||
|
||||
// For combo widgets, if value is undefined, use the first option as default
|
||||
if (
|
||||
value === undefined &&
|
||||
widget.type === 'combo' &&
|
||||
widget.options?.values &&
|
||||
Array.isArray(widget.options.values) &&
|
||||
widget.options.values.length > 0
|
||||
) {
|
||||
value = widget.options.values[0]
|
||||
}
|
||||
const spec = nodeDefStore.getInputSpecForWidget(node, widget.name)
|
||||
const slotInfo = slotMetadata.get(widget.name)
|
||||
|
||||
return {
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
value: value,
|
||||
label: widget.label,
|
||||
options: widget.options ? { ...widget.options } : undefined,
|
||||
callback: widget.callback,
|
||||
spec,
|
||||
slotMetadata: slotInfo,
|
||||
isDOMWidget: isDOMWidget(widget)
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
name: widget.name || 'unknown',
|
||||
type: widget.type || 'text',
|
||||
value: undefined
|
||||
}
|
||||
}
|
||||
}) ?? []
|
||||
)
|
||||
return node.widgets?.map(safeWidgetMapper(node, slotMetadata)) ?? []
|
||||
})
|
||||
|
||||
const nodeType =
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync'
|
||||
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
|
||||
import { ensureCorrectLayoutScale } from '@/renderer/extensions/vueNodes/layout/ensureCorrectLayoutScale'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
@@ -18,9 +19,7 @@ function useVueNodeLifecycleIndividual() {
|
||||
const canvasStore = useCanvasStore()
|
||||
const layoutMutations = useLayoutMutations()
|
||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
|
||||
const nodeManager = shallowRef<GraphNodeManager | null>(null)
|
||||
|
||||
const { startSync } = useLayoutSync()
|
||||
|
||||
const isVueNodeToastDismissed = useVueNodesMigrationDismissed()
|
||||
@@ -40,7 +39,10 @@ function useVueNodeLifecycleIndividual() {
|
||||
const nodes = activeGraph._nodes.map((node: LGraphNode) => ({
|
||||
id: node.id.toString(),
|
||||
pos: [node.pos[0], node.pos[1]] as [number, number],
|
||||
size: [node.size[0], node.size[1]] as [number, number]
|
||||
size: [node.size[0], removeNodeTitleHeight(node.size[1])] as [
|
||||
number,
|
||||
number
|
||||
]
|
||||
}))
|
||||
layoutStore.initializeFromLiteGraph(nodes)
|
||||
|
||||
|
||||
48
src/composables/maskeditor/useMaskEditor.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import TopBarHeader from '@/components/maskeditor/dialog/TopBarHeader.vue'
|
||||
import MaskEditorContent from '@/components/maskeditor/MaskEditorContent.vue'
|
||||
|
||||
export function useMaskEditor() {
|
||||
const openMaskEditor = (node: LGraphNode) => {
|
||||
if (!node) {
|
||||
console.error('[MaskEditor] No node provided')
|
||||
return
|
||||
}
|
||||
|
||||
if (!node.imgs?.length && node.previewMediaType !== 'image') {
|
||||
console.error('[MaskEditor] Node has no images')
|
||||
return
|
||||
}
|
||||
|
||||
useDialogStore().showDialog({
|
||||
key: 'global-mask-editor',
|
||||
headerComponent: TopBarHeader,
|
||||
component: MaskEditorContent,
|
||||
props: {
|
||||
node
|
||||
},
|
||||
dialogComponentProps: {
|
||||
style: 'width: 90vw; height: 90vh;',
|
||||
modal: true,
|
||||
maximizable: true,
|
||||
closable: true,
|
||||
pt: {
|
||||
root: {
|
||||
class: 'mask-editor-dialog flex flex-col'
|
||||
},
|
||||
content: {
|
||||
class: 'flex flex-col min-h-0 flex-1 !p-0'
|
||||
},
|
||||
header: {
|
||||
class: '!p-2'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
openMaskEditor
|
||||
}
|
||||
}
|
||||
@@ -1545,7 +1545,26 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
}
|
||||
},
|
||||
GeminiImageNode: {
|
||||
displayPrice: '$0.03 per 1K tokens'
|
||||
displayPrice: '~$0.039/Image (1K)'
|
||||
},
|
||||
GeminiImage2Node: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const resolutionWidget = node.widgets?.find(
|
||||
(w) => w.name === 'resolution'
|
||||
) as IComboWidget
|
||||
|
||||
if (!resolutionWidget) return 'Token-based'
|
||||
|
||||
const resolution = String(resolutionWidget.value)
|
||||
if (resolution.includes('1K')) {
|
||||
return '~$0.134/Image'
|
||||
} else if (resolution.includes('2K')) {
|
||||
return '~$0.134/Image'
|
||||
} else if (resolution.includes('4K')) {
|
||||
return '~$0.24/Image'
|
||||
}
|
||||
return 'Token-based'
|
||||
}
|
||||
},
|
||||
// OpenAI nodes
|
||||
OpenAIChatNode: {
|
||||
@@ -1829,6 +1848,7 @@ export const useNodePricing = () => {
|
||||
TripoTextureNode: ['texture_quality'],
|
||||
// Google/Gemini nodes
|
||||
GeminiNode: ['model'],
|
||||
GeminiImage2Node: ['resolution'],
|
||||
// OpenAI nodes
|
||||
OpenAIChatNode: ['model'],
|
||||
// ByteDance
|
||||
|
||||
@@ -1219,6 +1219,12 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
await settingStore.set('Comfy.Assets.UseAssetAPI', !current)
|
||||
await useWorkflowService().reloadCurrentWorkflow() // ensure changes take effect immediately
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.ToggleLinear',
|
||||
icon: 'pi pi-database',
|
||||
label: 'toggle linear mode',
|
||||
function: () => (canvasStore.linearMode = !canvasStore.linearMode)
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ export const CORE_MENU_COMMANDS = [
|
||||
[['Edit'], ['Comfy.Undo', 'Comfy.Redo']],
|
||||
[['Edit'], ['Comfy.ClearWorkflow']],
|
||||
[['Edit'], ['Comfy.OpenClipspace']],
|
||||
[['Edit'], ['Comfy.RefreshNodeDefinitions']],
|
||||
[
|
||||
['Edit'],
|
||||
[
|
||||
|
||||
@@ -5,10 +5,9 @@ import { app } from '@/scripts/app'
|
||||
import { ComfyApp } from '@/scripts/app'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import MaskEditorContent from '@/components/maskeditor/MaskEditorContent.vue'
|
||||
import TopBarHeader from '@/components/maskeditor/dialog/TopBarHeader.vue'
|
||||
import { MaskEditorDialogOld } from './maskEditorOld'
|
||||
import { ClipspaceDialog } from './clipspace'
|
||||
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
|
||||
|
||||
function openMaskEditor(node: LGraphNode): void {
|
||||
if (!node) {
|
||||
@@ -26,32 +25,7 @@ function openMaskEditor(node: LGraphNode): void {
|
||||
)
|
||||
|
||||
if (useNewEditor) {
|
||||
// Use new refactored editor
|
||||
useDialogStore().showDialog({
|
||||
key: 'global-mask-editor',
|
||||
headerComponent: TopBarHeader,
|
||||
component: MaskEditorContent,
|
||||
props: {
|
||||
node
|
||||
},
|
||||
dialogComponentProps: {
|
||||
style: 'width: 90vw; height: 90vh;',
|
||||
modal: true,
|
||||
maximizable: true,
|
||||
closable: true,
|
||||
pt: {
|
||||
root: {
|
||||
class: 'mask-editor-dialog flex flex-col'
|
||||
},
|
||||
content: {
|
||||
class: 'flex flex-col min-h-0 flex-1 !p-0'
|
||||
},
|
||||
header: {
|
||||
class: '!p-2'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
useMaskEditor().openMaskEditor(node)
|
||||
} else {
|
||||
// Use old editor
|
||||
ComfyApp.copyToClipspace(node)
|
||||
|
||||
@@ -17,10 +17,10 @@ useExtensionService().registerExtension({
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
onNodeCreated ? onNodeCreated.apply(this, []) : undefined
|
||||
|
||||
const showValueWidget = ComfyWidgets['STRING'](
|
||||
const showValueWidget = ComfyWidgets['MARKDOWN'](
|
||||
this,
|
||||
'preview',
|
||||
['STRING', { multiline: true }],
|
||||
['MARKDOWN', {}],
|
||||
app
|
||||
).widget as DOMWidget<HTMLTextAreaElement, string>
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ export type {
|
||||
LGraphTriggerParam
|
||||
} from './types/graphTriggers'
|
||||
|
||||
export type rendererType = 'LG' | 'Vue'
|
||||
export type RendererType = 'LG' | 'Vue'
|
||||
|
||||
export interface LGraphState {
|
||||
lastGroupId: number
|
||||
@@ -106,7 +106,7 @@ export interface LGraphExtra extends Dictionary<unknown> {
|
||||
reroutes?: SerialisableReroute[]
|
||||
linkExtensions?: { id: number; parentId: number | undefined }[]
|
||||
ds?: DragAndScaleState
|
||||
workflowRendererVersion?: rendererType
|
||||
workflowRendererVersion?: RendererType
|
||||
}
|
||||
|
||||
export interface BaseLGraph {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculatio
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
|
||||
|
||||
import { CanvasPointer } from './CanvasPointer'
|
||||
import type { ContextMenu } from './ContextMenu'
|
||||
@@ -1771,18 +1772,19 @@ export class LGraphCanvas
|
||||
}
|
||||
|
||||
static onMenuNodeClone(
|
||||
// @ts-expect-error - unused parameter
|
||||
value: IContextMenuValue,
|
||||
// @ts-expect-error - unused parameter
|
||||
options: IContextMenuOptions,
|
||||
// @ts-expect-error - unused parameter
|
||||
e: MouseEvent,
|
||||
// @ts-expect-error - unused parameter
|
||||
menu: ContextMenu,
|
||||
_value: IContextMenuValue,
|
||||
_options: IContextMenuOptions,
|
||||
_e: MouseEvent,
|
||||
_menu: ContextMenu,
|
||||
node: LGraphNode
|
||||
): void {
|
||||
const canvas = LGraphCanvas.active_canvas
|
||||
const nodes = canvas.selectedItems.size ? canvas.selectedItems : [node]
|
||||
const nodes = canvas.selectedItems.size ? [...canvas.selectedItems] : [node]
|
||||
if (nodes.length) LGraphCanvas.cloneNodes(nodes)
|
||||
}
|
||||
|
||||
static cloneNodes(nodes: Positionable[]) {
|
||||
const canvas = LGraphCanvas.active_canvas
|
||||
|
||||
// Find top-left-most boundary
|
||||
let offsetX = Infinity
|
||||
@@ -1792,11 +1794,11 @@ export class LGraphCanvas
|
||||
throw new TypeError(
|
||||
'Invalid node encountered on clone. `pos` was null.'
|
||||
)
|
||||
if (item.pos[0] < offsetX) offsetX = item.pos[0]
|
||||
if (item.pos[1] < offsetY) offsetY = item.pos[1]
|
||||
offsetX = Math.min(offsetX, item.pos[0])
|
||||
offsetY = Math.min(offsetY, item.pos[1])
|
||||
}
|
||||
|
||||
canvas._deserializeItems(canvas._serializeItems(nodes), {
|
||||
return canvas._deserializeItems(canvas._serializeItems(nodes), {
|
||||
position: [offsetX + 5, offsetY + 5]
|
||||
})
|
||||
}
|
||||
@@ -4042,16 +4044,25 @@ export class LGraphCanvas
|
||||
|
||||
// TODO: Report failures, i.e. `failedNodes`
|
||||
|
||||
const newPositions = created.map((node) => ({
|
||||
nodeId: String(node.id),
|
||||
bounds: {
|
||||
x: node.pos[0],
|
||||
y: node.pos[1],
|
||||
width: node.size?.[0] ?? 100,
|
||||
height: node.size?.[1] ?? 200
|
||||
}
|
||||
}))
|
||||
const newPositions = created
|
||||
.filter((item): item is LGraphNode => item instanceof LGraphNode)
|
||||
.map((node) => {
|
||||
const fullHeight = node.size?.[1] ?? 200
|
||||
const layoutHeight = LiteGraph.vueNodesMode
|
||||
? removeNodeTitleHeight(fullHeight)
|
||||
: fullHeight
|
||||
return {
|
||||
nodeId: String(node.id),
|
||||
bounds: {
|
||||
x: node.pos[0],
|
||||
y: node.pos[1],
|
||||
width: node.size?.[0] ?? 100,
|
||||
height: layoutHeight
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (newPositions.length) layoutStore.setSource(LayoutSource.Canvas)
|
||||
layoutStore.batchUpdateNodeBounds(newPositions)
|
||||
|
||||
this.selectItems(created)
|
||||
|
||||
@@ -2,7 +2,8 @@ import { LGraphNodeProperties } from '@/lib/litegraph/src/LGraphNodeProperties'
|
||||
import {
|
||||
calculateInputSlotPos,
|
||||
calculateInputSlotPosFromSlot,
|
||||
calculateOutputSlotPos
|
||||
calculateOutputSlotPos,
|
||||
getSlotPosition
|
||||
} from '@/renderer/core/canvas/litegraph/slotCalculations'
|
||||
import type { SlotPositionContext } from '@/renderer/core/canvas/litegraph/slotCalculations'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
@@ -3340,6 +3341,16 @@ export class LGraphNode
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get slot position using layout tree if available, fallback to node's position * Unified implementation used by both LitegraphLinkAdapter and useLinkLayoutSync
|
||||
* @param slotIndex The slot index
|
||||
* @param isInput Whether this is an input slot
|
||||
* @returns Position of the slot center in graph coordinates
|
||||
*/
|
||||
getSlotPosition(slotIndex: number, isInput: boolean): Point {
|
||||
return getSlotPosition(this, slotIndex, isInput)
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
snapToGrid(snapTo: number): boolean {
|
||||
return this.pinned ? false : snapPoint(this.pos, snapTo)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"g": {
|
||||
"beta": "Beta",
|
||||
"user": "User",
|
||||
"currentUser": "Current user",
|
||||
"empty": "Empty",
|
||||
@@ -504,6 +505,8 @@
|
||||
"cannotWrite": "Unable to write to the selected path",
|
||||
"insufficientFreeSpace": "Insufficient space - minimum free space",
|
||||
"isOneDrive": "OneDrive is not supported. Please install ComfyUI in another location.",
|
||||
"insideAppInstallDir": "This folder is inside the ComfyUI Desktop application bundle and will be deleted during updates. Choose a directory outside the install folder, such as Documents/ComfyUI.",
|
||||
"insideUpdaterCache": "This folder is inside the ComfyUI updater cache, which is cleared on every update. Select a different location for your data.",
|
||||
"nonDefaultDrive": "Please install ComfyUI on your system drive (eg. C:\\). Drives with different file systems may cause unpredicable issues. Models and other files can be stored on other drives after installation.",
|
||||
"parentMissing": "Path does not exist - create the containing directory first",
|
||||
"unhandledError": "Unknown error",
|
||||
@@ -1497,6 +1500,14 @@
|
||||
"taskFailed": "Task failed to run.",
|
||||
"cannotContinue": "Unable to continue - errors remain",
|
||||
"defaultDescription": "An error occurred while running a maintenance task."
|
||||
},
|
||||
"unsafeMigration": {
|
||||
"title": "Unsafe install location detected",
|
||||
"generic": "Your current ComfyUI base path is in a location that may be deleted or modified during updates. To avoid data loss, move it to a safe folder.",
|
||||
"appInstallDir": "Your base path is inside the ComfyUI Desktop application bundle. This folder may be deleted or overwritten during updates. Choose a directory outside the install folder, such as Documents/ComfyUI.",
|
||||
"updaterCache": "Your base path is inside the ComfyUI updater cache, which is cleared on each update. Choose a different location for your data.",
|
||||
"oneDrive": "Your base path is on OneDrive, which can cause sync issues and accidental data loss. Choose a local folder that is not managed by OneDrive.",
|
||||
"action": "Use the \"Base path\" maintenance task below to move ComfyUI to a safe location."
|
||||
}
|
||||
},
|
||||
"missingModelsDialog": {
|
||||
@@ -2075,7 +2086,36 @@
|
||||
"failedToCreateNode": "Failed to create node. Please try again or check console for details.",
|
||||
"noModelsInFolder": "No {type} available in this folder",
|
||||
"searchAssetsPlaceholder": "Type to search...",
|
||||
"uploadModel": "Upload model",
|
||||
"uploadModel": "Import model",
|
||||
"uploadModelFromCivitai": "Import a model from Civitai",
|
||||
"uploadModelFailedToRetrieveMetadata": "Failed to retrieve metadata. Please check the link and try again.",
|
||||
"onlyCivitaiUrlsSupported": "Only Civitai URLs are supported",
|
||||
"uploadModelDescription1": "Paste a Civitai model download link to add it to your library.",
|
||||
"uploadModelDescription2": "Only links from https://civitai.com are supported at the moment",
|
||||
"uploadModelDescription3": "Max file size: 1 GB",
|
||||
"civitaiLinkLabel": "Civitai model download link",
|
||||
"civitaiLinkPlaceholder": "Paste link here",
|
||||
"civitaiLinkExample": "Example: https://civitai.com/api/download/models/833921?type=Model&format=SafeTensor",
|
||||
"confirmModelDetails": "Confirm Model Details",
|
||||
"fileName": "File Name",
|
||||
"fileSize": "File Size",
|
||||
"modelName": "Model Name",
|
||||
"modelNamePlaceholder": "Enter a name for this model",
|
||||
"tags": "Tags",
|
||||
"tagsPlaceholder": "e.g., models, checkpoint",
|
||||
"tagsHelp": "Separate tags with commas",
|
||||
"upload": "Import",
|
||||
"uploadingModel": "Importing model...",
|
||||
"uploadSuccess": "Model imported successfully!",
|
||||
"uploadFailed": "Import failed",
|
||||
"modelAssociatedWithLink": "The model associated with the link you provided:",
|
||||
"modelTypeSelectorLabel": "What type of model is this?",
|
||||
"modelTypeSelectorPlaceholder": "Select model type",
|
||||
"selectModelType": "Select model type",
|
||||
"notSureLeaveAsIs": "Not sure? Just leave this as is",
|
||||
"modelUploaded": "Model imported! 🎉",
|
||||
"findInLibrary": "Find it in the {type} section of the models library.",
|
||||
"finish": "Finish",
|
||||
"allModels": "All Models",
|
||||
"allCategory": "All {category}",
|
||||
"unknown": "Unknown",
|
||||
@@ -2087,6 +2127,13 @@
|
||||
"sortZA": "Z-A",
|
||||
"sortRecent": "Recent",
|
||||
"sortPopular": "Popular",
|
||||
"errorFileTooLarge": "File exceeds the maximum allowed size limit",
|
||||
"errorFormatNotAllowed": "Only SafeTensor format is allowed",
|
||||
"errorUnsafePickleScan": "CivitAI detected potentially unsafe code in this file",
|
||||
"errorUnsafeVirusScan": "CivitAI detected malware or suspicious content in this file",
|
||||
"errorModelTypeNotSupported": "This model type is not supported",
|
||||
"errorUnknown": "An unexpected error occurred",
|
||||
"errorUploadFailed": "Failed to import asset. Please try again.",
|
||||
"ariaLabel": {
|
||||
"assetCard": "{name} - {type} asset",
|
||||
"loadingAsset": "Loading asset"
|
||||
@@ -2151,7 +2198,8 @@
|
||||
}
|
||||
},
|
||||
"vueNodesBanner": {
|
||||
"message": "Introducing Nodes 2.0 – More flexible workflows, powerful new widgets, built for extensibility",
|
||||
"title": "Introducing Nodes 2.0",
|
||||
"desc": "– More flexible workflows, powerful new widgets, built for extensibility",
|
||||
"tryItOut": "Try it out"
|
||||
},
|
||||
"vueNodesMigration": {
|
||||
@@ -2161,6 +2209,10 @@
|
||||
"vueNodesMigrationMainMenu": {
|
||||
"message": "Switch back to Nodes 2.0 anytime from the main menu."
|
||||
},
|
||||
"linearMode": {
|
||||
"share": "Share",
|
||||
"openWorkflow": "Open Workflow"
|
||||
},
|
||||
"missingNodes": {
|
||||
"cloud": {
|
||||
"title": "These nodes aren't available on Comfy Cloud yet",
|
||||
@@ -2176,4 +2228,4 @@
|
||||
"replacementInstruction": "Install these nodes to run this workflow, or replace them with installed alternatives. Missing nodes are highlighted in red on the canvas."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,11 +335,11 @@
|
||||
"name": "Validate workflows"
|
||||
},
|
||||
"Comfy_VueNodes_AutoScaleLayout": {
|
||||
"name": "Auto-scale layout (Vue nodes)",
|
||||
"name": "Auto-scale layout (Nodes 2.0)",
|
||||
"tooltip": "Automatically scale node positions when switching to Vue rendering to prevent overlap"
|
||||
},
|
||||
"Comfy_VueNodes_Enabled": {
|
||||
"name": "Modern Node Design (Vue Nodes)",
|
||||
"name": "Modern Node Design (Nodes 2.0)",
|
||||
"tooltip": "Modern: DOM-based rendering with enhanced interactivity, native browser features, and updated visual design. Classic: Traditional canvas rendering."
|
||||
},
|
||||
"Comfy_WidgetControlMode": {
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
:on-click="handleUploadClick"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--upload]" />
|
||||
<i class="icon-[lucide--package-plus]" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</div>
|
||||
@@ -73,11 +73,14 @@ import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import AssetFilterBar from '@/platform/assets/components/AssetFilterBar.vue'
|
||||
import AssetGrid from '@/platform/assets/components/AssetGrid.vue'
|
||||
import UploadModelDialog from '@/platform/assets/components/UploadModelDialog.vue'
|
||||
import UploadModelDialogHeader from '@/platform/assets/components/UploadModelDialogHeader.vue'
|
||||
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { formatCategoryLabel } from '@/platform/assets/utils/categoryLabel'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
|
||||
@@ -92,6 +95,7 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'asset-select': [asset: AssetDisplayItem]
|
||||
@@ -189,6 +193,15 @@ const { flags } = useFeatureFlags()
|
||||
const isUploadButtonEnabled = computed(() => flags.modelUploadButtonEnabled)
|
||||
|
||||
function handleUploadClick() {
|
||||
// Will be implemented in the future commit
|
||||
dialogStore.showDialog({
|
||||
key: 'upload-model',
|
||||
headerComponent: UploadModelDialogHeader,
|
||||
component: UploadModelDialog,
|
||||
props: {
|
||||
onUploadSuccess: async () => {
|
||||
await execute()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
58
src/platform/assets/components/UploadModelConfirmation.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Model Info Section -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-sm text-muted m-0">
|
||||
{{ $t('assetBrowser.modelAssociatedWithLink') }}
|
||||
</p>
|
||||
<p class="text-sm mt-0">
|
||||
{{ metadata?.name || metadata?.filename }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Model Type Selection -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm text-muted">
|
||||
{{ $t('assetBrowser.modelTypeSelectorLabel') }}
|
||||
</label>
|
||||
<SingleSelect
|
||||
v-model="selectedModelType"
|
||||
:label="
|
||||
isLoading
|
||||
? $t('g.loading')
|
||||
: $t('assetBrowser.modelTypeSelectorPlaceholder')
|
||||
"
|
||||
:options="modelTypes"
|
||||
:disabled="isLoading"
|
||||
/>
|
||||
<div class="flex items-center gap-2 text-sm text-muted">
|
||||
<i class="icon-[lucide--info]" />
|
||||
<span>{{ $t('assetBrowser.notSureLeaveAsIs') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
|
||||
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string | undefined
|
||||
metadata: AssetMetadata | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string | undefined]
|
||||
}>()
|
||||
|
||||
const { modelTypes, isLoading } = useModelTypes()
|
||||
|
||||
const selectedModelType = computed({
|
||||
get: () => props.modelValue ?? null,
|
||||
set: (value: string | null) => emit('update:modelValue', value ?? undefined)
|
||||
})
|
||||
</script>
|
||||
108
src/platform/assets/components/UploadModelDialog.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div class="upload-model-dialog flex flex-col justify-between gap-6 p-4 pt-6">
|
||||
<!-- Step 1: Enter URL -->
|
||||
<UploadModelUrlInput
|
||||
v-if="currentStep === 1"
|
||||
v-model="wizardData.url"
|
||||
:error="uploadError"
|
||||
/>
|
||||
|
||||
<!-- Step 2: Confirm Metadata -->
|
||||
<UploadModelConfirmation
|
||||
v-else-if="currentStep === 2"
|
||||
v-model="selectedModelType"
|
||||
:metadata="wizardData.metadata"
|
||||
/>
|
||||
|
||||
<!-- Step 3: Upload Progress -->
|
||||
<UploadModelProgress
|
||||
v-else-if="currentStep === 3"
|
||||
:status="uploadStatus"
|
||||
:error="uploadError"
|
||||
:metadata="wizardData.metadata"
|
||||
:model-type="selectedModelType"
|
||||
/>
|
||||
|
||||
<!-- Navigation Footer -->
|
||||
<UploadModelFooter
|
||||
:current-step="currentStep"
|
||||
:is-fetching-metadata="isFetchingMetadata"
|
||||
:is-uploading="isUploading"
|
||||
:can-fetch-metadata="canFetchMetadata"
|
||||
:can-upload-model="canUploadModel"
|
||||
:upload-status="uploadStatus"
|
||||
@back="goToPreviousStep"
|
||||
@fetch-metadata="handleFetchMetadata"
|
||||
@upload="handleUploadModel"
|
||||
@close="handleClose"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
import UploadModelConfirmation from '@/platform/assets/components/UploadModelConfirmation.vue'
|
||||
import UploadModelFooter from '@/platform/assets/components/UploadModelFooter.vue'
|
||||
import UploadModelProgress from '@/platform/assets/components/UploadModelProgress.vue'
|
||||
import UploadModelUrlInput from '@/platform/assets/components/UploadModelUrlInput.vue'
|
||||
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
|
||||
import { useUploadModelWizard } from '@/platform/assets/composables/useUploadModelWizard'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
const { modelTypes, fetchModelTypes } = useModelTypes()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'upload-success': []
|
||||
}>()
|
||||
|
||||
const {
|
||||
currentStep,
|
||||
isFetchingMetadata,
|
||||
isUploading,
|
||||
uploadStatus,
|
||||
uploadError,
|
||||
wizardData,
|
||||
selectedModelType,
|
||||
canFetchMetadata,
|
||||
canUploadModel,
|
||||
fetchMetadata,
|
||||
uploadModel,
|
||||
goToPreviousStep
|
||||
} = useUploadModelWizard(modelTypes)
|
||||
|
||||
async function handleFetchMetadata() {
|
||||
await fetchMetadata()
|
||||
}
|
||||
|
||||
async function handleUploadModel() {
|
||||
const success = await uploadModel()
|
||||
if (success) {
|
||||
emit('upload-success')
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
dialogStore.closeDialog({ key: 'upload-model' })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchModelTypes()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.upload-model-dialog {
|
||||
width: 90vw;
|
||||
max-width: 800px;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.upload-model-dialog {
|
||||
width: auto;
|
||||
min-width: 600px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
12
src/platform/assets/components/UploadModelDialogHeader.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-3 px-4 py-2 font-bold">
|
||||
<span>{{ $t('assetBrowser.uploadModelFromCivitai') }}</span>
|
||||
<span
|
||||
class="rounded-full bg-white px-1.5 py-0 text-xxs font-medium uppercase text-black"
|
||||
>
|
||||
{{ $t('g.beta') }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
72
src/platform/assets/components/UploadModelFooter.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<div class="flex justify-end gap-2">
|
||||
<TextButton
|
||||
v-if="currentStep !== 1 && currentStep !== 3"
|
||||
:label="$t('g.back')"
|
||||
type="secondary"
|
||||
size="md"
|
||||
:disabled="isFetchingMetadata || isUploading"
|
||||
@click="emit('back')"
|
||||
/>
|
||||
<span v-else />
|
||||
|
||||
<IconTextButton
|
||||
v-if="currentStep === 1"
|
||||
:label="$t('g.continue')"
|
||||
type="primary"
|
||||
size="md"
|
||||
:disabled="!canFetchMetadata || isFetchingMetadata"
|
||||
@click="emit('fetchMetadata')"
|
||||
>
|
||||
<template #icon>
|
||||
<i
|
||||
v-if="isFetchingMetadata"
|
||||
class="icon-[lucide--loader-circle] animate-spin"
|
||||
/>
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<IconTextButton
|
||||
v-else-if="currentStep === 2"
|
||||
:label="$t('assetBrowser.upload')"
|
||||
type="primary"
|
||||
size="md"
|
||||
:disabled="!canUploadModel || isUploading"
|
||||
@click="emit('upload')"
|
||||
>
|
||||
<template #icon>
|
||||
<i
|
||||
v-if="isUploading"
|
||||
class="icon-[lucide--loader-circle] animate-spin"
|
||||
/>
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<TextButton
|
||||
v-else-if="currentStep === 3 && uploadStatus === 'success'"
|
||||
:label="$t('assetBrowser.finish')"
|
||||
type="primary"
|
||||
size="md"
|
||||
@click="emit('close')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import TextButton from '@/components/button/TextButton.vue'
|
||||
|
||||
defineProps<{
|
||||
currentStep: number
|
||||
isFetchingMetadata: boolean
|
||||
isUploading: boolean
|
||||
canFetchMetadata: boolean
|
||||
canUploadModel: boolean
|
||||
uploadStatus: 'idle' | 'uploading' | 'success' | 'error'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'back'): void
|
||||
(e: 'fetchMetadata'): void
|
||||
(e: 'upload'): void
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
</script>
|
||||
68
src/platform/assets/components/UploadModelProgress.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div class="flex flex-1 flex-col gap-6">
|
||||
<!-- Uploading State -->
|
||||
<div
|
||||
v-if="status === 'uploading'"
|
||||
class="flex flex-1 flex-col items-center justify-center gap-6"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--loader-circle] animate-spin text-6xl text-primary"
|
||||
/>
|
||||
<div class="text-center">
|
||||
<p class="m-0 text-sm font-bold">
|
||||
{{ $t('assetBrowser.uploadingModel') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success State -->
|
||||
<div v-else-if="status === 'success'" class="flex flex-col gap-8">
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-sm text-muted m-0 font-bold">
|
||||
{{ $t('assetBrowser.modelUploaded') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted m-0">
|
||||
{{ $t('assetBrowser.findInLibrary', { type: modelType }) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row items-start p-8 bg-neutral-800 rounded-lg">
|
||||
<div class="flex flex-col justify-center items-start gap-1 flex-1">
|
||||
<p class="text-sm m-0">
|
||||
{{ metadata?.name || metadata?.filename }}
|
||||
</p>
|
||||
<p class="text-sm text-muted m-0">
|
||||
{{ modelType }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div
|
||||
v-else-if="status === 'error'"
|
||||
class="flex flex-1 flex-col items-center justify-center gap-6"
|
||||
>
|
||||
<i class="icon-[lucide--x-circle] text-6xl text-error" />
|
||||
<div class="text-center">
|
||||
<p class="m-0 text-sm font-bold">
|
||||
{{ $t('assetBrowser.uploadFailed') }}
|
||||
</p>
|
||||
<p v-if="error" class="text-sm text-muted mb-0">
|
||||
{{ error }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
defineProps<{
|
||||
status: 'idle' | 'uploading' | 'success' | 'error'
|
||||
error?: string
|
||||
metadata: AssetMetadata | null
|
||||
modelType: string | undefined
|
||||
}>()
|
||||
</script>
|
||||
50
src/platform/assets/components/UploadModelUrlInput.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-sm text-muted m-0">
|
||||
{{ $t('assetBrowser.uploadModelDescription1') }}
|
||||
</p>
|
||||
<ul class="list-disc space-y-1 pl-5 mt-0 text-sm text-muted">
|
||||
<li>{{ $t('assetBrowser.uploadModelDescription2') }}</li>
|
||||
<li>{{ $t('assetBrowser.uploadModelDescription3') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm text-muted mb-0">
|
||||
{{ $t('assetBrowser.civitaiLinkLabel') }}
|
||||
</label>
|
||||
<UrlInput
|
||||
v-model="url"
|
||||
:placeholder="$t('assetBrowser.civitaiLinkPlaceholder')"
|
||||
:disable-validation="true"
|
||||
/>
|
||||
<p v-if="error" class="text-xs text-error">
|
||||
{{ error }}
|
||||
</p>
|
||||
<p v-else class="text-xs text-muted">
|
||||
{{ $t('assetBrowser.civitaiLinkExample') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import UrlInput from '@/components/common/UrlInput.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
error?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const url = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: string) => emit('update:modelValue', value)
|
||||
})
|
||||
</script>
|
||||
73
src/platform/assets/composables/useModelTypes.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { createSharedComposable, useAsyncState } from '@vueuse/core'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
/**
|
||||
* Format folder name to display name
|
||||
* Converts "upscale_models" -> "Upscale Models"
|
||||
* Converts "loras" -> "LoRAs"
|
||||
*/
|
||||
function formatDisplayName(folderName: string): string {
|
||||
// Special cases for acronyms and proper nouns
|
||||
const specialCases: Record<string, string> = {
|
||||
loras: 'LoRAs',
|
||||
ipadapter: 'IP-Adapter',
|
||||
sams: 'SAMs',
|
||||
clip_vision: 'CLIP Vision',
|
||||
animatediff_motion_lora: 'AnimateDiff Motion LoRA',
|
||||
animatediff_models: 'AnimateDiff Models',
|
||||
vae: 'VAE',
|
||||
sam2: 'SAM 2',
|
||||
controlnet: 'ControlNet',
|
||||
gligen: 'GLIGEN'
|
||||
}
|
||||
|
||||
if (specialCases[folderName]) {
|
||||
return specialCases[folderName]
|
||||
}
|
||||
|
||||
return folderName
|
||||
.split('_')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
interface ModelTypeOption {
|
||||
name: string // Display name
|
||||
value: string // Actual tag value
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for fetching and managing model types from the API
|
||||
* Uses shared state to ensure data is only fetched once
|
||||
*/
|
||||
export const useModelTypes = createSharedComposable(() => {
|
||||
const {
|
||||
state: modelTypes,
|
||||
isLoading,
|
||||
error,
|
||||
execute: fetchModelTypes
|
||||
} = useAsyncState(
|
||||
async (): Promise<ModelTypeOption[]> => {
|
||||
const response = await api.getModelFolders()
|
||||
return response.map((folder) => ({
|
||||
name: formatDisplayName(folder.name),
|
||||
value: folder.name
|
||||
}))
|
||||
},
|
||||
[] as ModelTypeOption[],
|
||||
{
|
||||
immediate: false,
|
||||
onError: (err) => {
|
||||
console.error('Failed to fetch model types:', err)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
modelTypes,
|
||||
isLoading,
|
||||
error,
|
||||
fetchModelTypes
|
||||
}
|
||||
})
|
||||
175
src/platform/assets/composables/useUploadModelWizard.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { st } from '@/i18n'
|
||||
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
|
||||
interface WizardData {
|
||||
url: string
|
||||
metadata: AssetMetadata | null
|
||||
name: string
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
interface ModelTypeOption {
|
||||
name: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
|
||||
const currentStep = ref(1)
|
||||
const isFetchingMetadata = ref(false)
|
||||
const isUploading = ref(false)
|
||||
const uploadStatus = ref<'idle' | 'uploading' | 'success' | 'error'>('idle')
|
||||
const uploadError = ref('')
|
||||
|
||||
const wizardData = ref<WizardData>({
|
||||
url: '',
|
||||
metadata: null,
|
||||
name: '',
|
||||
tags: []
|
||||
})
|
||||
|
||||
const selectedModelType = ref<string | undefined>(undefined)
|
||||
|
||||
// Clear error when URL changes
|
||||
watch(
|
||||
() => wizardData.value.url,
|
||||
() => {
|
||||
uploadError.value = ''
|
||||
}
|
||||
)
|
||||
|
||||
// Validation
|
||||
const canFetchMetadata = computed(() => {
|
||||
return wizardData.value.url.trim().length > 0
|
||||
})
|
||||
|
||||
const canUploadModel = computed(() => {
|
||||
return !!selectedModelType.value
|
||||
})
|
||||
|
||||
function isCivitaiUrl(url: string): boolean {
|
||||
try {
|
||||
const hostname = new URL(url).hostname.toLowerCase()
|
||||
return hostname === 'civitai.com' || hostname.endsWith('.civitai.com')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMetadata() {
|
||||
if (!canFetchMetadata.value) return
|
||||
|
||||
if (!isCivitaiUrl(wizardData.value.url)) {
|
||||
uploadError.value = st(
|
||||
'assetBrowser.onlyCivitaiUrlsSupported',
|
||||
'Only Civitai URLs are supported'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
isFetchingMetadata.value = true
|
||||
try {
|
||||
const metadata = await assetService.getAssetMetadata(wizardData.value.url)
|
||||
wizardData.value.metadata = metadata
|
||||
|
||||
// Pre-fill name from metadata
|
||||
wizardData.value.name = metadata.filename || metadata.name || ''
|
||||
|
||||
// Pre-fill model type from metadata tags if available
|
||||
if (metadata.tags && metadata.tags.length > 0) {
|
||||
wizardData.value.tags = metadata.tags
|
||||
// Try to detect model type from tags
|
||||
const typeTag = metadata.tags.find((tag) =>
|
||||
modelTypes.value.some((type) => type.value === tag)
|
||||
)
|
||||
if (typeTag) {
|
||||
selectedModelType.value = typeTag
|
||||
}
|
||||
}
|
||||
|
||||
currentStep.value = 2
|
||||
} catch (error) {
|
||||
console.error('Failed to retrieve metadata:', error)
|
||||
uploadError.value =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: st(
|
||||
'assetBrowser.uploadModelFailedToRetrieveMetadata',
|
||||
'Failed to retrieve metadata. Please check the link and try again.'
|
||||
)
|
||||
currentStep.value = 1
|
||||
} finally {
|
||||
isFetchingMetadata.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadModel() {
|
||||
if (!canUploadModel.value) return
|
||||
|
||||
isUploading.value = true
|
||||
uploadStatus.value = 'uploading'
|
||||
|
||||
try {
|
||||
const tags = selectedModelType.value
|
||||
? ['models', selectedModelType.value]
|
||||
: ['models']
|
||||
const filename =
|
||||
wizardData.value.metadata?.filename ||
|
||||
wizardData.value.metadata?.name ||
|
||||
'model'
|
||||
|
||||
await assetService.uploadAssetFromUrl({
|
||||
url: wizardData.value.url,
|
||||
name: filename,
|
||||
tags,
|
||||
user_metadata: {
|
||||
source: 'civitai',
|
||||
source_url: wizardData.value.url,
|
||||
model_type: selectedModelType.value
|
||||
}
|
||||
})
|
||||
|
||||
uploadStatus.value = 'success'
|
||||
currentStep.value = 3
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to upload asset:', error)
|
||||
uploadStatus.value = 'error'
|
||||
uploadError.value =
|
||||
error instanceof Error ? error.message : 'Failed to upload model'
|
||||
currentStep.value = 3
|
||||
return false
|
||||
} finally {
|
||||
isUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goToPreviousStep() {
|
||||
if (currentStep.value > 1) {
|
||||
currentStep.value = currentStep.value - 1
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
currentStep,
|
||||
isFetchingMetadata,
|
||||
isUploading,
|
||||
uploadStatus,
|
||||
uploadError,
|
||||
wizardData,
|
||||
selectedModelType,
|
||||
|
||||
// Computed
|
||||
canFetchMetadata,
|
||||
canUploadModel,
|
||||
|
||||
// Actions
|
||||
fetchMetadata,
|
||||
uploadModel,
|
||||
goToPreviousStep
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,29 @@ const zModelFile = z.object({
|
||||
pathIndex: z.number()
|
||||
})
|
||||
|
||||
const zValidationError = z.object({
|
||||
code: z.string(),
|
||||
message: z.string(),
|
||||
field: z.string()
|
||||
})
|
||||
|
||||
const zValidationResult = z.object({
|
||||
is_valid: z.boolean(),
|
||||
errors: z.array(zValidationError).optional(),
|
||||
warnings: z.array(zValidationError).optional()
|
||||
})
|
||||
|
||||
const zAssetMetadata = z.object({
|
||||
content_length: z.number(),
|
||||
final_url: z.string(),
|
||||
content_type: z.string().optional(),
|
||||
filename: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
preview_url: z.string().optional(),
|
||||
validation: zValidationResult.optional()
|
||||
})
|
||||
|
||||
// Filename validation schema
|
||||
export const assetFilenameSchema = z
|
||||
.string()
|
||||
@@ -48,6 +71,7 @@ export const assetResponseSchema = zAssetResponse
|
||||
// Export types derived from Zod schemas
|
||||
export type AssetItem = z.infer<typeof zAsset>
|
||||
export type AssetResponse = z.infer<typeof zAssetResponse>
|
||||
export type AssetMetadata = z.infer<typeof zAssetMetadata>
|
||||
export type ModelFolder = z.infer<typeof zModelFolder>
|
||||
export type ModelFile = z.infer<typeof zModelFile>
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { fromZodError } from 'zod-validation-error'
|
||||
|
||||
import { st } from '@/i18n'
|
||||
import { assetResponseSchema } from '@/platform/assets/schemas/assetSchema'
|
||||
import type {
|
||||
AssetItem,
|
||||
AssetMetadata,
|
||||
AssetResponse,
|
||||
ModelFile,
|
||||
ModelFolder
|
||||
@@ -10,6 +12,36 @@ import type {
|
||||
import { api } from '@/scripts/api'
|
||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
|
||||
/**
|
||||
* Maps CivitAI validation error codes to localized error messages
|
||||
*/
|
||||
function getLocalizedErrorMessage(errorCode: string): string {
|
||||
const errorMessages: Record<string, string> = {
|
||||
FILE_TOO_LARGE: st('assetBrowser.errorFileTooLarge', 'File too large'),
|
||||
FORMAT_NOT_ALLOWED: st(
|
||||
'assetBrowser.errorFormatNotAllowed',
|
||||
'Format not allowed'
|
||||
),
|
||||
UNSAFE_PICKLE_SCAN: st(
|
||||
'assetBrowser.errorUnsafePickleScan',
|
||||
'Unsafe pickle scan'
|
||||
),
|
||||
UNSAFE_VIRUS_SCAN: st(
|
||||
'assetBrowser.errorUnsafeVirusScan',
|
||||
'Unsafe virus scan'
|
||||
),
|
||||
MODEL_TYPE_NOT_SUPPORTED: st(
|
||||
'assetBrowser.errorModelTypeNotSupported',
|
||||
'Model type not supported'
|
||||
)
|
||||
}
|
||||
return (
|
||||
errorMessages[errorCode] ||
|
||||
st('assetBrowser.errorUnknown', 'Unknown error') ||
|
||||
'Unknown error'
|
||||
)
|
||||
}
|
||||
|
||||
const ASSETS_ENDPOINT = '/assets'
|
||||
const EXPERIMENTAL_WARNING = `EXPERIMENTAL: If you are seeing this please make sure "Comfy.Assets.UseAssetAPI" is set to "false" in your ComfyUI Settings.\n`
|
||||
const DEFAULT_LIMIT = 500
|
||||
@@ -249,6 +281,77 @@ function createAssetService() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves metadata from a download URL without downloading the file
|
||||
*
|
||||
* @param url - Download URL to retrieve metadata from (will be URL-encoded)
|
||||
* @returns Promise with metadata including content_length, final_url, filename, etc.
|
||||
* @throws Error if metadata retrieval fails
|
||||
*/
|
||||
async function getAssetMetadata(url: string): Promise<AssetMetadata> {
|
||||
const encodedUrl = encodeURIComponent(url)
|
||||
const res = await api.fetchApi(
|
||||
`${ASSETS_ENDPOINT}/remote-metadata?url=${encodedUrl}`
|
||||
)
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json().catch(() => ({}))
|
||||
throw new Error(
|
||||
getLocalizedErrorMessage(errorData.code || 'UNKNOWN_ERROR')
|
||||
)
|
||||
}
|
||||
|
||||
const data: AssetMetadata = await res.json()
|
||||
if (data.validation?.is_valid === false) {
|
||||
throw new Error(
|
||||
getLocalizedErrorMessage(
|
||||
data.validation?.errors?.[0]?.code || 'UNKNOWN_ERROR'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads an asset by providing a URL to download from
|
||||
*
|
||||
* @param params - Upload parameters
|
||||
* @param params.url - HTTP/HTTPS URL to download from
|
||||
* @param params.name - Display name (determines extension)
|
||||
* @param params.tags - Optional freeform tags
|
||||
* @param params.user_metadata - Optional custom metadata object
|
||||
* @param params.preview_id - Optional UUID for preview asset
|
||||
* @returns Promise<AssetItem & { created_new: boolean }> - Asset object with created_new flag
|
||||
* @throws Error if upload fails
|
||||
*/
|
||||
async function uploadAssetFromUrl(params: {
|
||||
url: string
|
||||
name: string
|
||||
tags?: string[]
|
||||
user_metadata?: Record<string, any>
|
||||
preview_id?: string
|
||||
}): Promise<AssetItem & { created_new: boolean }> {
|
||||
const res = await api.fetchApi(ASSETS_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(params)
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
st(
|
||||
'assetBrowser.errorUploadFailed',
|
||||
'Failed to upload asset. Please try again.'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return await res.json()
|
||||
}
|
||||
|
||||
return {
|
||||
getAssetModelFolders,
|
||||
getAssetModels,
|
||||
@@ -256,7 +359,9 @@ function createAssetService() {
|
||||
getAssetsForNodeType,
|
||||
getAssetDetails,
|
||||
getAssetsByTag,
|
||||
deleteAsset
|
||||
deleteAsset,
|
||||
getAssetMetadata,
|
||||
uploadAssetFromUrl
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -107,10 +107,17 @@ const {
|
||||
|
||||
const authActions = useFirebaseAuthActions()
|
||||
|
||||
// Get max sortOrder from settings in a group
|
||||
const getGroupSortOrder = (group: SettingTreeNode): number =>
|
||||
Math.max(0, ...flattenTree<SettingParams>(group).map((s) => s.sortOrder ?? 0))
|
||||
|
||||
// Sort groups for a category
|
||||
const sortedGroups = (category: SettingTreeNode): ISettingGroup[] => {
|
||||
return [...(category.children ?? [])]
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
.sort((a, b) => {
|
||||
const orderDiff = getGroupSortOrder(b) - getGroupSortOrder(a)
|
||||
return orderDiff !== 0 ? orderDiff : a.label.localeCompare(b.label)
|
||||
})
|
||||
.map((group) => ({
|
||||
label: group.label,
|
||||
settings: flattenTree<SettingParams>(group).sort((a, b) => {
|
||||
|
||||
@@ -8,9 +8,11 @@ import {
|
||||
} from '@/platform/settings/settingStore'
|
||||
import type { ISettingGroup, SettingParams } from '@/platform/settings/types'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
|
||||
export function useSettingSearch() {
|
||||
const settingStore = useSettingStore()
|
||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
|
||||
const searchQuery = ref<string>('')
|
||||
const filteredSettingIds = ref<string[]>([])
|
||||
@@ -54,7 +56,11 @@ export function useSettingSearch() {
|
||||
const allSettings = Object.values(settingStore.settingsById)
|
||||
const filteredSettings = allSettings.filter((setting) => {
|
||||
// Filter out hidden and deprecated settings, just like in normal settings tree
|
||||
if (setting.type === 'hidden' || setting.deprecated) {
|
||||
if (
|
||||
setting.type === 'hidden' ||
|
||||
setting.deprecated ||
|
||||
(shouldRenderVueNodes.value && setting.hideInVueNodes)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { SettingParams } from '@/platform/settings/types'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
import { buildTree } from '@/utils/treeUtil'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
|
||||
interface SettingPanelItem {
|
||||
node: SettingTreeNode
|
||||
@@ -31,10 +32,14 @@ export function useSettingUI(
|
||||
const settingStore = useSettingStore()
|
||||
const activeCategory = ref<SettingTreeNode | null>(null)
|
||||
|
||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
|
||||
const settingRoot = computed<SettingTreeNode>(() => {
|
||||
const root = buildTree(
|
||||
Object.values(settingStore.settingsById).filter(
|
||||
(setting: SettingParams) => setting.type !== 'hidden'
|
||||
(setting: SettingParams) =>
|
||||
setting.type !== 'hidden' &&
|
||||
!(shouldRenderVueNodes.value && setting.hideInVueNodes)
|
||||
),
|
||||
(setting: SettingParams) => setting.category || setting.id.split('.')
|
||||
)
|
||||
|
||||
@@ -919,7 +919,8 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
step: 1
|
||||
},
|
||||
defaultValue: 8,
|
||||
versionAdded: '1.26.7'
|
||||
versionAdded: '1.26.7',
|
||||
hideInVueNodes: true
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Canvas.SelectionToolbox',
|
||||
@@ -1082,24 +1083,28 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
},
|
||||
|
||||
/**
|
||||
* Vue Node System Settings
|
||||
* Nodes 2.0 Settings
|
||||
*/
|
||||
{
|
||||
id: 'Comfy.VueNodes.Enabled',
|
||||
name: 'Modern Node Design (Vue Nodes)',
|
||||
category: ['Comfy', 'Nodes 2.0', 'VueNodesEnabled'],
|
||||
name: 'Modern Node Design (Nodes 2.0)',
|
||||
type: 'boolean',
|
||||
tooltip:
|
||||
'Modern: DOM-based rendering with enhanced interactivity, native browser features, and updated visual design. Classic: Traditional canvas rendering.',
|
||||
defaultValue: false,
|
||||
sortOrder: 100,
|
||||
experimental: true,
|
||||
versionAdded: '1.27.1'
|
||||
},
|
||||
{
|
||||
id: 'Comfy.VueNodes.AutoScaleLayout',
|
||||
name: 'Auto-scale layout (Vue nodes)',
|
||||
category: ['Comfy', 'Nodes 2.0', 'AutoScaleLayout'],
|
||||
name: 'Auto-scale layout (Nodes 2.0)',
|
||||
tooltip:
|
||||
'Automatically scale node positions when switching to Vue rendering to prevent overlap',
|
||||
type: 'boolean',
|
||||
sortOrder: 50,
|
||||
experimental: true,
|
||||
defaultValue: true,
|
||||
versionAdded: '1.30.3'
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface SettingParams<TValue = unknown> extends FormItem {
|
||||
// sortOrder for sorting settings within a group. Higher values appear first.
|
||||
// Default is 0 if not specified.
|
||||
sortOrder?: number
|
||||
hideInVueNodes?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
ComfyWorkflowJSON,
|
||||
NodeId
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
@@ -329,6 +330,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
tabActivationHistory.value.shift()
|
||||
}
|
||||
|
||||
useCanvasStore().linearMode = !!loadedWorkflow.activeState.extra?.linearMode
|
||||
return loadedWorkflow
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ export const useCanvasStore = defineStore('canvas', () => {
|
||||
// Reactive scale percentage that syncs with app.canvas.ds.scale
|
||||
const appScalePercentage = ref(100)
|
||||
|
||||
const linearMode = ref(false)
|
||||
|
||||
// Set up scale synchronization when canvas is available
|
||||
let originalOnChanged: ((scale: number, offset: Point) => void) | undefined =
|
||||
undefined
|
||||
@@ -138,6 +140,7 @@ export const useCanvasStore = defineStore('canvas', () => {
|
||||
groupSelected,
|
||||
rerouteSelected,
|
||||
appScalePercentage,
|
||||
linearMode,
|
||||
updateSelectedItems,
|
||||
getCanvas,
|
||||
setAppZoomFromPercentage,
|
||||
|
||||
@@ -29,12 +29,6 @@ vi.mock('@/renderer/core/layout/transform/useTransformState', () => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/renderer/extensions/vueNodes/lod/useLOD', () => ({
|
||||
useLOD: vi.fn(() => ({
|
||||
isLOD: false
|
||||
}))
|
||||
}))
|
||||
|
||||
function createMockCanvas(): LGraphCanvas {
|
||||
return {
|
||||
canvas: {
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { InjectionKey } from 'vue'
|
||||
|
||||
import type { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
|
||||
|
||||
/**
|
||||
* Lightweight, injectable transform state used by layout-aware components.
|
||||
*
|
||||
* Consumers use this interface to convert coordinates between LiteGraph's
|
||||
* canvas space and the DOM's screen space, access the current pan/zoom
|
||||
* (camera), and perform basic viewport culling checks.
|
||||
*
|
||||
* Coordinate mapping:
|
||||
* - screen = (canvas + offset) * scale
|
||||
* - canvas = screen / scale - offset
|
||||
*
|
||||
* The full implementation and additional helpers live in
|
||||
* `useTransformState()`. This interface deliberately exposes only the
|
||||
* minimal surface needed outside that composable.
|
||||
*
|
||||
* @example
|
||||
* const state = inject(TransformStateKey)!
|
||||
* const screen = state.canvasToScreen({ x: 100, y: 50 })
|
||||
*/
|
||||
export interface TransformState
|
||||
extends Pick<
|
||||
ReturnType<typeof useTransformState>,
|
||||
'screenToCanvas' | 'canvasToScreen' | 'camera' | 'isNodeInViewport'
|
||||
> {}
|
||||
|
||||
export const TransformStateKey: InjectionKey<TransformState> =
|
||||
Symbol('transformState')
|
||||
@@ -9,6 +9,8 @@ import { computed, customRef, ref } from 'vue'
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import * as Y from 'yjs'
|
||||
|
||||
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
|
||||
|
||||
import { ACTOR_CONFIG } from '@/renderer/core/layout/constants'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type {
|
||||
@@ -136,6 +138,8 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
|
||||
// Vue dragging state for selection toolbox (public ref for direct mutation)
|
||||
public isDraggingVueNodes = ref(false)
|
||||
// Vue resizing state to prevent drag from activating during resize
|
||||
public isResizingVueNodes = ref(false)
|
||||
|
||||
constructor() {
|
||||
// Initialize Yjs data structures
|
||||
@@ -1414,8 +1418,8 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
batchUpdateNodeBounds(updates: NodeBoundsUpdate[]): void {
|
||||
if (updates.length === 0) return
|
||||
|
||||
// Set source to Vue for these DOM-driven updates
|
||||
const originalSource = this.currentSource
|
||||
const shouldNormalizeHeights = originalSource === LayoutSource.DOM
|
||||
this.currentSource = LayoutSource.Vue
|
||||
|
||||
const nodeIds: NodeId[] = []
|
||||
@@ -1426,8 +1430,15 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
if (!ynode) continue
|
||||
const currentLayout = yNodeToLayout(ynode)
|
||||
|
||||
const normalizedBounds = shouldNormalizeHeights
|
||||
? {
|
||||
...bounds,
|
||||
height: removeNodeTitleHeight(bounds.height)
|
||||
}
|
||||
: bounds
|
||||
|
||||
boundsRecord[nodeId] = {
|
||||
bounds,
|
||||
bounds: normalizedBounds,
|
||||
previousBounds: currentLayout.bounds
|
||||
}
|
||||
nodeIds.push(nodeId)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { onUnmounted, ref } from 'vue'
|
||||
|
||||
import type { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { addNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
|
||||
|
||||
/**
|
||||
* Composable for syncing LiteGraph with the Layout system
|
||||
@@ -43,12 +44,13 @@ export function useLayoutSync() {
|
||||
liteNode.pos[1] = layout.position.y
|
||||
}
|
||||
|
||||
const targetHeight = addNodeTitleHeight(layout.size.height)
|
||||
if (
|
||||
liteNode.size[0] !== layout.size.width ||
|
||||
liteNode.size[1] !== layout.size.height
|
||||
liteNode.size[1] !== targetHeight
|
||||
) {
|
||||
// Use setSize() to trigger onResize callback
|
||||
liteNode.setSize([layout.size.width, layout.size.height])
|
||||
liteNode.setSize([layout.size.width, targetHeight])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-0 w-full h-full pointer-events-none',
|
||||
isInteracting ? 'transform-pane--interacting' : 'will-change-auto',
|
||||
isLOD && 'isLOD'
|
||||
isInteracting ? 'transform-pane--interacting' : 'will-change-auto'
|
||||
)
|
||||
"
|
||||
:style="transformStyle"
|
||||
@@ -17,13 +16,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRafFn } from '@vueuse/core'
|
||||
import { computed, provide } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
|
||||
import { useTransformSettling } from '@/renderer/core/layout/transform/useTransformSettling'
|
||||
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
|
||||
import { useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
interface TransformPaneProps {
|
||||
@@ -32,29 +29,13 @@ interface TransformPaneProps {
|
||||
|
||||
const props = defineProps<TransformPaneProps>()
|
||||
|
||||
const {
|
||||
camera,
|
||||
transformStyle,
|
||||
syncWithCanvas,
|
||||
canvasToScreen,
|
||||
screenToCanvas,
|
||||
isNodeInViewport
|
||||
} = useTransformState()
|
||||
|
||||
const { isLOD } = useLOD(camera)
|
||||
const { transformStyle, syncWithCanvas } = useTransformState()
|
||||
|
||||
const canvasElement = computed(() => props.canvas?.canvas)
|
||||
const { isTransforming: isInteracting } = useTransformSettling(canvasElement, {
|
||||
settleDelay: 512
|
||||
})
|
||||
|
||||
provide(TransformStateKey, {
|
||||
camera,
|
||||
canvasToScreen,
|
||||
screenToCanvas,
|
||||
isNodeInViewport
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
transformUpdate: []
|
||||
}>()
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
import { computed, reactive, readonly } from 'vue'
|
||||
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
|
||||
interface Point {
|
||||
x: number
|
||||
@@ -64,7 +65,7 @@ interface Camera {
|
||||
z: number // scale/zoom
|
||||
}
|
||||
|
||||
export const useTransformState = () => {
|
||||
function useTransformStateIndividual() {
|
||||
// Reactive state mirroring LiteGraph's canvas transform
|
||||
const camera = reactive<Camera>({
|
||||
x: 0,
|
||||
@@ -91,7 +92,7 @@ export const useTransformState = () => {
|
||||
*
|
||||
* @param canvas - LiteGraph canvas instance with DragAndScale (ds) transform state
|
||||
*/
|
||||
const syncWithCanvas = (canvas: LGraphCanvas) => {
|
||||
function syncWithCanvas(canvas: LGraphCanvas) {
|
||||
if (!canvas || !canvas.ds) return
|
||||
|
||||
// Mirror LiteGraph's transform state to Vue's reactive state
|
||||
@@ -112,7 +113,7 @@ export const useTransformState = () => {
|
||||
* @param point - Point in canvas coordinate system
|
||||
* @returns Point in screen coordinate system
|
||||
*/
|
||||
const canvasToScreen = (point: Point): Point => {
|
||||
function canvasToScreen(point: Point): Point {
|
||||
return {
|
||||
x: (point.x + camera.x) * camera.z,
|
||||
y: (point.y + camera.y) * camera.z
|
||||
@@ -138,10 +139,10 @@ export const useTransformState = () => {
|
||||
}
|
||||
|
||||
// Get node's screen bounds for culling
|
||||
const getNodeScreenBounds = (
|
||||
pos: ArrayLike<number>,
|
||||
size: ArrayLike<number>
|
||||
): DOMRect => {
|
||||
function getNodeScreenBounds(
|
||||
pos: [number, number],
|
||||
size: [number, number]
|
||||
): DOMRect {
|
||||
const topLeft = canvasToScreen({ x: pos[0], y: pos[1] })
|
||||
const width = size[0] * camera.z
|
||||
const height = size[1] * camera.z
|
||||
@@ -150,23 +151,23 @@ export const useTransformState = () => {
|
||||
}
|
||||
|
||||
// Helper: Calculate zoom-adjusted margin for viewport culling
|
||||
const calculateAdjustedMargin = (baseMargin: number): number => {
|
||||
function calculateAdjustedMargin(baseMargin: number): number {
|
||||
if (camera.z < 0.1) return Math.min(baseMargin * 5, 2.0)
|
||||
if (camera.z > 3.0) return Math.max(baseMargin * 0.5, 0.05)
|
||||
return baseMargin
|
||||
}
|
||||
|
||||
// Helper: Check if node is too small to be visible at current zoom
|
||||
const isNodeTooSmall = (nodeSize: ArrayLike<number>): boolean => {
|
||||
function isNodeTooSmall(nodeSize: [number, number]): boolean {
|
||||
const nodeScreenSize = Math.max(nodeSize[0], nodeSize[1]) * camera.z
|
||||
return nodeScreenSize < 4
|
||||
}
|
||||
|
||||
// Helper: Calculate expanded viewport bounds with margin
|
||||
const getExpandedViewportBounds = (
|
||||
function getExpandedViewportBounds(
|
||||
viewport: { width: number; height: number },
|
||||
margin: number
|
||||
) => {
|
||||
) {
|
||||
const marginX = viewport.width * margin
|
||||
const marginY = viewport.height * margin
|
||||
return {
|
||||
@@ -178,11 +179,11 @@ export const useTransformState = () => {
|
||||
}
|
||||
|
||||
// Helper: Test if node intersects with viewport bounds
|
||||
const testViewportIntersection = (
|
||||
function testViewportIntersection(
|
||||
screenPos: { x: number; y: number },
|
||||
nodeSize: ArrayLike<number>,
|
||||
nodeSize: [number, number],
|
||||
bounds: { left: number; right: number; top: number; bottom: number }
|
||||
): boolean => {
|
||||
): boolean {
|
||||
const nodeRight = screenPos.x + nodeSize[0] * camera.z
|
||||
const nodeBottom = screenPos.y + nodeSize[1] * camera.z
|
||||
|
||||
@@ -195,12 +196,12 @@ export const useTransformState = () => {
|
||||
}
|
||||
|
||||
// Check if node is within viewport with frustum and size-based culling
|
||||
const isNodeInViewport = (
|
||||
nodePos: ArrayLike<number>,
|
||||
nodeSize: ArrayLike<number>,
|
||||
function isNodeInViewport(
|
||||
nodePos: [number, number],
|
||||
nodeSize: [number, number],
|
||||
viewport: { width: number; height: number },
|
||||
margin: number = 0.2
|
||||
): boolean => {
|
||||
): boolean {
|
||||
// Early exit for tiny nodes
|
||||
if (isNodeTooSmall(nodeSize)) return false
|
||||
|
||||
@@ -212,10 +213,10 @@ export const useTransformState = () => {
|
||||
}
|
||||
|
||||
// Get viewport bounds in canvas coordinates (for spatial index queries)
|
||||
const getViewportBounds = (
|
||||
function getViewportBounds(
|
||||
viewport: { width: number; height: number },
|
||||
margin: number = 0.2
|
||||
) => {
|
||||
) {
|
||||
const marginX = viewport.width * margin
|
||||
const marginY = viewport.height * margin
|
||||
|
||||
@@ -244,3 +245,7 @@ export const useTransformState = () => {
|
||||
getViewportBounds
|
||||
}
|
||||
}
|
||||
|
||||
export const useTransformState = createSharedComposable(
|
||||
useTransformStateIndividual
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { ComputedRef, Ref } from 'vue'
|
||||
export enum LayoutSource {
|
||||
Canvas = 'canvas',
|
||||
Vue = 'vue',
|
||||
DOM = 'dom',
|
||||
External = 'external'
|
||||
}
|
||||
|
||||
|
||||
7
src/renderer/core/layout/utils/nodeSizeUtil.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
export const removeNodeTitleHeight = (height: number) =>
|
||||
Math.max(0, height - (LiteGraph.NODE_TITLE_HEIGHT || 0))
|
||||
|
||||
export const addNodeTitleHeight = (height: number) =>
|
||||
height + LiteGraph.NODE_TITLE_HEIGHT
|
||||
@@ -11,9 +11,9 @@ interface SpatialBounds {
|
||||
height: number
|
||||
}
|
||||
|
||||
interface PositionedNode {
|
||||
pos: ArrayLike<number>
|
||||
size: ArrayLike<number>
|
||||
export interface PositionedNode {
|
||||
pos: [number, number]
|
||||
size: [number, number]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { calculateNodeBounds } from '@/renderer/core/spatial/boundsCalculator'
|
||||
import type { PositionedNode } from '@/renderer/core/spatial/boundsCalculator'
|
||||
|
||||
import type {
|
||||
IMinimapDataSource,
|
||||
@@ -29,10 +30,12 @@ export abstract class AbstractMinimapDataSource implements IMinimapDataSource {
|
||||
}
|
||||
|
||||
// Convert MinimapNodeData to the format expected by calculateNodeBounds
|
||||
const compatibleNodes = nodes.map((node) => ({
|
||||
pos: [node.x, node.y],
|
||||
size: [node.width, node.height]
|
||||
}))
|
||||
const compatibleNodes = nodes.map(
|
||||
(node): PositionedNode => ({
|
||||
pos: [node.x, node.y],
|
||||
size: [node.width, node.height]
|
||||
})
|
||||
)
|
||||
|
||||
const bounds = calculateNodeBounds(compatibleNodes)
|
||||
if (!bounds) {
|
||||
|
||||
@@ -83,20 +83,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<!-- Video Dimensions -->
|
||||
<div class="mt-2 text-center text-xs text-white">
|
||||
<span v-if="videoError" class="text-red-400">
|
||||
{{ $t('g.errorLoadingVideo') }}
|
||||
</span>
|
||||
<span v-else-if="isLoading" class="text-smoke-400">
|
||||
{{ $t('g.loading') }}...
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ actualDimensions || $t('g.calculatingDimensions') }}
|
||||
</span>
|
||||
</div>
|
||||
<LODFallback />
|
||||
<!-- Video Dimensions -->
|
||||
<div class="mt-2 text-center text-xs text-white">
|
||||
<span v-if="videoError" class="text-red-400">
|
||||
{{ $t('g.errorLoadingVideo') }}
|
||||
</span>
|
||||
<span v-else-if="isLoading" class="text-smoke-400">
|
||||
{{ $t('g.loading') }}...
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ actualDimensions || $t('g.calculatingDimensions') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -110,8 +107,6 @@ import { useI18n } from 'vue-i18n'
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
|
||||
import LODFallback from './components/LODFallback.vue'
|
||||
|
||||
interface VideoPreviewProps {
|
||||
/** Array of video URLs to display */
|
||||
readonly imageUrls: readonly string[] // Named imageUrls for consistency with parent components
|
||||
|
||||