mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-18 05:31:03 +00:00
Compare commits
20 Commits
test/queue
...
v1.32.8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d64c18b06c | ||
|
|
c26438bd0c | ||
|
|
d4d6ed0bb5 | ||
|
|
d7f11dd852 | ||
|
|
7a212522fe | ||
|
|
d3044fe765 | ||
|
|
b66a181879 | ||
|
|
0507d333fe | ||
|
|
55f842f4cb | ||
|
|
0d42b62d4f | ||
|
|
8c2fe715bd | ||
|
|
e94a74f167 | ||
|
|
662f79edf4 | ||
|
|
07e4004c2d | ||
|
|
15794a83e3 | ||
|
|
689634e4d3 | ||
|
|
fe1daa2c29 | ||
|
|
6600a8a13b | ||
|
|
5e5bf8248f | ||
|
|
7b6fdce0f2 |
@@ -91,7 +91,7 @@
|
|||||||
"build-storybook": "storybook build -o dist/storybook"
|
"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:",
|
||||||
|
|||||||
@@ -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}`)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
159
apps/desktop-ui/src/views/MaintenanceView.stories.ts
Normal file
159
apps/desktop-ui/src/views/MaintenanceView.stories.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
// eslint-disable-next-line storybook/no-renderer-packages
|
||||||
|
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||||
|
import { defineAsyncComponent } from 'vue'
|
||||||
|
|
||||||
|
type UnsafeReason = 'appInstallDir' | 'updaterCache' | 'oneDrive' | null
|
||||||
|
type ValidationIssueState = 'OK' | 'warning' | 'error' | 'skipped'
|
||||||
|
|
||||||
|
type ValidationState = {
|
||||||
|
inProgress: boolean
|
||||||
|
installState: string
|
||||||
|
basePath?: ValidationIssueState
|
||||||
|
unsafeBasePath: boolean
|
||||||
|
unsafeBasePathReason: UnsafeReason
|
||||||
|
venvDirectory?: ValidationIssueState
|
||||||
|
pythonInterpreter?: ValidationIssueState
|
||||||
|
pythonPackages?: ValidationIssueState
|
||||||
|
uv?: ValidationIssueState
|
||||||
|
git?: ValidationIssueState
|
||||||
|
vcRedist?: ValidationIssueState
|
||||||
|
upgradePackages?: ValidationIssueState
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationState: ValidationState = {
|
||||||
|
inProgress: false,
|
||||||
|
installState: 'installed',
|
||||||
|
basePath: 'OK',
|
||||||
|
unsafeBasePath: false,
|
||||||
|
unsafeBasePathReason: null,
|
||||||
|
venvDirectory: 'OK',
|
||||||
|
pythonInterpreter: 'OK',
|
||||||
|
pythonPackages: 'OK',
|
||||||
|
uv: 'OK',
|
||||||
|
git: 'OK',
|
||||||
|
vcRedist: 'OK',
|
||||||
|
upgradePackages: 'OK'
|
||||||
|
}
|
||||||
|
|
||||||
|
const createMockElectronAPI = () => {
|
||||||
|
const logListeners: Array<(message: string) => void> = []
|
||||||
|
|
||||||
|
const getValidationUpdate = () => ({
|
||||||
|
...validationState
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
getPlatform: () => 'darwin',
|
||||||
|
changeTheme: (_theme: unknown) => {},
|
||||||
|
onLogMessage: (listener: (message: string) => void) => {
|
||||||
|
logListeners.push(listener)
|
||||||
|
},
|
||||||
|
showContextMenu: (_options: unknown) => {},
|
||||||
|
Events: {
|
||||||
|
trackEvent: (_eventName: string, _data?: unknown) => {}
|
||||||
|
},
|
||||||
|
Validation: {
|
||||||
|
onUpdate: (_callback: (update: unknown) => void) => {},
|
||||||
|
async getStatus() {
|
||||||
|
return getValidationUpdate()
|
||||||
|
},
|
||||||
|
async validateInstallation(callback: (update: unknown) => void) {
|
||||||
|
callback(getValidationUpdate())
|
||||||
|
},
|
||||||
|
async complete() {
|
||||||
|
// Only allow completion when the base path is safe
|
||||||
|
return !validationState.unsafeBasePath
|
||||||
|
},
|
||||||
|
dispose: () => {}
|
||||||
|
},
|
||||||
|
setBasePath: () => Promise.resolve(true),
|
||||||
|
reinstall: () => Promise.resolve(),
|
||||||
|
uv: {
|
||||||
|
installRequirements: () => Promise.resolve(),
|
||||||
|
clearCache: () => Promise.resolve(),
|
||||||
|
resetVenv: () => Promise.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureElectronAPI = () => {
|
||||||
|
const globalWindow = window as unknown as { electronAPI?: unknown }
|
||||||
|
if (!globalWindow.electronAPI) {
|
||||||
|
globalWindow.electronAPI = createMockElectronAPI()
|
||||||
|
}
|
||||||
|
|
||||||
|
return globalWindow.electronAPI
|
||||||
|
}
|
||||||
|
|
||||||
|
const MaintenanceView = defineAsyncComponent(async () => {
|
||||||
|
ensureElectronAPI()
|
||||||
|
const module = await import('./MaintenanceView.vue')
|
||||||
|
return module.default
|
||||||
|
})
|
||||||
|
|
||||||
|
const meta: Meta<typeof MaintenanceView> = {
|
||||||
|
title: 'Desktop/Views/MaintenanceView',
|
||||||
|
component: MaintenanceView,
|
||||||
|
parameters: {
|
||||||
|
layout: 'fullscreen',
|
||||||
|
backgrounds: {
|
||||||
|
default: 'dark',
|
||||||
|
values: [
|
||||||
|
{ name: 'dark', value: '#0a0a0a' },
|
||||||
|
{ name: 'neutral-900', value: '#171717' },
|
||||||
|
{ name: 'neutral-950', value: '#0a0a0a' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
name: 'All tasks OK',
|
||||||
|
render: () => ({
|
||||||
|
components: { MaintenanceView },
|
||||||
|
setup() {
|
||||||
|
validationState.inProgress = false
|
||||||
|
validationState.installState = 'installed'
|
||||||
|
validationState.basePath = 'OK'
|
||||||
|
validationState.unsafeBasePath = false
|
||||||
|
validationState.unsafeBasePathReason = null
|
||||||
|
validationState.venvDirectory = 'OK'
|
||||||
|
validationState.pythonInterpreter = 'OK'
|
||||||
|
validationState.pythonPackages = 'OK'
|
||||||
|
validationState.uv = 'OK'
|
||||||
|
validationState.git = 'OK'
|
||||||
|
validationState.vcRedist = 'OK'
|
||||||
|
validationState.upgradePackages = 'OK'
|
||||||
|
ensureElectronAPI()
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
template: '<MaintenanceView />'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UnsafeBasePathOneDrive: Story = {
|
||||||
|
name: 'Unsafe base path (OneDrive)',
|
||||||
|
render: () => ({
|
||||||
|
components: { MaintenanceView },
|
||||||
|
setup() {
|
||||||
|
validationState.inProgress = false
|
||||||
|
validationState.installState = 'installed'
|
||||||
|
validationState.basePath = 'error'
|
||||||
|
validationState.unsafeBasePath = true
|
||||||
|
validationState.unsafeBasePathReason = 'oneDrive'
|
||||||
|
validationState.venvDirectory = 'OK'
|
||||||
|
validationState.pythonInterpreter = 'OK'
|
||||||
|
validationState.pythonPackages = 'OK'
|
||||||
|
validationState.uv = 'OK'
|
||||||
|
validationState.git = 'OK'
|
||||||
|
validationState.vcRedist = 'OK'
|
||||||
|
validationState.upgradePackages = 'OK'
|
||||||
|
ensureElectronAPI()
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
template: '<MaintenanceView />'
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -47,6 +47,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</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()
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 |
@@ -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()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
55
pnpm-lock.yaml
generated
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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) -->
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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<{
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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<{
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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[] }>()
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
48
src/composables/maskeditor/useMaskEditor.ts
Normal file
48
src/composables/maskeditor/useMaskEditor.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||||
|
import { useDialogStore } from '@/stores/dialogStore'
|
||||||
|
import TopBarHeader from '@/components/maskeditor/dialog/TopBarHeader.vue'
|
||||||
|
import MaskEditorContent from '@/components/maskeditor/MaskEditorContent.vue'
|
||||||
|
|
||||||
|
export function useMaskEditor() {
|
||||||
|
const openMaskEditor = (node: LGraphNode) => {
|
||||||
|
if (!node) {
|
||||||
|
console.error('[MaskEditor] No node provided')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!node.imgs?.length && node.previewMediaType !== 'image') {
|
||||||
|
console.error('[MaskEditor] Node has no images')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
useDialogStore().showDialog({
|
||||||
|
key: 'global-mask-editor',
|
||||||
|
headerComponent: TopBarHeader,
|
||||||
|
component: MaskEditorContent,
|
||||||
|
props: {
|
||||||
|
node
|
||||||
|
},
|
||||||
|
dialogComponentProps: {
|
||||||
|
style: 'width: 90vw; height: 90vh;',
|
||||||
|
modal: true,
|
||||||
|
maximizable: true,
|
||||||
|
closable: true,
|
||||||
|
pt: {
|
||||||
|
root: {
|
||||||
|
class: 'mask-editor-dialog flex flex-col'
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
class: 'flex flex-col min-h-0 flex-1 !p-0'
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
class: '!p-2'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
openMaskEditor
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1545,7 +1545,26 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
GeminiImageNode: {
|
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
|
||||||
|
|||||||
42
src/composables/settings/useRenderModeSetting.ts
Normal file
42
src/composables/settings/useRenderModeSetting.ts
Normal 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 })
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
58
src/platform/assets/components/UploadModelConfirmation.vue
Normal file
58
src/platform/assets/components/UploadModelConfirmation.vue
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<!-- Model Info Section -->
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<p class="text-sm text-muted m-0">
|
||||||
|
{{ $t('assetBrowser.modelAssociatedWithLink') }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm mt-0">
|
||||||
|
{{ metadata?.name || metadata?.filename }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Model Type Selection -->
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-sm text-muted">
|
||||||
|
{{ $t('assetBrowser.modelTypeSelectorLabel') }}
|
||||||
|
</label>
|
||||||
|
<SingleSelect
|
||||||
|
v-model="selectedModelType"
|
||||||
|
:label="
|
||||||
|
isLoading
|
||||||
|
? $t('g.loading')
|
||||||
|
: $t('assetBrowser.modelTypeSelectorPlaceholder')
|
||||||
|
"
|
||||||
|
:options="modelTypes"
|
||||||
|
:disabled="isLoading"
|
||||||
|
/>
|
||||||
|
<div class="flex items-center gap-2 text-sm text-muted">
|
||||||
|
<i class="icon-[lucide--info]" />
|
||||||
|
<span>{{ $t('assetBrowser.notSureLeaveAsIs') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||||
|
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
|
||||||
|
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string | undefined
|
||||||
|
metadata: AssetMetadata | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string | undefined]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { modelTypes, isLoading } = useModelTypes()
|
||||||
|
|
||||||
|
const selectedModelType = computed({
|
||||||
|
get: () => props.modelValue ?? null,
|
||||||
|
set: (value: string | null) => emit('update:modelValue', value ?? undefined)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
108
src/platform/assets/components/UploadModelDialog.vue
Normal file
108
src/platform/assets/components/UploadModelDialog.vue
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<template>
|
||||||
|
<div class="upload-model-dialog flex flex-col justify-between gap-6 p-4 pt-6">
|
||||||
|
<!-- Step 1: Enter URL -->
|
||||||
|
<UploadModelUrlInput
|
||||||
|
v-if="currentStep === 1"
|
||||||
|
v-model="wizardData.url"
|
||||||
|
:error="uploadError"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Step 2: Confirm Metadata -->
|
||||||
|
<UploadModelConfirmation
|
||||||
|
v-else-if="currentStep === 2"
|
||||||
|
v-model="selectedModelType"
|
||||||
|
:metadata="wizardData.metadata"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Step 3: Upload Progress -->
|
||||||
|
<UploadModelProgress
|
||||||
|
v-else-if="currentStep === 3"
|
||||||
|
:status="uploadStatus"
|
||||||
|
:error="uploadError"
|
||||||
|
:metadata="wizardData.metadata"
|
||||||
|
:model-type="selectedModelType"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Navigation Footer -->
|
||||||
|
<UploadModelFooter
|
||||||
|
:current-step="currentStep"
|
||||||
|
:is-fetching-metadata="isFetchingMetadata"
|
||||||
|
:is-uploading="isUploading"
|
||||||
|
:can-fetch-metadata="canFetchMetadata"
|
||||||
|
:can-upload-model="canUploadModel"
|
||||||
|
:upload-status="uploadStatus"
|
||||||
|
@back="goToPreviousStep"
|
||||||
|
@fetch-metadata="handleFetchMetadata"
|
||||||
|
@upload="handleUploadModel"
|
||||||
|
@close="handleClose"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
|
||||||
|
import UploadModelConfirmation from '@/platform/assets/components/UploadModelConfirmation.vue'
|
||||||
|
import UploadModelFooter from '@/platform/assets/components/UploadModelFooter.vue'
|
||||||
|
import UploadModelProgress from '@/platform/assets/components/UploadModelProgress.vue'
|
||||||
|
import UploadModelUrlInput from '@/platform/assets/components/UploadModelUrlInput.vue'
|
||||||
|
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
|
||||||
|
import { useUploadModelWizard } from '@/platform/assets/composables/useUploadModelWizard'
|
||||||
|
import { useDialogStore } from '@/stores/dialogStore'
|
||||||
|
|
||||||
|
const dialogStore = useDialogStore()
|
||||||
|
const { modelTypes, fetchModelTypes } = useModelTypes()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'upload-success': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const {
|
||||||
|
currentStep,
|
||||||
|
isFetchingMetadata,
|
||||||
|
isUploading,
|
||||||
|
uploadStatus,
|
||||||
|
uploadError,
|
||||||
|
wizardData,
|
||||||
|
selectedModelType,
|
||||||
|
canFetchMetadata,
|
||||||
|
canUploadModel,
|
||||||
|
fetchMetadata,
|
||||||
|
uploadModel,
|
||||||
|
goToPreviousStep
|
||||||
|
} = useUploadModelWizard(modelTypes)
|
||||||
|
|
||||||
|
async function handleFetchMetadata() {
|
||||||
|
await fetchMetadata()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUploadModel() {
|
||||||
|
const success = await uploadModel()
|
||||||
|
if (success) {
|
||||||
|
emit('upload-success')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
dialogStore.closeDialog({ key: 'upload-model' })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchModelTypes()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.upload-model-dialog {
|
||||||
|
width: 90vw;
|
||||||
|
max-width: 800px;
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.upload-model-dialog {
|
||||||
|
width: auto;
|
||||||
|
min-width: 600px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
12
src/platform/assets/components/UploadModelDialogHeader.vue
Normal file
12
src/platform/assets/components/UploadModelDialogHeader.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex items-center gap-3 px-4 py-2 font-bold">
|
||||||
|
<span>{{ $t('assetBrowser.uploadModelFromCivitai') }}</span>
|
||||||
|
<span
|
||||||
|
class="rounded-full bg-white px-1.5 py-0 text-xxs font-medium uppercase text-black"
|
||||||
|
>
|
||||||
|
{{ $t('g.beta') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts"></script>
|
||||||
72
src/platform/assets/components/UploadModelFooter.vue
Normal file
72
src/platform/assets/components/UploadModelFooter.vue
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<TextButton
|
||||||
|
v-if="currentStep !== 1 && currentStep !== 3"
|
||||||
|
:label="$t('g.back')"
|
||||||
|
type="secondary"
|
||||||
|
size="md"
|
||||||
|
:disabled="isFetchingMetadata || isUploading"
|
||||||
|
@click="emit('back')"
|
||||||
|
/>
|
||||||
|
<span v-else />
|
||||||
|
|
||||||
|
<IconTextButton
|
||||||
|
v-if="currentStep === 1"
|
||||||
|
:label="$t('g.continue')"
|
||||||
|
type="primary"
|
||||||
|
size="md"
|
||||||
|
:disabled="!canFetchMetadata || isFetchingMetadata"
|
||||||
|
@click="emit('fetchMetadata')"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<i
|
||||||
|
v-if="isFetchingMetadata"
|
||||||
|
class="icon-[lucide--loader-circle] animate-spin"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</IconTextButton>
|
||||||
|
<IconTextButton
|
||||||
|
v-else-if="currentStep === 2"
|
||||||
|
:label="$t('assetBrowser.upload')"
|
||||||
|
type="primary"
|
||||||
|
size="md"
|
||||||
|
:disabled="!canUploadModel || isUploading"
|
||||||
|
@click="emit('upload')"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<i
|
||||||
|
v-if="isUploading"
|
||||||
|
class="icon-[lucide--loader-circle] animate-spin"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</IconTextButton>
|
||||||
|
<TextButton
|
||||||
|
v-else-if="currentStep === 3 && uploadStatus === 'success'"
|
||||||
|
:label="$t('assetBrowser.finish')"
|
||||||
|
type="primary"
|
||||||
|
size="md"
|
||||||
|
@click="emit('close')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||||
|
import TextButton from '@/components/button/TextButton.vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
currentStep: number
|
||||||
|
isFetchingMetadata: boolean
|
||||||
|
isUploading: boolean
|
||||||
|
canFetchMetadata: boolean
|
||||||
|
canUploadModel: boolean
|
||||||
|
uploadStatus: 'idle' | 'uploading' | 'success' | 'error'
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'back'): void
|
||||||
|
(e: 'fetchMetadata'): void
|
||||||
|
(e: 'upload'): void
|
||||||
|
(e: 'close'): void
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
68
src/platform/assets/components/UploadModelProgress.vue
Normal file
68
src/platform/assets/components/UploadModelProgress.vue
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-1 flex-col gap-6">
|
||||||
|
<!-- Uploading State -->
|
||||||
|
<div
|
||||||
|
v-if="status === 'uploading'"
|
||||||
|
class="flex flex-1 flex-col items-center justify-center gap-6"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
class="icon-[lucide--loader-circle] animate-spin text-6xl text-primary"
|
||||||
|
/>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="m-0 text-sm font-bold">
|
||||||
|
{{ $t('assetBrowser.uploadingModel') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Success State -->
|
||||||
|
<div v-else-if="status === 'success'" class="flex flex-col gap-8">
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<p class="text-sm text-muted m-0 font-bold">
|
||||||
|
{{ $t('assetBrowser.modelUploaded') }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-muted m-0">
|
||||||
|
{{ $t('assetBrowser.findInLibrary', { type: modelType }) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-row items-start p-8 bg-neutral-800 rounded-lg">
|
||||||
|
<div class="flex flex-col justify-center items-start gap-1 flex-1">
|
||||||
|
<p class="text-sm m-0">
|
||||||
|
{{ metadata?.name || metadata?.filename }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-muted m-0">
|
||||||
|
{{ modelType }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error State -->
|
||||||
|
<div
|
||||||
|
v-else-if="status === 'error'"
|
||||||
|
class="flex flex-1 flex-col items-center justify-center gap-6"
|
||||||
|
>
|
||||||
|
<i class="icon-[lucide--x-circle] text-6xl text-error" />
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="m-0 text-sm font-bold">
|
||||||
|
{{ $t('assetBrowser.uploadFailed') }}
|
||||||
|
</p>
|
||||||
|
<p v-if="error" class="text-sm text-muted mb-0">
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
status: 'idle' | 'uploading' | 'success' | 'error'
|
||||||
|
error?: string
|
||||||
|
metadata: AssetMetadata | null
|
||||||
|
modelType: string | undefined
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
50
src/platform/assets/components/UploadModelUrlInput.vue
Normal file
50
src/platform/assets/components/UploadModelUrlInput.vue
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<p class="text-sm text-muted m-0">
|
||||||
|
{{ $t('assetBrowser.uploadModelDescription1') }}
|
||||||
|
</p>
|
||||||
|
<ul class="list-disc space-y-1 pl-5 mt-0 text-sm text-muted">
|
||||||
|
<li>{{ $t('assetBrowser.uploadModelDescription2') }}</li>
|
||||||
|
<li>{{ $t('assetBrowser.uploadModelDescription3') }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-sm text-muted mb-0">
|
||||||
|
{{ $t('assetBrowser.civitaiLinkLabel') }}
|
||||||
|
</label>
|
||||||
|
<UrlInput
|
||||||
|
v-model="url"
|
||||||
|
:placeholder="$t('assetBrowser.civitaiLinkPlaceholder')"
|
||||||
|
:disable-validation="true"
|
||||||
|
/>
|
||||||
|
<p v-if="error" class="text-xs text-error">
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
|
<p v-else class="text-xs text-muted">
|
||||||
|
{{ $t('assetBrowser.civitaiLinkExample') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import UrlInput from '@/components/common/UrlInput.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string
|
||||||
|
error?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const url = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value: string) => emit('update:modelValue', value)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
73
src/platform/assets/composables/useModelTypes.ts
Normal file
73
src/platform/assets/composables/useModelTypes.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { createSharedComposable, useAsyncState } from '@vueuse/core'
|
||||||
|
|
||||||
|
import { api } from '@/scripts/api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format folder name to display name
|
||||||
|
* Converts "upscale_models" -> "Upscale Models"
|
||||||
|
* Converts "loras" -> "LoRAs"
|
||||||
|
*/
|
||||||
|
function formatDisplayName(folderName: string): string {
|
||||||
|
// Special cases for acronyms and proper nouns
|
||||||
|
const specialCases: Record<string, string> = {
|
||||||
|
loras: 'LoRAs',
|
||||||
|
ipadapter: 'IP-Adapter',
|
||||||
|
sams: 'SAMs',
|
||||||
|
clip_vision: 'CLIP Vision',
|
||||||
|
animatediff_motion_lora: 'AnimateDiff Motion LoRA',
|
||||||
|
animatediff_models: 'AnimateDiff Models',
|
||||||
|
vae: 'VAE',
|
||||||
|
sam2: 'SAM 2',
|
||||||
|
controlnet: 'ControlNet',
|
||||||
|
gligen: 'GLIGEN'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (specialCases[folderName]) {
|
||||||
|
return specialCases[folderName]
|
||||||
|
}
|
||||||
|
|
||||||
|
return folderName
|
||||||
|
.split('_')
|
||||||
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
|
.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModelTypeOption {
|
||||||
|
name: string // Display name
|
||||||
|
value: string // Actual tag value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable for fetching and managing model types from the API
|
||||||
|
* Uses shared state to ensure data is only fetched once
|
||||||
|
*/
|
||||||
|
export const useModelTypes = createSharedComposable(() => {
|
||||||
|
const {
|
||||||
|
state: modelTypes,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
execute: fetchModelTypes
|
||||||
|
} = useAsyncState(
|
||||||
|
async (): Promise<ModelTypeOption[]> => {
|
||||||
|
const response = await api.getModelFolders()
|
||||||
|
return response.map((folder) => ({
|
||||||
|
name: formatDisplayName(folder.name),
|
||||||
|
value: folder.name
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
[] as ModelTypeOption[],
|
||||||
|
{
|
||||||
|
immediate: false,
|
||||||
|
onError: (err) => {
|
||||||
|
console.error('Failed to fetch model types:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
modelTypes,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
fetchModelTypes
|
||||||
|
}
|
||||||
|
})
|
||||||
175
src/platform/assets/composables/useUploadModelWizard.ts
Normal file
175
src/platform/assets/composables/useUploadModelWizard.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import type { Ref } from 'vue'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import { st } from '@/i18n'
|
||||||
|
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
|
||||||
|
import { assetService } from '@/platform/assets/services/assetService'
|
||||||
|
|
||||||
|
interface WizardData {
|
||||||
|
url: string
|
||||||
|
metadata: AssetMetadata | null
|
||||||
|
name: string
|
||||||
|
tags: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModelTypeOption {
|
||||||
|
name: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
|
||||||
|
const currentStep = ref(1)
|
||||||
|
const isFetchingMetadata = ref(false)
|
||||||
|
const isUploading = ref(false)
|
||||||
|
const uploadStatus = ref<'idle' | 'uploading' | 'success' | 'error'>('idle')
|
||||||
|
const uploadError = ref('')
|
||||||
|
|
||||||
|
const wizardData = ref<WizardData>({
|
||||||
|
url: '',
|
||||||
|
metadata: null,
|
||||||
|
name: '',
|
||||||
|
tags: []
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedModelType = ref<string | undefined>(undefined)
|
||||||
|
|
||||||
|
// Clear error when URL changes
|
||||||
|
watch(
|
||||||
|
() => wizardData.value.url,
|
||||||
|
() => {
|
||||||
|
uploadError.value = ''
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
const canFetchMetadata = computed(() => {
|
||||||
|
return wizardData.value.url.trim().length > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const canUploadModel = computed(() => {
|
||||||
|
return !!selectedModelType.value
|
||||||
|
})
|
||||||
|
|
||||||
|
function isCivitaiUrl(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const hostname = new URL(url).hostname.toLowerCase()
|
||||||
|
return hostname === 'civitai.com' || hostname.endsWith('.civitai.com')
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchMetadata() {
|
||||||
|
if (!canFetchMetadata.value) return
|
||||||
|
|
||||||
|
if (!isCivitaiUrl(wizardData.value.url)) {
|
||||||
|
uploadError.value = st(
|
||||||
|
'assetBrowser.onlyCivitaiUrlsSupported',
|
||||||
|
'Only Civitai URLs are supported'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isFetchingMetadata.value = true
|
||||||
|
try {
|
||||||
|
const metadata = await assetService.getAssetMetadata(wizardData.value.url)
|
||||||
|
wizardData.value.metadata = metadata
|
||||||
|
|
||||||
|
// Pre-fill name from metadata
|
||||||
|
wizardData.value.name = metadata.filename || metadata.name || ''
|
||||||
|
|
||||||
|
// Pre-fill model type from metadata tags if available
|
||||||
|
if (metadata.tags && metadata.tags.length > 0) {
|
||||||
|
wizardData.value.tags = metadata.tags
|
||||||
|
// Try to detect model type from tags
|
||||||
|
const typeTag = metadata.tags.find((tag) =>
|
||||||
|
modelTypes.value.some((type) => type.value === tag)
|
||||||
|
)
|
||||||
|
if (typeTag) {
|
||||||
|
selectedModelType.value = typeTag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentStep.value = 2
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to retrieve metadata:', error)
|
||||||
|
uploadError.value =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: st(
|
||||||
|
'assetBrowser.uploadModelFailedToRetrieveMetadata',
|
||||||
|
'Failed to retrieve metadata. Please check the link and try again.'
|
||||||
|
)
|
||||||
|
currentStep.value = 1
|
||||||
|
} finally {
|
||||||
|
isFetchingMetadata.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadModel() {
|
||||||
|
if (!canUploadModel.value) return
|
||||||
|
|
||||||
|
isUploading.value = true
|
||||||
|
uploadStatus.value = 'uploading'
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tags = selectedModelType.value
|
||||||
|
? ['models', selectedModelType.value]
|
||||||
|
: ['models']
|
||||||
|
const filename =
|
||||||
|
wizardData.value.metadata?.filename ||
|
||||||
|
wizardData.value.metadata?.name ||
|
||||||
|
'model'
|
||||||
|
|
||||||
|
await assetService.uploadAssetFromUrl({
|
||||||
|
url: wizardData.value.url,
|
||||||
|
name: filename,
|
||||||
|
tags,
|
||||||
|
user_metadata: {
|
||||||
|
source: 'civitai',
|
||||||
|
source_url: wizardData.value.url,
|
||||||
|
model_type: selectedModelType.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
uploadStatus.value = 'success'
|
||||||
|
currentStep.value = 3
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to upload asset:', error)
|
||||||
|
uploadStatus.value = 'error'
|
||||||
|
uploadError.value =
|
||||||
|
error instanceof Error ? error.message : 'Failed to upload model'
|
||||||
|
currentStep.value = 3
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
isUploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToPreviousStep() {
|
||||||
|
if (currentStep.value > 1) {
|
||||||
|
currentStep.value = currentStep.value - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
currentStep,
|
||||||
|
isFetchingMetadata,
|
||||||
|
isUploading,
|
||||||
|
uploadStatus,
|
||||||
|
uploadError,
|
||||||
|
wizardData,
|
||||||
|
selectedModelType,
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
canFetchMetadata,
|
||||||
|
canUploadModel,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
fetchMetadata,
|
||||||
|
uploadModel,
|
||||||
|
goToPreviousStep
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,29 @@ const zModelFile = z.object({
|
|||||||
pathIndex: z.number()
|
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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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')
|
|
||||||
@@ -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: []
|
||||||
}>()
|
}>()
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
215
src/renderer/extensions/vueNodes/layout/useNodeDrag.ts
Normal file
215
src/renderer/extensions/vueNodes/layout/useNodeDrag.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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 }],
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -610,8 +610,7 @@ describe('useRemoteWidget', () => {
|
|||||||
false,
|
false,
|
||||||
expect.any(Function),
|
expect.any(Function),
|
||||||
{
|
{
|
||||||
serialize: false,
|
serialize: false
|
||||||
canvasOnly: true
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user