Compare commits

...

20 Commits

Author SHA1 Message Date
Comfy Org PR Bot
d64c18b06c 1.32.8 (#6890)
Patch version increment to 1.32.8

**Base branch:** `core/1.32`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6890-1-32-8-2b56d73d365081f182b4c5e1690d9d66)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-11-24 14:42:02 -07:00
Comfy Org PR Bot
c26438bd0c [backport core/1.32] Feat: Load Image (from Outputs) support in Vue Nodes (#6871)
Backport of #6836 to `core/1.32`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6871-backport-core-1-32-Feat-Load-Image-from-Outputs-support-in-Vue-Nodes-2b46d73d36508121b3b3c9dac9e3de5a)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-11-23 14:01:37 -07:00
Comfy Org PR Bot
d4d6ed0bb5 [backport core/1.32] Fix: Opening mask editor on context menu (#6869)
Backport of #6825 to `core/1.32`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6869-backport-core-1-32-Fix-Opening-mask-editor-on-context-menu-2b46d73d36508196a2bfd8412f468196)
by [Unito](https://www.unito.io)

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2025-11-23 13:58:15 -07:00
Comfy Org PR Bot
d7f11dd852 [backport core/1.32] Style: Fix the filter/search/sort controls on the Template Select Modal (#6867)
Backport of #6835 to `core/1.32`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6867-backport-core-1-32-Style-Fix-the-filter-search-sort-controls-on-the-Template-Select-Mo-2b46d73d3650817ea7c1d8adfbc0a69e)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-11-23 13:58:07 -07:00
Comfy Org PR Bot
7a212522fe [backport core/1.32] Fix: TextArea context menu (#6865)
Backport of #6834 to `core/1.32`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6865-backport-core-1-32-Fix-TextArea-context-menu-2b46d73d3650810aa94ccefc1e1e01c4)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-11-23 13:56:45 -07:00
Comfy Org PR Bot
d3044fe765 [backport core/1.32] Cleanup: Vue <--> Litegraph scaling logic. (#6863)
Backport of #6745 to `core/1.32`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6863-backport-core-1-32-Cleanup-Vue-Litegraph-scaling-logic-2b46d73d36508109a65ac6e7757f2548)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-11-23 13:40:30 -07:00
Comfy Org PR Bot
b66a181879 [backport core/1.32] Feat: Alt+Drag to clone - Vue Nodes (#6860)
Backport of #6789 to `core/1.32`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6860-backport-core-1-32-Feat-Alt-Drag-to-clone-Vue-Nodes-2b46d73d365081e0ac21c1dc61189a7c)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: github-actions <github-actions@github.com>
2025-11-23 13:24:14 -07:00
Comfy Org PR Bot
0507d333fe [backport core/1.32] Feat: Show Progress Text on Vue Nodes, Markdown for Preview as Text (#6857)
Backport of #6805 to `core/1.32`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6857-backport-core-1-32-Feat-Show-Progress-Text-on-Vue-Nodes-Markdown-for-Preview-as-Text-2b46d73d3650817691d3d5ce30e7d4e3)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-11-23 12:20:20 -07:00
Comfy Org PR Bot
55f842f4cb [backport core/1.32] Use shared button components in queue overlay (#6854)
Backport of #6793 to `core/1.32`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6854-backport-core-1-32-Use-shared-button-components-in-queue-overlay-2b46d73d365081f488aed067909200bc)
by [Unito](https://www.unito.io)

Co-authored-by: Benjamin Lu <benceruleanlu@proton.me>
2025-11-23 12:19:51 -07:00
Comfy Org PR Bot
0d42b62d4f [backport core/1.32] hotfix: Stop clicks on the textarea from propagating to the node itself (#6851)
Backport of #6788 to `core/1.32`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6851-backport-core-1-32-hotfix-Stop-clicks-on-the-textarea-from-propagating-to-the-node-its-2b46d73d36508133ac9cc8f1ddd793e4)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-11-23 12:02:12 -07:00
Comfy Org PR Bot
8c2fe715bd [backport core/1.32] feat: LOD setting for LG and Vue (#6848)
Backport of #6755 to `core/1.32`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6848-backport-core-1-32-feat-LOD-setting-for-LG-and-Vue-2b46d73d3650813d8105d5df3e3685e1)
by [Unito](https://www.unito.io)

Co-authored-by: Simula_r <18093452+simula-r@users.noreply.github.com>
2025-11-23 11:44:56 -07:00
Comfy Org PR Bot
e94a74f167 [backport core/1.32] make vue node settings appear higher in the settings dialog (#6821)
Backport of #6820 to `core/1.32`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6821-backport-core-1-32-make-vue-node-settings-appear-higher-in-the-settings-dialog-2b36d73d365081f98ab0f9540ffc362c)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-11-21 23:03:43 -07:00
Comfy Org PR Bot
662f79edf4 [backport core/1.32] refactor: change model button terminology from Upload to Import (#6802)
Backport of #6800 to `core/1.32`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6802-backport-core-1-32-refactor-change-model-button-terminology-from-Upload-to-Import-2b26d73d36508151bbcdf461bee06304)
by [Unito](https://www.unito.io)

Co-authored-by: Luke Mino-Altherr <luke@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-20 22:35:37 -08:00
Comfy Org PR Bot
07e4004c2d [backport core/1.32] fix: Conditionally hide bottom border in missing nodes modal on non-cloud environments (#6790)
Backport of #6779 to `core/1.32`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6790-backport-core-1-32-fix-Conditionally-hide-bottom-border-in-missing-nodes-modal-on-non--2b16d73d36508172be63cc0e2ccb0554)
by [Unito](https://www.unito.io)

Co-authored-by: Jin Yi <jin12cc@gmail.com>
2025-11-20 17:19:58 -07:00
Comfy Org PR Bot
15794a83e3 [backport core/1.32] [feat] Add Civitai model upload wizard (#6770)
Backport of #6694 to `core/1.32`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6770-backport-core-1-32-feat-Add-Civitai-model-upload-wizard-2b16d73d365081f8a732f69713f95b61)
by [Unito](https://www.unito.io)

Co-authored-by: Luke Mino-Altherr <luke@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-20 16:52:39 -07:00
Comfy Org PR Bot
689634e4d3 [backport core/1.32] fix: node preview background color (#6771)
Backport of #6768 to `core/1.32`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6771-backport-core-1-32-fix-node-preview-background-color-2b16d73d3650817e959af4b89351b641)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2025-11-20 16:51:11 -07:00
Comfy Org PR Bot
fe1daa2c29 [backport core/1.32] feat(api-nodes-pricing): add Nano-Banana-2 prices (#6785)
Backport of #6781 to `core/1.32`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6785-backport-core-1-32-feat-api-nodes-pricing-add-Nano-Banana-2-prices-2b16d73d3650812ea14bca41248e0d67)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Piskun <13381981+bigcat88@users.noreply.github.com>
2025-11-20 10:34:36 -07:00
Comfy Org PR Bot
6600a8a13b [backport core/1.32] [bugfix] Fix execute button incorrectly disabled on empty workflows (#6775)
Backport of #6774 to `core/1.32`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6775-backport-core-1-32-bugfix-Fix-execute-button-incorrectly-disabled-on-empty-workflows-2b16d73d36508128a87ffc64a8c70184)
by [Unito](https://www.unito.io)

Co-authored-by: Jin Yi <jin12cc@gmail.com>
2025-11-19 22:17:58 -08:00
Comfy Org PR Bot
5e5bf8248f 1.32.7 (#6765)
Patch version increment to 1.32.7

**Base branch:** `core/1.32`

Co-authored-by: benceruleanlu <162923238+benceruleanlu@users.noreply.github.com>
2025-11-19 19:19:51 -08:00
Benjamin Lu
7b6fdce0f2 Backport desktop update issue fixes to 1.32 (#6761)
Backport of these two PRs:

#6733
#6750

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6761-Fix-desktop-update-issues-2b16d73d3650817fad89d402d39cf9a0)
by [Unito](https://www.unito.io)
2025-11-19 19:53:25 -07:00
93 changed files with 2171 additions and 1968 deletions

View File

@@ -91,7 +91,7 @@
"build-storybook": "storybook build -o dist/storybook" "build-storybook": "storybook build -o dist/storybook"
}, },
"dependencies": { "dependencies": {
"@comfyorg/comfyui-electron-types": "0.4.73-0", "@comfyorg/comfyui-electron-types": "catalog:",
"@comfyorg/shared-frontend-utils": "workspace:*", "@comfyorg/shared-frontend-utils": "workspace:*",
"@primevue/core": "catalog:", "@primevue/core": "catalog:",
"@primevue/themes": "catalog:", "@primevue/themes": "catalog:",

View File

@@ -115,19 +115,18 @@ import Button from 'primevue/button'
import Divider from 'primevue/divider' import Divider from 'primevue/divider'
import InputText from 'primevue/inputtext' import InputText from 'primevue/inputtext'
import Message from 'primevue/message' 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 { useI18n } from 'vue-i18n'
import MigrationPicker from '@/components/install/MigrationPicker.vue' import { PYPI_MIRROR, PYTHON_MIRROR } from '@/constants/uvMirrors'
import MirrorItem from '@/components/install/mirror/MirrorItem.vue' import type { UVMirror } from '@/constants/uvMirrors'
import {
PYPI_MIRROR,
PYTHON_MIRROR,
type UVMirror
} from '@/constants/uvMirrors'
import { electronAPI } from '@/utils/envUtil' import { electronAPI } from '@/utils/envUtil'
import { ValidationState } from '@/utils/validationUtil' import { ValidationState } from '@/utils/validationUtil'
import MigrationPicker from './MigrationPicker.vue'
import MirrorItem from './mirror/MirrorItem.vue'
const { t } = useI18n() const { t } = useI18n()
const installPath = defineModel<string>('installPath', { required: true }) 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.parentMissing) errors.push(t('install.parentMissing'))
if (validation.isOneDrive) errors.push(t('install.isOneDrive')) 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) if (validation.error)
errors.push(`${t('install.unhandledError')}: ${validation.error}`) errors.push(`${t('install.unhandledError')}: ${validation.error}`)

View File

@@ -16,7 +16,8 @@ export const DESKTOP_MAINTENANCE_TASKS: Readonly<MaintenanceTask>[] = [
execute: async () => await electron.setBasePath(), execute: async () => await electron.setBasePath(),
name: 'Base path', name: 'Base path',
shortDescription: 'Change the application 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: 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.', '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, isInstallationFix: true,

View File

@@ -85,6 +85,7 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
const electron = electronAPI() const electron = electronAPI()
// Reactive state // Reactive state
const lastUpdate = ref<InstallValidation | null>(null)
const isRefreshing = ref(false) const isRefreshing = ref(false)
const isRunningTerminalCommand = computed(() => const isRunningTerminalCommand = computed(() =>
tasks.value tasks.value
@@ -97,6 +98,13 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
.some((task) => getRunner(task)?.executing) .some((task) => getRunner(task)?.executing)
) )
const unsafeBasePath = computed(
() => lastUpdate.value?.unsafeBasePath === true
)
const unsafeBasePathReason = computed(
() => lastUpdate.value?.unsafeBasePathReason
)
// Task list // Task list
const tasks = ref(DESKTOP_MAINTENANCE_TASKS) const tasks = ref(DESKTOP_MAINTENANCE_TASKS)
@@ -123,6 +131,7 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
* @param validationUpdate Update details passed in by electron * @param validationUpdate Update details passed in by electron
*/ */
const processUpdate = (validationUpdate: InstallValidation) => { const processUpdate = (validationUpdate: InstallValidation) => {
lastUpdate.value = validationUpdate
const update = validationUpdate as IndexedUpdate const update = validationUpdate as IndexedUpdate
isRefreshing.value = true isRefreshing.value = true
@@ -155,7 +164,11 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
} }
const execute = async (task: 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 { return {
@@ -163,6 +176,8 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
isRefreshing, isRefreshing,
isRunningTerminalCommand, isRunningTerminalCommand,
isRunningInstallationFix, isRunningInstallationFix,
unsafeBasePath,
unsafeBasePathReason,
execute, execute,
getRunner, getRunner,
processUpdate, processUpdate,

View 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 />'
})
}

View File

@@ -47,6 +47,28 @@
</div> </div>
</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 --> <!-- Tasks -->
<TaskListPanel <TaskListPanel
class="border-neutral-700 border-solid border-x-0 border-y" class="border-neutral-700 border-solid border-x-0 border-y"
@@ -89,10 +111,10 @@
import { PrimeIcons } from '@primevue/core/api' import { PrimeIcons } from '@primevue/core/api'
import Button from 'primevue/button' import Button from 'primevue/button'
import SelectButton from 'primevue/selectbutton' import SelectButton from 'primevue/selectbutton'
import Tag from 'primevue/tag'
import Toast from 'primevue/toast' import Toast from 'primevue/toast'
import { useToast } from 'primevue/usetoast' import { useToast } from 'primevue/usetoast'
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { watch } from 'vue'
import RefreshButton from '@/components/common/RefreshButton.vue' import RefreshButton from '@/components/common/RefreshButton.vue'
import StatusTag from '@/components/maintenance/StatusTag.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. */ /** Filter binding; can be set to show all tasks, or only errors. */
const filter = ref<MaintenanceFilter>(filterOptions.value[0]) 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. */ /** If valid, leave the validation window. */
const completeValidation = async () => { const completeValidation = async () => {
const isValid = await electron.Validation.complete() const isValid = await electron.Validation.complete()

View File

@@ -564,7 +564,7 @@ export class ComfyPage {
async dragAndDrop(source: Position, target: Position) { async dragAndDrop(source: Position, target: Position) {
await this.page.mouse.move(source.x, source.y) await this.page.mouse.move(source.x, source.y)
await this.page.mouse.down() 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.page.mouse.up()
await this.nextFrame() await this.nextFrame()
} }

View File

@@ -65,7 +65,9 @@ export class VueNodeHelpers {
* Select a specific Vue node by ID * Select a specific Vue node by ID
*/ */
async selectNode(nodeId: string): Promise<void> { 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 // Select first node normally
await this.selectNode(nodeIds[0]) 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++) { for (let i = 1; i < nodeIds.length; i++) {
await this.page.locator(`[data-node-id="${nodeIds[i]}"]`).click({ await this.page
modifiers: ['Control'] .locator(`[data-node-id="${nodeIds[i]}"] .lg-node-header`)
}) .click({
modifiers: ['Control']
})
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 97 KiB

View File

@@ -6,6 +6,7 @@ import {
test.describe('Vue Nodes Zoom', () => { test.describe('Vue Nodes Zoom', () => {
test.beforeEach(async ({ comfyPage }) => { test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true) await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setSetting('LiteGraph.Canvas.MinFontSizeForLOD', 8)
await comfyPage.vueNodes.waitForNodes() await comfyPage.vueNodes.waitForNodes()
}) })

View File

@@ -9,6 +9,7 @@ test.beforeEach(async ({ comfyPage }) => {
test.describe('Vue Nodes - LOD', () => { test.describe('Vue Nodes - LOD', () => {
test.beforeEach(async ({ comfyPage }) => { test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true) await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setSetting('LiteGraph.Canvas.MinFontSizeForLOD', 8)
await comfyPage.setup() await comfyPage.setup()
await comfyPage.loadWorkflow('default') await comfyPage.loadWorkflow('default')
}) })

View File

@@ -1,7 +1,7 @@
{ {
"name": "@comfyorg/comfyui-frontend", "name": "@comfyorg/comfyui-frontend",
"private": true, "private": true,
"version": "1.32.6", "version": "1.32.8",
"type": "module", "type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend", "repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org", "homepage": "https://comfy.org",
@@ -128,7 +128,7 @@
"dependencies": { "dependencies": {
"@alloc/quick-lru": "catalog:", "@alloc/quick-lru": "catalog:",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1", "@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/design-system": "workspace:*",
"@comfyorg/registry-types": "workspace:*", "@comfyorg/registry-types": "workspace:*",
"@comfyorg/shared-frontend-utils": "workspace:*", "@comfyorg/shared-frontend-utils": "workspace:*",

55
pnpm-lock.yaml generated
View File

@@ -9,6 +9,9 @@ catalogs:
'@alloc/quick-lru': '@alloc/quick-lru':
specifier: ^5.2.0 specifier: ^5.2.0
version: 5.2.0 version: 5.2.0
'@comfyorg/comfyui-electron-types':
specifier: 0.5.5
version: 0.5.5
'@eslint/js': '@eslint/js':
specifier: ^9.35.0 specifier: ^9.35.0
version: 9.35.0 version: 9.35.0
@@ -318,8 +321,8 @@ importers:
specifier: ^1.3.1 specifier: ^1.3.1
version: 1.3.1 version: 1.3.1
'@comfyorg/comfyui-electron-types': '@comfyorg/comfyui-electron-types':
specifier: 0.4.73-0 specifier: 'catalog:'
version: 0.4.73-0 version: 0.5.5
'@comfyorg/design-system': '@comfyorg/design-system':
specifier: workspace:* specifier: workspace:*
version: link:packages/design-system version: link:packages/design-system
@@ -709,8 +712,8 @@ importers:
apps/desktop-ui: apps/desktop-ui:
dependencies: dependencies:
'@comfyorg/comfyui-electron-types': '@comfyorg/comfyui-electron-types':
specifier: 0.4.73-0 specifier: 'catalog:'
version: 0.4.73-0 version: 0.5.5
'@comfyorg/shared-frontend-utils': '@comfyorg/shared-frontend-utils':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/shared-frontend-utils version: link:../../packages/shared-frontend-utils
@@ -1453,8 +1456,8 @@ packages:
'@cacheable/utils@2.0.3': '@cacheable/utils@2.0.3':
resolution: {integrity: sha512-m7Rce68cMHlAUjvWBy9Ru1Nmw5gU0SjGGtQDdhpe6E0xnbcvrIY0Epy//JU1VYYBUTzrG9jvgmTauULGKzOkWA==} resolution: {integrity: sha512-m7Rce68cMHlAUjvWBy9Ru1Nmw5gU0SjGGtQDdhpe6E0xnbcvrIY0Epy//JU1VYYBUTzrG9jvgmTauULGKzOkWA==}
'@comfyorg/comfyui-electron-types@0.4.73-0': '@comfyorg/comfyui-electron-types@0.5.5':
resolution: {integrity: sha512-WlItGJQx9ZWShNG9wypx3kq+19pSig/U+s5sD2SAeEcMph4u8A/TS+lnRgdKhT58VT1uD7cMcj2SJpfdBPNWvw==} resolution: {integrity: sha512-f3XOXpMsALIwHakz7FekVPm4/Fh2pvJPEi8tRe8jYGBt8edsd4Mkkq31Yjs2Weem3BP7yNwbdNuSiQdP/pxJyg==}
'@csstools/color-helpers@5.1.0': '@csstools/color-helpers@5.1.0':
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
@@ -4413,6 +4416,9 @@ packages:
csstype@3.1.3: csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
data-urls@5.0.0: data-urls@5.0.0:
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -7000,6 +7006,11 @@ packages:
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
hasBin: true hasBin: true
resolve@1.22.11:
resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==}
engines: {node: '>= 0.4'}
hasBin: true
restore-cursor@3.1.0: restore-cursor@3.1.0:
resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -7095,6 +7106,11 @@ packages:
engines: {node: '>=10'} engines: {node: '>=10'}
hasBin: true hasBin: true
semver@7.7.3:
resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
engines: {node: '>=10'}
hasBin: true
set-function-length@1.2.2: set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -7815,8 +7831,8 @@ packages:
vue-component-type-helpers@3.1.1: vue-component-type-helpers@3.1.1:
resolution: {integrity: sha512-B0kHv7qX6E7+kdc5nsaqjdGZ1KwNKSUQDWGy7XkTYT7wFsOpkEyaJ1Vq79TjwrrtuLRgizrTV7PPuC4rRQo+vw==} resolution: {integrity: sha512-B0kHv7qX6E7+kdc5nsaqjdGZ1KwNKSUQDWGy7XkTYT7wFsOpkEyaJ1Vq79TjwrrtuLRgizrTV7PPuC4rRQo+vw==}
vue-component-type-helpers@3.1.3: vue-component-type-helpers@3.1.4:
resolution: {integrity: sha512-V1dOD8XYfstOKCnXbWyEJIrhTBMwSyNjv271L1Jlx9ExpNlCSuqOs3OdWrGJ0V544zXufKbcYabi/o+gK8lyfQ==} resolution: {integrity: sha512-Uws7Ew1OzTTqHW8ZVl/qLl/HB+jf08M0NdFONbVWAx0N4gMLK8yfZDgeB77hDnBmaigWWEn5qP8T9BG59jIeyQ==}
vue-demi@0.14.10: vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
@@ -8992,7 +9008,7 @@ snapshots:
'@cacheable/utils@2.0.3': {} '@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': {} '@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)) 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 type-fest: 2.19.0
vue: 3.5.13(typescript@5.9.2) 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': '@swc/helpers@0.5.17':
dependencies: dependencies:
@@ -10989,7 +11005,7 @@ snapshots:
'@types/react@19.1.9': '@types/react@19.1.9':
dependencies: dependencies:
csstype: 3.1.3 csstype: 3.2.3
'@types/semver@7.7.0': {} '@types/semver@7.7.0': {}
@@ -12168,6 +12184,8 @@ snapshots:
csstype@3.1.3: {} csstype@3.1.3: {}
csstype@3.2.3: {}
data-urls@5.0.0: data-urls@5.0.0:
dependencies: dependencies:
whatwg-mimetype: 4.0.0 whatwg-mimetype: 4.0.0
@@ -12594,7 +12612,7 @@ snapshots:
dependencies: dependencies:
debug: 3.2.7 debug: 3.2.7
is-core-module: 2.16.1 is-core-module: 2.16.1
resolve: 1.22.10 resolve: 1.22.11
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
optional: true optional: true
@@ -13740,7 +13758,7 @@ snapshots:
acorn: 8.15.0 acorn: 8.15.0
eslint-visitor-keys: 3.4.3 eslint-visitor-keys: 3.4.3
espree: 9.6.1 espree: 9.6.1
semver: 7.7.2 semver: 7.7.3
jsonc-parser@3.2.0: {} jsonc-parser@3.2.0: {}
@@ -15345,6 +15363,13 @@ snapshots:
path-parse: 1.0.7 path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0 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: restore-cursor@3.1.0:
dependencies: dependencies:
onetime: 5.1.2 onetime: 5.1.2
@@ -15449,6 +15474,8 @@ snapshots:
semver@7.7.2: {} semver@7.7.2: {}
semver@7.7.3: {}
set-function-length@1.2.2: set-function-length@1.2.2:
dependencies: dependencies:
define-data-property: 1.1.4 define-data-property: 1.1.4
@@ -16343,7 +16370,7 @@ snapshots:
vue-component-type-helpers@3.1.1: {} 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)): vue-demi@0.14.10(vue@3.5.13(typescript@5.9.2)):
dependencies: dependencies:

View File

@@ -4,6 +4,7 @@ packages:
catalog: catalog:
'@alloc/quick-lru': ^5.2.0 '@alloc/quick-lru': ^5.2.0
'@comfyorg/comfyui-electron-types': 0.5.5
'@eslint/js': ^9.35.0 '@eslint/js': ^9.35.0
'@iconify-json/lucide': ^1.1.178 '@iconify-json/lucide': ^1.1.178
'@iconify/json': ^2.2.380 '@iconify/json': ^2.2.380

View File

@@ -24,7 +24,7 @@ import {
import { cn } from '@/utils/tailwindUtil' import { cn } from '@/utils/tailwindUtil'
interface IconButtonProps extends BaseButtonProps { interface IconButtonProps extends BaseButtonProps {
onClick: (event: Event) => void onClick?: (event: MouseEvent) => void
} }
defineOptions({ defineOptions({

View File

@@ -47,7 +47,7 @@ const {
} = defineProps<IconTextButtonProps>() } = defineProps<IconTextButtonProps>()
const buttonStyle = computed(() => { const buttonStyle = computed(() => {
const baseClasses = `${getBaseButtonClasses()} justify-start! gap-2` const baseClasses = `${getBaseButtonClasses()} justify-start gap-2`
const sizeClasses = getButtonSizeClasses(size) const sizeClasses = getButtonSizeClasses(size)
const typeClasses = border const typeClasses = border
? getBorderButtonTypeClasses(type) ? getBorderButtonTypeClasses(type)

View File

@@ -35,6 +35,7 @@ import { ValidationState } from '@/utils/validationUtil'
const props = defineProps<{ const props = defineProps<{
modelValue: string modelValue: string
validateUrlFn?: (url: string) => Promise<boolean> validateUrlFn?: (url: string) => Promise<boolean>
disableValidation?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -101,6 +102,8 @@ const defaultValidateUrl = async (url: string): Promise<boolean> => {
} }
const validateUrl = async (value: string) => { const validateUrl = async (value: string) => {
if (props.disableValidation) return
if (validationState.value === ValidationState.LOADING) return if (validationState.value === ValidationState.LOADING) return
const url = cleanInput(value) const url = cleanInput(value)

View File

@@ -92,7 +92,7 @@
class="w-62.5" class="w-62.5"
> >
<template #icon> <template #icon>
<i class="icon-[lucide--arrow-up-down]" /> <i class="icon-[lucide--arrow-up-down] text-muted-foreground" />
</template> </template>
</SingleSelect> </SingleSelect>
</div> </div>

View File

@@ -1,6 +1,7 @@
<template> <template>
<div <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"> <div class="flex h-full w-full flex-col gap-4 p-4">
<!-- Description --> <!-- Description -->

View File

@@ -17,7 +17,7 @@
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({ root: ({ props }: MultiSelectPassThroughMethodOptions) => ({
class: cn( class: cn(
'h-10 relative inline-flex cursor-pointer select-none', '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', 'transition-all duration-200 ease-in-out',
'border-[2.5px] border-solid', 'border-[2.5px] border-solid',
selectedCount > 0 selectedCount > 0
@@ -127,7 +127,7 @@
<!-- Trigger value (keep text scale identical) --> <!-- Trigger value (keep text scale identical) -->
<template #value> <template #value>
<span class="text-sm text-muted-foreground"> <span class="text-sm">
{{ label }} {{ label }}
</span> </span>
<span <span
@@ -140,7 +140,7 @@
<!-- Chevron size identical to current --> <!-- Chevron size identical to current -->
<template #dropdownicon> <template #dropdownicon>
<i class="icon-[lucide--chevron-down] text-lg text-neutral-400" /> <i class="icon-[lucide--chevron-down] text-muted-foreground" />
</template> </template>
<!-- Custom option row: square checkbox + label (unchanged layout/colors) --> <!-- Custom option row: square checkbox + label (unchanged layout/colors) -->

View File

@@ -1,6 +1,6 @@
<template> <template>
<div :class="wrapperStyle" @click="focusInput"> <div :class="wrapperStyle" @click="focusInput">
<i class="icon-[lucide--search] text-muted" /> <i class="icon-[lucide--search] text-muted-foreground" />
<InputText <InputText
ref="input" ref="input"
v-model="internalSearchQuery" v-model="internalSearchQuery"
@@ -73,7 +73,7 @@ onMounted(() => autofocus && focusInput())
const wrapperStyle = computed(() => { const wrapperStyle = computed(() => {
const baseClasses = 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) { if (showBorder) {
return cn( return cn(

View File

@@ -20,7 +20,7 @@
'h-10 relative inline-flex cursor-pointer select-none items-center', 'h-10 relative inline-flex cursor-pointer select-none items-center',
// trigger surface // trigger surface
'rounded-lg', 'rounded-lg',
'bg-base-background text-base-foreground', 'bg-secondary-background text-base-foreground',
'border-[2.5px] border-solid border-transparent', 'border-[2.5px] border-solid border-transparent',
'transition-all duration-200 ease-in-out', 'transition-all duration-200 ease-in-out',
'focus-within:border-node-component-border', 'focus-within:border-node-component-border',
@@ -84,7 +84,7 @@
> >
<!-- Trigger value --> <!-- Trigger value -->
<template #value="slotProps"> <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" /> <slot name="icon" />
<span <span
v-if="slotProps.value !== null && slotProps.value !== undefined" v-if="slotProps.value !== null && slotProps.value !== undefined"
@@ -100,7 +100,7 @@
<!-- Trigger caret --> <!-- Trigger caret -->
<template #dropdownicon> <template #dropdownicon>
<i class="icon-[lucide--chevron-down] text-base text-neutral-500" /> <i class="icon-[lucide--chevron-down] text-muted-foreground" />
</template> </template>
<!-- Option row --> <!-- Option row -->

View File

@@ -3,7 +3,7 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
--> -->
<template> <template>
<LGraphNodePreview v-if="shouldRenderVueNodes" :node-def="nodeDef" /> <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="_sb_table">
<div <div
class="node_header mr-4 text-ellipsis" class="node_header mr-4 text-ellipsis"
@@ -200,7 +200,6 @@ const truncateDefaultValue = (value: any, charLimit: number = 32): string => {
} }
._sb_node_preview { ._sb_node_preview {
background-color: var(--comfy-menu-bg);
font-family: 'Open Sans', sans-serif; font-family: 'Open Sans', sans-serif;
color: var(--descrip-text); color: var(--descrip-text);
border: 1px solid var(--descrip-text); border: 1px solid var(--descrip-text);

View File

@@ -1,8 +1,10 @@
<template> <template>
<button <IconButton
type="button" type="secondary"
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" 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" :aria-label="props.ariaLabel"
@click="emit('click', $event)"
> >
<span class="inline-flex items-center gap-2"> <span class="inline-flex items-center gap-2">
<span v-if="props.mode === 'allFailed'" class="inline-flex items-center"> <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" /> <i class="icon-[lucide--chevron-down] block size-4 leading-none" />
</span> </span>
</button> </IconButton>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import IconButton from '@/components/button/IconButton.vue'
import type { import type {
CompletionSummary, CompletionSummary,
CompletionSummaryMode CompletionSummaryMode
@@ -96,4 +99,8 @@ type Props = {
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
thumbnailUrls: () => [] thumbnailUrls: () => []
}) })
const emit = defineEmits<{
(e: 'click', event: MouseEvent): void
}>()
</script> </script>

View File

@@ -42,17 +42,19 @@
t('sideToolbar.queueProgressOverlay.running') t('sideToolbar.queueProgressOverlay.running')
}}</span> }}</span>
</span> </span>
<button <IconButton
v-if="runningCount > 0" v-if="runningCount > 0"
v-tooltip.top="cancelJobTooltip" 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')" :aria-label="t('sideToolbar.queueProgressOverlay.interruptAll')"
@click="$emit('interruptAll')" @click="$emit('interruptAll')"
> >
<i <i
class="icon-[lucide--x] block size-4 leading-none text-text-primary" class="icon-[lucide--x] block size-4 leading-none text-text-primary"
/> />
</button> </IconButton>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -62,26 +64,28 @@
t('sideToolbar.queueProgressOverlay.queuedSuffix') t('sideToolbar.queueProgressOverlay.queuedSuffix')
}}</span> }}</span>
</span> </span>
<button <IconButton
v-if="queuedCount > 0" v-if="queuedCount > 0"
v-tooltip.top="clearQueueTooltip" 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')" :aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
@click="$emit('clearQueued')" @click="$emit('clearQueued')"
> >
<i <i
class="icon-[lucide--list-x] block size-4 leading-none text-text-primary" class="icon-[lucide--list-x] block size-4 leading-none text-text-primary"
/> />
</button> </IconButton>
</div> </div>
</div> </div>
<button <TextButton
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" class="h-6 min-w-[120px] flex-1 px-2 py-0 text-[12px]"
type="secondary"
:label="t('sideToolbar.queueProgressOverlay.viewAllJobs')"
@click="$emit('viewAllJobs')" @click="$emit('viewAllJobs')"
> />
{{ t('sideToolbar.queueProgressOverlay.viewAllJobs') }}
</button>
</div> </div>
</div> </div>
</template> </template>
@@ -90,6 +94,8 @@
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import TextButton from '@/components/button/TextButton.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig' import { buildTooltipConfig } from '@/composables/useTooltipConfig'
defineProps<{ defineProps<{

View File

@@ -8,17 +8,20 @@
/> />
<div class="flex items-center justify-between px-3"> <div class="flex items-center justify-between px-3">
<button <IconTextButton
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" 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')" :aria-label="t('sideToolbar.queueProgressOverlay.showAssets')"
@click="$emit('showAssets')" @click="$emit('showAssets')"
> >
<div <template #icon>
class="pointer-events-none block size-4 shrink-0 leading-none icon-[comfy--image-ai-edit]" <div
aria-hidden="true" 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>
</IconTextButton>
<div class="ml-4 inline-flex items-center"> <div class="ml-4 inline-flex items-center">
<div <div
class="inline-flex h-6 items-center text-[12px] leading-none text-text-primary opacity-90" class="inline-flex h-6 items-center text-[12px] leading-none text-text-primary opacity-90"
@@ -28,16 +31,18 @@
t('sideToolbar.queueProgressOverlay.queuedSuffix') t('sideToolbar.queueProgressOverlay.queuedSuffix')
}}</span> }}</span>
</div> </div>
<button <IconButton
v-if="queuedCount > 0" 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')" :aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
@click="$emit('clearQueued')" @click="$emit('clearQueued')"
> >
<i <i
class="pointer-events-none icon-[lucide--list-x] block size-4 leading-none text-text-primary transition-colors group-hover:text-base-background" 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>
</div> </div>
@@ -75,6 +80,8 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import type { import type {
JobGroup, JobGroup,
JobListItem, JobListItem,

View File

@@ -18,16 +18,18 @@
</span> </span>
</div> </div>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<button <IconButton
v-tooltip.top="moreTooltipConfig" 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')" :aria-label="t('sideToolbar.queueProgressOverlay.moreOptions')"
@click="onMoreClick" @click="onMoreClick"
> >
<i <i
class="icon-[lucide--more-horizontal] block size-4 leading-none text-text-secondary" class="icon-[lucide--more-horizontal] block size-4 leading-none text-text-secondary"
/> />
</button> </IconButton>
<Popover <Popover
ref="morePopoverRef" ref="morePopoverRef"
:dismissable="true" :dismissable="true"
@@ -45,18 +47,19 @@
<div <div
class="flex flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3 font-inter" class="flex flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3 font-inter"
> >
<button <IconTextButton
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" 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')" :aria-label="t('sideToolbar.queueProgressOverlay.clearHistory')"
@click="onClearHistoryFromMenu" @click="onClearHistoryFromMenu"
> >
<i <template #icon>
class="icon-[lucide--file-x-2] block size-4 leading-none text-text-secondary" <i
/> class="icon-[lucide--file-x-2] block size-4 leading-none text-text-secondary"
<span>{{ />
t('sideToolbar.queueProgressOverlay.clearHistory') </template>
}}</span> </IconTextButton>
</button>
</div> </div>
</Popover> </Popover>
</div> </div>
@@ -69,6 +72,8 @@ import type { PopoverMethods } from 'primevue/popover'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig' import { buildTooltipConfig } from '@/composables/useTooltipConfig'
defineProps<{ defineProps<{

View File

@@ -8,13 +8,15 @@
<p class="m-0 text-[14px] font-normal leading-none"> <p class="m-0 text-[14px] font-normal leading-none">
{{ t('sideToolbar.queueProgressOverlay.clearHistoryDialogTitle') }} {{ t('sideToolbar.queueProgressOverlay.clearHistoryDialogTitle') }}
</p> </p>
<button <IconButton
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" type="transparent"
size="sm"
class="size-6 bg-transparent text-text-secondary hover:bg-secondary-background hover:opacity-100"
:aria-label="t('g.close')" :aria-label="t('g.close')"
@click="onCancel" @click="onCancel"
> >
<i class="icon-[lucide--x] block size-4 leading-none" /> <i class="icon-[lucide--x] block size-4 leading-none" />
</button> </IconButton>
</header> </header>
<div class="flex flex-col gap-4 px-4 py-4 text-[14px] text-text-secondary"> <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"> <footer class="flex items-center justify-end px-4 py-4">
<div class="flex items-center gap-4 text-[14px] leading-none"> <div class="flex items-center gap-4 text-[14px] leading-none">
<button <TextButton
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" class="min-h-[24px] px-1 py-1 text-[14px] leading-[1] text-text-secondary hover:text-text-primary"
:aria-label="t('g.cancel')" type="transparent"
:label="t('g.cancel')"
@click="onCancel" @click="onCancel"
> />
{{ t('g.cancel') }} <TextButton
</button> class="min-h-[32px] px-4 py-2 text-[12px] font-normal leading-[1]"
<button type="secondary"
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" :label="t('g.clear')"
:aria-label="t('g.clear')"
:disabled="isClearing" :disabled="isClearing"
@click="onConfirm" @click="onConfirm"
> />
{{ t('g.clear') }}
</button>
</div> </div>
</footer> </footer>
</section> </section>
@@ -54,6 +54,8 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useI18n } from 'vue-i18n' 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 { useErrorHandling } from '@/composables/useErrorHandling'
import { useDialogStore } from '@/stores/dialogStore' import { useDialogStore } from '@/stores/dialogStore'
import { useQueueStore } from '@/stores/queueStore' import { useQueueStore } from '@/stores/queueStore'

View File

@@ -20,21 +20,24 @@
<div v-if="entry.kind === 'divider'" class="px-2 py-1"> <div v-if="entry.kind === 'divider'" class="px-2 py-1">
<div class="h-px bg-interface-stroke" /> <div class="h-px bg-interface-stroke" />
</div> </div>
<button <IconTextButton
v-else 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" :aria-label="entry.label"
@click="onEntry(entry)" @click="onEntry(entry)"
> >
<i <template #icon>
v-if="entry.icon" <i
:class="[ v-if="entry.icon"
entry.icon, :class="[
'block size-4 shrink-0 leading-none text-text-secondary' entry.icon,
]" 'block size-4 shrink-0 leading-none text-text-secondary'
/> ]"
<span>{{ entry.label }}</span> />
</button> </template>
</IconTextButton>
</template> </template>
</div> </div>
</Popover> </Popover>
@@ -44,6 +47,7 @@
import Popover from 'primevue/popover' import Popover from 'primevue/popover'
import { ref } from 'vue' import { ref } from 'vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import type { MenuEntry } from '@/composables/queue/useJobMenu' import type { MenuEntry } from '@/composables/queue/useJobMenu'
defineProps<{ entries: MenuEntry[] }>() defineProps<{ entries: MenuEntry[] }>()

View File

@@ -20,17 +20,18 @@
class="flex min-w-0 items-center text-[0.75rem] leading-normal font-normal text-text-secondary" 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> <span class="block min-w-0 truncate">{{ row.value }}</span>
<button <IconButton
v-if="row.canCopy" v-if="row.canCopy"
type="button" type="transparent"
class="ml-2 inline-flex size-6 items-center justify-center rounded border-0 bg-transparent p-0 hover:opacity-90" size="sm"
class="ml-2 size-6 bg-transparent hover:opacity-90"
:aria-label="copyAriaLabel" :aria-label="copyAriaLabel"
@click.stop="copyJobId" @click.stop="copyJobId"
> >
<i <i
class="icon-[lucide--copy] block size-4 leading-none text-text-secondary" class="icon-[lucide--copy] block size-4 leading-none text-text-secondary"
/> />
</button> </IconButton>
</div> </div>
</template> </template>
</div> </div>
@@ -60,25 +61,31 @@
{{ t('queue.jobDetails.errorMessage') }} {{ t('queue.jobDetails.errorMessage') }}
</div> </div>
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<button <IconTextButton
type="button" class="h-6 justify-start gap-2 bg-transparent px-0 text-[0.75rem] leading-none text-text-secondary hover:opacity-90"
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" type="transparent"
:label="copyAriaLabel"
:aria-label="copyAriaLabel" :aria-label="copyAriaLabel"
icon-position="right"
@click.stop="copyErrorMessage" @click.stop="copyErrorMessage"
> >
<span>{{ copyAriaLabel }}</span> <template #icon>
<i class="icon-[lucide--copy] block size-3.5 leading-none" /> <i class="icon-[lucide--copy] block size-3.5 leading-none" />
</button> </template>
<button </IconTextButton>
type="button" <IconTextButton
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" 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" @click.stop="reportJobError"
> >
<span>{{ t('queue.jobDetails.report') }}</span> <template #icon>
<i <i
class="icon-[lucide--message-circle-warning] block size-3.5 leading-none" class="icon-[lucide--message-circle-warning] block size-3.5 leading-none"
/> />
</button> </template>
</IconTextButton>
</div> </div>
<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" 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 { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n' 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 { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { t } from '@/i18n' import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types' import { isCloud } from '@/platform/distribution/types'

View File

@@ -2,26 +2,26 @@
<div class="flex items-center justify-between gap-2 px-3"> <div class="flex items-center justify-between gap-2 px-3">
<div class="min-w-0 flex-1 overflow-x-auto"> <div class="min-w-0 flex-1 overflow-x-auto">
<div class="inline-flex items-center gap-1 whitespace-nowrap"> <div class="inline-flex items-center gap-1 whitespace-nowrap">
<button <TextButton
v-for="tab in visibleJobTabs" v-for="tab in visibleJobTabs"
:key="tab" :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="[ :class="[
selectedJobTab === tab selectedJobTab === tab ? 'text-text-primary' : 'text-text-secondary'
? 'bg-secondary-background text-text-primary'
: 'bg-transparent text-text-secondary'
]" ]"
:label="tabLabel(tab)"
@click="$emit('update:selectedJobTab', tab)" @click="$emit('update:selectedJobTab', tab)"
> />
{{ tabLabel(tab) }}
</button>
</div> </div>
</div> </div>
<div class="ml-2 flex shrink-0 items-center gap-2"> <div class="ml-2 flex shrink-0 items-center gap-2">
<button <IconButton
v-if="showWorkflowFilter" v-if="showWorkflowFilter"
v-tooltip.top="filterTooltipConfig" 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')" :aria-label="t('sideToolbar.queueProgressOverlay.filterJobs')"
@click="onFilterClick" @click="onFilterClick"
> >
@@ -32,7 +32,7 @@
v-if="selectedWorkflowFilter !== 'all'" v-if="selectedWorkflowFilter !== 'all'"
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground" class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
/> />
</button> </IconButton>
<Popover <Popover
v-if="showWorkflowFilter" v-if="showWorkflowFilter"
ref="filterPopoverRef" ref="filterPopoverRef"
@@ -51,46 +51,48 @@
<div <div
class="flex min-w-[12rem] flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3" class="flex min-w-[12rem] flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3"
> >
<button <IconTextButton
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" 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=" :aria-label="
t('sideToolbar.queueProgressOverlay.filterAllWorkflows') t('sideToolbar.queueProgressOverlay.filterAllWorkflows')
" "
@click="selectWorkflowFilter('all')" @click="selectWorkflowFilter('all')"
> >
<span>{{ <template #icon>
t('sideToolbar.queueProgressOverlay.filterAllWorkflows')
}}</span>
<span class="ml-auto inline-flex items-center">
<i <i
v-if="selectedWorkflowFilter === 'all'" v-if="selectedWorkflowFilter === 'all'"
class="icon-[lucide--check] block size-4 leading-none text-text-secondary" class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
/> />
</span> </template>
</button> </IconTextButton>
<div class="mx-2 mt-1 h-px" /> <div class="mx-2 mt-1 h-px" />
<button <IconTextButton
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" 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=" :aria-label="
t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow') t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow')
" "
@click="selectWorkflowFilter('current')" @click="selectWorkflowFilter('current')"
> >
<span>{{ <template #icon>
t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow')
}}</span>
<span class="ml-auto inline-flex items-center">
<i <i
v-if="selectedWorkflowFilter === 'current'" v-if="selectedWorkflowFilter === 'current'"
class="icon-[lucide--check] block size-4 leading-none text-text-secondary" class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
/> />
</span> </template>
</button> </IconTextButton>
</div> </div>
</Popover> </Popover>
<button <IconButton
v-tooltip.top="sortTooltipConfig" 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')" :aria-label="t('sideToolbar.queueProgressOverlay.sortJobs')"
@click="onSortClick" @click="onSortClick"
> >
@@ -101,7 +103,7 @@
v-if="selectedSortMode !== 'mostRecent'" v-if="selectedSortMode !== 'mostRecent'"
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground" class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
/> />
</button> </IconButton>
<Popover <Popover
ref="sortPopoverRef" ref="sortPopoverRef"
:dismissable="true" :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" 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"> <template v-for="(mode, index) in jobSortModes" :key="mode">
<button <IconTextButton
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" 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)" :aria-label="sortLabel(mode)"
@click="selectSortMode(mode)" @click="selectSortMode(mode)"
> >
<span>{{ sortLabel(mode) }}</span> <template #icon>
<span class="ml-auto inline-flex items-center">
<i <i
v-if="selectedSortMode === mode" v-if="selectedSortMode === mode"
class="icon-[lucide--check] block size-4 leading-none text-text-secondary" class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
/> />
</span> </template>
</button> </IconTextButton>
<div <div
v-if="index < jobSortModes.length - 1" v-if="index < jobSortModes.length - 1"
class="mx-2 mt-1 h-px" class="mx-2 mt-1 h-px"
@@ -149,6 +153,9 @@ import Popover from 'primevue/popover'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n' 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 { jobSortModes, jobTabs } from '@/composables/queue/useJobList'
import type { JobSortMode, JobTab } from '@/composables/queue/useJobList' import type { JobSortMode, JobTab } from '@/composables/queue/useJobList'
import { buildTooltipConfig } from '@/composables/useTooltipConfig' import { buildTooltipConfig } from '@/composables/useTooltipConfig'

View File

@@ -108,45 +108,47 @@
key="actions" key="actions"
class="inline-flex items-center gap-2 pr-1" class="inline-flex items-center gap-2 pr-1"
> >
<button <IconButton
v-if="props.state === 'failed' && computedShowClear" v-if="props.state === 'failed' && computedShowClear"
v-tooltip.top="deleteTooltipConfig" v-tooltip.top="deleteTooltipConfig"
type="button" type="transparent"
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" 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')" :aria-label="t('g.delete')"
@click.stop="emit('delete')" @click.stop="emit('delete')"
> >
<i class="icon-[lucide--trash-2] size-4" /> <i class="icon-[lucide--trash-2] size-4" />
</button> </IconButton>
<button <IconButton
v-else-if="props.state !== 'completed' && computedShowClear" v-else-if="props.state !== 'completed' && computedShowClear"
v-tooltip.top="cancelTooltipConfig" v-tooltip.top="cancelTooltipConfig"
type="button" type="transparent"
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" 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')" :aria-label="t('g.cancel')"
@click.stop="emit('cancel')" @click.stop="emit('cancel')"
> >
<i class="icon-[lucide--x] size-4" /> <i class="icon-[lucide--x] size-4" />
</button> </IconButton>
<button <TextButton
v-else-if="props.state === 'completed'" v-else-if="props.state === 'completed'"
type="button" 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"
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" type="transparent"
:label="t('menuLabels.View')"
:aria-label="t('menuLabels.View')" :aria-label="t('menuLabels.View')"
@click.stop="emit('view')" @click.stop="emit('view')"
> />
<span>{{ t('menuLabels.View') }}</span> <IconButton
</button>
<button
v-if="props.showMenu !== undefined ? props.showMenu : true" v-if="props.showMenu !== undefined ? props.showMenu : true"
v-tooltip.top="moreTooltipConfig" v-tooltip.top="moreTooltipConfig"
type="button" type="transparent"
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" 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')" :aria-label="t('g.more')"
@click.stop="emit('menu', $event)" @click.stop="emit('menu', $event)"
> >
<i class="icon-[lucide--more-horizontal] size-4" /> <i class="icon-[lucide--more-horizontal] size-4" />
</button> </IconButton>
</div> </div>
<div v-else key="secondary" class="pr-2"> <div v-else key="secondary" class="pr-2">
<slot name="secondary">{{ props.rightText }}</slot> <slot name="secondary">{{ props.rightText }}</slot>
@@ -161,6 +163,8 @@
import { computed, nextTick, ref, watch } from 'vue' import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' 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 JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
import QueueAssetPreview from '@/components/queue/job/QueueAssetPreview.vue' import QueueAssetPreview from '@/components/queue/job/QueueAssetPreview.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig' import { buildTooltipConfig } from '@/composables/useTooltipConfig'

View File

@@ -13,6 +13,7 @@ import type {
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations' import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types' import { LayoutSource } from '@/renderer/core/layout/types'
import type { NodeId } from '@/renderer/core/layout/types'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { isDOMWidget } from '@/scripts/domWidget' import { isDOMWidget } from '@/scripts/domWidget'
import { useNodeDefStore } from '@/stores/nodeDefStore' import { useNodeDefStore } from '@/stores/nodeDefStore'
@@ -46,7 +47,7 @@ export interface SafeWidgetData {
} }
export interface VueNodeData { export interface VueNodeData {
id: string id: NodeId
title: string title: string
type: string type: string
mode: number mode: number

View File

@@ -3,6 +3,7 @@ import { shallowRef, watch } from 'vue'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager' import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import type { GraphNodeManager } from '@/composables/graph/useGraphNodeManager' import type { GraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import { useRenderModeSetting } from '@/composables/settings/useRenderModeSetting'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags' import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import { useVueNodesMigrationDismissed } from '@/composables/useVueNodesMigrationDismissed' import { useVueNodesMigrationDismissed } from '@/composables/useVueNodesMigrationDismissed'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph' import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
@@ -18,15 +19,18 @@ function useVueNodeLifecycleIndividual() {
const canvasStore = useCanvasStore() const canvasStore = useCanvasStore()
const layoutMutations = useLayoutMutations() const layoutMutations = useLayoutMutations()
const { shouldRenderVueNodes } = useVueFeatureFlags() const { shouldRenderVueNodes } = useVueFeatureFlags()
const nodeManager = shallowRef<GraphNodeManager | null>(null) const nodeManager = shallowRef<GraphNodeManager | null>(null)
const { startSync } = useLayoutSync() const { startSync } = useLayoutSync()
const isVueNodeToastDismissed = useVueNodesMigrationDismissed() const isVueNodeToastDismissed = useVueNodesMigrationDismissed()
let hasShownMigrationToast = false let hasShownMigrationToast = false
useRenderModeSetting(
{ setting: 'LiteGraph.Canvas.MinFontSizeForLOD', vue: 0, litegraph: 8 },
shouldRenderVueNodes
)
const initializeNodeManager = () => { const initializeNodeManager = () => {
// Use canvas graph if available (handles subgraph contexts), fallback to app graph // Use canvas graph if available (handles subgraph contexts), fallback to app graph
const activeGraph = comfyApp.canvas?.graph const activeGraph = comfyApp.canvas?.graph

View 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
}
}

View File

@@ -1545,7 +1545,26 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
} }
}, },
GeminiImageNode: { 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 // OpenAI nodes
OpenAIChatNode: { OpenAIChatNode: {
@@ -1829,6 +1848,7 @@ export const useNodePricing = () => {
TripoTextureNode: ['texture_quality'], TripoTextureNode: ['texture_quality'],
// Google/Gemini nodes // Google/Gemini nodes
GeminiNode: ['model'], GeminiNode: ['model'],
GeminiImage2Node: ['resolution'],
// OpenAI nodes // OpenAI nodes
OpenAIChatNode: ['model'], OpenAIChatNode: ['model'],
// ByteDance // ByteDance

View File

@@ -0,0 +1,42 @@
import type { ComputedRef } from 'vue'
import { ref, watch } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { Settings } from '@/schemas/apiSchema'
interface RenderModeSettingConfig<TSettingKey extends keyof Settings> {
setting: TSettingKey
vue: Settings[TSettingKey]
litegraph: Settings[TSettingKey]
}
export function useRenderModeSetting<TSettingKey extends keyof Settings>(
config: RenderModeSettingConfig<TSettingKey>,
isVueMode: ComputedRef<boolean>
) {
const settingStore = useSettingStore()
const vueValue = ref(config.vue)
const litegraphValue = ref(config.litegraph)
const lastWasVue = ref<boolean | null>(null)
const load = async (vue: boolean) => {
if (lastWasVue.value === vue) return
if (lastWasVue.value !== null) {
const currentValue = settingStore.get(config.setting)
if (lastWasVue.value) {
vueValue.value = currentValue
} else {
litegraphValue.value = currentValue
}
}
await settingStore.set(
config.setting,
vue ? vueValue.value : litegraphValue.value
)
lastWasVue.value = vue
}
watch(isVueMode, load, { immediate: true })
}

View File

@@ -5,10 +5,9 @@ import { app } from '@/scripts/app'
import { ComfyApp } from '@/scripts/app' import { ComfyApp } from '@/scripts/app'
import { useMaskEditorStore } from '@/stores/maskEditorStore' import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { useDialogStore } from '@/stores/dialogStore' 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 { MaskEditorDialogOld } from './maskEditorOld'
import { ClipspaceDialog } from './clipspace' import { ClipspaceDialog } from './clipspace'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
function openMaskEditor(node: LGraphNode): void { function openMaskEditor(node: LGraphNode): void {
if (!node) { if (!node) {
@@ -26,32 +25,7 @@ function openMaskEditor(node: LGraphNode): void {
) )
if (useNewEditor) { if (useNewEditor) {
// Use new refactored editor useMaskEditor().openMaskEditor(node)
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'
}
}
}
})
} else { } else {
// Use old editor // Use old editor
ComfyApp.copyToClipspace(node) ComfyApp.copyToClipspace(node)

View File

@@ -17,10 +17,10 @@ useExtensionService().registerExtension({
nodeType.prototype.onNodeCreated = function () { nodeType.prototype.onNodeCreated = function () {
onNodeCreated ? onNodeCreated.apply(this, []) : undefined onNodeCreated ? onNodeCreated.apply(this, []) : undefined
const showValueWidget = ComfyWidgets['STRING']( const showValueWidget = ComfyWidgets['MARKDOWN'](
this, this,
'preview', 'preview',
['STRING', { multiline: true }], ['MARKDOWN', {}],
app app
).widget as DOMWidget<HTMLTextAreaElement, string> ).widget as DOMWidget<HTMLTextAreaElement, string>

View File

@@ -79,7 +79,7 @@ export type {
LGraphTriggerParam LGraphTriggerParam
} from './types/graphTriggers' } from './types/graphTriggers'
export type rendererType = 'LG' | 'Vue' export type RendererType = 'LG' | 'Vue'
export interface LGraphState { export interface LGraphState {
lastGroupId: number lastGroupId: number
@@ -106,7 +106,7 @@ export interface LGraphExtra extends Dictionary<unknown> {
reroutes?: SerialisableReroute[] reroutes?: SerialisableReroute[]
linkExtensions?: { id: number; parentId: number | undefined }[] linkExtensions?: { id: number; parentId: number | undefined }[]
ds?: DragAndScaleState ds?: DragAndScaleState
workflowRendererVersion?: rendererType workflowRendererVersion?: RendererType
} }
export interface BaseLGraph { export interface BaseLGraph {

View File

@@ -1771,18 +1771,19 @@ export class LGraphCanvas
} }
static onMenuNodeClone( static onMenuNodeClone(
// @ts-expect-error - unused parameter _value: IContextMenuValue,
value: IContextMenuValue, _options: IContextMenuOptions,
// @ts-expect-error - unused parameter _e: MouseEvent,
options: IContextMenuOptions, _menu: ContextMenu,
// @ts-expect-error - unused parameter
e: MouseEvent,
// @ts-expect-error - unused parameter
menu: ContextMenu,
node: LGraphNode node: LGraphNode
): void { ): void {
const canvas = LGraphCanvas.active_canvas 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 // Find top-left-most boundary
let offsetX = Infinity let offsetX = Infinity
@@ -1792,11 +1793,11 @@ export class LGraphCanvas
throw new TypeError( throw new TypeError(
'Invalid node encountered on clone. `pos` was null.' 'Invalid node encountered on clone. `pos` was null.'
) )
if (item.pos[0] < offsetX) offsetX = item.pos[0] offsetX = Math.min(offsetX, item.pos[0])
if (item.pos[1] < offsetY) offsetY = item.pos[1] offsetY = Math.min(offsetY, item.pos[1])
} }
canvas._deserializeItems(canvas._serializeItems(nodes), { return canvas._deserializeItems(canvas._serializeItems(nodes), {
position: [offsetX + 5, offsetY + 5] position: [offsetX + 5, offsetY + 5]
}) })
} }

View File

@@ -504,6 +504,8 @@
"cannotWrite": "Unable to write to the selected path", "cannotWrite": "Unable to write to the selected path",
"insufficientFreeSpace": "Insufficient space - minimum free space", "insufficientFreeSpace": "Insufficient space - minimum free space",
"isOneDrive": "OneDrive is not supported. Please install ComfyUI in another location.", "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.", "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", "parentMissing": "Path does not exist - create the containing directory first",
"unhandledError": "Unknown error", "unhandledError": "Unknown error",
@@ -1497,6 +1499,14 @@
"taskFailed": "Task failed to run.", "taskFailed": "Task failed to run.",
"cannotContinue": "Unable to continue - errors remain", "cannotContinue": "Unable to continue - errors remain",
"defaultDescription": "An error occurred while running a maintenance task." "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": { "missingModelsDialog": {
@@ -2075,7 +2085,36 @@
"failedToCreateNode": "Failed to create node. Please try again or check console for details.", "failedToCreateNode": "Failed to create node. Please try again or check console for details.",
"noModelsInFolder": "No {type} available in this folder", "noModelsInFolder": "No {type} available in this folder",
"searchAssetsPlaceholder": "Type to search...", "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", "allModels": "All Models",
"allCategory": "All {category}", "allCategory": "All {category}",
"unknown": "Unknown", "unknown": "Unknown",
@@ -2087,6 +2126,13 @@
"sortZA": "Z-A", "sortZA": "Z-A",
"sortRecent": "Recent", "sortRecent": "Recent",
"sortPopular": "Popular", "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": { "ariaLabel": {
"assetCard": "{name} - {type} asset", "assetCard": "{name} - {type} asset",
"loadingAsset": "Loading asset" "loadingAsset": "Loading asset"

View File

@@ -335,11 +335,11 @@
"name": "Validate workflows" "name": "Validate workflows"
}, },
"Comfy_VueNodes_AutoScaleLayout": { "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" "tooltip": "Automatically scale node positions when switching to Vue rendering to prevent overlap"
}, },
"Comfy_VueNodes_Enabled": { "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." "tooltip": "Modern: DOM-based rendering with enhanced interactivity, native browser features, and updated visual design. Classic: Traditional canvas rendering."
}, },
"Comfy_WidgetControlMode": { "Comfy_WidgetControlMode": {

View File

@@ -38,7 +38,7 @@
:on-click="handleUploadClick" :on-click="handleUploadClick"
> >
<template #icon> <template #icon>
<i class="icon-[lucide--upload]" /> <i class="icon-[lucide--package-plus]" />
</template> </template>
</IconTextButton> </IconTextButton>
</div> </div>
@@ -73,11 +73,14 @@ import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags' import { useFeatureFlags } from '@/composables/useFeatureFlags'
import AssetFilterBar from '@/platform/assets/components/AssetFilterBar.vue' import AssetFilterBar from '@/platform/assets/components/AssetFilterBar.vue'
import AssetGrid from '@/platform/assets/components/AssetGrid.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 type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser' import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema' import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService' import { assetService } from '@/platform/assets/services/assetService'
import { formatCategoryLabel } from '@/platform/assets/utils/categoryLabel' import { formatCategoryLabel } from '@/platform/assets/utils/categoryLabel'
import { useDialogStore } from '@/stores/dialogStore'
import { useModelToNodeStore } from '@/stores/modelToNodeStore' import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import { OnCloseKey } from '@/types/widgetTypes' import { OnCloseKey } from '@/types/widgetTypes'
@@ -92,6 +95,7 @@ const props = defineProps<{
}>() }>()
const { t } = useI18n() const { t } = useI18n()
const dialogStore = useDialogStore()
const emit = defineEmits<{ const emit = defineEmits<{
'asset-select': [asset: AssetDisplayItem] 'asset-select': [asset: AssetDisplayItem]
@@ -189,6 +193,15 @@ const { flags } = useFeatureFlags()
const isUploadButtonEnabled = computed(() => flags.modelUploadButtonEnabled) const isUploadButtonEnabled = computed(() => flags.modelUploadButtonEnabled)
function handleUploadClick() { function handleUploadClick() {
// Will be implemented in the future commit dialogStore.showDialog({
key: 'upload-model',
headerComponent: UploadModelDialogHeader,
component: UploadModelDialog,
props: {
onUploadSuccess: async () => {
await execute()
}
}
})
} }
</script> </script>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
}
})

View 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
}
}

View File

@@ -33,6 +33,29 @@ const zModelFile = z.object({
pathIndex: z.number() 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 // Filename validation schema
export const assetFilenameSchema = z export const assetFilenameSchema = z
.string() .string()
@@ -48,6 +71,7 @@ export const assetResponseSchema = zAssetResponse
// Export types derived from Zod schemas // Export types derived from Zod schemas
export type AssetItem = z.infer<typeof zAsset> export type AssetItem = z.infer<typeof zAsset>
export type AssetResponse = z.infer<typeof zAssetResponse> export type AssetResponse = z.infer<typeof zAssetResponse>
export type AssetMetadata = z.infer<typeof zAssetMetadata>
export type ModelFolder = z.infer<typeof zModelFolder> export type ModelFolder = z.infer<typeof zModelFolder>
export type ModelFile = z.infer<typeof zModelFile> export type ModelFile = z.infer<typeof zModelFile>

View File

@@ -1,8 +1,10 @@
import { fromZodError } from 'zod-validation-error' import { fromZodError } from 'zod-validation-error'
import { st } from '@/i18n'
import { assetResponseSchema } from '@/platform/assets/schemas/assetSchema' import { assetResponseSchema } from '@/platform/assets/schemas/assetSchema'
import type { import type {
AssetItem, AssetItem,
AssetMetadata,
AssetResponse, AssetResponse,
ModelFile, ModelFile,
ModelFolder ModelFolder
@@ -10,6 +12,36 @@ import type {
import { api } from '@/scripts/api' import { api } from '@/scripts/api'
import { useModelToNodeStore } from '@/stores/modelToNodeStore' 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 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 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 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 { return {
getAssetModelFolders, getAssetModelFolders,
getAssetModels, getAssetModels,
@@ -256,7 +359,9 @@ function createAssetService() {
getAssetsForNodeType, getAssetsForNodeType,
getAssetDetails, getAssetDetails,
getAssetsByTag, getAssetsByTag,
deleteAsset deleteAsset,
getAssetMetadata,
uploadAssetFromUrl
} }
} }

View File

@@ -107,10 +107,17 @@ const {
const authActions = useFirebaseAuthActions() 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 // Sort groups for a category
const sortedGroups = (category: SettingTreeNode): ISettingGroup[] => { const sortedGroups = (category: SettingTreeNode): ISettingGroup[] => {
return [...(category.children ?? [])] 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) => ({ .map((group) => ({
label: group.label, label: group.label,
settings: flattenTree<SettingParams>(group).sort((a, b) => { settings: flattenTree<SettingParams>(group).sort((a, b) => {

View File

@@ -1082,24 +1082,28 @@ export const CORE_SETTINGS: SettingParams[] = [
}, },
/** /**
* Vue Node System Settings * Nodes 2.0 Settings
*/ */
{ {
id: 'Comfy.VueNodes.Enabled', 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', type: 'boolean',
tooltip: tooltip:
'Modern: DOM-based rendering with enhanced interactivity, native browser features, and updated visual design. Classic: Traditional canvas rendering.', 'Modern: DOM-based rendering with enhanced interactivity, native browser features, and updated visual design. Classic: Traditional canvas rendering.',
defaultValue: false, defaultValue: false,
sortOrder: 100,
experimental: true, experimental: true,
versionAdded: '1.27.1' versionAdded: '1.27.1'
}, },
{ {
id: 'Comfy.VueNodes.AutoScaleLayout', id: 'Comfy.VueNodes.AutoScaleLayout',
name: 'Auto-scale layout (Vue nodes)', category: ['Comfy', 'Nodes 2.0', 'AutoScaleLayout'],
name: 'Auto-scale layout (Nodes 2.0)',
tooltip: tooltip:
'Automatically scale node positions when switching to Vue rendering to prevent overlap', 'Automatically scale node positions when switching to Vue rendering to prevent overlap',
type: 'boolean', type: 'boolean',
sortOrder: 50,
experimental: true, experimental: true,
defaultValue: true, defaultValue: true,
versionAdded: '1.30.3' versionAdded: '1.30.3'

View File

@@ -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')

View File

@@ -17,10 +17,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { useRafFn } from '@vueuse/core' import { useRafFn } from '@vueuse/core'
import { computed, provide } from 'vue' import { computed } from 'vue'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph' import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
import { useTransformSettling } from '@/renderer/core/layout/transform/useTransformSettling' import { useTransformSettling } from '@/renderer/core/layout/transform/useTransformSettling'
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState' import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
import { useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD' import { useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
@@ -32,14 +31,7 @@ interface TransformPaneProps {
const props = defineProps<TransformPaneProps>() const props = defineProps<TransformPaneProps>()
const { const { camera, transformStyle, syncWithCanvas } = useTransformState()
camera,
transformStyle,
syncWithCanvas,
canvasToScreen,
screenToCanvas,
isNodeInViewport
} = useTransformState()
const { isLOD } = useLOD(camera) const { isLOD } = useLOD(camera)
@@ -48,13 +40,6 @@ const { isTransforming: isInteracting } = useTransformSettling(canvasElement, {
settleDelay: 512 settleDelay: 512
}) })
provide(TransformStateKey, {
camera,
canvasToScreen,
screenToCanvas,
isNodeInViewport
})
const emit = defineEmits<{ const emit = defineEmits<{
transformUpdate: [] transformUpdate: []
}>() }>()

View File

@@ -52,6 +52,7 @@
import { computed, reactive, readonly } from 'vue' import { computed, reactive, readonly } from 'vue'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph' import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { createSharedComposable } from '@vueuse/core'
interface Point { interface Point {
x: number x: number
@@ -64,7 +65,7 @@ interface Camera {
z: number // scale/zoom z: number // scale/zoom
} }
export const useTransformState = () => { function useTransformStateIndividual() {
// Reactive state mirroring LiteGraph's canvas transform // Reactive state mirroring LiteGraph's canvas transform
const camera = reactive<Camera>({ const camera = reactive<Camera>({
x: 0, x: 0,
@@ -91,7 +92,7 @@ export const useTransformState = () => {
* *
* @param canvas - LiteGraph canvas instance with DragAndScale (ds) transform state * @param canvas - LiteGraph canvas instance with DragAndScale (ds) transform state
*/ */
const syncWithCanvas = (canvas: LGraphCanvas) => { function syncWithCanvas(canvas: LGraphCanvas) {
if (!canvas || !canvas.ds) return if (!canvas || !canvas.ds) return
// Mirror LiteGraph's transform state to Vue's reactive state // Mirror LiteGraph's transform state to Vue's reactive state
@@ -112,7 +113,7 @@ export const useTransformState = () => {
* @param point - Point in canvas coordinate system * @param point - Point in canvas coordinate system
* @returns Point in screen coordinate system * @returns Point in screen coordinate system
*/ */
const canvasToScreen = (point: Point): Point => { function canvasToScreen(point: Point): Point {
return { return {
x: (point.x + camera.x) * camera.z, x: (point.x + camera.x) * camera.z,
y: (point.y + camera.y) * camera.z y: (point.y + camera.y) * camera.z
@@ -138,10 +139,10 @@ export const useTransformState = () => {
} }
// Get node's screen bounds for culling // Get node's screen bounds for culling
const getNodeScreenBounds = ( function getNodeScreenBounds(
pos: ArrayLike<number>, pos: [number, number],
size: ArrayLike<number> size: [number, number]
): DOMRect => { ): DOMRect {
const topLeft = canvasToScreen({ x: pos[0], y: pos[1] }) const topLeft = canvasToScreen({ x: pos[0], y: pos[1] })
const width = size[0] * camera.z const width = size[0] * camera.z
const height = size[1] * camera.z const height = size[1] * camera.z
@@ -150,23 +151,23 @@ export const useTransformState = () => {
} }
// Helper: Calculate zoom-adjusted margin for viewport culling // 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 < 0.1) return Math.min(baseMargin * 5, 2.0)
if (camera.z > 3.0) return Math.max(baseMargin * 0.5, 0.05) if (camera.z > 3.0) return Math.max(baseMargin * 0.5, 0.05)
return baseMargin return baseMargin
} }
// Helper: Check if node is too small to be visible at current zoom // 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 const nodeScreenSize = Math.max(nodeSize[0], nodeSize[1]) * camera.z
return nodeScreenSize < 4 return nodeScreenSize < 4
} }
// Helper: Calculate expanded viewport bounds with margin // Helper: Calculate expanded viewport bounds with margin
const getExpandedViewportBounds = ( function getExpandedViewportBounds(
viewport: { width: number; height: number }, viewport: { width: number; height: number },
margin: number margin: number
) => { ) {
const marginX = viewport.width * margin const marginX = viewport.width * margin
const marginY = viewport.height * margin const marginY = viewport.height * margin
return { return {
@@ -178,11 +179,11 @@ export const useTransformState = () => {
} }
// Helper: Test if node intersects with viewport bounds // Helper: Test if node intersects with viewport bounds
const testViewportIntersection = ( function testViewportIntersection(
screenPos: { x: number; y: number }, screenPos: { x: number; y: number },
nodeSize: ArrayLike<number>, nodeSize: [number, number],
bounds: { left: number; right: number; top: number; bottom: number } bounds: { left: number; right: number; top: number; bottom: number }
): boolean => { ): boolean {
const nodeRight = screenPos.x + nodeSize[0] * camera.z const nodeRight = screenPos.x + nodeSize[0] * camera.z
const nodeBottom = screenPos.y + nodeSize[1] * 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 // Check if node is within viewport with frustum and size-based culling
const isNodeInViewport = ( function isNodeInViewport(
nodePos: ArrayLike<number>, nodePos: [number, number],
nodeSize: ArrayLike<number>, nodeSize: [number, number],
viewport: { width: number; height: number }, viewport: { width: number; height: number },
margin: number = 0.2 margin: number = 0.2
): boolean => { ): boolean {
// Early exit for tiny nodes // Early exit for tiny nodes
if (isNodeTooSmall(nodeSize)) return false if (isNodeTooSmall(nodeSize)) return false
@@ -212,10 +213,10 @@ export const useTransformState = () => {
} }
// Get viewport bounds in canvas coordinates (for spatial index queries) // Get viewport bounds in canvas coordinates (for spatial index queries)
const getViewportBounds = ( function getViewportBounds(
viewport: { width: number; height: number }, viewport: { width: number; height: number },
margin: number = 0.2 margin: number = 0.2
) => { ) {
const marginX = viewport.width * margin const marginX = viewport.width * margin
const marginY = viewport.height * margin const marginY = viewport.height * margin
@@ -244,3 +245,7 @@ export const useTransformState = () => {
getViewportBounds getViewportBounds
} }
} }
export const useTransformState = createSharedComposable(
useTransformStateIndividual
)

View File

@@ -11,9 +11,9 @@ interface SpatialBounds {
height: number height: number
} }
interface PositionedNode { export interface PositionedNode {
pos: ArrayLike<number> pos: [number, number]
size: ArrayLike<number> size: [number, number]
} }
/** /**

View File

@@ -1,5 +1,6 @@
import type { LGraph } from '@/lib/litegraph/src/litegraph' import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { calculateNodeBounds } from '@/renderer/core/spatial/boundsCalculator' import { calculateNodeBounds } from '@/renderer/core/spatial/boundsCalculator'
import type { PositionedNode } from '@/renderer/core/spatial/boundsCalculator'
import type { import type {
IMinimapDataSource, IMinimapDataSource,
@@ -29,10 +30,12 @@ export abstract class AbstractMinimapDataSource implements IMinimapDataSource {
} }
// Convert MinimapNodeData to the format expected by calculateNodeBounds // Convert MinimapNodeData to the format expected by calculateNodeBounds
const compatibleNodes = nodes.map((node) => ({ const compatibleNodes = nodes.map(
pos: [node.x, node.y], (node): PositionedNode => ({
size: [node.width, node.height] pos: [node.x, node.y],
})) size: [node.width, node.height]
})
)
const bounds = calculateNodeBounds(compatibleNodes) const bounds = calculateNodeBounds(compatibleNodes)
if (!bounds) { if (!bounds) {

View File

@@ -5,7 +5,7 @@
<SlotConnectionDot <SlotConnectionDot
ref="connectionDotRef" ref="connectionDotRef"
:color="slotColor" :color="slotColor"
:class="cn('-translate-x-1/2', 'w-3', errorClassesDot)" :class="cn('-translate-x-1/2 w-3', errorClassesDot)"
@pointerdown="onPointerDown" @pointerdown="onPointerDown"
/> />
@@ -48,6 +48,7 @@ interface InputSlotProps {
connected?: boolean connected?: boolean
compatible?: boolean compatible?: boolean
dotOnly?: boolean dotOnly?: boolean
socketless?: boolean
} }
const props = defineProps<InputSlotProps>() const props = defineProps<InputSlotProps>()
@@ -121,7 +122,8 @@ const slotWrapperClass = computed(() =>
'lg-slot--connected': props.connected, 'lg-slot--connected': props.connected,
'lg-slot--compatible': props.compatible, 'lg-slot--compatible': props.compatible,
'opacity-40': shouldDim.value 'opacity-40': shouldDim.value
} },
props.socketless && 'pointer-events-none invisible'
) )
) )

View File

@@ -19,12 +19,12 @@
'outline-transparent outline-2', 'outline-transparent outline-2',
borderClass, borderClass,
outlineClass, outlineClass,
cursorClass,
{ {
'before:rounded-2xl before:pointer-events-none before:absolute before:bg-bypass/60 before:inset-0': 'before:rounded-2xl before:pointer-events-none before:absolute before:bg-bypass/60 before:inset-0':
bypassed, bypassed,
'before:rounded-2xl before:pointer-events-none before:absolute before:inset-0': 'before:rounded-2xl before:pointer-events-none before:absolute before:inset-0':
muted, muted,
'will-change-transform': isDragging,
'ring-4 ring-primary-500 bg-primary-500/10': isDraggingOver 'ring-4 ring-primary-500 bg-primary-500/10': isDraggingOver
}, },
@@ -39,10 +39,10 @@
zIndex: zIndex, zIndex: zIndex,
opacity: nodeOpacity, opacity: nodeOpacity,
'--component-node-background': nodeBodyBackgroundColor '--component-node-background': nodeBodyBackgroundColor
}, }
dragStyle
]" ]"
v-bind="pointerHandlers" v-bind="remainingPointerHandlers"
@pointerdown="nodeOnPointerdown"
@wheel="handleWheel" @wheel="handleWheel"
@contextmenu="handleContextMenu" @contextmenu="handleContextMenu"
@dragover.prevent="handleDragOver" @dragover.prevent="handleDragOver"
@@ -137,24 +137,31 @@
<script setup lang="ts"> <script setup lang="ts">
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { computed, inject, onErrorCaptured, onMounted, ref, watch } from 'vue' import { computed, nextTick, onErrorCaptured, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager' import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { toggleNodeOptions } from '@/composables/graph/useMoreOptionsMenu' import { toggleNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
import { useErrorHandling } from '@/composables/useErrorHandling' import { useErrorHandling } from '@/composables/useErrorHandling'
import { st } from '@/i18n' import { st } from '@/i18n'
import { LGraphEventMode, LiteGraph } from '@/lib/litegraph/src/litegraph' import {
LGraphCanvas,
LGraphEventMode,
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore' import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry' import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions' import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys' import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
import SlotConnectionDot from '@/renderer/extensions/vueNodes/components/SlotConnectionDot.vue' import SlotConnectionDot from '@/renderer/extensions/vueNodes/components/SlotConnectionDot.vue'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers' import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/composables/useNodePointerInteractions' import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/composables/useNodePointerInteractions'
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking' import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState' import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState'
import { useNodeDrag } from '@/renderer/extensions/vueNodes/layout/useNodeDrag'
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout' import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
import { useNodePreviewState } from '@/renderer/extensions/vueNodes/preview/useNodePreviewState' import { useNodePreviewState } from '@/renderer/extensions/vueNodes/preview/useNodePreviewState'
import { nonWidgetedInputs } from '@/renderer/extensions/vueNodes/utils/nodeDataUtils' import { nonWidgetedInputs } from '@/renderer/extensions/vueNodes/utils/nodeDataUtils'
@@ -188,16 +195,13 @@ const { nodeData, error = null } = defineProps<LGraphNodeProps>()
const { t } = useI18n() const { t } = useI18n()
const { const { handleNodeCollapse, handleNodeTitleUpdate, handleNodeRightClick } =
handleNodeCollapse, useNodeEventHandlers()
handleNodeTitleUpdate, const { bringNodeToFront } = useNodeZIndex()
handleNodeSelect,
handleNodeRightClick
} = useNodeEventHandlers()
useVueElementTracking(() => nodeData.id, 'node') useVueElementTracking(() => nodeData.id, 'node')
const transformState = inject(TransformStateKey) const transformState = useTransformState()
if (!transformState) { if (!transformState) {
throw new Error( throw new Error(
'TransformState must be provided for node resize functionality' 'TransformState must be provided for node resize functionality'
@@ -272,10 +276,24 @@ onErrorCaptured((error) => {
}) })
const { position, size, zIndex, moveNodeTo } = useNodeLayout(() => nodeData.id) const { position, size, zIndex, moveNodeTo } = useNodeLayout(() => nodeData.id)
const { pointerHandlers, isDragging, dragStyle } = useNodePointerInteractions( const { pointerHandlers } = useNodePointerInteractions(() => nodeData.id)
() => nodeData, const { onPointerdown, ...remainingPointerHandlers } = pointerHandlers
handleNodeSelect const { startDrag } = useNodeDrag()
)
async function nodeOnPointerdown(event: PointerEvent) {
if (event.altKey && lgraphNode.value) {
const result = LGraphCanvas.cloneNodes([lgraphNode.value])
if (result?.created?.length) {
const [newNode] = result.created
startDrag(event, `${newNode.id}`)
layoutStore.isDraggingVueNodes.value = true
await nextTick()
bringNodeToFront(`${newNode.id}`)
return
}
}
onPointerdown(event)
}
// Handle right-click context menu // Handle right-click context menu
const handleContextMenu = (event: MouseEvent) => { const handleContextMenu = (event: MouseEvent) => {
@@ -283,7 +301,7 @@ const handleContextMenu = (event: MouseEvent) => {
event.stopPropagation() event.stopPropagation()
// First handle the standard right-click behavior (selection) // First handle the standard right-click behavior (selection)
handleNodeRightClick(event as PointerEvent, nodeData) handleNodeRightClick(event as PointerEvent, nodeData.id)
// Show the node options menu at the cursor position // Show the node options menu at the cursor position
const targetElement = event.currentTarget as HTMLElement const targetElement = event.currentTarget as HTMLElement
@@ -422,6 +440,16 @@ const outlineClass = computed(() => {
) )
}) })
const cursorClass = computed(() => {
return cn(
nodeData.flags?.pinned
? 'cursor-default'
: layoutStore.isDraggingVueNodes.value
? 'cursor-grabbing'
: 'cursor-grab'
)
})
// Event handlers // Event handlers
const handleCollapse = () => { const handleCollapse = () => {
handleNodeCollapse(nodeData.id, !isCollapsed.value) handleNodeCollapse(nodeData.id, !isCollapsed.value)

View File

@@ -40,6 +40,7 @@
}" }"
:node-id="nodeData?.id != null ? String(nodeData.id) : ''" :node-id="nodeData?.id != null ? String(nodeData.id) : ''"
:index="widget.slotMetadata.index" :index="widget.slotMetadata.index"
:socketless="widget.simplified.spec?.socketless"
dot-only dot-only
/> />
</div> </div>

View File

@@ -10,12 +10,12 @@
*/ */
import { createSharedComposable } from '@vueuse/core' import { createSharedComposable } from '@vueuse/core'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle' import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions' import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex' import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
import { isMultiSelectKey } from '@/renderer/extensions/vueNodes/utils/selectionUtils' import { isMultiSelectKey } from '@/renderer/extensions/vueNodes/utils/selectionUtils'
import type { NodeId } from '@/renderer/core/layout/types'
function useNodeEventHandlersIndividual() { function useNodeEventHandlersIndividual() {
const canvasStore = useCanvasStore() const canvasStore = useCanvasStore()
@@ -27,12 +27,12 @@ function useNodeEventHandlersIndividual() {
* Handle node selection events * Handle node selection events
* Supports single selection and multi-select with Ctrl/Cmd * Supports single selection and multi-select with Ctrl/Cmd
*/ */
const handleNodeSelect = (event: PointerEvent, nodeData: VueNodeData) => { function handleNodeSelect(event: PointerEvent, nodeId: NodeId) {
if (!shouldHandleNodePointerEvents.value) return if (!shouldHandleNodePointerEvents.value) return
if (!canvasStore.canvas || !nodeManager.value) return if (!canvasStore.canvas || !nodeManager.value) return
const node = nodeManager.value.getNode(nodeData.id) const node = nodeManager.value.getNode(nodeId)
if (!node) return if (!node) return
const multiSelect = isMultiSelectKey(event) const multiSelect = isMultiSelectKey(event)
@@ -53,7 +53,7 @@ function useNodeEventHandlersIndividual() {
// Bring node to front when clicked (similar to LiteGraph behavior) // Bring node to front when clicked (similar to LiteGraph behavior)
// Skip if node is pinned to avoid unwanted movement // Skip if node is pinned to avoid unwanted movement
if (!node.flags?.pinned) { if (!node.flags?.pinned) {
bringNodeToFront(nodeData.id) bringNodeToFront(nodeId)
} }
// Update canvas selection tracking // Update canvas selection tracking
@@ -64,7 +64,7 @@ function useNodeEventHandlersIndividual() {
* Handle node collapse/expand state changes * Handle node collapse/expand state changes
* Uses LiteGraph's native collapse method for proper state management * Uses LiteGraph's native collapse method for proper state management
*/ */
const handleNodeCollapse = (nodeId: string, collapsed: boolean) => { function handleNodeCollapse(nodeId: NodeId, collapsed: boolean) {
if (!shouldHandleNodePointerEvents.value) return if (!shouldHandleNodePointerEvents.value) return
if (!nodeManager.value) return if (!nodeManager.value) return
@@ -83,7 +83,7 @@ function useNodeEventHandlersIndividual() {
* Handle node title updates * Handle node title updates
* Updates the title in LiteGraph for persistence across sessions * Updates the title in LiteGraph for persistence across sessions
*/ */
const handleNodeTitleUpdate = (nodeId: string, newTitle: string) => { function handleNodeTitleUpdate(nodeId: NodeId, newTitle: string) {
if (!shouldHandleNodePointerEvents.value) return if (!shouldHandleNodePointerEvents.value) return
if (!nodeManager.value) return if (!nodeManager.value) return
@@ -95,41 +95,16 @@ function useNodeEventHandlersIndividual() {
node.title = newTitle node.title = newTitle
} }
/**
* Handle node double-click events
* Can be used for custom actions like opening node editor
*/
const handleNodeDoubleClick = (
event: PointerEvent,
nodeData: VueNodeData
) => {
if (!shouldHandleNodePointerEvents.value) return
if (!canvasStore.canvas || !nodeManager.value) return
const node = nodeManager.value.getNode(nodeData.id)
if (!node) return
// Prevent default browser behavior
event.preventDefault()
// TODO: add custom double-click behavior here
// For now, ensure node is selected
if (!node.selected) {
handleNodeSelect(event, nodeData)
}
}
/** /**
* Handle node right-click context menu events * Handle node right-click context menu events
* Integrates with LiteGraph's context menu system * Integrates with LiteGraph's context menu system
*/ */
const handleNodeRightClick = (event: PointerEvent, nodeData: VueNodeData) => { function handleNodeRightClick(event: PointerEvent, nodeId: NodeId) {
if (!shouldHandleNodePointerEvents.value) return if (!shouldHandleNodePointerEvents.value) return
if (!canvasStore.canvas || !nodeManager.value) return if (!canvasStore.canvas || !nodeManager.value) return
const node = nodeManager.value.getNode(nodeData.id) const node = nodeManager.value.getNode(nodeId)
if (!node) return if (!node) return
// Prevent default context menu // Prevent default context menu
@@ -137,128 +112,17 @@ function useNodeEventHandlersIndividual() {
// Select the node if not already selected // Select the node if not already selected
if (!node.selected) { if (!node.selected) {
handleNodeSelect(event, nodeData) handleNodeSelect(event, nodeId)
} }
// Let LiteGraph handle the context menu // Let LiteGraph handle the context menu
// The canvas will handle showing the appropriate context menu // The canvas will handle showing the appropriate context menu
} }
/** function toggleNodeSelectionAfterPointerUp(
* Handle node drag start events nodeId: NodeId,
* Prepares node for dragging and sets appropriate visual state multiSelect: boolean
*/ ) {
const handleNodeDragStart = (event: DragEvent, nodeData: VueNodeData) => {
if (!shouldHandleNodePointerEvents.value) return
if (!canvasStore.canvas || !nodeManager.value) return
const node = nodeManager.value.getNode(nodeData.id)
if (!node) return
// Ensure node is selected before dragging
if (!node.selected) {
// Create a synthetic pointer event for selection
const syntheticEvent = new PointerEvent('pointerdown', {
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
bubbles: true
})
handleNodeSelect(syntheticEvent, nodeData)
}
// Set drag data for potential drop operations
if (event.dataTransfer) {
event.dataTransfer.setData('application/comfy-node-id', nodeData.id)
event.dataTransfer.effectAllowed = 'move'
}
}
/**
* Batch select multiple nodes
* Useful for selection toolbox or area selection
*/
const selectNodes = (nodeIds: string[], addToSelection = false) => {
if (!shouldHandleNodePointerEvents.value) return
if (!canvasStore.canvas || !nodeManager.value) return
if (!addToSelection) {
canvasStore.canvas.deselectAll()
}
nodeIds.forEach((nodeId) => {
const node = nodeManager.value?.getNode(nodeId)
if (node && canvasStore.canvas) {
canvasStore.canvas.select(node)
}
})
canvasStore.updateSelectedItems()
}
/**
* Ensure node is selected for shift-drag operations
* Handles special logic for promoting a node to selection when shift-dragging
* @param event - The pointer event (for multi-select key detection)
* @param nodeData - The node data for the node being dragged
* @param wasSelectedAtPointerDown - Whether the node was selected when pointer-down occurred
*/
const ensureNodeSelectedForShiftDrag = (
event: PointerEvent,
nodeData: VueNodeData,
wasSelectedAtPointerDown: boolean
) => {
if (wasSelectedAtPointerDown) return
const multiSelectKeyPressed = isMultiSelectKey(event)
if (!multiSelectKeyPressed) return
if (!canvasStore.canvas || !nodeManager.value) return
const node = nodeManager.value.getNode(nodeData.id)
if (!node || node.selected) return
const selectionCount = canvasStore.selectedItems.length
const addToSelection = selectionCount > 0
selectNodes([nodeData.id], addToSelection)
}
/**
* Deselect specific nodes
*/
const deselectNodes = (nodeIds: string[]) => {
if (!shouldHandleNodePointerEvents.value) return
if (!canvasStore.canvas || !nodeManager.value) return
nodeIds.forEach((nodeId) => {
const node = nodeManager.value?.getNode(nodeId)
if (node && canvasStore.canvas) {
canvasStore.canvas.deselect(node)
}
})
canvasStore.updateSelectedItems()
}
const deselectNode = (nodeId: string) => {
const node = nodeManager.value?.getNode(nodeId)
if (node) {
canvasStore.canvas?.deselect(node)
canvasStore.updateSelectedItems()
}
}
const toggleNodeSelectionAfterPointerUp = (
nodeId: string,
{
wasSelectedAtPointerDown,
multiSelect
}: {
wasSelectedAtPointerDown: boolean
multiSelect: boolean
}
) => {
if (!shouldHandleNodePointerEvents.value) return if (!shouldHandleNodePointerEvents.value) return
if (!canvasStore.canvas || !nodeManager.value) return if (!canvasStore.canvas || !nodeManager.value) return
@@ -267,22 +131,19 @@ function useNodeEventHandlersIndividual() {
if (!node) return if (!node) return
if (!multiSelect) { if (!multiSelect) {
const multipleSelected = canvasStore.selectedItems.length > 1 canvasStore.canvas.deselectAll()
if (multipleSelected && wasSelectedAtPointerDown) { canvasStore.canvas.select(node)
canvasStore.canvas.deselectAll() canvasStore.updateSelectedItems()
canvasStore.canvas.select(node)
canvasStore.updateSelectedItems()
}
return return
} }
if (wasSelectedAtPointerDown) { if (node.selected) {
canvasStore.canvas.deselect(node) canvasStore.canvas.deselect(node)
canvasStore.updateSelectedItems() } else {
canvasStore.canvas.select(node)
} }
// No action needed when the node was not previously selected since the pointer-down canvasStore.updateSelectedItems()
// handler already added it to the selection.
} }
return { return {
@@ -290,15 +151,9 @@ function useNodeEventHandlersIndividual() {
handleNodeSelect, handleNodeSelect,
handleNodeCollapse, handleNodeCollapse,
handleNodeTitleUpdate, handleNodeTitleUpdate,
handleNodeDoubleClick,
handleNodeRightClick, handleNodeRightClick,
handleNodeDragStart,
// Batch operations // Batch operations
selectNodes,
deselectNodes,
deselectNode,
ensureNodeSelectedForShiftDrag,
toggleNodeSelectionAfterPointerUp toggleNodeSelectionAfterPointerUp
} }
} }

View File

@@ -1,15 +1,15 @@
import { createPinia, setActivePinia } from 'pinia' import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue' import { nextTick, ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/composables/useNodePointerInteractions' import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/composables/useNodePointerInteractions'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { createTestingPinia } from '@pinia/testing'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { NodeLayout } from '@/renderer/core/layout/types'
import { useNodeDrag } from '@/renderer/extensions/vueNodes/layout/useNodeDrag'
const forwardEventToCanvasMock = vi.fn() const forwardEventToCanvasMock = vi.fn()
const deselectNodeMock = vi.fn()
const selectNodesMock = vi.fn()
const toggleNodeSelectionAfterPointerUpMock = vi.fn()
const ensureNodeSelectedForShiftDragMock = vi.fn()
const selectedItemsState: { items: Array<{ id?: string }> } = { items: [] } const selectedItemsState: { items: Array<{ id?: string }> } = { items: [] }
// Mock the dependencies // Mock the dependencies
@@ -20,19 +20,18 @@ vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({
}) })
})) }))
vi.mock('@/renderer/extensions/vueNodes/layout/useNodeLayout', () => ({ vi.mock('@/renderer/extensions/vueNodes/layout/useNodeDrag', () => {
useNodeLayout: () => ({ const startDrag = vi.fn()
startDrag: vi.fn(), const handleDrag = vi.fn()
endDrag: vi.fn().mockResolvedValue(undefined), const endDrag = vi.fn()
handleDrag: vi.fn().mockResolvedValue(undefined) return {
}) useNodeDrag: () => ({
})) startDrag,
handleDrag,
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({ endDrag
layoutStore: { })
isDraggingVueNodes: ref(false)
} }
})) })
vi.mock('@/renderer/core/canvas/canvasStore', () => ({ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({ useCanvasStore: () => ({
@@ -44,14 +43,23 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
vi.mock( vi.mock(
'@/renderer/extensions/vueNodes/composables/useNodeEventHandlers', '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers',
() => ({ () => {
useNodeEventHandlers: () => ({ const handleNodeSelect = vi.fn()
deselectNode: deselectNodeMock, const deselectNode = vi.fn()
selectNodes: selectNodesMock, const selectNodes = vi.fn()
toggleNodeSelectionAfterPointerUp: toggleNodeSelectionAfterPointerUpMock, const toggleNodeSelectionAfterPointerUp = vi.fn()
ensureNodeSelectedForShiftDrag: ensureNodeSelectedForShiftDragMock const ensureNodeSelectedForShiftDrag = vi.fn()
})
}) return {
useNodeEventHandlers: () => ({
handleNodeSelect,
deselectNode,
selectNodes,
toggleNodeSelectionAfterPointerUp,
ensureNodeSelectedForShiftDrag
})
}
}
) )
vi.mock('@/composables/graph/useVueNodeLifecycle', () => ({ vi.mock('@/composables/graph/useVueNodeLifecycle', () => ({
@@ -65,19 +73,35 @@ vi.mock('@/composables/graph/useVueNodeLifecycle', () => ({
}) })
})) }))
const createMockVueNodeData = ( const mockData = vi.hoisted(() => {
overrides: Partial<VueNodeData> = {} const fakeNodeLayout: NodeLayout = {
): VueNodeData => ({ id: '',
id: 'test-node-123', position: { x: 0, y: 0 },
title: 'Test Node', size: { width: 100, height: 100 },
type: 'TestNodeType', zIndex: 1,
mode: 0, visible: true,
selected: false, bounds: {
executing: false, x: 0,
inputs: [], y: 0,
outputs: [], width: 100,
widgets: [], height: 100
...overrides }
}
return { fakeNodeLayout }
})
vi.mock('@/renderer/core/layout/store/layoutStore', () => {
const isDraggingVueNodes = ref(false)
const fakeNodeLayoutRef = ref(mockData.fakeNodeLayout)
const getNodeLayoutRef = vi.fn(() => fakeNodeLayoutRef)
const setSource = vi.fn()
return {
layoutStore: {
isDraggingVueNodes,
getNodeLayoutRef,
setSource
}
}
}) })
const createPointerEvent = ( const createPointerEvent = (
@@ -107,46 +131,34 @@ const createMouseEvent = (
describe('useNodePointerInteractions', () => { describe('useNodePointerInteractions', () => {
beforeEach(async () => { beforeEach(async () => {
vi.clearAllMocks() vi.restoreAllMocks()
selectedItemsState.items = [] selectedItemsState.items = []
setActivePinia(createPinia()) setActivePinia(createTestingPinia())
// Reset layout store state between tests
const { layoutStore } = await import(
'@/renderer/core/layout/store/layoutStore'
)
layoutStore.isDraggingVueNodes.value = false
}) })
it('should only start drag on left-click', async () => { it('should only start drag on left-click', async () => {
const mockNodeData = createMockVueNodeData() const { handleNodeSelect } = useNodeEventHandlers()
const mockOnNodeSelect = vi.fn() const { startDrag } = useNodeDrag()
const { pointerHandlers } = useNodePointerInteractions( const { pointerHandlers } = useNodePointerInteractions('test-node-123')
ref(mockNodeData),
mockOnNodeSelect
)
// Right-click should not trigger selection // Right-click should not trigger selection
const rightClickEvent = createPointerEvent('pointerdown', { button: 2 }) const rightClickEvent = createPointerEvent('pointerdown', { button: 2 })
pointerHandlers.onPointerdown(rightClickEvent) pointerHandlers.onPointerdown(rightClickEvent)
expect(mockOnNodeSelect).not.toHaveBeenCalled() expect(handleNodeSelect).not.toHaveBeenCalled()
// Left-click should trigger selection on pointer down // Left-click should trigger selection on pointer down
const leftClickEvent = createPointerEvent('pointerdown', { button: 0 }) const leftClickEvent = createPointerEvent('pointerdown', { button: 0 })
pointerHandlers.onPointerdown(leftClickEvent) pointerHandlers.onPointerdown(leftClickEvent)
expect(mockOnNodeSelect).toHaveBeenCalledWith(leftClickEvent, mockNodeData) expect(startDrag).toHaveBeenCalledWith(leftClickEvent, 'test-node-123')
}) })
it('should call onNodeSelect on pointer down', async () => { it.skip('should call onNodeSelect on pointer down', async () => {
const mockNodeData = createMockVueNodeData() const { handleNodeSelect } = useNodeEventHandlers()
const mockOnNodeSelect = vi.fn()
const { pointerHandlers } = useNodePointerInteractions( const { pointerHandlers } = useNodePointerInteractions('test-node-123')
ref(mockNodeData),
mockOnNodeSelect
)
// Selection should happen on pointer down // Selection should happen on pointer down
const downEvent = createPointerEvent('pointerdown', { const downEvent = createPointerEvent('pointerdown', {
@@ -155,9 +167,9 @@ describe('useNodePointerInteractions', () => {
}) })
pointerHandlers.onPointerdown(downEvent) pointerHandlers.onPointerdown(downEvent)
expect(mockOnNodeSelect).toHaveBeenCalledWith(downEvent, mockNodeData) expect(handleNodeSelect).toHaveBeenCalledWith(downEvent, 'test-node-123')
mockOnNodeSelect.mockClear() vi.mocked(handleNodeSelect).mockClear()
// Even if we drag, selection already happened on pointer down // Even if we drag, selection already happened on pointer down
pointerHandlers.onPointerup( pointerHandlers.onPointerup(
@@ -165,35 +177,36 @@ describe('useNodePointerInteractions', () => {
) )
// onNodeSelect should not be called again on pointer up // onNodeSelect should not be called again on pointer up
expect(mockOnNodeSelect).not.toHaveBeenCalled() expect(handleNodeSelect).not.toHaveBeenCalled()
}) })
it('should handle drag termination via cancel and context menu', async () => { it('should handle drag termination via cancel and context menu', async () => {
const mockNodeData = createMockVueNodeData() const { handleNodeSelect } = useNodeEventHandlers()
const mockOnNodeSelect = vi.fn()
const { pointerHandlers } = useNodePointerInteractions( const { pointerHandlers } = useNodePointerInteractions('test-node-123')
ref(mockNodeData),
mockOnNodeSelect
)
// Test pointer cancel - selection happens on pointer down // Test pointer cancel - selection happens on pointer down
pointerHandlers.onPointerdown( pointerHandlers.onPointerdown(
createPointerEvent('pointerdown', { clientX: 100, clientY: 100 }) createPointerEvent('pointerdown', { clientX: 100, clientY: 100 })
) )
expect(mockOnNodeSelect).toHaveBeenCalledTimes(1)
// Simulate drag by moving pointer beyond threshold // Simulate drag by moving pointer beyond threshold
pointerHandlers.onPointermove( pointerHandlers.onPointermove(
createPointerEvent('pointermove', { clientX: 110, clientY: 110 }) createPointerEvent('pointermove', {
clientX: 110,
clientY: 110,
buttons: 1
})
) )
expect(handleNodeSelect).toHaveBeenCalledTimes(1)
pointerHandlers.onPointercancel(createPointerEvent('pointercancel')) pointerHandlers.onPointercancel(createPointerEvent('pointercancel'))
// Selection should have been called on pointer down only // Selection should have been called on pointer down only
expect(mockOnNodeSelect).toHaveBeenCalledTimes(1) expect(handleNodeSelect).toHaveBeenCalledTimes(1)
mockOnNodeSelect.mockClear() vi.mocked(handleNodeSelect).mockClear()
// Test context menu during drag prevents default // Test context menu during drag prevents default
pointerHandlers.onPointerdown( pointerHandlers.onPointerdown(
@@ -201,7 +214,11 @@ describe('useNodePointerInteractions', () => {
) )
// Simulate drag by moving pointer beyond threshold // Simulate drag by moving pointer beyond threshold
pointerHandlers.onPointermove( pointerHandlers.onPointermove(
createPointerEvent('pointermove', { clientX: 110, clientY: 110 }) createPointerEvent('pointermove', {
clientX: 110,
clientY: 110,
buttons: 1
})
) )
const contextMenuEvent = createMouseEvent('contextmenu') const contextMenuEvent = createMouseEvent('contextmenu')
@@ -212,36 +229,8 @@ describe('useNodePointerInteractions', () => {
expect(preventDefaultSpy).toHaveBeenCalled() expect(preventDefaultSpy).toHaveBeenCalled()
}) })
it('should not call onNodeSelect when nodeData is null', async () => {
const mockNodeData = createMockVueNodeData()
const mockOnNodeSelect = vi.fn()
const nodeDataRef = ref<VueNodeData | null>(mockNodeData)
const { pointerHandlers } = useNodePointerInteractions(
nodeDataRef,
mockOnNodeSelect
)
// Clear nodeData before pointer down
nodeDataRef.value = null
await nextTick()
pointerHandlers.onPointerdown(createPointerEvent('pointerdown'))
expect(mockOnNodeSelect).not.toHaveBeenCalled()
})
it('should integrate with layout store dragging state', async () => { it('should integrate with layout store dragging state', async () => {
const mockNodeData = createMockVueNodeData() const { pointerHandlers } = useNodePointerInteractions('test-node-123')
const mockOnNodeSelect = vi.fn()
const { layoutStore } = await import(
'@/renderer/core/layout/store/layoutStore'
)
const { pointerHandlers } = useNodePointerInteractions(
ref(mockNodeData),
mockOnNodeSelect
)
// Pointer down alone shouldn't set dragging state // Pointer down alone shouldn't set dragging state
pointerHandlers.onPointerdown( pointerHandlers.onPointerdown(
@@ -251,7 +240,11 @@ describe('useNodePointerInteractions', () => {
// Move pointer beyond threshold to start drag // Move pointer beyond threshold to start drag
pointerHandlers.onPointermove( pointerHandlers.onPointermove(
createPointerEvent('pointermove', { clientX: 110, clientY: 110 }) createPointerEvent('pointermove', {
clientX: 110,
clientY: 110,
buttons: 1
})
) )
await nextTick() await nextTick()
expect(layoutStore.isDraggingVueNodes.value).toBe(true) expect(layoutStore.isDraggingVueNodes.value).toBe(true)
@@ -262,63 +255,8 @@ describe('useNodePointerInteractions', () => {
expect(layoutStore.isDraggingVueNodes.value).toBe(false) expect(layoutStore.isDraggingVueNodes.value).toBe(false)
}) })
it('should select node on pointer down with ctrl key for multi-select', async () => {
const mockNodeData = createMockVueNodeData()
const mockOnNodeSelect = vi.fn()
const { pointerHandlers } = useNodePointerInteractions(
ref(mockNodeData),
mockOnNodeSelect
)
// Pointer down with ctrl key should pass the event with ctrl key set
const ctrlDownEvent = createPointerEvent('pointerdown', {
ctrlKey: true,
clientX: 100,
clientY: 100
})
pointerHandlers.onPointerdown(ctrlDownEvent)
expect(mockOnNodeSelect).toHaveBeenCalledWith(ctrlDownEvent, mockNodeData)
expect(mockOnNodeSelect).toHaveBeenCalledTimes(1)
})
it('should select pinned node on pointer down but not start drag', async () => {
const mockNodeData = createMockVueNodeData({
flags: { pinned: true }
})
const mockOnNodeSelect = vi.fn()
const { layoutStore } = await import(
'@/renderer/core/layout/store/layoutStore'
)
const { pointerHandlers } = useNodePointerInteractions(
ref(mockNodeData),
mockOnNodeSelect
)
// Pointer down on pinned node
const downEvent = createPointerEvent('pointerdown')
pointerHandlers.onPointerdown(downEvent)
// Should select the node
expect(mockOnNodeSelect).toHaveBeenCalledWith(downEvent, mockNodeData)
// But should not start dragging
expect(layoutStore.isDraggingVueNodes.value).toBe(false)
})
it('should select node immediately when drag starts', async () => { it('should select node immediately when drag starts', async () => {
const mockNodeData = createMockVueNodeData() const { pointerHandlers } = useNodePointerInteractions('test-node-123')
const mockOnNodeSelect = vi.fn()
const { layoutStore } = await import(
'@/renderer/core/layout/store/layoutStore'
)
const { pointerHandlers } = useNodePointerInteractions(
ref(mockNodeData),
mockOnNodeSelect
)
// Pointer down should select node immediately // Pointer down should select node immediately
const downEvent = createPointerEvent('pointerdown', { const downEvent = createPointerEvent('pointerdown', {
@@ -326,24 +264,25 @@ describe('useNodePointerInteractions', () => {
clientY: 100 clientY: 100
}) })
pointerHandlers.onPointerdown(downEvent) pointerHandlers.onPointerdown(downEvent)
const { handleNodeSelect } = useNodeEventHandlers()
// Selection should happen on pointer down (before move)
expect(mockOnNodeSelect).toHaveBeenCalledWith(downEvent, mockNodeData)
expect(mockOnNodeSelect).toHaveBeenCalledTimes(1)
// Dragging state should NOT be active yet // Dragging state should NOT be active yet
expect(layoutStore.isDraggingVueNodes.value).toBe(false) expect(layoutStore.isDraggingVueNodes.value).toBe(false)
const pointerMove = createPointerEvent('pointermove', {
clientX: 150,
clientY: 150,
buttons: 1
})
// Move the pointer beyond threshold (start dragging) // Move the pointer beyond threshold (start dragging)
pointerHandlers.onPointermove( pointerHandlers.onPointermove(pointerMove)
createPointerEvent('pointermove', { clientX: 150, clientY: 150 })
)
// Now dragging state should be active // Now dragging state should be active
expect(layoutStore.isDraggingVueNodes.value).toBe(true) expect(layoutStore.isDraggingVueNodes.value).toBe(true)
// Selection should still only have been called once (on pointer down) // Selection should happen on pointer down (before move)
expect(mockOnNodeSelect).toHaveBeenCalledTimes(1) expect(handleNodeSelect).toHaveBeenCalledWith(pointerMove, 'test-node-123')
expect(handleNodeSelect).toHaveBeenCalledTimes(1)
// End drag // End drag
pointerHandlers.onPointerup( pointerHandlers.onPointerup(
@@ -351,17 +290,12 @@ describe('useNodePointerInteractions', () => {
) )
// Selection should still only have been called once // Selection should still only have been called once
expect(mockOnNodeSelect).toHaveBeenCalledTimes(1) expect(handleNodeSelect).toHaveBeenCalledTimes(1)
}) })
it('on ctrl+click: calls toggleNodeSelectionAfterPointerUp on pointer up (not pointer down)', async () => { it('on ctrl+click: calls toggleNodeSelectionAfterPointerUp on pointer up (not pointer down)', async () => {
const mockNodeData = createMockVueNodeData() const { pointerHandlers } = useNodePointerInteractions('test-node-123')
const mockOnNodeSelect = vi.fn() const { toggleNodeSelectionAfterPointerUp } = useNodeEventHandlers()
const { pointerHandlers } = useNodePointerInteractions(
ref(mockNodeData),
mockOnNodeSelect
)
// Pointer down with ctrl // Pointer down with ctrl
const downEvent = createPointerEvent('pointerdown', { const downEvent = createPointerEvent('pointerdown', {
@@ -372,7 +306,7 @@ describe('useNodePointerInteractions', () => {
pointerHandlers.onPointerdown(downEvent) pointerHandlers.onPointerdown(downEvent)
// On pointer down: toggle handler should NOT be called yet // On pointer down: toggle handler should NOT be called yet
expect(toggleNodeSelectionAfterPointerUpMock).not.toHaveBeenCalled() expect(toggleNodeSelectionAfterPointerUp).not.toHaveBeenCalled()
// Pointer up with ctrl (no drag - same position) // Pointer up with ctrl (no drag - same position)
const upEvent = createPointerEvent('pointerup', { const upEvent = createPointerEvent('pointerup', {
@@ -383,116 +317,9 @@ describe('useNodePointerInteractions', () => {
pointerHandlers.onPointerup(upEvent) pointerHandlers.onPointerup(upEvent)
// On pointer up: toggle handler IS called with correct params // On pointer up: toggle handler IS called with correct params
expect(toggleNodeSelectionAfterPointerUpMock).toHaveBeenCalledWith( expect(toggleNodeSelectionAfterPointerUp).toHaveBeenCalledWith(
mockNodeData.id, 'test-node-123',
{ true
wasSelectedAtPointerDown: false,
multiSelect: true
}
)
})
it('on ctrl+drag: does NOT call toggleNodeSelectionAfterPointerUp', async () => {
const mockNodeData = createMockVueNodeData()
const mockOnNodeSelect = vi.fn()
const { pointerHandlers } = useNodePointerInteractions(
ref(mockNodeData),
mockOnNodeSelect
)
// Pointer down with ctrl
const downEvent = createPointerEvent('pointerdown', {
ctrlKey: true,
clientX: 100,
clientY: 100
})
pointerHandlers.onPointerdown(downEvent)
// Move beyond drag threshold
pointerHandlers.onPointermove(
createPointerEvent('pointermove', {
ctrlKey: true,
clientX: 110,
clientY: 110
})
)
// Pointer up after drag
const upEvent = createPointerEvent('pointerup', {
ctrlKey: true,
clientX: 110,
clientY: 110
})
pointerHandlers.onPointerup(upEvent)
// When dragging: toggle handler should NOT be called
expect(toggleNodeSelectionAfterPointerUpMock).not.toHaveBeenCalled()
})
it('selects node when shift drag starts without multi selection', async () => {
selectedItemsState.items = []
const mockNodeData = createMockVueNodeData()
const mockOnNodeSelect = vi.fn()
const { pointerHandlers } = useNodePointerInteractions(
ref(mockNodeData),
mockOnNodeSelect
)
const pointerDownEvent = createPointerEvent('pointerdown', {
clientX: 0,
clientY: 0,
shiftKey: true
})
pointerHandlers.onPointerdown(pointerDownEvent)
const pointerMoveEvent = createPointerEvent('pointermove', {
clientX: 10,
clientY: 10,
shiftKey: true
})
pointerHandlers.onPointermove(pointerMoveEvent)
expect(ensureNodeSelectedForShiftDragMock).toHaveBeenCalledWith(
pointerMoveEvent,
mockNodeData,
false
)
})
it('still ensures selection when shift drag starts with existing multi select', async () => {
selectedItemsState.items = [{ id: 'a' }, { id: 'b' }]
const mockNodeData = createMockVueNodeData()
const mockOnNodeSelect = vi.fn()
const { pointerHandlers } = useNodePointerInteractions(
ref(mockNodeData),
mockOnNodeSelect
)
const pointerDownEvent = createPointerEvent('pointerdown', {
clientX: 0,
clientY: 0,
shiftKey: true
})
pointerHandlers.onPointerdown(pointerDownEvent)
const pointerMoveEvent = createPointerEvent('pointermove', {
clientX: 10,
clientY: 10,
shiftKey: true
})
pointerHandlers.onPointermove(pointerMoveEvent)
expect(ensureNodeSelectedForShiftDragMock).toHaveBeenCalledWith(
pointerMoveEvent,
mockNodeData,
false
) )
}) })
}) })

View File

@@ -1,37 +1,22 @@
import { computed, onUnmounted, ref, toValue } from 'vue' import { onScopeDispose, ref, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue' import type { MaybeRefOrGetter } from 'vue'
import { isMiddlePointerInput } from '@/base/pointerUtils' import { isMiddlePointerInput } from '@/base/pointerUtils'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle' import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions' import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore' import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers' import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { isMultiSelectKey } from '@/renderer/extensions/vueNodes/utils/selectionUtils' import { isMultiSelectKey } from '@/renderer/extensions/vueNodes/utils/selectionUtils'
import { useNodeDrag } from '@/renderer/extensions/vueNodes/layout/useNodeDrag'
export function useNodePointerInteractions( export function useNodePointerInteractions(
nodeDataMaybe: MaybeRefOrGetter<VueNodeData | null>, nodeIdRef: MaybeRefOrGetter<string>
onNodeSelect: (event: PointerEvent, nodeData: VueNodeData) => void
) { ) {
const nodeData = computed(() => { const { startDrag, endDrag, handleDrag } = useNodeDrag()
const value = toValue(nodeDataMaybe)
if (!value) {
console.warn(
'useNodePointerInteractions: nodeDataMaybe resolved to null/undefined'
)
return null
}
return value
})
// Avoid potential null access during component initialization
const nodeIdComputed = computed(() => nodeData.value?.id ?? '')
const { startDrag, endDrag, handleDrag } = useNodeLayout(nodeIdComputed)
// Use canvas interactions for proper wheel event handling and pointer event capture control // Use canvas interactions for proper wheel event handling and pointer event capture control
const { forwardEventToCanvas, shouldHandleNodePointerEvents } = const { forwardEventToCanvas, shouldHandleNodePointerEvents } =
useCanvasInteractions() useCanvasInteractions()
const { toggleNodeSelectionAfterPointerUp, ensureNodeSelectedForShiftDrag } = const { handleNodeSelect, toggleNodeSelectionAfterPointerUp } =
useNodeEventHandlers() useNodeEventHandlers()
const { nodeManager } = useVueNodeLifecycle() const { nodeManager } = useVueNodeLifecycle()
@@ -41,33 +26,15 @@ export function useNodePointerInteractions(
return true return true
} }
// Drag state for styling
const isDragging = ref(false)
const isPointerDown = ref(false)
const wasSelectedAtPointerDown = ref(false) // Track if node was selected when pointer down occurred
const dragStyle = computed(() => {
if (nodeData.value?.flags?.pinned) {
return { cursor: 'default' }
}
return { cursor: isDragging.value ? 'grabbing' : 'grab' }
})
const startPosition = ref({ x: 0, y: 0 }) const startPosition = ref({ x: 0, y: 0 })
const DRAG_THRESHOLD = 3 // pixels const DRAG_THRESHOLD = 3 // pixels
const handlePointerDown = (event: PointerEvent) => { function onPointerdown(event: PointerEvent) {
if (!nodeData.value) {
console.warn(
'LGraphNode: nodeData is null/undefined in handlePointerDown'
)
return
}
if (forwardMiddlePointerIfNeeded(event)) return if (forwardMiddlePointerIfNeeded(event)) return
// Only start drag on left-click (button 0) // Only start drag on left-click (button 0)
if (event.button !== 0) { if (event.button !== 0) return
return
}
// Don't handle pointer events when canvas is in panning mode - forward to canvas instead // Don't handle pointer events when canvas is in panning mode - forward to canvas instead
if (!shouldHandleNodePointerEvents.value) { if (!shouldHandleNodePointerEvents.value) {
@@ -75,69 +42,67 @@ export function useNodePointerInteractions(
return return
} }
// Track if node was selected before this pointer down const nodeId = toValue(nodeIdRef)
// IMPORTANT: Read from actual LGraphNode, not nodeData, to get correct state if (!nodeId) {
const lgNode = nodeManager.value?.getNode(nodeData.value.id) console.warn(
wasSelectedAtPointerDown.value = lgNode?.selected ?? false 'LGraphNode: nodeData is null/undefined in handlePointerDown'
)
onNodeSelect(event, nodeData.value)
if (nodeData.value.flags?.pinned) {
return return
} }
// Record position for drag threshold calculation // IMPORTANT: Read from actual LGraphNode to get correct state
startPosition.value = { x: event.clientX, y: event.clientY } if (nodeManager.value?.getNode(nodeId)?.flags?.pinned) {
isPointerDown.value = true return
}
// Don't start drag yet - wait for pointer move to exceed threshold startPosition.value = { x: event.clientX, y: event.clientY }
startDrag(event)
startDrag(event, nodeId)
} }
const handlePointerMove = (event: PointerEvent) => { function onPointermove(event: PointerEvent) {
if (forwardMiddlePointerIfNeeded(event)) return if (forwardMiddlePointerIfNeeded(event)) return
const nodeId = toValue(nodeIdRef)
if (nodeManager.value?.getNode(nodeId)?.flags?.pinned) {
return
}
const multiSelect = isMultiSelectKey(event)
const lmbDown = event.buttons & 1
if (lmbDown && multiSelect && !layoutStore.isDraggingVueNodes.value) {
layoutStore.isDraggingVueNodes.value = true
handleNodeSelect(event, nodeId)
startDrag(event, nodeId)
return
}
// Check if we should start dragging (pointer moved beyond threshold) // Check if we should start dragging (pointer moved beyond threshold)
if (isPointerDown.value && !isDragging.value) { if (lmbDown && !layoutStore.isDraggingVueNodes.value) {
const dx = event.clientX - startPosition.value.x const dx = event.clientX - startPosition.value.x
const dy = event.clientY - startPosition.value.y const dy = event.clientY - startPosition.value.y
const distance = Math.sqrt(dx * dx + dy * dy) const distance = Math.sqrt(dx * dx + dy * dy)
if (distance > DRAG_THRESHOLD && nodeData.value) { if (distance > DRAG_THRESHOLD) {
// Start drag
isDragging.value = true
layoutStore.isDraggingVueNodes.value = true layoutStore.isDraggingVueNodes.value = true
ensureNodeSelectedForShiftDrag( handleNodeSelect(event, nodeId)
event,
nodeData.value,
wasSelectedAtPointerDown.value
)
} }
} }
if (isDragging.value) { if (layoutStore.isDraggingVueNodes.value) {
void handleDrag(event) handleDrag(event, nodeId)
} }
} }
/** function cleanupDragState() {
* Centralized cleanup function for drag state
* Ensures consistent cleanup across all drag termination scenarios
*/
const cleanupDragState = () => {
isDragging.value = false
isPointerDown.value = false
wasSelectedAtPointerDown.value = false
layoutStore.isDraggingVueNodes.value = false layoutStore.isDraggingVueNodes.value = false
} }
/** function safeDragEnd(event: PointerEvent) {
* Safely ends drag operation with proper error handling
* @param event - PointerEvent to end the drag with
*/
const safeDragEnd = async (event: PointerEvent): Promise<void> => {
try { try {
await endDrag(event) const nodeId = toValue(nodeIdRef)
endDrag(event, nodeId)
} catch (error) { } catch (error) {
console.error('Error during endDrag:', error) console.error('Error during endDrag:', error)
} finally { } finally {
@@ -145,61 +110,39 @@ export function useNodePointerInteractions(
} }
} }
/** function onPointerup(event: PointerEvent) {
* Common drag termination handler with fallback cleanup
*/
const handleDragTermination = (event: PointerEvent, errorContext: string) => {
safeDragEnd(event).catch((error) => {
console.error(`Failed to complete ${errorContext}:`, error)
cleanupDragState() // Fallback cleanup
})
}
const handlePointerUp = (event: PointerEvent) => {
if (forwardMiddlePointerIfNeeded(event)) return if (forwardMiddlePointerIfNeeded(event)) return
const wasDragging = isDragging.value
const multiSelect = isMultiSelectKey(event)
const canHandlePointer = shouldHandleNodePointerEvents.value
if (wasDragging) {
handleDragTermination(event, 'drag end')
} else {
// Clean up pointer state even if not dragging
isPointerDown.value = false
const wasSelected = wasSelectedAtPointerDown.value
wasSelectedAtPointerDown.value = false
if (nodeData.value && canHandlePointer) {
toggleNodeSelectionAfterPointerUp(nodeData.value.id, {
wasSelectedAtPointerDown: wasSelected,
multiSelect
})
}
}
// Don't handle pointer events when canvas is in panning mode - forward to canvas instead // Don't handle pointer events when canvas is in panning mode - forward to canvas instead
const canHandlePointer = shouldHandleNodePointerEvents.value
if (!canHandlePointer) { if (!canHandlePointer) {
forwardEventToCanvas(event) forwardEventToCanvas(event)
return return
} }
const wasDragging = layoutStore.isDraggingVueNodes.value
if (wasDragging) {
safeDragEnd(event)
return
}
const multiSelect = isMultiSelectKey(event)
const nodeId = toValue(nodeIdRef)
if (nodeId) {
toggleNodeSelectionAfterPointerUp(nodeId, multiSelect)
}
} }
/** function onPointercancel(event: PointerEvent) {
* Handles pointer cancellation events (e.g., touch cancelled by browser) if (!layoutStore.isDraggingVueNodes.value) return
* Ensures drag state is properly cleaned up when pointer interaction is interrupted safeDragEnd(event)
*/
const handlePointerCancel = (event: PointerEvent) => {
if (!isDragging.value) return
handleDragTermination(event, 'drag cancellation')
} }
/** /**
* Handles right-click during drag operations * Handles right-click during drag operations
* Cancels the current drag to prevent context menu from appearing while dragging * Cancels the current drag to prevent context menu from appearing while dragging
*/ */
const handleContextMenu = (event: MouseEvent) => { function onContextmenu(event: MouseEvent) {
if (!isDragging.value) return if (!layoutStore.isDraggingVueNodes.value) return
event.preventDefault() event.preventDefault()
// Simply cleanup state without calling endDrag to avoid synthetic event creation // Simply cleanup state without calling endDrag to avoid synthetic event creation
@@ -207,22 +150,19 @@ export function useNodePointerInteractions(
} }
// Cleanup on unmount to prevent resource leaks // Cleanup on unmount to prevent resource leaks
onUnmounted(() => { onScopeDispose(() => {
if (!isDragging.value) return
cleanupDragState() cleanupDragState()
}) })
const pointerHandlers = { const pointerHandlers = {
onPointerdown: handlePointerDown, onPointerdown,
onPointermove: handlePointerMove, onPointermove,
onPointerup: handlePointerUp, onPointerup,
onPointercancel: handlePointerCancel, onPointercancel,
onContextmenu: handleContextMenu onContextmenu
} } as const
return { return {
isDragging,
dragStyle,
pointerHandlers pointerHandlers
} }
} }

View File

@@ -384,6 +384,8 @@ export function useSlotLinkInteraction({
const handlePointerMove = (event: PointerEvent) => { const handlePointerMove = (event: PointerEvent) => {
if (!pointerSession.matches(event)) return if (!pointerSession.matches(event)) return
event.stopPropagation()
dragContext.pendingPointerMove = { dragContext.pendingPointerMove = {
clientX: event.clientX, clientX: event.clientX,
clientY: event.clientY, clientY: event.clientY,
@@ -507,6 +509,7 @@ export function useSlotLinkInteraction({
} }
const handlePointerUp = (event: PointerEvent) => { const handlePointerUp = (event: PointerEvent) => {
event.stopPropagation()
finishInteraction(event) finishInteraction(event)
} }

View File

@@ -1,17 +1,17 @@
import { useEventListener } from '@vueuse/core' import { useEventListener } from '@vueuse/core'
import { ref } from 'vue' import { ref } from 'vue'
import type { TransformState } from '@/renderer/core/layout/injectionKeys'
import type { Point, Size } from '@/renderer/core/layout/types' import type { Point, Size } from '@/renderer/core/layout/types'
import { useNodeSnap } from '@/renderer/extensions/vueNodes/composables/useNodeSnap' import { useNodeSnap } from '@/renderer/extensions/vueNodes/composables/useNodeSnap'
import { useShiftKeySync } from '@/renderer/extensions/vueNodes/composables/useShiftKeySync' import { useShiftKeySync } from '@/renderer/extensions/vueNodes/composables/useShiftKeySync'
import type { ResizeHandleDirection } from './resizeMath' import type { ResizeHandleDirection } from './resizeMath'
import { createResizeSession, toCanvasDelta } from './resizeMath' import { createResizeSession, toCanvasDelta } from './resizeMath'
import type { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
interface UseNodeResizeOptions { interface UseNodeResizeOptions {
/** Transform state for coordinate conversion */ /** Transform state for coordinate conversion */
transformState: TransformState transformState: ReturnType<typeof useTransformState>
} }
interface ResizeCallbackPayload { interface ResizeCallbackPayload {

View File

@@ -1,5 +1,5 @@
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags' import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import type { LGraph, rendererType } from '@/lib/litegraph/src/LGraph' import type { LGraph, RendererType } from '@/lib/litegraph/src/LGraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph' import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { createBounds } from '@/lib/litegraph/src/measure' import { createBounds } from '@/lib/litegraph/src/measure'
import { useSettingStore } from '@/platform/settings/settingStore' import { useSettingStore } from '@/platform/settings/settingStore'
@@ -13,135 +13,108 @@ import type { SubgraphOutputNode } from '@/lib/litegraph/src/subgraph/SubgraphOu
const SCALE_FACTOR = 1.2 const SCALE_FACTOR = 1.2
export function ensureCorrectLayoutScale( export function ensureCorrectLayoutScale(
renderer?: rendererType, renderer: RendererType = 'LG',
targetGraph?: LGraph targetGraph?: LGraph
) { ) {
const settingStore = useSettingStore() const autoScaleLayoutSetting = useSettingStore().get(
const autoScaleLayoutSetting = settingStore.get(
'Comfy.VueNodes.AutoScaleLayout' 'Comfy.VueNodes.AutoScaleLayout'
) )
if (autoScaleLayoutSetting === false) { if (!autoScaleLayoutSetting) return
return
}
const { shouldRenderVueNodes } = useVueFeatureFlags()
const canvas = comfyApp.canvas const canvas = comfyApp.canvas
const graph = targetGraph ?? canvas?.graph const graph = targetGraph ?? canvas?.graph
if (!graph || !graph.nodes) return if (!graph?.nodes) return
// Use renderer from graph, default to 'LG' for the check (but don't modify graph yet) const { shouldRenderVueNodes } = useVueFeatureFlags()
if (!renderer) {
// Always assume legacy LG format when unknown (pre-dates this feature)
renderer = 'LG'
}
const doesntNeedScale = const needsUpscale = renderer === 'LG' && shouldRenderVueNodes.value
(renderer === 'LG' && shouldRenderVueNodes.value === false) || const needsDownscale = renderer === 'Vue' && !shouldRenderVueNodes.value
(renderer === 'Vue' && shouldRenderVueNodes.value === true)
if (doesntNeedScale) { if (!needsUpscale && !needsDownscale) {
// Don't scale, but ensure workflowRendererVersion is set for future checks // Don't scale, but ensure workflowRendererVersion is set for future checks
if (!graph.extra.workflowRendererVersion) { graph.extra.workflowRendererVersion ??= renderer
graph.extra.workflowRendererVersion = renderer
}
return return
} }
const needsUpscale = renderer === 'LG' && shouldRenderVueNodes.value === true
const needsDownscale =
renderer === 'Vue' && shouldRenderVueNodes.value === false
const lgBounds = createBounds(graph.nodes) const lgBounds = createBounds(graph.nodes)
if (!lgBounds) return if (!lgBounds) return
const originX = lgBounds[0] const [originX, originY] = lgBounds
const originY = lgBounds[1]
const lgNodesById = new Map(graph.nodes.map((node) => [node.id, node])) const lgNodesById = new Map(graph.nodes.map((node) => [node.id, node]))
const yjsMoveNodeUpdates: NodeBoundsUpdate[] = [] const yjsMoveNodeUpdates: NodeBoundsUpdate[] = []
const scaleFactor = needsUpscale const scaleFactor = needsUpscale ? SCALE_FACTOR : 1 / SCALE_FACTOR
? SCALE_FACTOR
: needsDownscale const onActiveGraph = !targetGraph || targetGraph === canvas?.graph
? 1 / SCALE_FACTOR
: 1
//TODO: once we remove the need for LiteGraph.NODE_TITLE_HEIGHT in vue nodes we nned to remove everything here. //TODO: once we remove the need for LiteGraph.NODE_TITLE_HEIGHT in vue nodes we nned to remove everything here.
for (const node of graph.nodes) { for (const node of graph.nodes) {
const lgNode = lgNodesById.get(node.id) const lgNode = lgNodesById.get(node.id)
if (!lgNode) continue if (!lgNode) continue
const lgBodyY = lgNode.pos[1] const [oldX, oldY] = lgNode.pos
const adjustedY = needsDownscale const adjustedY = oldY - (needsUpscale ? LiteGraph.NODE_TITLE_HEIGHT : 0)
? lgBodyY - LiteGraph.NODE_TITLE_HEIGHT / 2
: lgBodyY
const relativeX = lgNode.pos[0] - originX const relativeX = oldX - originX
const relativeY = adjustedY - originY const relativeY = adjustedY - originY
const newX = originX + relativeX * scaleFactor
const scaledY = originY + relativeY * scaleFactor
const newWidth = lgNode.width * scaleFactor
const newHeight = lgNode.height * scaleFactor
const finalY = needsUpscale const scaledX = originX + relativeX * scaleFactor
? scaledY + LiteGraph.NODE_TITLE_HEIGHT / 2 const scaledY = originY + relativeY * scaleFactor
: scaledY
const scaledWidth = lgNode.width * scaleFactor
const scaledHeight =
lgNode.height * scaleFactor -
(needsUpscale ? 0 : LiteGraph.NODE_TITLE_HEIGHT)
const finalY = scaledY + (needsUpscale ? 0 : LiteGraph.NODE_TITLE_HEIGHT) // Litegraph Position further down
// Directly update LiteGraph node to ensure immediate consistency // Directly update LiteGraph node to ensure immediate consistency
// Dont need to reference vue directly because the pos and dims are already in yjs // Dont need to reference vue directly because the pos and dims are already in yjs
lgNode.pos[0] = newX lgNode.pos[0] = scaledX
lgNode.pos[1] = finalY lgNode.pos[1] = finalY
lgNode.size[0] = newWidth lgNode.size[0] = scaledWidth
lgNode.size[1] = lgNode.size[1] = scaledHeight
newHeight - (needsDownscale ? LiteGraph.NODE_TITLE_HEIGHT : 0)
// Track updates for layout store (only if this is the active graph) // Track updates for layout store (only if this is the active graph)
if (!targetGraph || targetGraph === canvas?.graph) { if (onActiveGraph) {
yjsMoveNodeUpdates.push({ yjsMoveNodeUpdates.push({
nodeId: String(lgNode.id), nodeId: String(lgNode.id),
bounds: { bounds: {
x: newX, x: scaledX,
y: finalY, y: finalY,
width: newWidth, width: scaledWidth,
height: newHeight - (needsDownscale ? LiteGraph.NODE_TITLE_HEIGHT : 0) height: scaledHeight
} }
}) })
} }
} }
if ( if (onActiveGraph && yjsMoveNodeUpdates.length > 0) {
(!targetGraph || targetGraph === canvas?.graph) &&
yjsMoveNodeUpdates.length > 0
) {
layoutStore.batchUpdateNodeBounds(yjsMoveNodeUpdates) layoutStore.batchUpdateNodeBounds(yjsMoveNodeUpdates)
} }
for (const reroute of graph.reroutes.values()) { for (const reroute of graph.reroutes.values()) {
const oldX = reroute.pos[0] const [oldX, oldY] = reroute.pos
const oldY = reroute.pos[1]
const relativeX = oldX - originX const relativeX = oldX - originX
const relativeY = oldY - originY const relativeY = oldY - originY
const newX = originX + relativeX * scaleFactor
const newY = originY + relativeY * scaleFactor
reroute.pos = [newX, newY] const scaledX = originX + relativeX * scaleFactor
const scaledY = originY + relativeY * scaleFactor
if ( reroute.pos = [scaledX, scaledY]
(!targetGraph || targetGraph === canvas?.graph) &&
shouldRenderVueNodes.value if (onActiveGraph && shouldRenderVueNodes.value) {
) {
const layoutMutations = useLayoutMutations() const layoutMutations = useLayoutMutations()
layoutMutations.moveReroute( layoutMutations.moveReroute(
reroute.id, reroute.id,
{ x: newX, y: newY }, { x: scaledX, y: scaledY },
{ x: oldX, y: oldY } { x: oldX, y: oldY }
) )
} }
@@ -153,60 +126,48 @@ export function ensureCorrectLayoutScale(
graph.outputNode as SubgraphOutputNode graph.outputNode as SubgraphOutputNode
] ]
for (const ioNode of ioNodes) { for (const ioNode of ioNodes) {
const oldX = ioNode.pos[0] const [oldX, oldY] = ioNode.pos
const oldY = ioNode.pos[1] const [oldWidth, oldHeight] = ioNode.size
const oldWidth = ioNode.size[0]
const oldHeight = ioNode.size[1]
const relativeX = oldX - originX const relativeX = oldX - originX
const relativeY = oldY - originY const relativeY = oldY - originY
const newX = originX + relativeX * scaleFactor
const newY = originY + relativeY * scaleFactor
const newWidth = oldWidth * scaleFactor
const newHeight = oldHeight * scaleFactor
ioNode.pos = [newX, newY] const scaledX = originX + relativeX * scaleFactor
ioNode.size = [newWidth, newHeight] const scaledY = originY + relativeY * scaleFactor
const scaledWidth = oldWidth * scaleFactor
const scaledHeight = oldHeight * scaleFactor
ioNode.pos = [scaledX, scaledY]
ioNode.size = [scaledWidth, scaledHeight]
} }
} }
graph.groups.forEach((group) => { graph.groups.forEach((group) => {
const originalPosX = group.pos[0] const [oldX, oldY] = group.pos
const originalPosY = group.pos[1] const [oldWidth, oldHeight] = group.size
const originalWidth = group.size[0]
const originalHeight = group.size[1]
const adjustedY = needsDownscale const adjustedY = oldY - (needsUpscale ? LiteGraph.NODE_TITLE_HEIGHT : 0)
? originalPosY - LiteGraph.NODE_TITLE_HEIGHT
: originalPosY
const relativeX = originalPosX - originX const relativeX = oldX - originX
const relativeY = adjustedY - originY const relativeY = adjustedY - originY
const newWidth = originalWidth * scaleFactor
const newHeight = originalHeight * scaleFactor
const scaledX = originX + relativeX * scaleFactor const scaledX = originX + relativeX * scaleFactor
const scaledY = originY + relativeY * scaleFactor const scaledY = originY + relativeY * scaleFactor
const finalY = needsUpscale const scaledWidth = oldWidth * scaleFactor
? scaledY + LiteGraph.NODE_TITLE_HEIGHT const scaledHeight = oldHeight * scaleFactor
: scaledY
const finalY = scaledY + (needsUpscale ? 0 : LiteGraph.NODE_TITLE_HEIGHT)
group.pos = [scaledX, finalY] group.pos = [scaledX, finalY]
group.size = [newWidth, newHeight] group.size = [scaledWidth, scaledHeight]
}) })
if ((!targetGraph || targetGraph === canvas?.graph) && canvas) { if (onActiveGraph && canvas) {
const originScreen = canvas.ds.convertOffsetToCanvas([originX, originY]) const originScreen = canvas.ds.convertOffsetToCanvas([originX, originY])
canvas.ds.changeScale(canvas.ds.scale / scaleFactor, originScreen) canvas.ds.changeScale(canvas.ds.scale / scaleFactor, originScreen)
} }
if (needsUpscale) { graph.extra.workflowRendererVersion = needsUpscale ? 'Vue' : 'LG'
graph.extra.workflowRendererVersion = 'Vue'
}
if (needsDownscale) {
graph.extra.workflowRendererVersion = 'LG'
}
} }

View File

@@ -0,0 +1,215 @@
import { storeToRefs } from 'pinia'
import { toValue } from 'vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import type {
NodeBoundsUpdate,
NodeId,
Point
} from '@/renderer/core/layout/types'
import { useNodeSnap } from '@/renderer/extensions/vueNodes/composables/useNodeSnap'
import { useShiftKeySync } from '@/renderer/extensions/vueNodes/composables/useShiftKeySync'
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
import { createSharedComposable } from '@vueuse/core'
export const useNodeDrag = createSharedComposable(useNodeDragIndividual)
function useNodeDragIndividual() {
const mutations = useLayoutMutations()
const { selectedNodeIds } = storeToRefs(useCanvasStore())
// Get transform utilities from TransformPane if available
const transformState = useTransformState()
// Snap-to-grid functionality
const { shouldSnap, applySnapToPosition } = useNodeSnap()
// Shift key sync for LiteGraph canvas preview
const { trackShiftKey } = useShiftKeySync()
// Drag state
let dragStartPos: Point | null = null
let dragStartMouse: Point | null = null
let otherSelectedNodesStartPositions: Map<string, Point> | null = null
let rafId: number | null = null
let stopShiftSync: (() => void) | null = null
function startDrag(event: PointerEvent, nodeId: NodeId) {
const layout = toValue(layoutStore.getNodeLayoutRef(nodeId))
if (!layout) return
const position = layout.position ?? { x: 0, y: 0 }
// Track shift key state and sync to canvas for snap preview
stopShiftSync = trackShiftKey(event)
dragStartPos = { ...position }
dragStartMouse = { x: event.clientX, y: event.clientY }
const selectedNodes = toValue(selectedNodeIds)
// capture the starting positions of all other selected nodes
if (selectedNodes?.has(nodeId) && selectedNodes.size > 1) {
otherSelectedNodesStartPositions = new Map()
for (const id of selectedNodes) {
// Skip the current node being dragged
if (id === nodeId) continue
const nodeLayout = layoutStore.getNodeLayoutRef(id).value
if (nodeLayout) {
otherSelectedNodesStartPositions.set(id, { ...nodeLayout.position })
}
}
} else {
otherSelectedNodesStartPositions = null
}
mutations.setSource(LayoutSource.Vue)
}
function handleDrag(event: PointerEvent, nodeId: NodeId) {
if (!dragStartPos || !dragStartMouse) {
return
}
// Throttle position updates using requestAnimationFrame for better performance
if (rafId !== null) return // Skip if frame already scheduled
const { target, pointerId } = event
if (target instanceof HTMLElement && !target.hasPointerCapture(pointerId)) {
// Delay capture to drag to allow for the Node cloning
target.setPointerCapture(pointerId)
}
rafId = requestAnimationFrame(() => {
rafId = null
if (!dragStartPos || !dragStartMouse) return
// Calculate mouse delta in screen coordinates
const mouseDelta = {
x: event.clientX - dragStartMouse.x,
y: event.clientY - dragStartMouse.y
}
// Convert to canvas coordinates
const canvasOrigin = transformState.screenToCanvas({ x: 0, y: 0 })
const canvasWithDelta = transformState.screenToCanvas(mouseDelta)
const canvasDelta = {
x: canvasWithDelta.x - canvasOrigin.x,
y: canvasWithDelta.y - canvasOrigin.y
}
// Calculate new position for the current node
const newPosition = {
x: dragStartPos.x + canvasDelta.x,
y: dragStartPos.y + canvasDelta.y
}
// Apply mutation through the layout system (Vue batches DOM updates automatically)
mutations.moveNode(nodeId, newPosition)
// If we're dragging multiple selected nodes, move them all together
if (
otherSelectedNodesStartPositions &&
otherSelectedNodesStartPositions.size > 0
) {
for (const [
otherNodeId,
startPos
] of otherSelectedNodesStartPositions) {
const newOtherPosition = {
x: startPos.x + canvasDelta.x,
y: startPos.y + canvasDelta.y
}
mutations.moveNode(otherNodeId, newOtherPosition)
}
}
})
}
function endDrag(event: PointerEvent, nodeId: NodeId | undefined) {
// Apply snap to final position if snap was active (matches LiteGraph behavior)
if (shouldSnap(event) && nodeId) {
const boundsUpdates: NodeBoundsUpdate[] = []
// Snap main node
const currentLayout = toValue(layoutStore.getNodeLayoutRef(nodeId))
if (currentLayout) {
const currentPos = currentLayout.position
const snappedPos = applySnapToPosition({ ...currentPos })
// Only add update if position actually changed
if (snappedPos.x !== currentPos.x || snappedPos.y !== currentPos.y) {
boundsUpdates.push({
nodeId,
bounds: {
x: snappedPos.x,
y: snappedPos.y,
width: currentLayout.size.width,
height: currentLayout.size.height
}
})
}
}
// Also snap other selected nodes
// Capture all positions at the start to ensure consistent state
if (
otherSelectedNodesStartPositions &&
otherSelectedNodesStartPositions.size > 0
) {
for (const otherNodeId of otherSelectedNodesStartPositions.keys()) {
const nodeLayout = layoutStore.getNodeLayoutRef(otherNodeId).value
if (nodeLayout) {
const currentPos = { ...nodeLayout.position }
const snappedPos = applySnapToPosition(currentPos)
// Only add update if position actually changed
if (
snappedPos.x !== currentPos.x ||
snappedPos.y !== currentPos.y
) {
boundsUpdates.push({
nodeId: otherNodeId,
bounds: {
x: snappedPos.x,
y: snappedPos.y,
width: nodeLayout.size.width,
height: nodeLayout.size.height
}
})
}
}
}
}
// Apply all snap updates in a single batched transaction
if (boundsUpdates.length > 0) {
layoutStore.batchUpdateNodeBounds(boundsUpdates)
}
}
dragStartPos = null
dragStartMouse = null
otherSelectedNodesStartPositions = null
// Stop tracking shift key state
stopShiftSync?.()
stopShiftSync = null
// Cancel any pending animation frame
if (rafId !== null) {
cancelAnimationFrame(rafId)
rafId = null
}
}
return {
startDrag,
handleDrag,
endDrag
}
}

View File

@@ -1,15 +1,10 @@
import { storeToRefs } from 'pinia' import { computed, toValue } from 'vue'
import { computed, inject, ref, toValue } from 'vue' import type { MaybeRefOrGetter } from 'vue'
import type { CSSProperties, MaybeRefOrGetter } from 'vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations' import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore' import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types' import { LayoutSource } from '@/renderer/core/layout/types'
import type { NodeBoundsUpdate, Point } from '@/renderer/core/layout/types' import type { Point } from '@/renderer/core/layout/types'
import { useNodeSnap } from '@/renderer/extensions/vueNodes/composables/useNodeSnap'
import { useShiftKeySync } from '@/renderer/extensions/vueNodes/composables/useShiftKeySync'
/** /**
* Composable for individual Vue node components * Composable for individual Vue node components
@@ -18,16 +13,6 @@ import { useShiftKeySync } from '@/renderer/extensions/vueNodes/composables/useS
export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) { export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
const nodeId = toValue(nodeIdMaybe) const nodeId = toValue(nodeIdMaybe)
const mutations = useLayoutMutations() const mutations = useLayoutMutations()
const { selectedNodeIds } = storeToRefs(useCanvasStore())
// Get transform utilities from TransformPane if available
const transformState = inject(TransformStateKey)
// Snap-to-grid functionality
const { shouldSnap, applySnapToPosition } = useNodeSnap()
// Shift key sync for LiteGraph canvas preview
const { trackShiftKey } = useShiftKeySync()
// Get the customRef for this node (shared write access) // Get the customRef for this node (shared write access)
const layoutRef = layoutStore.getNodeLayoutRef(nodeId) const layoutRef = layoutStore.getNodeLayoutRef(nodeId)
@@ -41,215 +26,9 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
const size = computed( const size = computed(
() => layoutRef.value?.size ?? { width: 200, height: 100 } () => layoutRef.value?.size ?? { width: 200, height: 100 }
) )
const bounds = computed(
() =>
layoutRef.value?.bounds ?? {
x: position.value.x,
y: position.value.y,
width: size.value.width,
height: size.value.height
}
)
const isVisible = computed(() => layoutRef.value?.visible ?? true)
const zIndex = computed(() => layoutRef.value?.zIndex ?? 0) const zIndex = computed(() => layoutRef.value?.zIndex ?? 0)
// Drag state
const isDragging = ref(false)
let dragStartPos: Point | null = null
let dragStartMouse: Point | null = null
let otherSelectedNodesStartPositions: Map<string, Point> | null = null
let rafId: number | null = null
let stopShiftSync: (() => void) | null = null
/**
* Start dragging the node
*/
function startDrag(event: PointerEvent) {
if (!layoutRef.value || !transformState) return
// Track shift key state and sync to canvas for snap preview
stopShiftSync = trackShiftKey(event)
isDragging.value = true
dragStartPos = { ...position.value }
dragStartMouse = { x: event.clientX, y: event.clientY }
// capture the starting positions of all other selected nodes
if (selectedNodeIds?.value?.has(nodeId) && selectedNodeIds.value.size > 1) {
otherSelectedNodesStartPositions = new Map()
// Iterate through all selected node IDs
for (const id of selectedNodeIds.value) {
// Skip the current node being dragged
if (id === nodeId) continue
const nodeLayout = layoutStore.getNodeLayoutRef(id).value
if (nodeLayout) {
otherSelectedNodesStartPositions.set(id, { ...nodeLayout.position })
}
}
} else {
otherSelectedNodesStartPositions = null
}
// Set mutation source
mutations.setSource(LayoutSource.Vue)
// Capture pointer
if (!(event.target instanceof HTMLElement)) return
event.target.setPointerCapture(event.pointerId)
}
/**
* Handle drag movement
*/
const handleDrag = (event: PointerEvent) => {
if (
!isDragging.value ||
!dragStartPos ||
!dragStartMouse ||
!transformState
) {
return
}
// Throttle position updates using requestAnimationFrame for better performance
if (rafId !== null) return // Skip if frame already scheduled
rafId = requestAnimationFrame(() => {
rafId = null
if (!dragStartPos || !dragStartMouse || !transformState) return
// Calculate mouse delta in screen coordinates
const mouseDelta = {
x: event.clientX - dragStartMouse.x,
y: event.clientY - dragStartMouse.y
}
// Convert to canvas coordinates
const canvasOrigin = transformState.screenToCanvas({ x: 0, y: 0 })
const canvasWithDelta = transformState.screenToCanvas(mouseDelta)
const canvasDelta = {
x: canvasWithDelta.x - canvasOrigin.x,
y: canvasWithDelta.y - canvasOrigin.y
}
// Calculate new position for the current node
const newPosition = {
x: dragStartPos.x + canvasDelta.x,
y: dragStartPos.y + canvasDelta.y
}
// Apply mutation through the layout system (Vue batches DOM updates automatically)
mutations.moveNode(nodeId, newPosition)
// If we're dragging multiple selected nodes, move them all together
if (
otherSelectedNodesStartPositions &&
otherSelectedNodesStartPositions.size > 0
) {
for (const [
otherNodeId,
startPos
] of otherSelectedNodesStartPositions) {
const newOtherPosition = {
x: startPos.x + canvasDelta.x,
y: startPos.y + canvasDelta.y
}
mutations.moveNode(otherNodeId, newOtherPosition)
}
}
})
}
/**
* End dragging
*/
function endDrag(event: PointerEvent) {
if (!isDragging.value) return
// Apply snap to final position if snap was active (matches LiteGraph behavior)
if (shouldSnap(event)) {
const boundsUpdates: NodeBoundsUpdate[] = []
// Snap main node
const currentLayout = layoutStore.getNodeLayoutRef(nodeId).value
if (currentLayout) {
const currentPos = currentLayout.position
const snappedPos = applySnapToPosition({ ...currentPos })
// Only add update if position actually changed
if (snappedPos.x !== currentPos.x || snappedPos.y !== currentPos.y) {
boundsUpdates.push({
nodeId,
bounds: {
x: snappedPos.x,
y: snappedPos.y,
width: currentLayout.size.width,
height: currentLayout.size.height
}
})
}
}
// Also snap other selected nodes
// Capture all positions at the start to ensure consistent state
if (
otherSelectedNodesStartPositions &&
otherSelectedNodesStartPositions.size > 0
) {
for (const otherNodeId of otherSelectedNodesStartPositions.keys()) {
const nodeLayout = layoutStore.getNodeLayoutRef(otherNodeId).value
if (nodeLayout) {
const currentPos = { ...nodeLayout.position }
const snappedPos = applySnapToPosition(currentPos)
// Only add update if position actually changed
if (
snappedPos.x !== currentPos.x ||
snappedPos.y !== currentPos.y
) {
boundsUpdates.push({
nodeId: otherNodeId,
bounds: {
x: snappedPos.x,
y: snappedPos.y,
width: nodeLayout.size.width,
height: nodeLayout.size.height
}
})
}
}
}
}
// Apply all snap updates in a single batched transaction
if (boundsUpdates.length > 0) {
layoutStore.batchUpdateNodeBounds(boundsUpdates)
}
}
isDragging.value = false
dragStartPos = null
dragStartMouse = null
otherSelectedNodesStartPositions = null
// Stop tracking shift key state
stopShiftSync?.()
stopShiftSync = null
// Cancel any pending animation frame
if (rafId !== null) {
cancelAnimationFrame(rafId)
rafId = null
}
// Release pointer
if (!(event.target instanceof HTMLElement)) return
event.target.releasePointerCapture(event.pointerId)
}
/** /**
* Update node position directly (without drag) * Update node position directly (without drag)
*/ */
@@ -260,33 +39,11 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
return { return {
// Reactive state (via customRef) // Reactive state (via customRef)
layoutRef,
position, position,
size, size,
bounds,
isVisible,
zIndex, zIndex,
isDragging,
// Mutations // Mutations
moveNodeTo, moveNodeTo
// Drag handlers
startDrag,
handleDrag,
endDrag,
// Computed styles for Vue templates
nodeStyle: computed(
(): CSSProperties => ({
position: 'absolute' as const,
left: `${position.value.x}px`,
top: `${position.value.y}px`,
width: `${size.value.width}px`,
height: `${size.value.height}px`,
zIndex: zIndex.value,
cursor: isDragging.value ? 'grabbing' : 'grab'
})
)
} }
} }

View File

@@ -82,21 +82,11 @@ describe('WidgetButton Interactions', () => {
expect(button.exists()).toBe(true) expect(button.exists()).toBe(true)
}) })
it('renders widget label when name is provided', () => { it('renders widget text when name is provided', () => {
const widget = createMockWidget() const widget = createMockWidget()
const wrapper = mountComponent(widget) const wrapper = mountComponent(widget)
const label = wrapper.find('label') expect(wrapper.text()).toBe('test_button')
expect(label.exists()).toBe(true)
expect(label.text()).toBe('test_button')
})
it('does not render label when widget name is empty', () => {
const widget = createMockWidget({}, undefined, '')
const wrapper = mountComponent(widget)
const label = wrapper.find('label')
expect(label.exists()).toBe(false)
}) })
it('sets button size to small', () => { it('sets button size to small', () => {

View File

@@ -1,14 +1,15 @@
<template> <template>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-secondary text-sm">{{
widget.name
}}</label>
<Button <Button
v-bind="filteredProps" v-bind="filteredProps"
:aria-label="widget.name || widget.label" :aria-label="widget.name || widget.label"
size="small" size="small"
@click="handleClick" @click="handleClick"
/> >
<template v-if="widget.name">
{{ widget.name }}
</template>
</Button>
</div> </div>
</template> </template>

View File

@@ -57,7 +57,7 @@ const renderedHtml = computed(() => {
// Methods // Methods
const startEditing = async () => { const startEditing = async () => {
if (isEditing.value) return if (isEditing.value || widget.options?.read_only) return
isEditing.value = true isEditing.value = true
await nextTick() await nextTick()

View File

@@ -136,8 +136,8 @@ const outputItems = computed<DropdownItem[]>(() => {
}) })
}) })
return Array.from(outputs).map((output, index) => ({ return Array.from(outputs).map((output) => ({
id: `output-${index}`, id: `output-${output}`,
mediaSrc: getMediaUrl(output.replace(' [output]', ''), 'output'), mediaSrc: getMediaUrl(output.replace(' [output]', ''), 'output'),
name: output, name: output,
label: getDisplayLabel(output), label: getDisplayLabel(output),
@@ -215,16 +215,14 @@ const layoutMode = ref<LayoutMode>(props.defaultLayoutMode ?? 'grid')
watch( watch(
modelValue, modelValue,
(currentValue) => { (currentValue) => {
if (currentValue !== undefined) { if (currentValue === undefined) {
const item = dropdownItems.value.find(
(item) => item.name === currentValue
)
if (item) {
selectedSet.value.clear()
selectedSet.value.add(item.id)
}
} else {
selectedSet.value.clear() selectedSet.value.clear()
return
}
const item = dropdownItems.value.find((item) => item.name === currentValue)
if (item) {
selectedSet.value.clear()
selectedSet.value.add(item.id)
} }
}, },
{ immediate: true } { immediate: true }

View File

@@ -12,6 +12,10 @@
:disabled="widget.options?.read_only" :disabled="widget.options?.read_only"
fluid fluid
data-capture-wheel="true" data-capture-wheel="true"
@pointerdown.capture.stop
@pointermove.capture.stop
@pointerup.capture.stop
@contextmenu.capture.stop
/> />
<LODFallback /> <LODFallback />
</div> </div>

View File

@@ -32,24 +32,13 @@ const selectedItems = computed(() => {
return props.items.filter((item) => props.selected.has(item.id)) return props.items.filter((item) => props.selected.has(item.id))
}) })
const chevronClass = computed(() =>
cn(
'mr-2 size-4 transition-transform duration-200 flex-shrink-0 text-component-node-foreground-secondary',
{
'rotate-180': props.isOpen
}
)
)
const theButtonStyle = computed(() => const theButtonStyle = computed(() =>
cn( cn(
'border-0 bg-component-node-widget-background outline-none text-text-secondary', 'border-0 bg-component-node-widget-background outline-none text-text-secondary',
{ props.disabled
'hover:bg-component-node-widget-background-hovered cursor-pointer': ? 'cursor-not-allowed'
!props.disabled, : 'hover:bg-component-node-widget-background-hovered cursor-pointer',
'cursor-not-allowed': props.disabled, selectedItems.value.length > 0 && 'text-text-primary'
'text-text-primary': selectedItems.value.length > 0
}
) )
) )
</script> </script>
@@ -78,13 +67,21 @@ const theButtonStyle = computed(() =>
> >
<span class="min-w-0 flex-1 px-1 py-2 text-left truncate"> <span class="min-w-0 flex-1 px-1 py-2 text-left truncate">
<span v-if="!selectedItems.length"> <span v-if="!selectedItems.length">
{{ props.placeholder }} {{ placeholder }}
</span> </span>
<span v-else> <span v-else>
{{ selectedItems.map((item) => item.label ?? item.name).join(', ') }} {{ selectedItems.map((item) => item.label ?? item.name).join(', ') }}
</span> </span>
</span> </span>
<i class="icon-[lucide--chevron-down]" :class="chevronClass" /> <i
class="icon-[lucide--chevron-down]"
:class="
cn(
'mr-2 size-4 transition-transform duration-200 flex-shrink-0 text-component-node-foreground-secondary',
isOpen && 'rotate-180'
)
"
/>
</button> </button>
<!-- Open File --> <!-- Open File -->
<label <label

View File

@@ -6,6 +6,7 @@ import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget' import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import type { ComponentWidgetStandardProps } from '@/scripts/domWidget' import type { ComponentWidgetStandardProps } from '@/scripts/domWidget'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets' import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
type TextPreviewCustomProps = Omit< type TextPreviewCustomProps = Omit<
InstanceType<typeof TextPreviewWidget>['$props'], InstanceType<typeof TextPreviewWidget>['$props'],
@@ -14,15 +15,15 @@ type TextPreviewCustomProps = Omit<
const PADDING = 16 const PADDING = 16
export const useTextPreviewWidget = ( export function useTextPreviewWidget(
options: { options: {
minHeight?: number minHeight?: number
} = {} } = {}
) => { ): ComfyWidgetConstructorV2 {
const widgetConstructor: ComfyWidgetConstructorV2 = ( function widgetConstructor(
node: LGraphNode, node: LGraphNode,
inputSpec: InputSpec inputSpec: InputSpec
) => { ): IBaseWidget {
const widgetValue = ref<string>('') const widgetValue = ref<string>('')
const widget = new ComponentWidgetImpl< const widget = new ComponentWidgetImpl<
string | object, string | object,
@@ -41,8 +42,10 @@ export const useTextPreviewWidget = (
widgetValue.value = typeof value === 'string' ? value : String(value) widgetValue.value = typeof value === 'string' ? value : String(value)
}, },
getMinHeight: () => options.minHeight ?? 42 + PADDING, getMinHeight: () => options.minHeight ?? 42 + PADDING,
serialize: false serialize: false,
} read_only: true
},
type: inputSpec.type
}) })
addWidget(node, widget) addWidget(node, widget)
return widget return widget

View File

@@ -236,9 +236,7 @@ export function useRemoteWidget<
* Add a refresh button to the node that, when clicked, will force the widget to refresh * Add a refresh button to the node that, when clicked, will force the widget to refresh
*/ */
function addRefreshButton() { function addRefreshButton() {
node.addWidget('button', 'refresh', 'refresh', widget.refresh, { node.addWidget('button', 'refresh', 'refresh', widget.refresh)
canvasOnly: true
})
} }
/** /**
@@ -263,8 +261,7 @@ export function useRemoteWidget<
autoRefreshEnabled = value autoRefreshEnabled = value
}, },
{ {
serialize: false, serialize: false
canvasOnly: true
} }
) )

View File

@@ -134,7 +134,11 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
], ],
[ [
'markdown', 'markdown',
{ component: WidgetMarkdown, aliases: ['MARKDOWN'], essential: false } {
component: WidgetMarkdown,
aliases: ['MARKDOWN', 'progressText'],
essential: false
}
], ],
['legacy', { component: WidgetLegacy, aliases: [], essential: true }], ['legacy', { component: WidgetLegacy, aliases: [], essential: true }],
[ [

View File

@@ -29,6 +29,7 @@ export const zBaseInputOptions = z
defaultInput: z.boolean().optional(), defaultInput: z.boolean().optional(),
forceInput: z.boolean().optional(), forceInput: z.boolean().optional(),
tooltip: z.string().optional(), tooltip: z.string().optional(),
socketless: z.boolean().optional(),
hidden: z.boolean().optional(), hidden: z.boolean().optional(),
advanced: z.boolean().optional(), advanced: z.boolean().optional(),
widgetType: z.string().optional(), widgetType: z.string().optional(),

View File

@@ -303,10 +303,11 @@ export class ComponentWidgetImpl<
inputSpec: InputSpec inputSpec: InputSpec
props?: P props?: P
options: DOMWidgetOptions<V> options: DOMWidgetOptions<V>
type?: string
}) { }) {
super({ super({
...obj, type: 'custom',
type: 'custom' ...obj
}) })
this.component = obj.component this.component = obj.component
this.inputSpec = obj.inputSpec this.inputSpec = obj.inputSpec

View File

@@ -58,6 +58,7 @@ import {
import { getOrderedInputSpecs } from '@/workbench/utils/nodeDefOrderingUtil' import { getOrderedInputSpecs } from '@/workbench/utils/nodeDefOrderingUtil'
import { useExtensionService } from './extensionService' import { useExtensionService } from './extensionService'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
export const CONFIG = Symbol() export const CONFIG = Symbol()
export const GET_CONFIG = Symbol() export const GET_CONFIG = Symbol()
@@ -796,11 +797,7 @@ export const useLitegraphService = () => {
options.push({ options.push({
content: 'Open in MaskEditor | Image Canvas', content: 'Open in MaskEditor | Image Canvas',
callback: () => { callback: () => {
ComfyApp.copyToClipspace(this) useMaskEditor().openMaskEditor(this)
// @ts-expect-error fixme ts strict error
ComfyApp.clipspace_return_node = this
// @ts-expect-error fixme ts strict error
ComfyApp.open_maskeditor()
} }
}) })
} }

View File

@@ -114,7 +114,10 @@ export const useWorkflowPacks = (options: UseNodePacksOptions = {}) => {
const getWorkflowPacks = async () => { const getWorkflowPacks = async () => {
if (!app.graph) return [] if (!app.graph) return []
const allNodes = collectAllNodes(app.graph) const allNodes = collectAllNodes(app.graph)
if (!allNodes.length) return [] if (!allNodes.length) {
workflowPacks.value = []
return []
}
const packs = await Promise.all(allNodes.map(workflowNodeToPack)) const packs = await Promise.all(allNodes.map(workflowNodeToPack))
workflowPacks.value = packs.filter((pack) => pack !== undefined) workflowPacks.value = packs.filter((pack) => pack !== undefined)
} }

View File

@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it } from 'vitest' import { beforeEach, describe, expect, it } from 'vitest'
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState' import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
// Create a mock canvas context for transform testing // Create a mock canvas context for transform testing
function createMockCanvasContext() { function createMockCanvasContext() {
@@ -27,10 +28,12 @@ function createMockCanvasContext() {
} }
describe('useTransformState', () => { describe('useTransformState', () => {
let transformState: ReturnType<typeof useTransformState> const transformState = useTransformState()
beforeEach(() => { beforeEach(() => {
transformState = useTransformState() transformState.syncWithCanvas({
ds: { offset: [0, 0] }
} as unknown as LGraphCanvas)
}) })
describe('initial state', () => { describe('initial state', () => {
@@ -179,8 +182,8 @@ describe('useTransformState', () => {
it('should calculate correct screen bounds for a node', () => { it('should calculate correct screen bounds for a node', () => {
const { getNodeScreenBounds } = transformState const { getNodeScreenBounds } = transformState
const nodePos = [10, 20] const nodePos: [number, number] = [10, 20]
const nodeSize = [200, 100] const nodeSize: [number, number] = [200, 100]
const bounds = getNodeScreenBounds(nodePos, nodeSize) const bounds = getNodeScreenBounds(nodePos, nodeSize)
// Top-left: canvasToScreen(10, 20) = (220, 140) // Top-left: canvasToScreen(10, 20) = (220, 140)
@@ -206,8 +209,8 @@ describe('useTransformState', () => {
it('should return true for nodes inside viewport', () => { it('should return true for nodes inside viewport', () => {
const { isNodeInViewport } = transformState const { isNodeInViewport } = transformState
const nodePos = [100, 100] const nodePos: [number, number] = [100, 100]
const nodeSize = [200, 100] const nodeSize: [number, number] = [200, 100]
expect(isNodeInViewport(nodePos, nodeSize, viewport)).toBe(true) expect(isNodeInViewport(nodePos, nodeSize, viewport)).toBe(true)
}) })
@@ -232,8 +235,8 @@ describe('useTransformState', () => {
const { isNodeInViewport } = transformState const { isNodeInViewport } = transformState
// Node slightly outside but within margin // Node slightly outside but within margin
const nodePos = [-50, -50] const nodePos: [number, number] = [-50, -50]
const nodeSize = [100, 100] const nodeSize: [number, number] = [100, 100]
expect(isNodeInViewport(nodePos, nodeSize, viewport, 0.2)).toBe(true) expect(isNodeInViewport(nodePos, nodeSize, viewport, 0.2)).toBe(true)
}) })
@@ -242,8 +245,8 @@ describe('useTransformState', () => {
const { isNodeInViewport } = transformState const { isNodeInViewport } = transformState
// Node is in viewport but too small // Node is in viewport but too small
const nodePos = [100, 100] const nodePos: [number, number] = [100, 100]
const nodeSize = [3, 3] // Less than 4 pixels const nodeSize: [number, number] = [3, 3] // Less than 4 pixels
expect(isNodeInViewport(nodePos, nodeSize, viewport)).toBe(false) expect(isNodeInViewport(nodePos, nodeSize, viewport)).toBe(false)
}) })

View File

@@ -1763,7 +1763,7 @@ describe('useNodePricing', () => {
const node = createMockNode('GeminiImageNode') const node = createMockNode('GeminiImageNode')
const price = getNodeDisplayPrice(node) const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.03 per 1K tokens') expect(price).toBe('~$0.039/Image (1K)')
}) })
}) })

View File

@@ -277,6 +277,32 @@ describe('useMissingNodes', () => {
// Should update missing packs (2 missing since pack-3 is installed) // Should update missing packs (2 missing since pack-3 is installed)
expect(missingNodePacks.value).toHaveLength(2) expect(missingNodePacks.value).toHaveLength(2)
}) })
it('clears missing nodes when switching to empty workflow', async () => {
const workflowPacksRef = ref(mockWorkflowPacks)
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: workflowPacksRef,
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(true),
filterWorkflowPack: vi.fn()
})
const { hasMissingNodes, missingNodePacks } = useMissingNodes()
// Should have missing nodes initially (2 missing since pack-3 is installed)
expect(missingNodePacks.value).toHaveLength(2)
expect(hasMissingNodes.value).toBe(true)
// Switch to empty workflow (simulates creating a new empty workflow)
workflowPacksRef.value = []
await nextTick()
// Should clear missing nodes
expect(missingNodePacks.value).toHaveLength(0)
expect(hasMissingNodes.value).toBe(false)
})
}) })
describe('missing core nodes detection', () => { describe('missing core nodes detection', () => {

View File

@@ -1,483 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
// Mock canvas context for testing
const createMockCanvasContext = () => ({
ds: {
offset: [0, 0] as [number, number],
scale: 1
}
})
// Skip this entire suite on CI to avoid flaky performance timing
const isCI = Boolean(process.env.CI)
const describeIfNotCI = isCI ? describe.skip : describe
describeIfNotCI.skip('Transform Performance', () => {
let transformState: ReturnType<typeof useTransformState>
let mockCanvas: any
beforeEach(() => {
transformState = useTransformState()
mockCanvas = createMockCanvasContext()
})
describe('coordinate conversion performance', () => {
it('should handle large batches of coordinate conversions efficiently', () => {
// Set up a realistic transform state
mockCanvas.ds.offset = [500, 300]
mockCanvas.ds.scale = 1.5
transformState.syncWithCanvas(mockCanvas)
const conversionCount = 10000
const points = Array.from({ length: conversionCount }, () => ({
x: Math.random() * 5000,
y: Math.random() * 3000
}))
// Benchmark canvas to screen conversions
const canvasToScreenStart = performance.now()
const screenPoints = points.map((point) =>
transformState.canvasToScreen(point)
)
const canvasToScreenTime = performance.now() - canvasToScreenStart
// Benchmark screen to canvas conversions
const screenToCanvasStart = performance.now()
const backToCanvas = screenPoints.map((point) =>
transformState.screenToCanvas(point)
)
const screenToCanvasTime = performance.now() - screenToCanvasStart
// Performance expectations
expect(canvasToScreenTime).toBeLessThan(20) // 10k conversions in under 20ms
expect(screenToCanvasTime).toBeLessThan(20) // 10k conversions in under 20ms
// Verify accuracy of round-trip conversion
const maxError = points.reduce((max, original, i) => {
const converted = backToCanvas[i]
const errorX = Math.abs(original.x - converted.x)
const errorY = Math.abs(original.y - converted.y)
return Math.max(max, errorX, errorY)
}, 0)
expect(maxError).toBeLessThan(0.001) // Sub-pixel accuracy
})
it('should maintain performance across different zoom levels', () => {
const zoomLevels = [0.1, 0.5, 1.0, 2.0, 5.0, 10.0]
const conversionCount = 1000
const testPoints = Array.from({ length: conversionCount }, () => ({
x: Math.random() * 2000,
y: Math.random() * 1500
}))
const performanceResults: number[] = []
zoomLevels.forEach((scale) => {
mockCanvas.ds.scale = scale
transformState.syncWithCanvas(mockCanvas)
const startTime = performance.now()
testPoints.forEach((point) => {
const screen = transformState.canvasToScreen(point)
transformState.screenToCanvas(screen)
})
const duration = performance.now() - startTime
performanceResults.push(duration)
})
// Performance should be consistent across zoom levels
const maxTime = Math.max(...performanceResults)
const minTime = Math.min(...performanceResults)
const variance = (maxTime - minTime) / minTime
expect(maxTime).toBeLessThan(20) // All zoom levels under 20ms
expect(variance).toBeLessThan(3.0) // Less than 300% variance between zoom levels
})
it('should handle extreme coordinate values efficiently', () => {
// Test with very large coordinate values
const extremePoints = [
{ x: -100000, y: -100000 },
{ x: 100000, y: 100000 },
{ x: 0, y: 0 },
{ x: -50000, y: 50000 },
{ x: 1e6, y: -1e6 }
]
// Test at extreme zoom levels
const extremeScales = [0.001, 1000]
extremeScales.forEach((scale) => {
mockCanvas.ds.scale = scale
mockCanvas.ds.offset = [1000, 500]
transformState.syncWithCanvas(mockCanvas)
const startTime = performance.now()
// Convert each point 100 times
extremePoints.forEach((point) => {
for (let i = 0; i < 100; i++) {
const screen = transformState.canvasToScreen(point)
transformState.screenToCanvas(screen)
}
})
const duration = performance.now() - startTime
expect(duration).toBeLessThan(5) // Should handle extremes efficiently
expect(
Number.isFinite(transformState.canvasToScreen(extremePoints[0]).x)
).toBe(true)
expect(
Number.isFinite(transformState.canvasToScreen(extremePoints[0]).y)
).toBe(true)
})
})
})
describe('viewport culling performance', () => {
it('should efficiently determine node visibility for large numbers of nodes', () => {
// Set up realistic viewport
const viewport = { width: 1920, height: 1080 }
// Generate many node positions
const nodeCount = 1000
const nodes = Array.from({ length: nodeCount }, () => ({
pos: [Math.random() * 10000, Math.random() * 6000] as ArrayLike<number>,
size: [
150 + Math.random() * 100,
100 + Math.random() * 50
] as ArrayLike<number>
}))
// Test at different zoom levels and positions
const testConfigs = [
{ scale: 0.5, offset: [0, 0] },
{ scale: 1.0, offset: [2000, 1000] },
{ scale: 2.0, offset: [-1000, -500] }
]
testConfigs.forEach((config) => {
mockCanvas.ds.scale = config.scale
mockCanvas.ds.offset = config.offset
transformState.syncWithCanvas(mockCanvas)
const startTime = performance.now()
// Test viewport culling for all nodes
const visibleNodes = nodes.filter((node) =>
transformState.isNodeInViewport(node.pos, node.size, viewport)
)
const cullTime = performance.now() - startTime
expect(cullTime).toBeLessThan(10) // 1000 nodes culled in under 10ms
expect(visibleNodes.length).toBeLessThan(nodeCount) // Some culling should occur
expect(visibleNodes.length).toBeGreaterThanOrEqual(0) // Sanity check
})
})
it('should optimize culling with adaptive margins', () => {
const viewport = { width: 1280, height: 720 }
const testNode = {
pos: [1300, 100] as ArrayLike<number>, // Just outside viewport
size: [200, 100] as ArrayLike<number>
}
// Test margin adaptation at different zoom levels
const zoomTests = [
{ scale: 0.05, expectedVisible: true }, // Low zoom, larger margin
{ scale: 1.0, expectedVisible: true }, // Normal zoom, standard margin
{ scale: 4.0, expectedVisible: false } // High zoom, tighter margin
]
const marginTests: boolean[] = []
const timings: number[] = []
zoomTests.forEach((test) => {
mockCanvas.ds.scale = test.scale
mockCanvas.ds.offset = [0, 0]
transformState.syncWithCanvas(mockCanvas)
const startTime = performance.now()
const isVisible = transformState.isNodeInViewport(
testNode.pos,
testNode.size,
viewport,
0.2 // 20% margin
)
const duration = performance.now() - startTime
marginTests.push(isVisible)
timings.push(duration)
})
// All culling operations should be very fast
timings.forEach((time) => {
expect(time).toBeLessThan(0.1) // Individual culling under 0.1ms
})
// Verify adaptive behavior (margins should work as expected)
expect(marginTests[0]).toBe(zoomTests[0].expectedVisible)
expect(marginTests[2]).toBe(zoomTests[2].expectedVisible)
})
it('should handle size-based culling efficiently', () => {
// Test nodes of various sizes
const nodeSizes = [
[1, 1], // Tiny node
[5, 5], // Small node
[50, 50], // Medium node
[200, 100], // Large node
[500, 300] // Very large node
]
const viewport = { width: 1920, height: 1080 }
// Position all nodes in viewport center
const centerPos = [960, 540] as ArrayLike<number>
nodeSizes.forEach((size) => {
// Test at very low zoom where size culling should activate
mockCanvas.ds.scale = 0.01 // Very low zoom
transformState.syncWithCanvas(mockCanvas)
const startTime = performance.now()
const isVisible = transformState.isNodeInViewport(
centerPos,
size as ArrayLike<number>,
viewport
)
const cullTime = performance.now() - startTime
expect(cullTime).toBeLessThan(0.1) // Size culling under 0.1ms
// At 0.01 zoom, nodes need to be 400+ pixels to show as 4+ screen pixels
const screenSize = Math.max(size[0], size[1]) * 0.01
if (screenSize < 4) {
expect(isVisible).toBe(false)
} else {
expect(isVisible).toBe(true)
}
})
})
})
describe('transform state synchronization', () => {
it('should efficiently sync with canvas state changes', () => {
const syncCount = 1000
const transformUpdates = Array.from({ length: syncCount }, (_, i) => ({
offset: [Math.sin(i * 0.1) * 1000, Math.cos(i * 0.1) * 500],
scale: 0.5 + Math.sin(i * 0.05) * 0.4 // Scale between 0.1 and 0.9
}))
const startTime = performance.now()
transformUpdates.forEach((update) => {
mockCanvas.ds.offset = update.offset
mockCanvas.ds.scale = update.scale
transformState.syncWithCanvas(mockCanvas)
})
const syncTime = performance.now() - startTime
expect(syncTime).toBeLessThan(15) // 1000 syncs in under 15ms
// Verify final state is correct
const lastUpdate = transformUpdates[transformUpdates.length - 1]
expect(transformState.camera.x).toBe(lastUpdate.offset[0])
expect(transformState.camera.y).toBe(lastUpdate.offset[1])
expect(transformState.camera.z).toBe(lastUpdate.scale)
})
it('should generate CSS transform strings efficiently', () => {
const transformCount = 10000
// Set up varying transform states
const transforms = Array.from({ length: transformCount }, (_, i) => {
mockCanvas.ds.offset = [i * 10, i * 5]
mockCanvas.ds.scale = 0.5 + (i % 100) / 100
transformState.syncWithCanvas(mockCanvas)
return transformState.transformStyle.value
})
const startTime = performance.now()
// Access transform styles (triggers computed property)
transforms.forEach((style) => {
expect(style.transform).toContain('scale(')
expect(style.transform).toContain('translate(')
expect(style.transformOrigin).toBe('0 0')
})
const accessTime = performance.now() - startTime
expect(accessTime).toBeLessThan(200) // 10k style accesses in under 200ms
})
})
describe('bounds calculation performance', () => {
it('should calculate node screen bounds efficiently', () => {
// Set up realistic transform
mockCanvas.ds.offset = [200, 100]
mockCanvas.ds.scale = 1.5
transformState.syncWithCanvas(mockCanvas)
const nodeCount = 1000
const nodes = Array.from({ length: nodeCount }, () => ({
pos: [Math.random() * 5000, Math.random() * 3000] as ArrayLike<number>,
size: [
100 + Math.random() * 200,
80 + Math.random() * 120
] as ArrayLike<number>
}))
const startTime = performance.now()
const bounds = nodes.map((node) =>
transformState.getNodeScreenBounds(node.pos, node.size)
)
const calcTime = performance.now() - startTime
expect(calcTime).toBeLessThan(15) // 1000 bounds calculations in under 15ms
expect(bounds).toHaveLength(nodeCount)
// Verify bounds are reasonable
bounds.forEach((bound) => {
expect(bound.width).toBeGreaterThan(0)
expect(bound.height).toBeGreaterThan(0)
expect(Number.isFinite(bound.x)).toBe(true)
expect(Number.isFinite(bound.y)).toBe(true)
})
})
it('should calculate viewport bounds efficiently', () => {
const viewportSizes = [
{ width: 800, height: 600 },
{ width: 1920, height: 1080 },
{ width: 3840, height: 2160 },
{ width: 1280, height: 720 }
]
const margins = [0, 0.1, 0.2, 0.5]
const combinations = viewportSizes.flatMap((viewport) =>
margins.map((margin) => ({ viewport, margin }))
)
const startTime = performance.now()
const allBounds = combinations.map(({ viewport, margin }) => {
mockCanvas.ds.offset = [Math.random() * 1000, Math.random() * 500]
mockCanvas.ds.scale = 0.5 + Math.random() * 2
transformState.syncWithCanvas(mockCanvas)
return transformState.getViewportBounds(viewport, margin)
})
const calcTime = performance.now() - startTime
expect(calcTime).toBeLessThan(5) // All viewport calculations in under 5ms
expect(allBounds).toHaveLength(combinations.length)
// Verify bounds are reasonable
allBounds.forEach((bounds) => {
expect(bounds.width).toBeGreaterThan(0)
expect(bounds.height).toBeGreaterThan(0)
expect(Number.isFinite(bounds.x)).toBe(true)
expect(Number.isFinite(bounds.y)).toBe(true)
})
})
})
describe('real-world performance scenarios', () => {
it('should handle smooth panning performance', () => {
// Simulate smooth 60fps panning for 2 seconds
const frameCount = 120 // 2 seconds at 60fps
const panDistance = 2000 // Pan 2000 pixels
const frames: number[] = []
for (let frame = 0; frame < frameCount; frame++) {
const progress = frame / (frameCount - 1)
const x = progress * panDistance
const y = Math.sin(progress * Math.PI * 2) * 200 // Slight vertical wave
mockCanvas.ds.offset = [x, y]
const frameStart = performance.now()
// Typical operations during panning
transformState.syncWithCanvas(mockCanvas)
const style = transformState.transformStyle.value // Access transform style
expect(style.transform).toContain('translate') // Verify style is valid
// Simulate some coordinate conversions (mouse tracking, etc.)
for (let i = 0; i < 5; i++) {
const screen = transformState.canvasToScreen({
x: x + i * 100,
y: y + i * 50
})
transformState.screenToCanvas(screen)
}
const frameTime = performance.now() - frameStart
frames.push(frameTime)
// Each frame should be well under 16.67ms for 60fps
expect(frameTime).toBeLessThan(1) // Conservative: under 1ms per frame
}
const totalTime = frames.reduce((sum, time) => sum + time, 0)
const avgFrameTime = totalTime / frameCount
expect(avgFrameTime).toBeLessThan(0.5) // Average frame time under 0.5ms
expect(totalTime).toBeLessThan(60) // Total panning overhead under 60ms
})
it('should handle zoom performance with viewport updates', () => {
// Simulate smooth zoom from 0.1x to 10x
const zoomSteps = 100
const viewport = { width: 1920, height: 1080 }
const zoomTimes: number[] = []
for (let step = 0; step < zoomSteps; step++) {
const zoomLevel = Math.pow(10, (step / (zoomSteps - 1)) * 2 - 1) // 0.1 to 10
mockCanvas.ds.scale = zoomLevel
const stepStart = performance.now()
// Operations during zoom
transformState.syncWithCanvas(mockCanvas)
// Viewport bounds calculation (for culling)
transformState.getViewportBounds(viewport, 0.2)
// Test a few nodes for visibility
for (let i = 0; i < 10; i++) {
transformState.isNodeInViewport(
[i * 200, i * 150],
[200, 100],
viewport
)
}
const stepTime = performance.now() - stepStart
zoomTimes.push(stepTime)
}
const maxZoomTime = Math.max(...zoomTimes)
const avgZoomTime =
zoomTimes.reduce((sum, time) => sum + time, 0) / zoomSteps
expect(maxZoomTime).toBeLessThan(2) // No zoom step over 2ms
expect(avgZoomTime).toBeLessThan(1) // Average zoom step under 1ms
})
})
})

View File

@@ -6,7 +6,6 @@ import type { ComponentProps } from 'vue-component-type-helpers'
import { createI18n } from 'vue-i18n' import { createI18n } from 'vue-i18n'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager' import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue' import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking' import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
@@ -15,6 +14,17 @@ const mockData = vi.hoisted(() => ({
mockExecuting: false mockExecuting: false
})) }))
vi.mock('@/renderer/core/layout/transform/useTransformState', () => {
return {
useTransformState: () => ({
screenToCanvas: vi.fn(),
canvasToScreen: vi.fn(),
camera: { z: 1 },
isNodeInViewport: vi.fn()
})
}
})
vi.mock('@/renderer/core/canvas/canvasStore', () => { vi.mock('@/renderer/core/canvas/canvasStore', () => {
const getCanvas = vi.fn() const getCanvas = vi.fn()
const useCanvasStore = () => ({ const useCanvasStore = () => ({
@@ -105,14 +115,7 @@ function mountLGraphNode(props: ComponentProps<typeof LGraphNode>) {
}), }),
i18n i18n
], ],
provide: {
[TransformStateKey as symbol]: {
screenToCanvas: vi.fn(),
canvasToScreen: vi.fn(),
camera: { z: 1 },
isNodeInViewport: vi.fn()
}
},
stubs: { stubs: {
NodeHeader: true, NodeHeader: true,
NodeSlots: true, NodeSlots: true,
@@ -172,14 +175,6 @@ describe('LGraphNode', () => {
}), }),
i18n i18n
], ],
provide: {
[TransformStateKey as symbol]: {
screenToCanvas: vi.fn(),
canvasToScreen: vi.fn(),
camera: { z: 1 },
isNodeInViewport: vi.fn()
}
},
stubs: { stubs: {
NodeSlots: true, NodeSlots: true,
NodeWidgets: true, NodeWidgets: true,

View File

@@ -2,10 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, shallowRef } from 'vue' import { computed, shallowRef } from 'vue'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager' import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import type { import type { GraphNodeManager } from '@/composables/graph/useGraphNodeManager'
GraphNodeManager,
VueNodeData
} from '@/composables/graph/useGraphNodeManager'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle' import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import type { import type {
LGraph, LGraph,
@@ -81,18 +78,10 @@ describe('useNodeEventHandlers', () => {
const mockNode = mockNodeManager.value!.getNode('fake_id') const mockNode = mockNodeManager.value!.getNode('fake_id')
const mockLayoutMutations = useLayoutMutations() const mockLayoutMutations = useLayoutMutations()
const testNodeData: VueNodeData = { const testNodeId = 'node-1'
id: 'node-1',
title: 'Test Node',
type: 'test',
mode: 0,
selected: false,
executing: false
}
beforeEach(async () => { beforeEach(async () => {
vi.restoreAllMocks() vi.resetAllMocks()
vi.clearAllMocks()
canvasSelectedItems.length = 0 canvasSelectedItems.length = 0
}) })
@@ -107,7 +96,7 @@ describe('useNodeEventHandlers', () => {
metaKey: false metaKey: false
}) })
handleNodeSelect(event, testNodeData) handleNodeSelect(event, testNodeId)
expect(canvas?.deselectAll).toHaveBeenCalledOnce() expect(canvas?.deselectAll).toHaveBeenCalledOnce()
expect(canvas?.select).toHaveBeenCalledWith(mockNode) expect(canvas?.select).toHaveBeenCalledWith(mockNode)
@@ -126,7 +115,7 @@ describe('useNodeEventHandlers', () => {
metaKey: false metaKey: false
}) })
handleNodeSelect(ctrlClickEvent, testNodeData) handleNodeSelect(ctrlClickEvent, testNodeId)
// On pointer down with multi-select: bring to front // On pointer down with multi-select: bring to front
expect(mockLayoutMutations.bringNodeToFront).toHaveBeenCalledWith( expect(mockLayoutMutations.bringNodeToFront).toHaveBeenCalledWith(
@@ -152,7 +141,7 @@ describe('useNodeEventHandlers', () => {
metaKey: false metaKey: false
}) })
handleNodeSelect(ctrlClickEvent, testNodeData) handleNodeSelect(ctrlClickEvent, testNodeId)
// On pointer down: bring to front // On pointer down: bring to front
expect(mockLayoutMutations.bringNodeToFront).toHaveBeenCalledWith( expect(mockLayoutMutations.bringNodeToFront).toHaveBeenCalledWith(
@@ -177,7 +166,7 @@ describe('useNodeEventHandlers', () => {
metaKey: true metaKey: true
}) })
handleNodeSelect(metaClickEvent, testNodeData) handleNodeSelect(metaClickEvent, testNodeId)
// On pointer down with meta key: bring to front // On pointer down with meta key: bring to front
expect(mockLayoutMutations.bringNodeToFront).toHaveBeenCalledWith( expect(mockLayoutMutations.bringNodeToFront).toHaveBeenCalledWith(
@@ -202,7 +191,7 @@ describe('useNodeEventHandlers', () => {
shiftKey: true shiftKey: true
}) })
handleNodeSelect(shiftClickEvent, testNodeData) handleNodeSelect(shiftClickEvent, testNodeId)
// On pointer down with shift: bring to front // On pointer down with shift: bring to front
expect(mockLayoutMutations.bringNodeToFront).toHaveBeenCalledWith( expect(mockLayoutMutations.bringNodeToFront).toHaveBeenCalledWith(
@@ -228,7 +217,7 @@ describe('useNodeEventHandlers', () => {
metaKey: false metaKey: false
}) })
handleNodeSelect(event, testNodeData) handleNodeSelect(event, testNodeId)
expect(canvas?.deselectAll).not.toHaveBeenCalled() expect(canvas?.deselectAll).not.toHaveBeenCalled()
expect(canvas?.select).not.toHaveBeenCalled() expect(canvas?.select).not.toHaveBeenCalled()
@@ -240,7 +229,7 @@ describe('useNodeEventHandlers', () => {
mockNode!.flags.pinned = false mockNode!.flags.pinned = false
const event = new PointerEvent('pointerdown') const event = new PointerEvent('pointerdown')
handleNodeSelect(event, testNodeData) handleNodeSelect(event, testNodeId)
expect(mockLayoutMutations.bringNodeToFront).toHaveBeenCalledWith( expect(mockLayoutMutations.bringNodeToFront).toHaveBeenCalledWith(
'node-1' 'node-1'
@@ -253,7 +242,7 @@ describe('useNodeEventHandlers', () => {
mockNode!.flags.pinned = true mockNode!.flags.pinned = true
const event = new PointerEvent('pointerdown') const event = new PointerEvent('pointerdown')
handleNodeSelect(event, testNodeData) handleNodeSelect(event, testNodeId)
expect(mockLayoutMutations.bringNodeToFront).not.toHaveBeenCalled() expect(mockLayoutMutations.bringNodeToFront).not.toHaveBeenCalled()
}) })
@@ -266,10 +255,7 @@ describe('useNodeEventHandlers', () => {
mockNode!.selected = true mockNode!.selected = true
toggleNodeSelectionAfterPointerUp('node-1', { toggleNodeSelectionAfterPointerUp('node-1', true)
wasSelectedAtPointerDown: true,
multiSelect: true
})
expect(canvas?.deselect).toHaveBeenCalledWith(mockNode) expect(canvas?.deselect).toHaveBeenCalledWith(mockNode)
expect(updateSelectedItems).toHaveBeenCalledOnce() expect(updateSelectedItems).toHaveBeenCalledOnce()
@@ -281,13 +267,10 @@ describe('useNodeEventHandlers', () => {
mockNode!.selected = true mockNode!.selected = true
toggleNodeSelectionAfterPointerUp('node-1', { toggleNodeSelectionAfterPointerUp('node-1', true)
wasSelectedAtPointerDown: false,
multiSelect: true
})
expect(canvas?.select).not.toHaveBeenCalled() expect(canvas?.select).not.toHaveBeenCalled()
expect(updateSelectedItems).not.toHaveBeenCalled() expect(updateSelectedItems).toHaveBeenCalled()
}) })
it('on pointer up without multi-select: collapses multi-selection to clicked node', () => { it('on pointer up without multi-select: collapses multi-selection to clicked node', () => {
@@ -297,10 +280,7 @@ describe('useNodeEventHandlers', () => {
mockNode!.selected = true mockNode!.selected = true
canvasSelectedItems.push({ id: 'node-1' }, { id: 'node-2' }) canvasSelectedItems.push({ id: 'node-1' }, { id: 'node-2' })
toggleNodeSelectionAfterPointerUp('node-1', { toggleNodeSelectionAfterPointerUp('node-1', false)
wasSelectedAtPointerDown: true,
multiSelect: false
})
expect(canvas?.deselectAll).toHaveBeenCalledOnce() expect(canvas?.deselectAll).toHaveBeenCalledOnce()
expect(canvas?.select).toHaveBeenCalledWith(mockNode) expect(canvas?.select).toHaveBeenCalledWith(mockNode)
@@ -314,88 +294,10 @@ describe('useNodeEventHandlers', () => {
mockNode!.selected = true mockNode!.selected = true
canvasSelectedItems.push({ id: 'node-1' }) canvasSelectedItems.push({ id: 'node-1' })
toggleNodeSelectionAfterPointerUp('node-1', { toggleNodeSelectionAfterPointerUp('node-1', false)
wasSelectedAtPointerDown: true,
multiSelect: false
})
expect(canvas?.deselectAll).not.toHaveBeenCalled()
expect(canvas?.select).not.toHaveBeenCalled()
expect(updateSelectedItems).not.toHaveBeenCalled()
})
})
describe('ensureNodeSelectedForShiftDrag', () => {
it('does nothing when multi-select key is not pressed', () => {
const { ensureNodeSelectedForShiftDrag } = useNodeEventHandlers()
const { canvas } = useCanvasStore()
const event = new PointerEvent('pointermove', { shiftKey: false })
ensureNodeSelectedForShiftDrag(event, testNodeData, false)
expect(canvas?.select).not.toHaveBeenCalled()
expect(canvas?.deselectAll).not.toHaveBeenCalled()
})
it('selects node and clears existing selection when shift-dragging with no other selections', () => {
const { ensureNodeSelectedForShiftDrag } = useNodeEventHandlers()
const { canvas } = useCanvasStore()
mockNode!.selected = false
const event = new PointerEvent('pointermove', { shiftKey: true })
ensureNodeSelectedForShiftDrag(event, testNodeData, false)
expect(canvas?.deselectAll).toHaveBeenCalledOnce()
expect(canvas?.select).toHaveBeenCalledWith(mockNode) expect(canvas?.select).toHaveBeenCalledWith(mockNode)
}) expect(updateSelectedItems).toHaveBeenCalled()
it('adds node to existing multi-selection without clearing other nodes', () => {
const { ensureNodeSelectedForShiftDrag } = useNodeEventHandlers()
const { canvas, selectedItems } = useCanvasStore()
// Create mock Positionable objects for existing selection
const mockExisting1 = {
id: 'existing-1',
pos: [0, 0] as [number, number],
move: vi.fn(),
snapToGrid: vi.fn(),
boundingRect: vi.fn(() => [0, 0, 100, 100] as const)
} as unknown as LGraphNode
const mockExisting2 = {
id: 'existing-2',
pos: [0, 0] as [number, number],
move: vi.fn(),
snapToGrid: vi.fn(),
boundingRect: vi.fn(() => [0, 0, 100, 100] as const)
} as unknown as LGraphNode
selectedItems.push(mockExisting1, mockExisting2)
mockNode!.selected = false
if (canvas?.select) vi.mocked(canvas.select).mockClear()
if (canvas?.deselectAll) vi.mocked(canvas.deselectAll).mockClear()
const event = new PointerEvent('pointermove', { shiftKey: true })
ensureNodeSelectedForShiftDrag(event, testNodeData, false)
expect(canvas?.deselectAll).not.toHaveBeenCalled()
expect(canvas?.select).toHaveBeenCalledWith(mockNode)
})
it('does nothing if node is already selected (selection happened on pointer down)', () => {
const { ensureNodeSelectedForShiftDrag } = useNodeEventHandlers()
const { canvas } = useCanvasStore()
mockNode!.selected = true
const event = new PointerEvent('pointermove', { shiftKey: true })
ensureNodeSelectedForShiftDrag(event, testNodeData, false)
expect(canvas?.select).not.toHaveBeenCalled()
expect(canvas?.deselectAll).not.toHaveBeenCalled()
}) })
}) })
}) })

View File

@@ -610,8 +610,7 @@ describe('useRemoteWidget', () => {
false, false,
expect.any(Function), expect.any(Function),
{ {
serialize: false, serialize: false
canvasOnly: true
} }
) )
}) })