mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-02 11:40:00 +00:00
merge main into rh-test
This commit is contained in:
@@ -103,14 +103,22 @@ Utility composables for common patterns:
|
||||
| `useChainCallback` | Chains multiple callbacks together |
|
||||
|
||||
### Manager
|
||||
Composables for ComfyUI Manager integration:
|
||||
Composables for ComfyUI Manager integration (located in
|
||||
`@/workbench/extensions/manager/composables`):
|
||||
|
||||
| Composable | Description |
|
||||
|------------|-------------|
|
||||
| `useManagerStatePersistence` | Persists manager UI state |
|
||||
| `useManagerState` | Determines availability of manager UI modes |
|
||||
| `useManagerQueue` | Handles manager task queue state |
|
||||
| `useConflictAcknowledgment` | Tracks conflict dismissal state |
|
||||
| `useConflictDetection` | Orchestrates conflict detection workflow |
|
||||
| `useImportFailedDetection` | Handles import-failed conflict dialogs |
|
||||
| `useRegistrySearch` | Manages registry search UI state |
|
||||
|
||||
### Node Pack
|
||||
Composables for node package management:
|
||||
#### Node Pack
|
||||
Node package helpers live under
|
||||
`@/workbench/extensions/manager/composables/nodePack`:
|
||||
|
||||
| Composable | Description |
|
||||
|------------|-------------|
|
||||
@@ -118,6 +126,9 @@ Composables for node package management:
|
||||
| `useMissingNodes` | Detects and handles missing nodes |
|
||||
| `useNodePacks` | Core node package functionality |
|
||||
| `usePackUpdateStatus` | Tracks package update availability |
|
||||
| `usePacksSelection` | Provides selection helpers for pack lists |
|
||||
| `usePacksStatus` | Aggregates status across multiple packs |
|
||||
| `useUpdateAvailableNodes` | Detects available updates for nodes |
|
||||
| `useWorkflowPacks` | Manages packages used in workflows |
|
||||
|
||||
### Node
|
||||
@@ -408,4 +419,4 @@ export function useFetchData(url) {
|
||||
}
|
||||
```
|
||||
|
||||
For more information on Vue composables, refer to the [Vue.js Composition API documentation](https://vuejs.org/guide/reusability/composables.html) and the [VueUse documentation](https://vueuse.org/).
|
||||
For more information on Vue composables, refer to the [Vue.js Composition API documentation](https://vuejs.org/guide/reusability/composables.html) and the [VueUse documentation](https://vueuse.org/).
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { t } from '@/i18n'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
@@ -10,6 +13,8 @@ export const useCurrentUser = () => {
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const commandStore = useCommandStore()
|
||||
const apiKeyStore = useApiKeyAuthStore()
|
||||
const dialogService = useDialogService()
|
||||
const { deleteAccount } = useFirebaseAuthActions()
|
||||
|
||||
const firebaseUser = computed(() => authStore.currentUser)
|
||||
const isApiKeyLogin = computed(() => apiKeyStore.isAuthenticated)
|
||||
@@ -29,14 +34,8 @@ export const useCurrentUser = () => {
|
||||
return null
|
||||
})
|
||||
|
||||
const onUserResolved = (callback: (user: AuthUserInfo) => void) => {
|
||||
if (resolvedUserInfo.value) {
|
||||
callback(resolvedUserInfo.value)
|
||||
}
|
||||
|
||||
const stop = whenever(resolvedUserInfo, callback)
|
||||
return () => stop()
|
||||
}
|
||||
const onUserResolved = (callback: (user: AuthUserInfo) => void) =>
|
||||
whenever(resolvedUserInfo, callback, { immediate: true })
|
||||
|
||||
const userDisplayName = computed(() => {
|
||||
if (isApiKeyLogin.value) {
|
||||
@@ -108,6 +107,18 @@ export const useCurrentUser = () => {
|
||||
await commandStore.execute('Comfy.User.OpenSignInDialog')
|
||||
}
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
const confirmed = await dialogService.confirm({
|
||||
title: t('auth.deleteAccount.confirmTitle'),
|
||||
message: t('auth.deleteAccount.confirmMessage'),
|
||||
type: 'delete'
|
||||
})
|
||||
|
||||
if (confirmed) {
|
||||
await deleteAccount()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loading: authStore.loading,
|
||||
isLoggedIn,
|
||||
@@ -121,6 +132,7 @@ export const useCurrentUser = () => {
|
||||
resolvedUserInfo,
|
||||
handleSignOut,
|
||||
handleSignIn,
|
||||
handleDeleteAccount,
|
||||
onUserResolved
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import { useRouter } from 'vue-router'
|
||||
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { t } from '@/i18n'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
import { usdToMicros } from '@/utils/formatUtil'
|
||||
|
||||
/**
|
||||
@@ -149,6 +149,16 @@ export const useFirebaseAuthActions = () => {
|
||||
reportError
|
||||
)
|
||||
|
||||
const deleteAccount = wrapWithErrorHandlingAsync(async () => {
|
||||
await authStore.deleteAccount()
|
||||
toastStore.add({
|
||||
severity: 'success',
|
||||
summary: t('auth.deleteAccount.success'),
|
||||
detail: t('auth.deleteAccount.successDetail'),
|
||||
life: 5000
|
||||
})
|
||||
}, reportError)
|
||||
|
||||
return {
|
||||
logout,
|
||||
sendPasswordReset,
|
||||
@@ -161,6 +171,7 @@ export const useFirebaseAuthActions = () => {
|
||||
signUpWithEmail,
|
||||
updatePassword,
|
||||
accessError,
|
||||
reportError
|
||||
reportError,
|
||||
deleteAccount
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { type ComputedRef, computed } from 'vue'
|
||||
|
||||
import { type ComfyCommandImpl } from '@/stores/commandStore'
|
||||
import type { ComfyCommandImpl } from '@/stores/commandStore'
|
||||
|
||||
export type SubcategoryRule = {
|
||||
type SubcategoryRule = {
|
||||
pattern: string | RegExp
|
||||
subcategory: string
|
||||
}
|
||||
|
||||
export type SubcategoryConfig = {
|
||||
type SubcategoryConfig = {
|
||||
defaultSubcategory: string
|
||||
rules: SubcategoryRule[]
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import EssentialsPanel from '@/components/bottomPanel/tabs/shortcuts/EssentialsPanel.vue'
|
||||
import ViewControlsPanel from '@/components/bottomPanel/tabs/shortcuts/ViewControlsPanel.vue'
|
||||
import { BottomPanelExtension } from '@/types/extensionTypes'
|
||||
import type { BottomPanelExtension } from '@/types/extensionTypes'
|
||||
|
||||
export const useShortcutsTab = (): BottomPanelExtension[] => {
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -2,13 +2,15 @@ import { FitAddon } from '@xterm/addon-fit'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import { Ref, markRaw, onMounted, onUnmounted } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { markRaw, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
export function useTerminal(element: Ref<HTMLElement | undefined>) {
|
||||
const fitAddon = new FitAddon()
|
||||
const terminal = markRaw(
|
||||
new Terminal({
|
||||
convertEol: true
|
||||
convertEol: true,
|
||||
theme: { background: '#171717' }
|
||||
})
|
||||
)
|
||||
terminal.loadAddon(fitAddon)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import CommandTerminal from '@/components/bottomPanel/tabs/terminal/CommandTerminal.vue'
|
||||
import LogsTerminal from '@/components/bottomPanel/tabs/terminal/LogsTerminal.vue'
|
||||
import { BottomPanelExtension } from '@/types/extensionTypes'
|
||||
import type { BottomPanelExtension } from '@/types/extensionTypes'
|
||||
|
||||
export const useLogsTerminalTab = (): BottomPanelExtension => {
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
|
||||
import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
interface CanvasTransformSyncOptions {
|
||||
/**
|
||||
* Whether to automatically start syncing when canvas is available
|
||||
* @default true
|
||||
*/
|
||||
autoStart?: boolean
|
||||
/**
|
||||
* Called when sync starts
|
||||
*/
|
||||
onStart?: () => void
|
||||
/**
|
||||
* Called when sync stops
|
||||
*/
|
||||
onStop?: () => void
|
||||
}
|
||||
|
||||
interface CanvasTransform {
|
||||
scale: number
|
||||
offsetX: number
|
||||
offsetY: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages requestAnimationFrame-based synchronization with LiteGraph canvas transforms.
|
||||
*
|
||||
* This composable provides a clean way to sync Vue transform state with LiteGraph canvas
|
||||
* on every frame. It handles RAF lifecycle management, and ensures proper cleanup.
|
||||
*
|
||||
* The sync function typically reads canvas.ds properties like offset and scale to keep
|
||||
* Vue components aligned with the canvas coordinate system.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const syncWithCanvas = (canvas: LGraphCanvas) => {
|
||||
* canvas.ds.scale
|
||||
* canvas.ds.offset
|
||||
* }
|
||||
*
|
||||
* const { isActive, startSync, stopSync } = useCanvasTransformSync(
|
||||
* syncWithCanvas,
|
||||
* {
|
||||
* autoStart: false,
|
||||
* onStart: () => emit('rafStatusChange', true),
|
||||
* onStop: () => emit('rafStatusChange', false)
|
||||
* }
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
export function useCanvasTransformSync(
|
||||
syncFn: (canvas: LGraphCanvas) => void,
|
||||
options: CanvasTransformSyncOptions = {}
|
||||
) {
|
||||
const { onStart, onStop, autoStart = true } = options
|
||||
const { getCanvas } = useCanvasStore()
|
||||
|
||||
const isActive = ref(false)
|
||||
let rafId: number | null = null
|
||||
let lastTransform: CanvasTransform = {
|
||||
scale: 0,
|
||||
offsetX: 0,
|
||||
offsetY: 0
|
||||
}
|
||||
|
||||
const hasTransformChanged = (canvas: LGraphCanvas): boolean => {
|
||||
const ds = canvas.ds
|
||||
return (
|
||||
ds.scale !== lastTransform.scale ||
|
||||
ds.offset[0] !== lastTransform.offsetX ||
|
||||
ds.offset[1] !== lastTransform.offsetY
|
||||
)
|
||||
}
|
||||
|
||||
const sync = () => {
|
||||
if (!isActive.value) return
|
||||
|
||||
const canvas = getCanvas()
|
||||
if (!canvas) return
|
||||
|
||||
try {
|
||||
// Only run sync if transform actually changed
|
||||
if (hasTransformChanged(canvas)) {
|
||||
lastTransform = {
|
||||
scale: canvas.ds.scale,
|
||||
offsetX: canvas.ds.offset[0],
|
||||
offsetY: canvas.ds.offset[1]
|
||||
}
|
||||
|
||||
syncFn(canvas)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Canvas transform sync error:', error)
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(sync)
|
||||
}
|
||||
|
||||
const startSync = () => {
|
||||
if (isActive.value) return
|
||||
|
||||
isActive.value = true
|
||||
onStart?.()
|
||||
|
||||
// Reset last transform to force initial sync
|
||||
lastTransform = { scale: 0, offsetX: 0, offsetY: 0 }
|
||||
|
||||
sync()
|
||||
}
|
||||
|
||||
const stopSync = () => {
|
||||
isActive.value = false
|
||||
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = null
|
||||
}
|
||||
|
||||
onStop?.()
|
||||
}
|
||||
|
||||
onUnmounted(stopSync)
|
||||
|
||||
if (autoStart) {
|
||||
startSync()
|
||||
}
|
||||
|
||||
return {
|
||||
isActive,
|
||||
startSync,
|
||||
stopSync
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,7 @@
|
||||
import {
|
||||
LGraphEventMode,
|
||||
LGraphNode,
|
||||
Positionable,
|
||||
Reroute
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode, Positionable } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode, Reroute } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import {
|
||||
collectFromNodes,
|
||||
traverseNodesDepthFirst
|
||||
@@ -123,12 +119,14 @@ export function useSelectedLiteGraphItems() {
|
||||
for (const i in selectedNodes) {
|
||||
selectedNodeArray.push(selectedNodes[i])
|
||||
}
|
||||
const allNodesMatch = !selectedNodeArray.some(
|
||||
(selectedNode) => selectedNode.mode !== mode
|
||||
)
|
||||
const newModeForSelectedNode = allNodesMatch ? LGraphEventMode.ALWAYS : mode
|
||||
|
||||
// Process each selected node independently to determine its target state and apply to children
|
||||
selectedNodeArray.forEach((selectedNode) => {
|
||||
// Apply standard toggle logic to the selected node itself
|
||||
const newModeForSelectedNode =
|
||||
selectedNode.mode === mode ? LGraphEventMode.ALWAYS : mode
|
||||
|
||||
selectedNode.mode = newModeForSelectedNode
|
||||
|
||||
|
||||
@@ -1,21 +1,56 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRafFn } from '@vueuse/core'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
|
||||
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
|
||||
import { createBounds } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { computeUnionBounds } from '@/utils/mathUtil'
|
||||
|
||||
/**
|
||||
* Manages the position of the selection toolbox independently.
|
||||
* Uses CSS custom properties for performant transform updates.
|
||||
*/
|
||||
|
||||
// Shared signals for auxiliary UI (e.g., MoreOptions) to coordinate hide/restore
|
||||
export const moreOptionsOpen = ref(false)
|
||||
export const forceCloseMoreOptionsSignal = ref(0)
|
||||
export const restoreMoreOptionsSignal = ref(0)
|
||||
export const moreOptionsRestorePending = ref(false)
|
||||
let moreOptionsWasOpenBeforeDrag = false
|
||||
let moreOptionsSelectionSignature: string | null = null
|
||||
|
||||
function buildSelectionSignature(
|
||||
store: ReturnType<typeof useCanvasStore>
|
||||
): string | null {
|
||||
const c = store.canvas
|
||||
if (!c) return null
|
||||
const items = Array.from(c.selectedItems)
|
||||
if (items.length !== 1) return null
|
||||
const item = items[0]
|
||||
if (isLGraphNode(item)) return `N:${item.id}`
|
||||
if (isLGraphGroup(item)) return `G:${item.id}`
|
||||
return null
|
||||
}
|
||||
|
||||
function currentSelectionMatchesSignature(
|
||||
store: ReturnType<typeof useCanvasStore>
|
||||
) {
|
||||
if (!moreOptionsSelectionSignature) return false
|
||||
return buildSelectionSignature(store) === moreOptionsSelectionSignature
|
||||
}
|
||||
|
||||
export function useSelectionToolboxPosition(
|
||||
toolboxRef: Ref<HTMLElement | undefined>
|
||||
) {
|
||||
const canvasStore = useCanvasStore()
|
||||
const lgCanvas = canvasStore.getCanvas()
|
||||
const { getSelectableItems } = useSelectedLiteGraphItems()
|
||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
|
||||
// World position of selection center
|
||||
const worldPosition = ref({ x: 0, y: 0 })
|
||||
@@ -34,17 +69,42 @@ export function useSelectionToolboxPosition(
|
||||
}
|
||||
|
||||
visible.value = true
|
||||
const bounds = createBounds(selectableItems)
|
||||
|
||||
if (!bounds) {
|
||||
return
|
||||
// Get bounds for all selected items
|
||||
const allBounds: ReadOnlyRect[] = []
|
||||
for (const item of selectableItems) {
|
||||
// Skip items without valid IDs
|
||||
if (item.id == null) continue
|
||||
|
||||
if (shouldRenderVueNodes.value && typeof item.id === 'string') {
|
||||
// Use layout store for Vue nodes (only works with string IDs)
|
||||
const layout = layoutStore.getNodeLayoutRef(item.id).value
|
||||
if (layout) {
|
||||
allBounds.push([
|
||||
layout.bounds.x,
|
||||
layout.bounds.y,
|
||||
layout.bounds.width,
|
||||
layout.bounds.height
|
||||
])
|
||||
}
|
||||
} else {
|
||||
// Fallback to LiteGraph bounds for regular nodes or non-string IDs
|
||||
if (item instanceof LGraphNode) {
|
||||
const bounds = item.getBounding()
|
||||
allBounds.push([bounds[0], bounds[1], bounds[2], bounds[3]] as const)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [xBase, y, width] = bounds
|
||||
// Compute union bounds
|
||||
const unionBounds = computeUnionBounds(allBounds)
|
||||
if (!unionBounds) return
|
||||
|
||||
worldPosition.value = {
|
||||
x: xBase + width / 2,
|
||||
y: y
|
||||
x: unionBounds.x + unionBounds.width / 2,
|
||||
// createBounds() applied a default padding of 10px
|
||||
// so adjust Y to maintain visual consistency
|
||||
y: unionBounds.y - 10
|
||||
}
|
||||
|
||||
updateTransform()
|
||||
@@ -68,19 +128,24 @@ export function useSelectionToolboxPosition(
|
||||
}
|
||||
|
||||
// Sync with canvas transform
|
||||
const { startSync, stopSync } = useCanvasTransformSync(updateTransform, {
|
||||
autoStart: false
|
||||
})
|
||||
const { resume: startSync, pause: stopSync } = useRafFn(updateTransform)
|
||||
|
||||
// Watch for selection changes
|
||||
watch(
|
||||
() => canvasStore.getCanvas().state.selectionChanged,
|
||||
(changed) => {
|
||||
if (changed) {
|
||||
if (moreOptionsRestorePending.value || moreOptionsSelectionSignature) {
|
||||
moreOptionsRestorePending.value = false
|
||||
moreOptionsWasOpenBeforeDrag = false
|
||||
if (!moreOptionsOpen.value) {
|
||||
moreOptionsSelectionSignature = null
|
||||
} else {
|
||||
moreOptionsSelectionSignature = buildSelectionSignature(canvasStore)
|
||||
}
|
||||
}
|
||||
updateSelectionBounds()
|
||||
canvasStore.getCanvas().state.selectionChanged = false
|
||||
|
||||
// Start transform sync if we have selection
|
||||
if (visible.value) {
|
||||
startSync()
|
||||
} else {
|
||||
@@ -90,24 +155,102 @@ export function useSelectionToolboxPosition(
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Watch for dragging state
|
||||
watch(
|
||||
() => canvasStore.canvas?.state?.draggingItems,
|
||||
(dragging) => {
|
||||
if (dragging) {
|
||||
// Hide during node dragging
|
||||
visible.value = false
|
||||
} else {
|
||||
// Update after dragging ends
|
||||
requestAnimationFrame(() => {
|
||||
updateSelectionBounds()
|
||||
})
|
||||
() => moreOptionsOpen.value,
|
||||
(v) => {
|
||||
if (v) {
|
||||
moreOptionsSelectionSignature = buildSelectionSignature(canvasStore)
|
||||
} else if (!canvasStore.canvas?.state?.draggingItems) {
|
||||
moreOptionsSelectionSignature = null
|
||||
if (moreOptionsRestorePending.value)
|
||||
moreOptionsRestorePending.value = false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handleDragStateChange = (dragging: boolean) => {
|
||||
if (dragging) {
|
||||
handleDragStart()
|
||||
return
|
||||
}
|
||||
|
||||
handleDragEnd()
|
||||
}
|
||||
|
||||
const handleDragStart = () => {
|
||||
visible.value = false
|
||||
|
||||
// Early return if more options wasn't open
|
||||
if (!moreOptionsOpen.value) {
|
||||
moreOptionsRestorePending.value = false
|
||||
moreOptionsWasOpenBeforeDrag = false
|
||||
return
|
||||
}
|
||||
|
||||
// Handle more options cleanup
|
||||
const currentSig = buildSelectionSignature(canvasStore)
|
||||
const selectionChanged = currentSig !== moreOptionsSelectionSignature
|
||||
|
||||
if (selectionChanged) {
|
||||
moreOptionsSelectionSignature = null
|
||||
}
|
||||
moreOptionsOpen.value = false
|
||||
moreOptionsWasOpenBeforeDrag = true
|
||||
moreOptionsRestorePending.value = !!moreOptionsSelectionSignature
|
||||
|
||||
if (moreOptionsRestorePending.value) {
|
||||
forceCloseMoreOptionsSignal.value++
|
||||
return
|
||||
}
|
||||
|
||||
moreOptionsWasOpenBeforeDrag = false
|
||||
}
|
||||
|
||||
const handleDragEnd = () => {
|
||||
requestAnimationFrame(() => {
|
||||
updateSelectionBounds()
|
||||
|
||||
const selectionMatches = currentSelectionMatchesSignature(canvasStore)
|
||||
const shouldRestore =
|
||||
moreOptionsWasOpenBeforeDrag &&
|
||||
visible.value &&
|
||||
moreOptionsRestorePending.value &&
|
||||
selectionMatches
|
||||
|
||||
// Single point of assignment for each ref
|
||||
moreOptionsRestorePending.value =
|
||||
shouldRestore && moreOptionsRestorePending.value
|
||||
moreOptionsWasOpenBeforeDrag = false
|
||||
|
||||
if (shouldRestore) {
|
||||
restoreMoreOptionsSignal.value++
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Unified dragging state - combines both LiteGraph and Vue node dragging
|
||||
const isDragging = computed((): boolean => {
|
||||
const litegraphDragging = canvasStore.canvas?.state?.draggingItems ?? false
|
||||
const vueNodeDragging =
|
||||
shouldRenderVueNodes.value && layoutStore.isDraggingVueNodes.value
|
||||
return litegraphDragging || vueNodeDragging
|
||||
})
|
||||
|
||||
watch(isDragging, handleDragStateChange)
|
||||
|
||||
onUnmounted(() => {
|
||||
resetMoreOptionsState()
|
||||
})
|
||||
|
||||
return {
|
||||
visible
|
||||
}
|
||||
}
|
||||
|
||||
// External cleanup utility to be called when SelectionToolbox component unmounts
|
||||
function resetMoreOptionsState() {
|
||||
moreOptionsOpen.value = false
|
||||
moreOptionsRestorePending.value = false
|
||||
moreOptionsWasOpenBeforeDrag = false
|
||||
moreOptionsSelectionSignature = null
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { CSSProperties, ref, watch } from 'vue'
|
||||
import type { CSSProperties } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { useCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
|
||||
import type { Size, Vector2 } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
export interface PositionConfig {
|
||||
/* The position of the element on litegraph canvas */
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useElementBounding } from '@vueuse/core'
|
||||
|
||||
import type { LGraphCanvas, Vector2 } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphCanvas, Point } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
let sharedConverter: ReturnType<typeof useCanvasPositionConversion> | null =
|
||||
null
|
||||
|
||||
/**
|
||||
* Convert between canvas and client positions
|
||||
@@ -14,7 +18,7 @@ export const useCanvasPositionConversion = (
|
||||
) => {
|
||||
const { left, top, update } = useElementBounding(canvasElement)
|
||||
|
||||
const clientPosToCanvasPos = (pos: Vector2): Vector2 => {
|
||||
const clientPosToCanvasPos = (pos: Point): Point => {
|
||||
const { offset, scale } = lgCanvas.ds
|
||||
return [
|
||||
(pos[0] - left.value) / scale - offset[0],
|
||||
@@ -22,7 +26,7 @@ export const useCanvasPositionConversion = (
|
||||
]
|
||||
}
|
||||
|
||||
const canvasPosToClientPos = (pos: Vector2): Vector2 => {
|
||||
const canvasPosToClientPos = (pos: Point): Point => {
|
||||
const { offset, scale } = lgCanvas.ds
|
||||
return [
|
||||
(pos[0] + offset[0]) * scale + left.value,
|
||||
@@ -36,3 +40,10 @@ export const useCanvasPositionConversion = (
|
||||
update
|
||||
}
|
||||
}
|
||||
|
||||
export function useSharedCanvasPositionConversion() {
|
||||
if (sharedConverter) return sharedConverter
|
||||
const lgCanvas = useCanvasStore().getCanvas()
|
||||
sharedConverter = useCanvasPositionConversion(lgCanvas.canvas, lgCanvas)
|
||||
return sharedConverter
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CSSProperties, ref } from 'vue'
|
||||
import type { CSSProperties } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface Rect {
|
||||
x: number
|
||||
@@ -23,7 +24,7 @@ function intersect(a: Rect, b: Rect): [number, number, number, number] | null {
|
||||
return [x1, y1, x2 - x1, y2 - y1]
|
||||
}
|
||||
|
||||
export interface ClippingOptions {
|
||||
interface ClippingOptions {
|
||||
margin?: number
|
||||
}
|
||||
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { app } from '@/scripts/app'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
/**
|
||||
* Composable for handling canvas interactions from Vue components.
|
||||
* This provides a unified way to forward events to the LiteGraph canvas
|
||||
* and will be the foundation for migrating canvas interactions to Vue.
|
||||
*/
|
||||
export function useCanvasInteractions() {
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const isStandardNavMode = computed(
|
||||
() => settingStore.get('Comfy.Canvas.NavigationMode') === 'standard'
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles wheel events from UI components that should be forwarded to canvas
|
||||
* when appropriate (e.g., Ctrl+wheel for zoom in standard mode)
|
||||
*/
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
// In standard mode, Ctrl+wheel should go to canvas for zoom
|
||||
if (isStandardNavMode.value && (event.ctrlKey || event.metaKey)) {
|
||||
event.preventDefault() // Prevent browser zoom
|
||||
forwardEventToCanvas(event)
|
||||
return
|
||||
}
|
||||
|
||||
// In legacy mode, all wheel events go to canvas for zoom
|
||||
if (!isStandardNavMode.value) {
|
||||
event.preventDefault()
|
||||
forwardEventToCanvas(event)
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, let the component handle it normally
|
||||
}
|
||||
|
||||
/**
|
||||
* Forwards an event to the LiteGraph canvas
|
||||
*/
|
||||
const forwardEventToCanvas = (
|
||||
event: WheelEvent | PointerEvent | MouseEvent
|
||||
) => {
|
||||
const canvasEl = app.canvas?.canvas
|
||||
if (!canvasEl) return
|
||||
|
||||
// Create new event with same properties
|
||||
const EventConstructor = event.constructor as typeof WheelEvent
|
||||
const newEvent = new EventConstructor(event.type, event)
|
||||
canvasEl.dispatchEvent(newEvent)
|
||||
}
|
||||
|
||||
return {
|
||||
handleWheel,
|
||||
forwardEventToCanvas
|
||||
}
|
||||
}
|
||||
22
src/composables/graph/useCanvasRefresh.ts
Normal file
22
src/composables/graph/useCanvasRefresh.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// call nextTick on all changeTracker
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
/**
|
||||
* Composable for refreshing nodes in the graph
|
||||
* */
|
||||
export function useCanvasRefresh() {
|
||||
const canvasStore = useCanvasStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const refreshCanvas = () => {
|
||||
canvasStore.canvas?.emitBeforeChange()
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
canvasStore.canvas?.graph?.afterChange()
|
||||
canvasStore.canvas?.emitAfterChange()
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
|
||||
return {
|
||||
refreshCanvas
|
||||
}
|
||||
}
|
||||
30
src/composables/graph/useFrameNodes.ts
Normal file
30
src/composables/graph/useFrameNodes.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useSelectionState } from '@/composables/graph/useSelectionState'
|
||||
import { LGraphGroup } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTitleEditorStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
/**
|
||||
* Composable encapsulating logic for framing currently selected nodes into a group.
|
||||
*/
|
||||
export function useFrameNodes() {
|
||||
const settingStore = useSettingStore()
|
||||
const titleEditorStore = useTitleEditorStore()
|
||||
const { hasMultipleSelection } = useSelectionState()
|
||||
|
||||
const canFrame = computed(() => hasMultipleSelection.value)
|
||||
|
||||
const frameNodes = () => {
|
||||
const { canvas } = app
|
||||
if (!canvas.selectedItems?.size) return
|
||||
const group = new LGraphGroup()
|
||||
const padding = settingStore.get('Comfy.GroupSelectedNodes.Padding')
|
||||
group.resizeTo(canvas.selectedItems, padding)
|
||||
canvas.graph?.add(group)
|
||||
titleEditorStore.titleEditorTarget = group
|
||||
}
|
||||
|
||||
return { frameNodes, canFrame }
|
||||
}
|
||||
534
src/composables/graph/useGraphNodeManager.ts
Normal file
534
src/composables/graph/useGraphNodeManager.ts
Normal file
@@ -0,0 +1,534 @@
|
||||
/**
|
||||
* Vue node lifecycle management for LiteGraph integration
|
||||
* Provides event-driven reactivity with performance optimizations
|
||||
*/
|
||||
import { reactive } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type { INodeOutputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import type { WidgetValue } from '@/types/simplifiedWidget'
|
||||
|
||||
import type { LGraph, LGraphNode } from '../../lib/litegraph/src/litegraph'
|
||||
|
||||
export interface SafeWidgetData {
|
||||
name: string
|
||||
type: string
|
||||
value: WidgetValue
|
||||
label?: string
|
||||
options?: Record<string, unknown>
|
||||
callback?: ((value: unknown) => void) | undefined
|
||||
spec?: InputSpec
|
||||
}
|
||||
|
||||
export interface VueNodeData {
|
||||
id: string
|
||||
title: string
|
||||
type: string
|
||||
mode: number
|
||||
selected: boolean
|
||||
executing: boolean
|
||||
subgraphId?: string | null
|
||||
widgets?: SafeWidgetData[]
|
||||
inputs?: INodeInputSlot[]
|
||||
outputs?: INodeOutputSlot[]
|
||||
hasErrors?: boolean
|
||||
flags?: {
|
||||
collapsed?: boolean
|
||||
pinned?: boolean
|
||||
}
|
||||
color?: string
|
||||
bgcolor?: string
|
||||
}
|
||||
|
||||
export interface GraphNodeManager {
|
||||
// Reactive state - safe data extracted from LiteGraph nodes
|
||||
vueNodeData: ReadonlyMap<string, VueNodeData>
|
||||
|
||||
// Access to original LiteGraph nodes (non-reactive)
|
||||
getNode(id: string): LGraphNode | undefined
|
||||
|
||||
// Lifecycle methods
|
||||
cleanup(): void
|
||||
}
|
||||
|
||||
export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
// Get layout mutations composable
|
||||
const { createNode, deleteNode, setSource } = useLayoutMutations()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
// Safe reactive data extracted from LiteGraph nodes
|
||||
const vueNodeData = reactive(new Map<string, VueNodeData>())
|
||||
|
||||
// Non-reactive storage for original LiteGraph nodes
|
||||
const nodeRefs = new Map<string, LGraphNode>()
|
||||
|
||||
// Extract safe data from LiteGraph node for Vue consumption
|
||||
const extractVueNodeData = (node: LGraphNode): VueNodeData => {
|
||||
// Determine subgraph ID - null for root graph, string for subgraphs
|
||||
const subgraphId =
|
||||
node.graph && 'id' in node.graph && node.graph !== node.graph.rootGraph
|
||||
? String(node.graph.id)
|
||||
: null
|
||||
// Extract safe widget data
|
||||
const safeWidgets = node.widgets?.map((widget) => {
|
||||
try {
|
||||
// TODO: Use widget.getReactiveData() once TypeScript types are updated
|
||||
let value = widget.value
|
||||
|
||||
// For combo widgets, if value is undefined, use the first option as default
|
||||
if (
|
||||
value === undefined &&
|
||||
widget.type === 'combo' &&
|
||||
widget.options?.values &&
|
||||
Array.isArray(widget.options.values) &&
|
||||
widget.options.values.length > 0
|
||||
) {
|
||||
value = widget.options.values[0]
|
||||
}
|
||||
const spec = nodeDefStore.getInputSpecForWidget(node, widget.name)
|
||||
|
||||
return {
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
value: value,
|
||||
label: widget.label,
|
||||
options: widget.options ? { ...widget.options } : undefined,
|
||||
callback: widget.callback,
|
||||
spec
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
name: widget.name || 'unknown',
|
||||
type: widget.type || 'text',
|
||||
value: undefined
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const nodeType =
|
||||
node.type ||
|
||||
node.constructor?.comfyClass ||
|
||||
node.constructor?.title ||
|
||||
node.constructor?.name ||
|
||||
'Unknown'
|
||||
|
||||
return {
|
||||
id: String(node.id),
|
||||
title: typeof node.title === 'string' ? node.title : '',
|
||||
type: nodeType,
|
||||
mode: node.mode || 0,
|
||||
selected: node.selected || false,
|
||||
executing: false, // Will be updated separately based on execution state
|
||||
subgraphId,
|
||||
hasErrors: !!node.has_errors,
|
||||
widgets: safeWidgets,
|
||||
inputs: node.inputs ? [...node.inputs] : undefined,
|
||||
outputs: node.outputs ? [...node.outputs] : undefined,
|
||||
flags: node.flags ? { ...node.flags } : undefined,
|
||||
color: node.color || undefined,
|
||||
bgcolor: node.bgcolor || undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Get access to original LiteGraph node (non-reactive)
|
||||
const getNode = (id: string): LGraphNode | undefined => {
|
||||
return nodeRefs.get(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a value is a valid WidgetValue type
|
||||
*/
|
||||
const validateWidgetValue = (value: unknown): WidgetValue => {
|
||||
if (value === null || value === undefined || value === void 0) {
|
||||
return undefined
|
||||
}
|
||||
if (
|
||||
typeof value === 'string' ||
|
||||
typeof value === 'number' ||
|
||||
typeof value === 'boolean'
|
||||
) {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
// Check if it's a File array
|
||||
if (
|
||||
Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
value.every((item): item is File => item instanceof File)
|
||||
) {
|
||||
return value
|
||||
}
|
||||
// Otherwise it's a generic object
|
||||
return value
|
||||
}
|
||||
// If none of the above, return undefined
|
||||
console.warn(`Invalid widget value type: ${typeof value}`, value)
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates Vue state when widget values change
|
||||
*/
|
||||
const updateVueWidgetState = (
|
||||
nodeId: string,
|
||||
widgetName: string,
|
||||
value: unknown
|
||||
): void => {
|
||||
try {
|
||||
const currentData = vueNodeData.get(nodeId)
|
||||
if (!currentData?.widgets) return
|
||||
|
||||
const updatedWidgets = currentData.widgets.map((w) =>
|
||||
w.name === widgetName ? { ...w, value: validateWidgetValue(value) } : w
|
||||
)
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
widgets: updatedWidgets
|
||||
})
|
||||
} catch (error) {
|
||||
// Ignore widget update errors to prevent cascade failures
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a wrapped callback for a widget that maintains LiteGraph/Vue sync
|
||||
*/
|
||||
const createWrappedWidgetCallback = (
|
||||
widget: { value?: unknown; name: string }, // LiteGraph widget with minimal typing
|
||||
originalCallback: ((value: unknown) => void) | undefined,
|
||||
nodeId: string
|
||||
) => {
|
||||
let updateInProgress = false
|
||||
|
||||
return (value: unknown) => {
|
||||
if (updateInProgress) return
|
||||
updateInProgress = true
|
||||
|
||||
try {
|
||||
// 1. Update the widget value in LiteGraph (critical for LiteGraph state)
|
||||
// Validate that the value is of an acceptable type
|
||||
if (
|
||||
value !== null &&
|
||||
value !== undefined &&
|
||||
typeof value !== 'string' &&
|
||||
typeof value !== 'number' &&
|
||||
typeof value !== 'boolean' &&
|
||||
typeof value !== 'object'
|
||||
) {
|
||||
console.warn(`Invalid widget value type: ${typeof value}`)
|
||||
updateInProgress = false
|
||||
return
|
||||
}
|
||||
|
||||
// Always update widget.value to ensure sync
|
||||
widget.value = value
|
||||
|
||||
// 2. Call the original callback if it exists
|
||||
if (originalCallback) {
|
||||
originalCallback.call(widget, value)
|
||||
}
|
||||
|
||||
// 3. Update Vue state to maintain synchronization
|
||||
updateVueWidgetState(nodeId, widget.name, value)
|
||||
} finally {
|
||||
updateInProgress = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up widget callbacks for a node - now with reduced nesting
|
||||
*/
|
||||
const setupNodeWidgetCallbacks = (node: LGraphNode) => {
|
||||
if (!node.widgets) return
|
||||
|
||||
const nodeId = String(node.id)
|
||||
|
||||
node.widgets.forEach((widget) => {
|
||||
const originalCallback = widget.callback
|
||||
widget.callback = createWrappedWidgetCallback(
|
||||
widget,
|
||||
originalCallback,
|
||||
nodeId
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const syncWithGraph = () => {
|
||||
if (!graph?._nodes) return
|
||||
|
||||
const currentNodes = new Set(graph._nodes.map((n) => String(n.id)))
|
||||
|
||||
// Remove deleted nodes
|
||||
for (const id of Array.from(vueNodeData.keys())) {
|
||||
if (!currentNodes.has(id)) {
|
||||
nodeRefs.delete(id)
|
||||
vueNodeData.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
// Add/update existing nodes
|
||||
graph._nodes.forEach((node) => {
|
||||
const id = String(node.id)
|
||||
|
||||
// Store non-reactive reference
|
||||
nodeRefs.set(id, node)
|
||||
|
||||
// Set up widget callbacks BEFORE extracting data (critical order)
|
||||
setupNodeWidgetCallbacks(node)
|
||||
|
||||
// Extract and store safe data for Vue
|
||||
vueNodeData.set(id, extractVueNodeData(node))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles node addition to the graph - sets up Vue state and spatial indexing
|
||||
* Defers position extraction until after potential configure() calls
|
||||
*/
|
||||
const handleNodeAdded = (
|
||||
node: LGraphNode,
|
||||
originalCallback?: (node: LGraphNode) => void
|
||||
) => {
|
||||
const id = String(node.id)
|
||||
|
||||
// Store non-reactive reference to original node
|
||||
nodeRefs.set(id, node)
|
||||
|
||||
// Set up widget callbacks BEFORE extracting data (critical order)
|
||||
setupNodeWidgetCallbacks(node)
|
||||
|
||||
// Extract initial data for Vue (may be incomplete during graph configure)
|
||||
vueNodeData.set(id, extractVueNodeData(node))
|
||||
|
||||
const initializeVueNodeLayout = () => {
|
||||
// Extract actual positions after configure() has potentially updated them
|
||||
const nodePosition = { x: node.pos[0], y: node.pos[1] }
|
||||
const nodeSize = { width: node.size[0], height: node.size[1] }
|
||||
|
||||
// Add node to layout store with final positions
|
||||
setSource(LayoutSource.Canvas)
|
||||
void createNode(id, {
|
||||
position: nodePosition,
|
||||
size: nodeSize,
|
||||
zIndex: node.order || 0,
|
||||
visible: true
|
||||
})
|
||||
}
|
||||
|
||||
// Check if we're in the middle of configuring the graph (workflow loading)
|
||||
if (window.app?.configuringGraph) {
|
||||
// During workflow loading - defer layout initialization until configure completes
|
||||
// Chain our callback with any existing onAfterGraphConfigured callback
|
||||
node.onAfterGraphConfigured = useChainCallback(
|
||||
node.onAfterGraphConfigured,
|
||||
() => {
|
||||
// Re-extract data now that configure() has populated title/slots/widgets/etc.
|
||||
vueNodeData.set(id, extractVueNodeData(node))
|
||||
initializeVueNodeLayout()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
// Not during workflow loading - initialize layout immediately
|
||||
// This handles individual node additions during normal operation
|
||||
initializeVueNodeLayout()
|
||||
}
|
||||
|
||||
// Call original callback if provided
|
||||
if (originalCallback) {
|
||||
void originalCallback(node)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles node removal from the graph - cleans up all references
|
||||
*/
|
||||
const handleNodeRemoved = (
|
||||
node: LGraphNode,
|
||||
originalCallback?: (node: LGraphNode) => void
|
||||
) => {
|
||||
const id = String(node.id)
|
||||
|
||||
// Remove node from layout store
|
||||
setSource(LayoutSource.Canvas)
|
||||
void deleteNode(id)
|
||||
|
||||
// Clean up all tracking references
|
||||
nodeRefs.delete(id)
|
||||
vueNodeData.delete(id)
|
||||
|
||||
// Call original callback if provided
|
||||
if (originalCallback) {
|
||||
originalCallback(node)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates cleanup function for event listeners and state
|
||||
*/
|
||||
const createCleanupFunction = (
|
||||
originalOnNodeAdded: ((node: LGraphNode) => void) | undefined,
|
||||
originalOnNodeRemoved: ((node: LGraphNode) => void) | undefined,
|
||||
originalOnTrigger: ((action: string, param: unknown) => void) | undefined
|
||||
) => {
|
||||
return () => {
|
||||
// Restore original callbacks
|
||||
graph.onNodeAdded = originalOnNodeAdded || undefined
|
||||
graph.onNodeRemoved = originalOnNodeRemoved || undefined
|
||||
graph.onTrigger = originalOnTrigger || undefined
|
||||
|
||||
// Clear all state maps
|
||||
nodeRefs.clear()
|
||||
vueNodeData.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up event listeners - now simplified with extracted handlers
|
||||
*/
|
||||
const setupEventListeners = (): (() => void) => {
|
||||
// Store original callbacks
|
||||
const originalOnNodeAdded = graph.onNodeAdded
|
||||
const originalOnNodeRemoved = graph.onNodeRemoved
|
||||
const originalOnTrigger = graph.onTrigger
|
||||
|
||||
// Set up graph event handlers
|
||||
graph.onNodeAdded = (node: LGraphNode) => {
|
||||
handleNodeAdded(node, originalOnNodeAdded)
|
||||
}
|
||||
|
||||
graph.onNodeRemoved = (node: LGraphNode) => {
|
||||
handleNodeRemoved(node, originalOnNodeRemoved)
|
||||
}
|
||||
|
||||
// Listen for property change events from instrumented nodes
|
||||
graph.onTrigger = (action: string, param: unknown) => {
|
||||
if (
|
||||
action === 'node:property:changed' &&
|
||||
param &&
|
||||
typeof param === 'object'
|
||||
) {
|
||||
const event = param as {
|
||||
nodeId: string | number
|
||||
property: string
|
||||
oldValue: unknown
|
||||
newValue: unknown
|
||||
}
|
||||
|
||||
const nodeId = String(event.nodeId)
|
||||
const currentData = vueNodeData.get(nodeId)
|
||||
|
||||
if (currentData) {
|
||||
switch (event.property) {
|
||||
case 'title':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
title: String(event.newValue)
|
||||
})
|
||||
break
|
||||
case 'flags.collapsed':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
flags: {
|
||||
...currentData.flags,
|
||||
collapsed: Boolean(event.newValue)
|
||||
}
|
||||
})
|
||||
break
|
||||
case 'flags.pinned':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
flags: {
|
||||
...currentData.flags,
|
||||
pinned: Boolean(event.newValue)
|
||||
}
|
||||
})
|
||||
break
|
||||
case 'mode':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
mode: typeof event.newValue === 'number' ? event.newValue : 0
|
||||
})
|
||||
break
|
||||
case 'color':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
color:
|
||||
typeof event.newValue === 'string'
|
||||
? event.newValue
|
||||
: undefined
|
||||
})
|
||||
break
|
||||
case 'bgcolor':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
bgcolor:
|
||||
typeof event.newValue === 'string'
|
||||
? event.newValue
|
||||
: undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
action === 'node:slot-errors:changed' &&
|
||||
param &&
|
||||
typeof param === 'object'
|
||||
) {
|
||||
const event = param as { nodeId: string | number }
|
||||
const nodeId = String(event.nodeId)
|
||||
const litegraphNode = nodeRefs.get(nodeId)
|
||||
const currentData = vueNodeData.get(nodeId)
|
||||
|
||||
if (litegraphNode && currentData) {
|
||||
// Re-extract slot data with updated hasErrors properties
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
inputs: litegraphNode.inputs
|
||||
? [...litegraphNode.inputs]
|
||||
: undefined,
|
||||
outputs: litegraphNode.outputs
|
||||
? [...litegraphNode.outputs]
|
||||
: undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Call original trigger handler if it exists
|
||||
if (originalOnTrigger) {
|
||||
originalOnTrigger(action, param)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize state
|
||||
syncWithGraph()
|
||||
|
||||
// Return cleanup function
|
||||
return createCleanupFunction(
|
||||
originalOnNodeAdded || undefined,
|
||||
originalOnNodeRemoved || undefined,
|
||||
originalOnTrigger || undefined
|
||||
)
|
||||
}
|
||||
|
||||
// Set up event listeners immediately
|
||||
const cleanup = setupEventListeners()
|
||||
|
||||
// Process any existing nodes after event listeners are set up
|
||||
if (graph._nodes && graph._nodes.length > 0) {
|
||||
graph._nodes.forEach((node: LGraphNode) => {
|
||||
if (graph.onNodeAdded) {
|
||||
graph.onNodeAdded(node)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
vueNodeData,
|
||||
getNode,
|
||||
cleanup
|
||||
}
|
||||
}
|
||||
199
src/composables/graph/useGroupMenuOptions.ts
Normal file
199
src/composables/graph/useGroupMenuOptions.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
LGraphEventMode,
|
||||
type LGraphGroup,
|
||||
type LGraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
import { useCanvasRefresh } from './useCanvasRefresh'
|
||||
import type { MenuOption } from './useMoreOptionsMenu'
|
||||
import { useNodeCustomization } from './useNodeCustomization'
|
||||
|
||||
/**
|
||||
* Composable for group-related menu operations
|
||||
*/
|
||||
export function useGroupMenuOptions() {
|
||||
const { t } = useI18n()
|
||||
const canvasStore = useCanvasStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const settingStore = useSettingStore()
|
||||
const canvasRefresh = useCanvasRefresh()
|
||||
const { shapeOptions, colorOptions, isLightTheme } = useNodeCustomization()
|
||||
|
||||
const getFitGroupToNodesOption = (groupContext: LGraphGroup): MenuOption => ({
|
||||
label: 'Fit Group To Nodes',
|
||||
icon: 'icon-[lucide--move-diagonal-2]',
|
||||
action: () => {
|
||||
try {
|
||||
groupContext.recomputeInsideNodes()
|
||||
} catch (e) {
|
||||
console.warn('Failed to recompute group nodes:', e)
|
||||
return
|
||||
}
|
||||
|
||||
const padding = settingStore.get('Comfy.GroupSelectedNodes.Padding')
|
||||
groupContext.resizeTo(groupContext.children, padding)
|
||||
groupContext.graph?.change()
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
})
|
||||
|
||||
const getGroupShapeOptions = (
|
||||
groupContext: LGraphGroup,
|
||||
bump: () => void
|
||||
): MenuOption => ({
|
||||
label: t('contextMenu.Shape'),
|
||||
icon: 'icon-[lucide--box]',
|
||||
hasSubmenu: true,
|
||||
submenu: shapeOptions.map((shape) => ({
|
||||
label: shape.localizedName,
|
||||
action: () => {
|
||||
const nodes = (groupContext.nodes || []) as LGraphNode[]
|
||||
nodes.forEach((node) => (node.shape = shape.value))
|
||||
canvasRefresh.refreshCanvas()
|
||||
bump()
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
const getGroupColorOptions = (
|
||||
groupContext: LGraphGroup,
|
||||
bump: () => void
|
||||
): MenuOption => ({
|
||||
label: t('contextMenu.Color'),
|
||||
icon: 'icon-[lucide--palette]',
|
||||
hasSubmenu: true,
|
||||
submenu: colorOptions.map((colorOption) => ({
|
||||
label: colorOption.localizedName,
|
||||
color: isLightTheme.value
|
||||
? colorOption.value.light
|
||||
: colorOption.value.dark,
|
||||
action: () => {
|
||||
groupContext.color = isLightTheme.value
|
||||
? colorOption.value.light
|
||||
: colorOption.value.dark
|
||||
canvasRefresh.refreshCanvas()
|
||||
bump()
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
const getGroupModeOptions = (
|
||||
groupContext: LGraphGroup,
|
||||
bump: () => void
|
||||
): MenuOption[] => {
|
||||
const options: MenuOption[] = []
|
||||
|
||||
try {
|
||||
groupContext.recomputeInsideNodes()
|
||||
} catch (e) {
|
||||
console.warn('Failed to recompute group nodes for mode options:', e)
|
||||
return options
|
||||
}
|
||||
|
||||
const groupNodes = (groupContext.nodes || []) as LGraphNode[]
|
||||
if (!groupNodes.length) return options
|
||||
|
||||
// Check if all nodes have the same mode
|
||||
let allSame = true
|
||||
for (let i = 1; i < groupNodes.length; i++) {
|
||||
if (groupNodes[i].mode !== groupNodes[0].mode) {
|
||||
allSame = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const createModeAction = (label: string, mode: LGraphEventMode) => ({
|
||||
label: t(`selectionToolbox.${label}`),
|
||||
icon:
|
||||
mode === LGraphEventMode.BYPASS
|
||||
? 'icon-[lucide--ban]'
|
||||
: mode === LGraphEventMode.NEVER
|
||||
? 'icon-[lucide--zap-off]'
|
||||
: 'icon-[lucide--play]',
|
||||
action: () => {
|
||||
groupNodes.forEach((n) => {
|
||||
n.mode = mode
|
||||
})
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
groupContext.graph?.change()
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
bump()
|
||||
}
|
||||
})
|
||||
|
||||
if (allSame) {
|
||||
const current = groupNodes[0].mode
|
||||
switch (current) {
|
||||
case LGraphEventMode.ALWAYS:
|
||||
options.push(
|
||||
createModeAction('Set Group Nodes to Never', LGraphEventMode.NEVER)
|
||||
)
|
||||
options.push(
|
||||
createModeAction('Bypass Group Nodes', LGraphEventMode.BYPASS)
|
||||
)
|
||||
break
|
||||
case LGraphEventMode.NEVER:
|
||||
options.push(
|
||||
createModeAction(
|
||||
'Set Group Nodes to Always',
|
||||
LGraphEventMode.ALWAYS
|
||||
)
|
||||
)
|
||||
options.push(
|
||||
createModeAction('Bypass Group Nodes', LGraphEventMode.BYPASS)
|
||||
)
|
||||
break
|
||||
case LGraphEventMode.BYPASS:
|
||||
options.push(
|
||||
createModeAction(
|
||||
'Set Group Nodes to Always',
|
||||
LGraphEventMode.ALWAYS
|
||||
)
|
||||
)
|
||||
options.push(
|
||||
createModeAction('Set Group Nodes to Never', LGraphEventMode.NEVER)
|
||||
)
|
||||
break
|
||||
default:
|
||||
options.push(
|
||||
createModeAction(
|
||||
'Set Group Nodes to Always',
|
||||
LGraphEventMode.ALWAYS
|
||||
)
|
||||
)
|
||||
options.push(
|
||||
createModeAction('Set Group Nodes to Never', LGraphEventMode.NEVER)
|
||||
)
|
||||
options.push(
|
||||
createModeAction('Bypass Group Nodes', LGraphEventMode.BYPASS)
|
||||
)
|
||||
break
|
||||
}
|
||||
} else {
|
||||
options.push(
|
||||
createModeAction('Set Group Nodes to Always', LGraphEventMode.ALWAYS)
|
||||
)
|
||||
options.push(
|
||||
createModeAction('Set Group Nodes to Never', LGraphEventMode.NEVER)
|
||||
)
|
||||
options.push(
|
||||
createModeAction('Bypass Group Nodes', LGraphEventMode.BYPASS)
|
||||
)
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
return {
|
||||
getFitGroupToNodesOption,
|
||||
getGroupShapeOptions,
|
||||
getGroupColorOptions,
|
||||
getGroupModeOptions
|
||||
}
|
||||
}
|
||||
108
src/composables/graph/useImageMenuOptions.ts
Normal file
108
src/composables/graph/useImageMenuOptions.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
import type { MenuOption } from './useMoreOptionsMenu'
|
||||
|
||||
/**
|
||||
* Composable for image-related menu operations
|
||||
*/
|
||||
export function useImageMenuOptions() {
|
||||
const { t } = useI18n()
|
||||
|
||||
const openMaskEditor = () => {
|
||||
const commandStore = useCommandStore()
|
||||
void commandStore.execute('Comfy.MaskEditor.OpenMaskEditor')
|
||||
}
|
||||
|
||||
const openImage = (node: any) => {
|
||||
if (!node?.imgs?.length) return
|
||||
const img = node.imgs[node.imageIndex ?? 0]
|
||||
if (!img) return
|
||||
const url = new URL(img.src)
|
||||
url.searchParams.delete('preview')
|
||||
window.open(url.toString(), '_blank')
|
||||
}
|
||||
|
||||
const copyImage = async (node: any) => {
|
||||
if (!node?.imgs?.length) return
|
||||
const img = node.imgs[node.imageIndex ?? 0]
|
||||
if (!img) return
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
canvas.width = img.naturalWidth
|
||||
canvas.height = img.naturalHeight
|
||||
ctx.drawImage(img, 0, 0)
|
||||
|
||||
try {
|
||||
const blob = await new Promise<Blob | null>((resolve) => {
|
||||
canvas.toBlob(resolve, 'image/png')
|
||||
})
|
||||
|
||||
if (!blob) {
|
||||
console.warn('Failed to create image blob')
|
||||
return
|
||||
}
|
||||
|
||||
// Check if clipboard API is available
|
||||
if (!navigator.clipboard?.write) {
|
||||
console.warn('Clipboard API not available')
|
||||
return
|
||||
}
|
||||
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({ 'image/png': blob })
|
||||
])
|
||||
} catch (error) {
|
||||
console.error('Failed to copy image to clipboard:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const saveImage = (node: any) => {
|
||||
if (!node?.imgs?.length) return
|
||||
const img = node.imgs[node.imageIndex ?? 0]
|
||||
if (!img) return
|
||||
|
||||
try {
|
||||
const url = new URL(img.src)
|
||||
url.searchParams.delete('preview')
|
||||
downloadFile(url.toString())
|
||||
} catch (error) {
|
||||
console.error('Failed to save image:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const getImageMenuOptions = (node: any): MenuOption[] => {
|
||||
if (!node?.imgs?.length) return []
|
||||
|
||||
return [
|
||||
{
|
||||
label: t('contextMenu.Open in Mask Editor'),
|
||||
action: () => openMaskEditor()
|
||||
},
|
||||
{
|
||||
label: t('contextMenu.Open Image'),
|
||||
icon: 'icon-[lucide--external-link]',
|
||||
action: () => openImage(node)
|
||||
},
|
||||
{
|
||||
label: t('contextMenu.Copy Image'),
|
||||
icon: 'icon-[lucide--copy]',
|
||||
action: () => copyImage(node)
|
||||
},
|
||||
{
|
||||
label: t('contextMenu.Save Image'),
|
||||
icon: 'icon-[lucide--download]',
|
||||
action: () => saveImage(node)
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return {
|
||||
getImageMenuOptions
|
||||
}
|
||||
}
|
||||
228
src/composables/graph/useMoreOptionsMenu.ts
Normal file
228
src/composables/graph/useMoreOptionsMenu.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { type Ref, computed, ref } from 'vue'
|
||||
|
||||
import type { LGraphGroup } from '@/lib/litegraph/src/litegraph'
|
||||
import { isLGraphGroup } from '@/utils/litegraphUtil'
|
||||
|
||||
import { useGroupMenuOptions } from './useGroupMenuOptions'
|
||||
import { useImageMenuOptions } from './useImageMenuOptions'
|
||||
import { useNodeMenuOptions } from './useNodeMenuOptions'
|
||||
import { useSelectionMenuOptions } from './useSelectionMenuOptions'
|
||||
import { useSelectionState } from './useSelectionState'
|
||||
|
||||
export interface MenuOption {
|
||||
label?: string
|
||||
icon?: string
|
||||
shortcut?: string
|
||||
hasSubmenu?: boolean
|
||||
type?: 'divider'
|
||||
action?: () => void
|
||||
submenu?: SubMenuOption[]
|
||||
badge?: BadgeVariant
|
||||
}
|
||||
|
||||
export interface SubMenuOption {
|
||||
label: string
|
||||
icon?: string
|
||||
action: () => void
|
||||
color?: string
|
||||
}
|
||||
|
||||
export enum BadgeVariant {
|
||||
NEW = 'new',
|
||||
DEPRECATED = 'deprecated'
|
||||
}
|
||||
|
||||
// Global singleton for NodeOptions component reference
|
||||
let nodeOptionsInstance: null | NodeOptionsInstance = null
|
||||
|
||||
/**
|
||||
* Toggle the node options popover
|
||||
* @param event - The trigger event
|
||||
* @param element - The target element (button) that triggered the popover
|
||||
*/
|
||||
export function toggleNodeOptions(
|
||||
event: Event,
|
||||
element: HTMLElement,
|
||||
clickedFromToolbox: boolean = false
|
||||
) {
|
||||
if (nodeOptionsInstance?.toggle) {
|
||||
nodeOptionsInstance.toggle(event, element, clickedFromToolbox)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the node options popover
|
||||
*/
|
||||
interface NodeOptionsInstance {
|
||||
toggle: (
|
||||
event: Event,
|
||||
element: HTMLElement,
|
||||
clickedFromToolbox: boolean
|
||||
) => void
|
||||
hide: () => void
|
||||
isOpen: Ref<boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the NodeOptions component instance
|
||||
* @param instance - The NodeOptions component instance
|
||||
*/
|
||||
export function registerNodeOptionsInstance(
|
||||
instance: null | NodeOptionsInstance
|
||||
) {
|
||||
nodeOptionsInstance = instance
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for managing the More Options menu configuration
|
||||
* Refactored to use smaller, focused composables for better maintainability
|
||||
*/
|
||||
export function useMoreOptionsMenu() {
|
||||
const {
|
||||
selectedItems,
|
||||
selectedNodes,
|
||||
nodeDef,
|
||||
showNodeHelp,
|
||||
hasSubgraphs: hasSubgraphsComputed,
|
||||
hasImageNode,
|
||||
hasOutputNodesSelected,
|
||||
hasMultipleSelection,
|
||||
computeSelectionFlags
|
||||
} = useSelectionState()
|
||||
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const {
|
||||
getNodeInfoOption,
|
||||
getAdjustSizeOption,
|
||||
getNodeVisualOptions,
|
||||
getPinOption,
|
||||
getBypassOption,
|
||||
getRunBranchOption
|
||||
} = useNodeMenuOptions()
|
||||
const {
|
||||
getFitGroupToNodesOption,
|
||||
getGroupShapeOptions,
|
||||
getGroupColorOptions,
|
||||
getGroupModeOptions
|
||||
} = useGroupMenuOptions()
|
||||
const {
|
||||
getBasicSelectionOptions,
|
||||
getSubgraphOptions,
|
||||
getMultipleNodesOptions,
|
||||
getDeleteOption,
|
||||
getAlignmentOptions
|
||||
} = useSelectionMenuOptions()
|
||||
|
||||
const hasSubgraphs = hasSubgraphsComputed
|
||||
const hasMultipleNodes = hasMultipleSelection
|
||||
|
||||
// Internal version to force menu rebuild after state mutations
|
||||
const optionsVersion = ref(0)
|
||||
const bump = () => {
|
||||
optionsVersion.value++
|
||||
}
|
||||
|
||||
const menuOptions = computed((): MenuOption[] => {
|
||||
// Reference selection flags to ensure re-computation when they change
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
optionsVersion.value
|
||||
const states = computeSelectionFlags()
|
||||
|
||||
// Detect single group selection context (and no nodes explicitly selected)
|
||||
const selectedGroups = selectedItems.value.filter(
|
||||
isLGraphGroup
|
||||
) as LGraphGroup[]
|
||||
const groupContext: LGraphGroup | null =
|
||||
selectedGroups.length === 1 && selectedNodes.value.length === 0
|
||||
? selectedGroups[0]
|
||||
: null
|
||||
const hasSubgraphsSelected = hasSubgraphs.value
|
||||
const options: MenuOption[] = []
|
||||
|
||||
// Section 1: Basic selection operations (Rename, Copy, Duplicate)
|
||||
options.push(...getBasicSelectionOptions())
|
||||
options.push({ type: 'divider' })
|
||||
|
||||
// Section 2: Node Info & Size Adjustment
|
||||
if (nodeDef.value) {
|
||||
options.push(getNodeInfoOption(showNodeHelp))
|
||||
}
|
||||
|
||||
if (groupContext) {
|
||||
options.push(getFitGroupToNodesOption(groupContext))
|
||||
} else {
|
||||
options.push(getAdjustSizeOption())
|
||||
}
|
||||
|
||||
// Section 3: Collapse/Shape/Color
|
||||
if (groupContext) {
|
||||
// Group context: Shape, Color, Divider
|
||||
options.push(getGroupShapeOptions(groupContext, bump))
|
||||
options.push(getGroupColorOptions(groupContext, bump))
|
||||
options.push({ type: 'divider' })
|
||||
} else {
|
||||
// Node context: Expand/Minimize, Shape, Color, Divider
|
||||
options.push(...getNodeVisualOptions(states, bump))
|
||||
options.push({ type: 'divider' })
|
||||
}
|
||||
|
||||
// Section 4: Image operations (if image node)
|
||||
if (hasImageNode.value && selectedNodes.value.length > 0) {
|
||||
options.push(...getImageMenuOptions(selectedNodes.value[0]))
|
||||
}
|
||||
|
||||
// Section 5: Subgraph operations
|
||||
options.push(...getSubgraphOptions(hasSubgraphsSelected))
|
||||
|
||||
// Section 6: Multiple nodes operations
|
||||
if (hasMultipleNodes.value) {
|
||||
options.push(...getMultipleNodesOptions())
|
||||
}
|
||||
|
||||
// Section 7: Divider
|
||||
options.push({ type: 'divider' })
|
||||
|
||||
// Section 8: Pin/Unpin (non-group only)
|
||||
if (!groupContext) {
|
||||
options.push(getPinOption(states, bump))
|
||||
}
|
||||
|
||||
// Section 9: Alignment (if multiple nodes)
|
||||
if (hasMultipleNodes.value) {
|
||||
options.push(...getAlignmentOptions())
|
||||
}
|
||||
|
||||
// Section 10: Mode operations
|
||||
if (groupContext) {
|
||||
// Group mode operations
|
||||
options.push(...getGroupModeOptions(groupContext, bump))
|
||||
} else {
|
||||
// Bypass option for nodes
|
||||
options.push(getBypassOption(states, bump))
|
||||
}
|
||||
|
||||
// Section 11: Run Branch (if output nodes)
|
||||
if (hasOutputNodesSelected.value) {
|
||||
options.push(getRunBranchOption())
|
||||
}
|
||||
|
||||
// Section 12: Final divider and Delete
|
||||
options.push({ type: 'divider' })
|
||||
options.push(getDeleteOption())
|
||||
|
||||
return options
|
||||
})
|
||||
|
||||
// Computed property to get only menu items with submenus
|
||||
const menuOptionsWithSubmenu = computed(() =>
|
||||
menuOptions.value.filter((option) => option.hasSubmenu && option.submenu)
|
||||
)
|
||||
|
||||
return {
|
||||
menuOptions,
|
||||
menuOptionsWithSubmenu,
|
||||
bump,
|
||||
hasSubgraphs,
|
||||
registerNodeOptionsInstance
|
||||
}
|
||||
}
|
||||
106
src/composables/graph/useNodeArrangement.ts
Normal file
106
src/composables/graph/useNodeArrangement.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { Direction } from '@/lib/litegraph/src/interfaces'
|
||||
import { alignNodes, distributeNodes } from '@/lib/litegraph/src/utils/arrange'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||
|
||||
import { useCanvasRefresh } from './useCanvasRefresh'
|
||||
|
||||
interface AlignOption {
|
||||
name: string
|
||||
localizedName: string
|
||||
value: Direction
|
||||
icon: string
|
||||
}
|
||||
|
||||
interface DistributeOption {
|
||||
name: string
|
||||
localizedName: string
|
||||
value: boolean // true for horizontal, false for vertical
|
||||
icon: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for handling node alignment and distribution
|
||||
*/
|
||||
export function useNodeArrangement() {
|
||||
const { t } = useI18n()
|
||||
const canvasStore = useCanvasStore()
|
||||
const canvasRefresh = useCanvasRefresh()
|
||||
const alignOptions: AlignOption[] = [
|
||||
{
|
||||
name: 'top',
|
||||
localizedName: t('contextMenu.Top'),
|
||||
value: 'top',
|
||||
icon: 'icon-[lucide--align-start-vertical]'
|
||||
},
|
||||
{
|
||||
name: 'bottom',
|
||||
localizedName: t('contextMenu.Bottom'),
|
||||
value: 'bottom',
|
||||
icon: 'icon-[lucide--align-end-vertical]'
|
||||
},
|
||||
{
|
||||
name: 'left',
|
||||
localizedName: t('contextMenu.Left'),
|
||||
value: 'left',
|
||||
icon: 'icon-[lucide--align-start-horizontal]'
|
||||
},
|
||||
{
|
||||
name: 'right',
|
||||
localizedName: t('contextMenu.Right'),
|
||||
value: 'right',
|
||||
icon: 'icon-[lucide--align-end-horizontal]'
|
||||
}
|
||||
]
|
||||
|
||||
const distributeOptions: DistributeOption[] = [
|
||||
{
|
||||
name: 'horizontal',
|
||||
localizedName: t('contextMenu.Horizontal'),
|
||||
value: true,
|
||||
icon: 'icon-[lucide--align-center-horizontal]'
|
||||
},
|
||||
{
|
||||
name: 'vertical',
|
||||
localizedName: t('contextMenu.Vertical'),
|
||||
value: false,
|
||||
icon: 'icon-[lucide--align-center-vertical]'
|
||||
}
|
||||
]
|
||||
|
||||
const applyAlign = (alignOption: AlignOption) => {
|
||||
const selectedNodes = Array.from(canvasStore.selectedItems).filter((item) =>
|
||||
isLGraphNode(item)
|
||||
)
|
||||
|
||||
if (selectedNodes.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
alignNodes(selectedNodes, alignOption.value)
|
||||
|
||||
canvasRefresh.refreshCanvas()
|
||||
}
|
||||
|
||||
const applyDistribute = (distributeOption: DistributeOption) => {
|
||||
const selectedNodes = Array.from(canvasStore.selectedItems).filter((item) =>
|
||||
isLGraphNode(item)
|
||||
)
|
||||
|
||||
if (selectedNodes.length < 2) {
|
||||
return
|
||||
}
|
||||
|
||||
distributeNodes(selectedNodes, distributeOption.value)
|
||||
canvasRefresh.refreshCanvas()
|
||||
}
|
||||
|
||||
return {
|
||||
alignOptions,
|
||||
distributeOptions,
|
||||
applyAlign,
|
||||
applyDistribute
|
||||
}
|
||||
}
|
||||
167
src/composables/graph/useNodeCustomization.ts
Normal file
167
src/composables/graph/useNodeCustomization.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
LGraphCanvas,
|
||||
LGraphNode,
|
||||
LiteGraph,
|
||||
RenderShape,
|
||||
isColorable
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { adjustColor } from '@/utils/colorUtil'
|
||||
|
||||
import { useCanvasRefresh } from './useCanvasRefresh'
|
||||
|
||||
interface ColorOption {
|
||||
name: string
|
||||
localizedName: string
|
||||
value: {
|
||||
dark: string
|
||||
light: string
|
||||
}
|
||||
}
|
||||
|
||||
interface ShapeOption {
|
||||
name: string
|
||||
localizedName: string
|
||||
value: RenderShape
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for handling node color and shape customization
|
||||
*/
|
||||
export function useNodeCustomization() {
|
||||
const { t } = useI18n()
|
||||
const canvasStore = useCanvasStore()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const canvasRefresh = useCanvasRefresh()
|
||||
const isLightTheme = computed(
|
||||
() => colorPaletteStore.completedActivePalette.light_theme
|
||||
)
|
||||
|
||||
const toLightThemeColor = (color: string) =>
|
||||
adjustColor(color, { lightness: 0.5 })
|
||||
|
||||
// Color options
|
||||
const NO_COLOR_OPTION: ColorOption = {
|
||||
name: 'noColor',
|
||||
localizedName: t('color.noColor'),
|
||||
value: {
|
||||
dark: LiteGraph.NODE_DEFAULT_BGCOLOR,
|
||||
light: toLightThemeColor(LiteGraph.NODE_DEFAULT_BGCOLOR)
|
||||
}
|
||||
}
|
||||
|
||||
const colorOptions: ColorOption[] = [
|
||||
NO_COLOR_OPTION,
|
||||
...Object.entries(LGraphCanvas.node_colors).map(([name, color]) => ({
|
||||
name,
|
||||
localizedName: t(`color.${name}`),
|
||||
value: {
|
||||
dark: color.bgcolor,
|
||||
light: toLightThemeColor(color.bgcolor)
|
||||
}
|
||||
}))
|
||||
]
|
||||
|
||||
// Shape options
|
||||
const shapeOptions: ShapeOption[] = [
|
||||
{
|
||||
name: 'default',
|
||||
localizedName: t('shape.default'),
|
||||
value: RenderShape.ROUND
|
||||
},
|
||||
{
|
||||
name: 'box',
|
||||
localizedName: t('shape.box'),
|
||||
value: RenderShape.BOX
|
||||
},
|
||||
{
|
||||
name: 'card',
|
||||
localizedName: t('shape.CARD'),
|
||||
value: RenderShape.CARD
|
||||
}
|
||||
]
|
||||
|
||||
const applyColor = (colorOption: ColorOption | null) => {
|
||||
const colorName = colorOption?.name ?? NO_COLOR_OPTION.name
|
||||
const canvasColorOption =
|
||||
colorName === NO_COLOR_OPTION.name
|
||||
? null
|
||||
: LGraphCanvas.node_colors[colorName]
|
||||
|
||||
for (const item of canvasStore.selectedItems) {
|
||||
if (isColorable(item)) {
|
||||
item.setColorOption(canvasColorOption)
|
||||
}
|
||||
}
|
||||
|
||||
canvasRefresh.refreshCanvas()
|
||||
}
|
||||
|
||||
const applyShape = (shapeOption: ShapeOption) => {
|
||||
const selectedNodes = Array.from(canvasStore.selectedItems).filter(
|
||||
(item): item is LGraphNode => item instanceof LGraphNode
|
||||
)
|
||||
|
||||
if (selectedNodes.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
selectedNodes.forEach((node) => {
|
||||
node.shape = shapeOption.value
|
||||
})
|
||||
|
||||
canvasRefresh.refreshCanvas()
|
||||
}
|
||||
|
||||
const getCurrentColor = (): ColorOption | null => {
|
||||
const selectedItems = Array.from(canvasStore.selectedItems)
|
||||
if (selectedItems.length === 0) return null
|
||||
|
||||
// Get color from first colorable item
|
||||
const firstColorableItem = selectedItems.find((item) => isColorable(item))
|
||||
if (!firstColorableItem || !isColorable(firstColorableItem)) return null
|
||||
|
||||
// Get the current color option from the colorable item
|
||||
const currentColorOption = firstColorableItem.getColorOption()
|
||||
const currentBgColor = currentColorOption?.bgcolor ?? null
|
||||
|
||||
// Find matching color option
|
||||
return (
|
||||
colorOptions.find(
|
||||
(option) =>
|
||||
option.value.dark === currentBgColor ||
|
||||
option.value.light === currentBgColor
|
||||
) ?? NO_COLOR_OPTION
|
||||
)
|
||||
}
|
||||
|
||||
const getCurrentShape = (): ShapeOption | null => {
|
||||
const selectedNodes = Array.from(canvasStore.selectedItems).filter(
|
||||
(item): item is LGraphNode => item instanceof LGraphNode
|
||||
)
|
||||
|
||||
if (selectedNodes.length === 0) return null
|
||||
|
||||
const firstNode = selectedNodes[0]
|
||||
const currentShape = firstNode.shape ?? RenderShape.ROUND
|
||||
|
||||
return (
|
||||
shapeOptions.find((option) => option.value === currentShape) ??
|
||||
shapeOptions[0]
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
colorOptions,
|
||||
shapeOptions,
|
||||
applyColor,
|
||||
applyShape,
|
||||
getCurrentColor,
|
||||
getCurrentShape,
|
||||
isLightTheme
|
||||
}
|
||||
}
|
||||
128
src/composables/graph/useNodeMenuOptions.ts
Normal file
128
src/composables/graph/useNodeMenuOptions.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { MenuOption } from './useMoreOptionsMenu'
|
||||
import { useNodeCustomization } from './useNodeCustomization'
|
||||
import { useSelectedNodeActions } from './useSelectedNodeActions'
|
||||
import type { NodeSelectionState } from './useSelectionState'
|
||||
|
||||
/**
|
||||
* Composable for node-related menu operations
|
||||
*/
|
||||
export function useNodeMenuOptions() {
|
||||
const { t } = useI18n()
|
||||
const { shapeOptions, applyShape, applyColor, colorOptions, isLightTheme } =
|
||||
useNodeCustomization()
|
||||
const {
|
||||
adjustNodeSize,
|
||||
toggleNodeCollapse,
|
||||
toggleNodePin,
|
||||
toggleNodeBypass,
|
||||
runBranch
|
||||
} = useSelectedNodeActions()
|
||||
|
||||
const shapeSubmenu = computed(() =>
|
||||
shapeOptions.map((shape) => ({
|
||||
label: shape.localizedName,
|
||||
action: () => applyShape(shape)
|
||||
}))
|
||||
)
|
||||
|
||||
const colorSubmenu = computed(() => {
|
||||
return colorOptions.map((colorOption) => ({
|
||||
label: colorOption.localizedName,
|
||||
color: isLightTheme.value
|
||||
? colorOption.value.light
|
||||
: colorOption.value.dark,
|
||||
action: () =>
|
||||
applyColor(colorOption.name === 'noColor' ? null : colorOption)
|
||||
}))
|
||||
})
|
||||
|
||||
const getAdjustSizeOption = (): MenuOption => ({
|
||||
label: t('contextMenu.Adjust Size'),
|
||||
icon: 'icon-[lucide--move-diagonal-2]',
|
||||
action: adjustNodeSize
|
||||
})
|
||||
|
||||
const getNodeVisualOptions = (
|
||||
states: NodeSelectionState,
|
||||
bump: () => void
|
||||
): MenuOption[] => [
|
||||
{
|
||||
label: states.collapsed
|
||||
? t('contextMenu.Expand Node')
|
||||
: t('contextMenu.Minimize Node'),
|
||||
icon: states.collapsed
|
||||
? 'icon-[lucide--maximize-2]'
|
||||
: 'icon-[lucide--minimize-2]',
|
||||
action: () => {
|
||||
toggleNodeCollapse()
|
||||
bump()
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('contextMenu.Shape'),
|
||||
icon: 'icon-[lucide--box]',
|
||||
hasSubmenu: true,
|
||||
submenu: shapeSubmenu.value,
|
||||
action: () => {}
|
||||
},
|
||||
{
|
||||
label: t('contextMenu.Color'),
|
||||
icon: 'icon-[lucide--palette]',
|
||||
hasSubmenu: true,
|
||||
submenu: colorSubmenu.value,
|
||||
action: () => {}
|
||||
}
|
||||
]
|
||||
|
||||
const getPinOption = (
|
||||
states: NodeSelectionState,
|
||||
bump: () => void
|
||||
): MenuOption => ({
|
||||
label: states.pinned ? t('contextMenu.Unpin') : t('contextMenu.Pin'),
|
||||
icon: states.pinned ? 'icon-[lucide--pin-off]' : 'icon-[lucide--pin]',
|
||||
action: () => {
|
||||
toggleNodePin()
|
||||
bump()
|
||||
}
|
||||
})
|
||||
|
||||
const getBypassOption = (
|
||||
states: NodeSelectionState,
|
||||
bump: () => void
|
||||
): MenuOption => ({
|
||||
label: states.bypassed
|
||||
? t('contextMenu.Remove Bypass')
|
||||
: t('contextMenu.Bypass'),
|
||||
icon: states.bypassed ? 'icon-[lucide--zap-off]' : 'icon-[lucide--ban]',
|
||||
shortcut: 'Ctrl+B',
|
||||
action: () => {
|
||||
toggleNodeBypass()
|
||||
bump()
|
||||
}
|
||||
})
|
||||
|
||||
const getRunBranchOption = (): MenuOption => ({
|
||||
label: t('contextMenu.Run Branch'),
|
||||
icon: 'icon-[lucide--play]',
|
||||
action: runBranch
|
||||
})
|
||||
|
||||
const getNodeInfoOption = (showNodeHelp: () => void): MenuOption => ({
|
||||
label: t('contextMenu.Node Info'),
|
||||
icon: 'icon-[lucide--info]',
|
||||
action: showNodeHelp
|
||||
})
|
||||
|
||||
return {
|
||||
getNodeInfoOption,
|
||||
getAdjustSizeOption,
|
||||
getNodeVisualOptions,
|
||||
getPinOption,
|
||||
getBypassOption,
|
||||
getRunBranchOption,
|
||||
colorSubmenu
|
||||
}
|
||||
}
|
||||
68
src/composables/graph/useSelectedNodeActions.ts
Normal file
68
src/composables/graph/useSelectedNodeActions.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
|
||||
|
||||
/**
|
||||
* Composable for handling node information and utility operations
|
||||
*/
|
||||
export function useSelectedNodeActions() {
|
||||
const { getSelectedNodes, toggleSelectedNodesMode } =
|
||||
useSelectedLiteGraphItems()
|
||||
const commandStore = useCommandStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const adjustNodeSize = () => {
|
||||
const selectedNodes = getSelectedNodes()
|
||||
|
||||
selectedNodes.forEach((node) => {
|
||||
const optimalSize = node.computeSize()
|
||||
node.setSize([optimalSize[0], optimalSize[1]])
|
||||
})
|
||||
|
||||
app.canvas.setDirty(true, true)
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
|
||||
const toggleNodeCollapse = () => {
|
||||
const selectedNodes = getSelectedNodes()
|
||||
selectedNodes.forEach((node) => {
|
||||
node.collapse()
|
||||
})
|
||||
|
||||
app.canvas.setDirty(true, true)
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
|
||||
const toggleNodePin = () => {
|
||||
const selectedNodes = getSelectedNodes()
|
||||
selectedNodes.forEach((node) => {
|
||||
node.pin(!node.pinned)
|
||||
})
|
||||
|
||||
app.canvas.setDirty(true, true)
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
|
||||
const toggleNodeBypass = () => {
|
||||
toggleSelectedNodesMode(LGraphEventMode.BYPASS)
|
||||
app.canvas.setDirty(true, true)
|
||||
}
|
||||
|
||||
const runBranch = async () => {
|
||||
const selectedNodes = getSelectedNodes()
|
||||
const selectedOutputNodes = filterOutputNodes(selectedNodes)
|
||||
if (selectedOutputNodes.length === 0) return
|
||||
await commandStore.execute('Comfy.QueueSelectedOutputNodes')
|
||||
}
|
||||
|
||||
return {
|
||||
adjustNodeSize,
|
||||
toggleNodeCollapse,
|
||||
toggleNodePin,
|
||||
toggleNodeBypass,
|
||||
runBranch
|
||||
}
|
||||
}
|
||||
147
src/composables/graph/useSelectionMenuOptions.ts
Normal file
147
src/composables/graph/useSelectionMenuOptions.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
import { useFrameNodes } from './useFrameNodes'
|
||||
import { BadgeVariant, type MenuOption } from './useMoreOptionsMenu'
|
||||
import { useNodeArrangement } from './useNodeArrangement'
|
||||
import { useSelectionOperations } from './useSelectionOperations'
|
||||
import { useSubgraphOperations } from './useSubgraphOperations'
|
||||
|
||||
/**
|
||||
* Composable for selection-related menu operations
|
||||
*/
|
||||
export function useSelectionMenuOptions() {
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
copySelection,
|
||||
duplicateSelection,
|
||||
deleteSelection,
|
||||
renameSelection
|
||||
} = useSelectionOperations()
|
||||
|
||||
const { alignOptions, distributeOptions, applyAlign, applyDistribute } =
|
||||
useNodeArrangement()
|
||||
|
||||
const { convertToSubgraph, unpackSubgraph, addSubgraphToLibrary } =
|
||||
useSubgraphOperations()
|
||||
|
||||
const { frameNodes } = useFrameNodes()
|
||||
|
||||
const alignSubmenu = computed(() =>
|
||||
alignOptions.map((align) => ({
|
||||
label: align.localizedName,
|
||||
icon: align.icon,
|
||||
action: () => applyAlign(align)
|
||||
}))
|
||||
)
|
||||
|
||||
const distributeSubmenu = computed(() =>
|
||||
distributeOptions.map((distribute) => ({
|
||||
label: distribute.localizedName,
|
||||
icon: distribute.icon,
|
||||
action: () => applyDistribute(distribute)
|
||||
}))
|
||||
)
|
||||
|
||||
const getBasicSelectionOptions = (): MenuOption[] => [
|
||||
{
|
||||
label: t('contextMenu.Rename'),
|
||||
action: renameSelection
|
||||
},
|
||||
{
|
||||
label: t('contextMenu.Copy'),
|
||||
shortcut: 'Ctrl+C',
|
||||
action: copySelection
|
||||
},
|
||||
{
|
||||
label: t('contextMenu.Duplicate'),
|
||||
shortcut: 'Ctrl+D',
|
||||
action: duplicateSelection
|
||||
}
|
||||
]
|
||||
|
||||
const getSubgraphOptions = (hasSubgraphs: boolean): MenuOption[] => {
|
||||
if (hasSubgraphs) {
|
||||
return [
|
||||
{
|
||||
label: t('contextMenu.Add Subgraph to Library'),
|
||||
icon: 'icon-[lucide--folder-plus]',
|
||||
action: addSubgraphToLibrary
|
||||
},
|
||||
{
|
||||
label: t('contextMenu.Unpack Subgraph'),
|
||||
icon: 'icon-[lucide--expand]',
|
||||
action: unpackSubgraph
|
||||
}
|
||||
]
|
||||
} else {
|
||||
return [
|
||||
{
|
||||
label: t('contextMenu.Convert to Subgraph'),
|
||||
icon: 'icon-[lucide--shrink]',
|
||||
action: convertToSubgraph,
|
||||
badge: BadgeVariant.NEW
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const getMultipleNodesOptions = (): MenuOption[] => {
|
||||
const convertToGroupNodes = () => {
|
||||
const commandStore = useCommandStore()
|
||||
void commandStore.execute(
|
||||
'Comfy.GroupNode.ConvertSelectedNodesToGroupNode'
|
||||
)
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: t('contextMenu.Convert to Group Node'),
|
||||
icon: 'icon-[lucide--group]',
|
||||
action: convertToGroupNodes,
|
||||
badge: BadgeVariant.DEPRECATED
|
||||
},
|
||||
{
|
||||
label: t('g.frameNodes'),
|
||||
icon: 'icon-[lucide--frame]',
|
||||
action: frameNodes
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const getAlignmentOptions = (): MenuOption[] => [
|
||||
{
|
||||
label: t('contextMenu.Align Selected To'),
|
||||
icon: 'icon-[lucide--align-start-horizontal]',
|
||||
hasSubmenu: true,
|
||||
submenu: alignSubmenu.value,
|
||||
action: () => {}
|
||||
},
|
||||
{
|
||||
label: t('contextMenu.Distribute Nodes'),
|
||||
icon: 'icon-[lucide--align-center-horizontal]',
|
||||
hasSubmenu: true,
|
||||
submenu: distributeSubmenu.value,
|
||||
action: () => {}
|
||||
}
|
||||
]
|
||||
|
||||
const getDeleteOption = (): MenuOption => ({
|
||||
label: t('contextMenu.Delete'),
|
||||
icon: 'icon-[lucide--trash-2]',
|
||||
shortcut: 'Delete',
|
||||
action: deleteSelection
|
||||
})
|
||||
|
||||
return {
|
||||
getBasicSelectionOptions,
|
||||
getSubgraphOptions,
|
||||
getMultipleNodesOptions,
|
||||
getDeleteOption,
|
||||
getAlignmentOptions,
|
||||
alignSubmenu,
|
||||
distributeSubmenu
|
||||
}
|
||||
}
|
||||
168
src/composables/graph/useSelectionOperations.ts
Normal file
168
src/composables/graph/useSelectionOperations.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
// import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems' // Unused for now
|
||||
import { t } from '@/i18n'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import {
|
||||
useCanvasStore,
|
||||
useTitleEditorStore
|
||||
} from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
/**
|
||||
* Composable for handling basic selection operations like copy, paste, duplicate, delete, rename
|
||||
*/
|
||||
export function useSelectionOperations() {
|
||||
// const { getSelectedNodes } = useSelectedLiteGraphItems() // Unused for now
|
||||
const canvasStore = useCanvasStore()
|
||||
const toastStore = useToastStore()
|
||||
const dialogService = useDialogService()
|
||||
const titleEditorStore = useTitleEditorStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const copySelection = () => {
|
||||
const canvas = app.canvas
|
||||
if (!canvas.selectedItems || canvas.selectedItems.size === 0) {
|
||||
toastStore.add({
|
||||
severity: 'warn',
|
||||
summary: t('g.nothingToCopy'),
|
||||
detail: t('g.selectItemsToCopy'),
|
||||
life: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
canvas.copyToClipboard()
|
||||
toastStore.add({
|
||||
severity: 'success',
|
||||
summary: t('g.copied'),
|
||||
detail: t('g.itemsCopiedToClipboard'),
|
||||
life: 2000
|
||||
})
|
||||
}
|
||||
|
||||
const pasteSelection = () => {
|
||||
const canvas = app.canvas
|
||||
canvas.pasteFromClipboard({ connectInputs: false })
|
||||
|
||||
// Trigger change tracking
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
|
||||
const duplicateSelection = () => {
|
||||
const canvas = app.canvas
|
||||
if (!canvas.selectedItems || canvas.selectedItems.size === 0) {
|
||||
toastStore.add({
|
||||
severity: 'warn',
|
||||
summary: t('g.nothingToDuplicate'),
|
||||
detail: t('g.selectItemsToDuplicate'),
|
||||
life: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Copy current selection
|
||||
canvas.copyToClipboard()
|
||||
|
||||
// Clear selection to avoid confusion
|
||||
canvas.selectedItems.clear()
|
||||
canvasStore.updateSelectedItems()
|
||||
|
||||
// Paste to create duplicates
|
||||
canvas.pasteFromClipboard({ connectInputs: false })
|
||||
|
||||
// Trigger change tracking
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
|
||||
const deleteSelection = () => {
|
||||
const canvas = app.canvas
|
||||
if (!canvas.selectedItems || canvas.selectedItems.size === 0) {
|
||||
toastStore.add({
|
||||
severity: 'warn',
|
||||
summary: t('g.nothingToDelete'),
|
||||
detail: t('g.selectItemsToDelete'),
|
||||
life: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
canvas.deleteSelected()
|
||||
canvas.setDirty(true, true)
|
||||
|
||||
// Trigger change tracking
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
|
||||
const renameSelection = async () => {
|
||||
const selectedItems = Array.from(canvasStore.selectedItems)
|
||||
|
||||
// Handle single node selection
|
||||
if (selectedItems.length === 1) {
|
||||
const item = selectedItems[0]
|
||||
|
||||
// For nodes, use the title editor
|
||||
if (item instanceof LGraphNode) {
|
||||
titleEditorStore.titleEditorTarget = item
|
||||
return
|
||||
}
|
||||
|
||||
// For other items like groups, use prompt dialog
|
||||
const currentTitle = 'title' in item ? (item.title as string) : ''
|
||||
const newTitle = await dialogService.prompt({
|
||||
title: t('g.rename'),
|
||||
message: t('g.enterNewName'),
|
||||
defaultValue: currentTitle
|
||||
})
|
||||
|
||||
if (newTitle && newTitle !== currentTitle) {
|
||||
if ('title' in item) {
|
||||
// Type-safe assignment for items with title property
|
||||
const titledItem = item as { title: string }
|
||||
titledItem.title = newTitle
|
||||
app.canvas.setDirty(true, true)
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle multiple selections - batch rename
|
||||
if (selectedItems.length > 1) {
|
||||
const baseTitle = await dialogService.prompt({
|
||||
title: t('g.batchRename'),
|
||||
message: t('g.enterBaseName'),
|
||||
defaultValue: 'Item'
|
||||
})
|
||||
|
||||
if (baseTitle) {
|
||||
selectedItems.forEach((item, index) => {
|
||||
if ('title' in item) {
|
||||
// Type-safe assignment for items with title property
|
||||
const titledItem = item as { title: string }
|
||||
titledItem.title = `${baseTitle} ${index + 1}`
|
||||
}
|
||||
})
|
||||
app.canvas.setDirty(true, true)
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
toastStore.add({
|
||||
severity: 'warn',
|
||||
summary: t('g.nothingToRename'),
|
||||
detail: t('g.selectItemsToRename'),
|
||||
life: 3000
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
copySelection,
|
||||
pasteSelection,
|
||||
duplicateSelection,
|
||||
deleteSelection,
|
||||
renameSelection
|
||||
}
|
||||
}
|
||||
143
src/composables/graph/useSelectionState.ts
Normal file
143
src/composables/graph/useSelectionState.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import { isImageNode, isLGraphNode, isLoad3dNode } from '@/utils/litegraphUtil'
|
||||
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
|
||||
|
||||
export interface NodeSelectionState {
|
||||
collapsed: boolean
|
||||
pinned: boolean
|
||||
bypassed: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralized computed selection state + shared helper actions to avoid duplication
|
||||
* between selection toolbox, context menus, and other UI affordances.
|
||||
*/
|
||||
export function useSelectionState() {
|
||||
const canvasStore = useCanvasStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const sidebarTabStore = useSidebarTabStore()
|
||||
const nodeHelpStore = useNodeHelpStore()
|
||||
const { id: nodeLibraryTabId } = useNodeLibrarySidebarTab()
|
||||
|
||||
const { selectedItems } = storeToRefs(canvasStore)
|
||||
|
||||
const selectedNodes = computed(() => {
|
||||
return selectedItems.value.filter((i: unknown) =>
|
||||
isLGraphNode(i)
|
||||
) as LGraphNode[]
|
||||
})
|
||||
|
||||
const nodeDef = computed(() => {
|
||||
if (selectedNodes.value.length !== 1) return null
|
||||
return nodeDefStore.fromLGraphNode(selectedNodes.value[0])
|
||||
})
|
||||
|
||||
const hasAnySelection = computed(() => selectedItems.value.length > 0)
|
||||
const hasSingleSelection = computed(() => selectedItems.value.length === 1)
|
||||
const hasMultipleSelection = computed(() => selectedItems.value.length > 1)
|
||||
|
||||
const isSingleNode = computed(
|
||||
() => hasSingleSelection.value && isLGraphNode(selectedItems.value[0])
|
||||
)
|
||||
const isSingleSubgraph = computed(
|
||||
() =>
|
||||
isSingleNode.value &&
|
||||
(selectedItems.value[0] as LGraphNode)?.isSubgraphNode?.()
|
||||
)
|
||||
const isSingleImageNode = computed(
|
||||
() =>
|
||||
isSingleNode.value && isImageNode(selectedItems.value[0] as LGraphNode)
|
||||
)
|
||||
|
||||
const hasSubgraphs = computed(() =>
|
||||
selectedItems.value.some((i: unknown) => i instanceof SubgraphNode)
|
||||
)
|
||||
|
||||
const hasAny3DNodeSelected = computed(() => {
|
||||
const enable3DViewer = useSettingStore().get('Comfy.Load3D.3DViewerEnable')
|
||||
return (
|
||||
selectedNodes.value.length === 1 &&
|
||||
selectedNodes.value.some(isLoad3dNode) &&
|
||||
enable3DViewer
|
||||
)
|
||||
})
|
||||
|
||||
const hasImageNode = computed(() => isSingleImageNode.value)
|
||||
const hasOutputNodesSelected = computed(
|
||||
() => filterOutputNodes(selectedNodes.value).length > 0
|
||||
)
|
||||
|
||||
// Helper function to compute selection flags (reused by both computed and function)
|
||||
const computeSelectionStatesFromNodes = (
|
||||
nodes: LGraphNode[]
|
||||
): NodeSelectionState => {
|
||||
if (!nodes.length)
|
||||
return { collapsed: false, pinned: false, bypassed: false }
|
||||
return {
|
||||
collapsed: nodes.some((n) => n.flags?.collapsed),
|
||||
pinned: nodes.some((n) => n.pinned),
|
||||
bypassed: nodes.some((n) => n.mode === LGraphEventMode.BYPASS)
|
||||
}
|
||||
}
|
||||
|
||||
const selectedNodesStates = computed<NodeSelectionState>(() =>
|
||||
computeSelectionStatesFromNodes(selectedNodes.value)
|
||||
)
|
||||
|
||||
// On-demand computation (non-reactive) so callers can fetch fresh flags
|
||||
const computeSelectionFlags = (): NodeSelectionState =>
|
||||
computeSelectionStatesFromNodes(selectedNodes.value)
|
||||
|
||||
/** Toggle node help sidebar/panel for the single selected node (if any). */
|
||||
const showNodeHelp = () => {
|
||||
const def = nodeDef.value
|
||||
if (!def) return
|
||||
|
||||
const isSidebarActive =
|
||||
sidebarTabStore.activeSidebarTabId === nodeLibraryTabId
|
||||
const currentHelpNode: any = nodeHelpStore.currentHelpNode
|
||||
const isSameNodeHelpOpen =
|
||||
isSidebarActive &&
|
||||
nodeHelpStore.isHelpOpen &&
|
||||
currentHelpNode &&
|
||||
currentHelpNode.nodePath === def.nodePath
|
||||
|
||||
if (isSameNodeHelpOpen) {
|
||||
nodeHelpStore.closeHelp()
|
||||
sidebarTabStore.toggleSidebarTab(nodeLibraryTabId)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isSidebarActive) sidebarTabStore.toggleSidebarTab(nodeLibraryTabId)
|
||||
nodeHelpStore.openHelp(def)
|
||||
}
|
||||
|
||||
return {
|
||||
selectedItems,
|
||||
selectedNodes,
|
||||
nodeDef,
|
||||
showNodeHelp,
|
||||
hasAny3DNodeSelected,
|
||||
hasAnySelection,
|
||||
hasSingleSelection,
|
||||
hasMultipleSelection,
|
||||
isSingleNode,
|
||||
isSingleSubgraph,
|
||||
isSingleImageNode,
|
||||
hasSubgraphs,
|
||||
hasImageNode,
|
||||
hasOutputNodesSelected,
|
||||
selectedNodesStates,
|
||||
computeSelectionFlags
|
||||
}
|
||||
}
|
||||
131
src/composables/graph/useSubgraphOperations.ts
Normal file
131
src/composables/graph/useSubgraphOperations.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||
|
||||
/**
|
||||
* Composable for handling subgraph-related operations
|
||||
*/
|
||||
export function useSubgraphOperations() {
|
||||
const { getSelectedNodes } = useSelectedLiteGraphItems()
|
||||
const canvasStore = useCanvasStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const nodeBookmarkStore = useNodeBookmarkStore()
|
||||
|
||||
const convertToSubgraph = () => {
|
||||
const canvas = canvasStore.getCanvas()
|
||||
const graph = canvas.subgraph ?? canvas.graph
|
||||
if (!graph) {
|
||||
return null
|
||||
}
|
||||
|
||||
const res = graph.convertToSubgraph(canvas.selectedItems)
|
||||
if (!res) {
|
||||
return
|
||||
}
|
||||
|
||||
const { node } = res
|
||||
canvas.select(node)
|
||||
canvasStore.updateSelectedItems()
|
||||
// Trigger change tracking
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
|
||||
const unpackSubgraph = () => {
|
||||
const canvas = canvasStore.getCanvas()
|
||||
const graph = canvas.subgraph ?? canvas.graph
|
||||
|
||||
if (!graph) {
|
||||
return
|
||||
}
|
||||
|
||||
const selectedItems = Array.from(canvas.selectedItems)
|
||||
const subgraphNodes = selectedItems.filter(
|
||||
(item): item is SubgraphNode => item instanceof SubgraphNode
|
||||
)
|
||||
|
||||
if (subgraphNodes.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
subgraphNodes.forEach((subgraphNode) => {
|
||||
// Revoke any image previews for the subgraph
|
||||
nodeOutputStore.revokeSubgraphPreviews(subgraphNode)
|
||||
|
||||
// Unpack the subgraph
|
||||
graph.unpackSubgraph(subgraphNode)
|
||||
})
|
||||
|
||||
// Trigger change tracking
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
|
||||
const addSubgraphToLibrary = async () => {
|
||||
const selectedItems = Array.from(canvasStore.selectedItems)
|
||||
|
||||
// Handle single node selection like BookmarkButton.vue
|
||||
if (selectedItems.length === 1) {
|
||||
const item = selectedItems[0]
|
||||
if (isLGraphNode(item)) {
|
||||
const nodeDef = nodeDefStore.fromLGraphNode(item)
|
||||
if (nodeDef) {
|
||||
await nodeBookmarkStore.addBookmark(nodeDef.nodePath)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle multiple nodes - convert to subgraph first then bookmark
|
||||
const selectedNodes = getSelectedNodes()
|
||||
|
||||
if (selectedNodes.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if selection contains subgraph nodes
|
||||
const hasSubgraphs = selectedNodes.some(
|
||||
(node) => node instanceof SubgraphNode
|
||||
)
|
||||
|
||||
if (!hasSubgraphs) {
|
||||
// Convert regular nodes to subgraph first
|
||||
convertToSubgraph()
|
||||
return
|
||||
}
|
||||
|
||||
// For subgraph nodes, bookmark them
|
||||
let bookmarkedCount = 0
|
||||
for (const node of selectedNodes) {
|
||||
if (node instanceof SubgraphNode) {
|
||||
const nodeDef = nodeDefStore.fromLGraphNode(node)
|
||||
if (nodeDef) {
|
||||
await nodeBookmarkStore.addBookmark(nodeDef.nodePath)
|
||||
bookmarkedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isSubgraphSelected = (): boolean => {
|
||||
const selectedItems = Array.from(canvasStore.selectedItems)
|
||||
return selectedItems.some((item) => item instanceof SubgraphNode)
|
||||
}
|
||||
|
||||
const hasSelectableNodes = (): boolean => {
|
||||
return getSelectedNodes().length > 0
|
||||
}
|
||||
|
||||
return {
|
||||
convertToSubgraph,
|
||||
unpackSubgraph,
|
||||
addSubgraphToLibrary,
|
||||
isSubgraphSelected,
|
||||
hasSelectableNodes
|
||||
}
|
||||
}
|
||||
163
src/composables/graph/useSubmenuPositioning.ts
Normal file
163
src/composables/graph/useSubmenuPositioning.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import type { MenuOption } from './useMoreOptionsMenu'
|
||||
|
||||
/**
|
||||
* Composable for handling submenu positioning logic
|
||||
*/
|
||||
export function useSubmenuPositioning() {
|
||||
/**
|
||||
* Toggle submenu visibility with proper positioning
|
||||
* @param option - Menu option with submenu
|
||||
* @param event - Click event
|
||||
* @param submenu - PrimeVue Popover reference
|
||||
* @param currentSubmenu - Currently open submenu name
|
||||
* @param menuOptionsWithSubmenu - All menu options with submenus
|
||||
* @param submenuRefs - References to all submenu popovers
|
||||
*/
|
||||
const toggleSubmenu = async (
|
||||
option: MenuOption,
|
||||
event: Event,
|
||||
submenu: any, // Component instance with show/hide methods
|
||||
currentSubmenu: { value: string | null },
|
||||
menuOptionsWithSubmenu: MenuOption[],
|
||||
submenuRefs: Record<string, any> // Component instances
|
||||
): Promise<void> => {
|
||||
if (!option.label || !option.hasSubmenu) return
|
||||
|
||||
// Check if this submenu is currently open
|
||||
const isCurrentlyOpen = currentSubmenu.value === option.label
|
||||
|
||||
// Hide all submenus first
|
||||
menuOptionsWithSubmenu.forEach((opt) => {
|
||||
const sm = submenuRefs[`submenu-${opt.label}`]
|
||||
if (sm) {
|
||||
sm.hide()
|
||||
}
|
||||
})
|
||||
currentSubmenu.value = null
|
||||
|
||||
// If it wasn't open before, show it now
|
||||
if (!isCurrentlyOpen) {
|
||||
currentSubmenu.value = option.label
|
||||
await nextTick()
|
||||
|
||||
const menuItem = event.currentTarget as HTMLElement
|
||||
const menuItemRect = menuItem.getBoundingClientRect()
|
||||
|
||||
// Find the parent popover content element that contains this menu item
|
||||
const mainPopoverContent = menuItem.closest(
|
||||
'[data-pc-section="content"]'
|
||||
) as HTMLElement
|
||||
|
||||
if (mainPopoverContent) {
|
||||
const mainPopoverRect = mainPopoverContent.getBoundingClientRect()
|
||||
|
||||
// Create a temporary positioned element as the target
|
||||
const tempTarget = createPositionedTarget(
|
||||
mainPopoverRect.right + 8,
|
||||
menuItemRect.top,
|
||||
`submenu-target-${option.label}`
|
||||
)
|
||||
|
||||
// Create event using the temp target
|
||||
const tempEvent = createMouseEvent(
|
||||
mainPopoverRect.right + 8,
|
||||
menuItemRect.top
|
||||
)
|
||||
|
||||
// Show submenu relative to temp target
|
||||
submenu.show(tempEvent, tempTarget)
|
||||
|
||||
// Clean up temp target after a delay
|
||||
cleanupTempTarget(tempTarget, 100)
|
||||
} else {
|
||||
// Fallback: position to the right of the menu item
|
||||
const tempTarget = createPositionedTarget(
|
||||
menuItemRect.right + 8,
|
||||
menuItemRect.top,
|
||||
`submenu-fallback-target-${option.label}`
|
||||
)
|
||||
|
||||
// Create event using the temp target
|
||||
const tempEvent = createMouseEvent(
|
||||
menuItemRect.right + 8,
|
||||
menuItemRect.top
|
||||
)
|
||||
|
||||
// Show submenu relative to temp target
|
||||
submenu.show(tempEvent, tempTarget)
|
||||
|
||||
// Clean up temp target after a delay
|
||||
cleanupTempTarget(tempTarget, 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a temporary positioned DOM element for submenu targeting
|
||||
*/
|
||||
const createPositionedTarget = (
|
||||
left: number,
|
||||
top: number,
|
||||
id: string
|
||||
): HTMLElement => {
|
||||
const tempTarget = document.createElement('div')
|
||||
tempTarget.style.position = 'absolute'
|
||||
tempTarget.style.left = `${left}px`
|
||||
tempTarget.style.top = `${top}px`
|
||||
tempTarget.style.width = '1px'
|
||||
tempTarget.style.height = '1px'
|
||||
tempTarget.style.pointerEvents = 'none'
|
||||
tempTarget.style.visibility = 'hidden'
|
||||
tempTarget.id = id
|
||||
|
||||
document.body.appendChild(tempTarget)
|
||||
return tempTarget
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mouse event with specific coordinates
|
||||
*/
|
||||
const createMouseEvent = (clientX: number, clientY: number): MouseEvent => {
|
||||
return new MouseEvent('click', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX,
|
||||
clientY
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up temporary target element after delay
|
||||
*/
|
||||
const cleanupTempTarget = (target: HTMLElement, delay: number): void => {
|
||||
setTimeout(() => {
|
||||
if (target.parentNode) {
|
||||
target.parentNode.removeChild(target)
|
||||
}
|
||||
}, delay)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide all submenus
|
||||
*/
|
||||
const hideAllSubmenus = (
|
||||
menuOptionsWithSubmenu: MenuOption[],
|
||||
submenuRefs: Record<string, any>, // Component instances
|
||||
currentSubmenu: { value: string | null }
|
||||
): void => {
|
||||
menuOptionsWithSubmenu.forEach((option) => {
|
||||
const submenu = submenuRefs[`submenu-${option.label}`]
|
||||
if (submenu) {
|
||||
submenu.hide()
|
||||
}
|
||||
})
|
||||
currentSubmenu.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
toggleSubmenu,
|
||||
hideAllSubmenus
|
||||
}
|
||||
}
|
||||
103
src/composables/graph/useViewportCulling.ts
Normal file
103
src/composables/graph/useViewportCulling.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Vue Nodes Viewport Culling
|
||||
*
|
||||
* Principles:
|
||||
* 1. Query DOM directly using data attributes (no cache to maintain)
|
||||
* 2. Set display none on element to avoid cascade resolution overhead
|
||||
* 3. Only run when transform changes (event driven)
|
||||
*/
|
||||
import { createSharedComposable, useThrottleFn } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
type Bounds = [left: number, right: number, top: number, bottom: number]
|
||||
|
||||
function getNodeBounds(node: LGraphNode): Bounds {
|
||||
const [nodeLeft, nodeTop] = node.pos
|
||||
const nodeRight = nodeLeft + node.size[0]
|
||||
const nodeBottom = nodeTop + node.size[1]
|
||||
return [nodeLeft, nodeRight, nodeTop, nodeBottom]
|
||||
}
|
||||
|
||||
function viewportEdges(
|
||||
canvas: ReturnType<typeof useCanvasStore>['canvas']
|
||||
): Bounds | undefined {
|
||||
if (!canvas) {
|
||||
return
|
||||
}
|
||||
const ds = canvas.ds
|
||||
const viewport_width = canvas.canvas.width
|
||||
const viewport_height = canvas.canvas.height
|
||||
const margin = 500 * ds.scale
|
||||
|
||||
const [xOffset, yOffset] = ds.offset
|
||||
|
||||
const leftEdge = -margin / ds.scale - xOffset
|
||||
const rightEdge = (viewport_width + margin) / ds.scale - xOffset
|
||||
const topEdge = -margin / ds.scale - yOffset
|
||||
const bottomEdge = (viewport_height + margin) / ds.scale - yOffset
|
||||
return [leftEdge, rightEdge, topEdge, bottomEdge]
|
||||
}
|
||||
|
||||
function boundsIntersect(boxA: Bounds, boxB: Bounds): boolean {
|
||||
const [aLeft, aRight, aTop, aBottom] = boxA
|
||||
const [bLeft, bRight, bTop, bBottom] = boxB
|
||||
|
||||
const leftOf = aRight < bLeft
|
||||
const rightOf = aLeft > bRight
|
||||
const above = aBottom < bTop
|
||||
const below = aTop > bBottom
|
||||
return !(leftOf || rightOf || above || below)
|
||||
}
|
||||
|
||||
function useViewportCullingIndividual() {
|
||||
const canvasStore = useCanvasStore()
|
||||
const { nodeManager } = useVueNodeLifecycle()
|
||||
|
||||
const viewport = computed(() => viewportEdges(canvasStore.canvas))
|
||||
|
||||
function inViewport(node: LGraphNode | undefined): boolean {
|
||||
if (!viewport.value || !node) {
|
||||
return true
|
||||
}
|
||||
const nodeBounds = getNodeBounds(node)
|
||||
return boundsIntersect(nodeBounds, viewport.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update visibility of all nodes based on viewport
|
||||
* Queries DOM directly - no cache maintenance needed
|
||||
*/
|
||||
function updateVisibility() {
|
||||
if (!nodeManager.value || !app.canvas) return // load bearing app.canvas check for workflows being loaded.
|
||||
|
||||
const nodeElements = document.querySelectorAll('[data-node-id]')
|
||||
for (const element of nodeElements) {
|
||||
const nodeId = element.getAttribute('data-node-id')
|
||||
if (!nodeId) continue
|
||||
|
||||
const node = nodeManager.value.getNode(nodeId)
|
||||
if (!node) continue
|
||||
|
||||
const displayValue = inViewport(node) ? '' : 'none'
|
||||
if (
|
||||
element instanceof HTMLElement &&
|
||||
element.style.display !== displayValue
|
||||
) {
|
||||
element.style.display = displayValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleTransformUpdate = useThrottleFn(() => updateVisibility, 100, true)
|
||||
|
||||
return { handleTransformUpdate }
|
||||
}
|
||||
|
||||
export const useViewportCulling = createSharedComposable(
|
||||
useViewportCullingIndividual
|
||||
)
|
||||
172
src/composables/graph/useVueNodeLifecycle.ts
Normal file
172
src/composables/graph/useVueNodeLifecycle.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
import { shallowRef, watch } from 'vue'
|
||||
|
||||
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
||||
import type { GraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync'
|
||||
import { useLinkLayoutSync } from '@/renderer/core/layout/sync/useLinkLayoutSync'
|
||||
import { useSlotLayoutSync } from '@/renderer/core/layout/sync/useSlotLayoutSync'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
|
||||
function useVueNodeLifecycleIndividual() {
|
||||
const canvasStore = useCanvasStore()
|
||||
const layoutMutations = useLayoutMutations()
|
||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
|
||||
const nodeManager = shallowRef<GraphNodeManager | null>(null)
|
||||
|
||||
const { startSync } = useLayoutSync()
|
||||
const linkSyncManager = useLinkLayoutSync()
|
||||
const slotSyncManager = useSlotLayoutSync()
|
||||
|
||||
const initializeNodeManager = () => {
|
||||
// Use canvas graph if available (handles subgraph contexts), fallback to app graph
|
||||
const activeGraph = comfyApp.canvas?.graph
|
||||
if (!activeGraph || nodeManager.value) return
|
||||
|
||||
// Initialize the core node manager
|
||||
const manager = useGraphNodeManager(activeGraph)
|
||||
nodeManager.value = manager
|
||||
|
||||
// Initialize layout system with existing nodes from active graph
|
||||
const nodes = activeGraph._nodes.map((node: LGraphNode) => ({
|
||||
id: node.id.toString(),
|
||||
pos: [node.pos[0], node.pos[1]] as [number, number],
|
||||
size: [node.size[0], node.size[1]] as [number, number]
|
||||
}))
|
||||
layoutStore.initializeFromLiteGraph(nodes)
|
||||
|
||||
// Seed reroutes into the Layout Store so hit-testing uses the new path
|
||||
for (const reroute of activeGraph.reroutes.values()) {
|
||||
const [x, y] = reroute.pos
|
||||
const parent = reroute.parentId ?? undefined
|
||||
const linkIds = Array.from(reroute.linkIds)
|
||||
layoutMutations.createReroute(reroute.id, { x, y }, parent, linkIds)
|
||||
}
|
||||
|
||||
// Seed existing links into the Layout Store (topology only)
|
||||
for (const link of activeGraph._links.values()) {
|
||||
layoutMutations.createLink(
|
||||
link.id,
|
||||
link.origin_id,
|
||||
link.origin_slot,
|
||||
link.target_id,
|
||||
link.target_slot
|
||||
)
|
||||
}
|
||||
|
||||
// Initialize layout sync (one-way: Layout Store → LiteGraph)
|
||||
startSync(canvasStore.canvas)
|
||||
|
||||
if (comfyApp.canvas) {
|
||||
linkSyncManager.start(comfyApp.canvas)
|
||||
}
|
||||
}
|
||||
|
||||
const disposeNodeManagerAndSyncs = () => {
|
||||
if (!nodeManager.value) return
|
||||
|
||||
try {
|
||||
nodeManager.value.cleanup()
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
nodeManager.value = null
|
||||
|
||||
linkSyncManager.stop()
|
||||
}
|
||||
|
||||
// Watch for Vue nodes enabled state changes
|
||||
watch(
|
||||
() => shouldRenderVueNodes.value && Boolean(comfyApp.canvas?.graph),
|
||||
(enabled) => {
|
||||
if (enabled) {
|
||||
initializeNodeManager()
|
||||
} else {
|
||||
disposeNodeManagerAndSyncs()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Consolidated watch for slot layout sync management
|
||||
watch(
|
||||
[() => canvasStore.canvas, () => shouldRenderVueNodes.value],
|
||||
([canvas, vueMode], [, oldVueMode]) => {
|
||||
const modeChanged = vueMode !== oldVueMode
|
||||
|
||||
// Clear stale slot layouts when switching modes
|
||||
if (modeChanged) {
|
||||
layoutStore.clearAllSlotLayouts()
|
||||
}
|
||||
|
||||
// Switching to Vue
|
||||
if (vueMode) {
|
||||
slotSyncManager.stop()
|
||||
}
|
||||
|
||||
// Switching to LG
|
||||
const shouldRun = Boolean(canvas?.graph) && !vueMode
|
||||
if (shouldRun && canvas) {
|
||||
slotSyncManager.attemptStart(canvas as LGraphCanvas)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Handle case where Vue nodes are enabled but graph starts empty
|
||||
const setupEmptyGraphListener = () => {
|
||||
const activeGraph = comfyApp.canvas?.graph
|
||||
if (
|
||||
!shouldRenderVueNodes.value ||
|
||||
nodeManager.value ||
|
||||
activeGraph?._nodes.length !== 0
|
||||
) {
|
||||
return
|
||||
}
|
||||
const originalOnNodeAdded = activeGraph.onNodeAdded
|
||||
activeGraph.onNodeAdded = function (node: LGraphNode) {
|
||||
// Restore original handler
|
||||
activeGraph.onNodeAdded = originalOnNodeAdded
|
||||
|
||||
// Initialize node manager if needed
|
||||
if (shouldRenderVueNodes.value && !nodeManager.value) {
|
||||
initializeNodeManager()
|
||||
}
|
||||
|
||||
// Call original handler
|
||||
if (originalOnNodeAdded) {
|
||||
originalOnNodeAdded.call(this, node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup function for component unmounting
|
||||
const cleanup = () => {
|
||||
if (nodeManager.value) {
|
||||
nodeManager.value.cleanup()
|
||||
nodeManager.value = null
|
||||
}
|
||||
slotSyncManager.stop()
|
||||
linkSyncManager.stop()
|
||||
}
|
||||
|
||||
return {
|
||||
nodeManager,
|
||||
|
||||
// Lifecycle methods
|
||||
initializeNodeManager,
|
||||
disposeNodeManagerAndSyncs,
|
||||
setupEmptyGraphListener,
|
||||
cleanup
|
||||
}
|
||||
}
|
||||
|
||||
export const useVueNodeLifecycle = createSharedComposable(
|
||||
useVueNodeLifecycleIndividual
|
||||
)
|
||||
149
src/composables/graph/useWidgetValue.ts
Normal file
149
src/composables/graph/useWidgetValue.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Composable for managing widget value synchronization between Vue and LiteGraph
|
||||
* Provides consistent pattern for immediate UI updates and LiteGraph callbacks
|
||||
*/
|
||||
import { type Ref, ref, watch } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
|
||||
|
||||
interface UseWidgetValueOptions<T extends WidgetValue = WidgetValue, U = T> {
|
||||
/** The widget configuration from LiteGraph */
|
||||
widget: SimplifiedWidget<T>
|
||||
/** The current value from parent component */
|
||||
modelValue: T
|
||||
/** Default value if modelValue is null/undefined */
|
||||
defaultValue: T
|
||||
/** Emit function from component setup */
|
||||
emit: (event: 'update:modelValue', value: T) => void
|
||||
/** Optional value transformer before sending to LiteGraph */
|
||||
transform?: (value: U) => T
|
||||
}
|
||||
|
||||
interface UseWidgetValueReturn<T extends WidgetValue = WidgetValue, U = T> {
|
||||
/** Local value for immediate UI updates */
|
||||
localValue: Ref<T>
|
||||
/** Handler for user interactions */
|
||||
onChange: (newValue: U) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages widget value synchronization with LiteGraph
|
||||
*
|
||||
* @example
|
||||
* ```vue
|
||||
* const { localValue, onChange } = useWidgetValue({
|
||||
* widget: props.widget,
|
||||
* modelValue: props.modelValue,
|
||||
* defaultValue: ''
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function useWidgetValue<T extends WidgetValue = WidgetValue, U = T>({
|
||||
widget,
|
||||
modelValue,
|
||||
defaultValue,
|
||||
emit,
|
||||
transform
|
||||
}: UseWidgetValueOptions<T, U>): UseWidgetValueReturn<T, U> {
|
||||
// Local value for immediate UI updates
|
||||
const localValue = ref<T>(modelValue ?? defaultValue)
|
||||
|
||||
// Handle user changes
|
||||
const onChange = (newValue: U) => {
|
||||
// Handle different PrimeVue component signatures
|
||||
let processedValue: T
|
||||
if (transform) {
|
||||
processedValue = transform(newValue)
|
||||
} else {
|
||||
// Ensure type safety - only cast when types are compatible
|
||||
if (
|
||||
typeof newValue === typeof defaultValue ||
|
||||
newValue === null ||
|
||||
newValue === undefined
|
||||
) {
|
||||
processedValue = (newValue ?? defaultValue) as T
|
||||
} else {
|
||||
console.warn(
|
||||
`useWidgetValue: Type mismatch for widget ${widget.name}. Expected ${typeof defaultValue}, got ${typeof newValue}`
|
||||
)
|
||||
processedValue = defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Update local state for immediate UI feedback
|
||||
localValue.value = processedValue
|
||||
|
||||
// 2. Emit to parent component
|
||||
emit('update:modelValue', processedValue)
|
||||
}
|
||||
|
||||
// Watch for external updates from LiteGraph
|
||||
watch(
|
||||
() => modelValue,
|
||||
(newValue) => {
|
||||
localValue.value = newValue ?? defaultValue
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
localValue: localValue as Ref<T>,
|
||||
onChange
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-specific helper for string widgets
|
||||
*/
|
||||
export function useStringWidgetValue(
|
||||
widget: SimplifiedWidget<string>,
|
||||
modelValue: string,
|
||||
emit: (event: 'update:modelValue', value: string) => void
|
||||
) {
|
||||
return useWidgetValue({
|
||||
widget,
|
||||
modelValue,
|
||||
defaultValue: '',
|
||||
emit,
|
||||
transform: (value: string | undefined) => String(value || '') // Handle undefined from PrimeVue
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-specific helper for number widgets
|
||||
*/
|
||||
export function useNumberWidgetValue(
|
||||
widget: SimplifiedWidget<number>,
|
||||
modelValue: number,
|
||||
emit: (event: 'update:modelValue', value: number) => void
|
||||
) {
|
||||
return useWidgetValue({
|
||||
widget,
|
||||
modelValue,
|
||||
defaultValue: 0,
|
||||
emit,
|
||||
transform: (value: number | number[]) => {
|
||||
// Handle PrimeVue Slider which can emit number | number[]
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0 ? value[0] ?? 0 : 0
|
||||
}
|
||||
return Number(value) || 0
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-specific helper for boolean widgets
|
||||
*/
|
||||
export function useBooleanWidgetValue(
|
||||
widget: SimplifiedWidget<boolean>,
|
||||
modelValue: boolean,
|
||||
emit: (event: 'update:modelValue', value: boolean) => void
|
||||
) {
|
||||
return useWidgetValue({
|
||||
widget,
|
||||
modelValue,
|
||||
defaultValue: false,
|
||||
emit,
|
||||
transform: (value: boolean) => Boolean(value)
|
||||
})
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import {
|
||||
ManagerState,
|
||||
ManagerTab,
|
||||
SortableAlgoliaField
|
||||
} from '@/types/comfyManagerTypes'
|
||||
|
||||
const STORAGE_KEY = 'Comfy.Manager.UI.State'
|
||||
|
||||
export const useManagerStatePersistence = () => {
|
||||
/**
|
||||
* Load the UI state from localStorage.
|
||||
*/
|
||||
const loadStoredState = (): ManagerState => {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) {
|
||||
return JSON.parse(stored)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load manager UI state:', e)
|
||||
}
|
||||
return {
|
||||
selectedTabId: ManagerTab.All,
|
||||
searchQuery: '',
|
||||
searchMode: 'packs',
|
||||
sortField: SortableAlgoliaField.Downloads
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the UI state to localStorage.
|
||||
*/
|
||||
const persistState = (state: ManagerState) => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the UI state to the default values.
|
||||
*/
|
||||
const reset = () => {
|
||||
persistState({
|
||||
selectedTabId: ManagerTab.All,
|
||||
searchQuery: '',
|
||||
searchMode: 'packs',
|
||||
sortField: SortableAlgoliaField.Downloads
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
loadStoredState,
|
||||
persistState,
|
||||
reset
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,11 @@ import {
|
||||
LGraphBadge,
|
||||
type LGraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExtensionStore } from '@/stores/extensionStore'
|
||||
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
import { adjustColor } from '@/utils/colorUtil'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useImagePreviewWidget } from '@/composables/widgets/useImagePreviewWidget'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useImagePreviewWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useImagePreviewWidget'
|
||||
|
||||
const CANVAS_IMAGE_PREVIEW_WIDGET = '$$canvas-image-preview'
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type ChatHistoryWidget from '@/components/graph/widgets/ChatHistoryWidget.vue'
|
||||
import { useChatHistoryWidget } from '@/composables/widgets/useChatHistoryWidget'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useChatHistoryWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useChatHistoryWidget'
|
||||
|
||||
const CHAT_HISTORY_WIDGET_NAME = '$$node-chat-history'
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { fitDimensionsToNodeWidth } from '@/utils/imageUtil'
|
||||
|
||||
@@ -34,7 +35,7 @@ const createContainer = () => {
|
||||
const createTimeout = (ms: number) =>
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), ms))
|
||||
|
||||
export const useNodePreview = <T extends MediaElement>(
|
||||
const useNodePreview = <T extends MediaElement>(
|
||||
node: LGraphNode,
|
||||
options: NodePreviewOptions<T>
|
||||
) => {
|
||||
@@ -130,6 +131,8 @@ export const useNodeVideo = (node: LGraphNode) => {
|
||||
let minHeight = DEFAULT_VIDEO_SIZE
|
||||
let minWidth = DEFAULT_VIDEO_SIZE
|
||||
|
||||
const { handleWheel, handlePointer } = useCanvasInteractions()
|
||||
|
||||
const setMinDimensions = (video: HTMLVideoElement) => {
|
||||
const { minHeight: calculatedHeight, minWidth: calculatedWidth } =
|
||||
fitDimensionsToNodeWidth(
|
||||
@@ -146,6 +149,12 @@ export const useNodeVideo = (node: LGraphNode) => {
|
||||
new Promise((resolve) => {
|
||||
const video = document.createElement('video')
|
||||
Object.assign(video, VIDEO_DEFAULT_OPTIONS)
|
||||
|
||||
// Add event listeners for canvas interactions
|
||||
video.addEventListener('wheel', handleWheel)
|
||||
video.addEventListener('pointermove', handlePointer)
|
||||
video.addEventListener('pointerdown', handlePointer)
|
||||
|
||||
video.onloadeddata = () => {
|
||||
setMinDimensions(video)
|
||||
resolve(video)
|
||||
|
||||
@@ -2,9 +2,9 @@ import { useNodeDragAndDrop } from '@/composables/node/useNodeDragAndDrop'
|
||||
import { useNodeFileInput } from '@/composables/node/useNodeFileInput'
|
||||
import { useNodePaste } from '@/composables/node/useNodePaste'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import type { ResultItemType } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
|
||||
const PASTED_IMAGE_EXPIRY_MS = 2000
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { INodeInputSlot, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
/**
|
||||
@@ -109,6 +109,66 @@ const pixversePricingCalculator = (node: LGraphNode): string => {
|
||||
return '$0.9/Run'
|
||||
}
|
||||
|
||||
const byteDanceVideoPricingCalculator = (node: LGraphNode): string => {
|
||||
const modelWidget = node.widgets?.find(
|
||||
(w) => w.name === 'model'
|
||||
) as IComboWidget
|
||||
const durationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'duration'
|
||||
) as IComboWidget
|
||||
const resolutionWidget = node.widgets?.find(
|
||||
(w) => w.name === 'resolution'
|
||||
) as IComboWidget
|
||||
|
||||
if (!modelWidget || !durationWidget || !resolutionWidget) return 'Token-based'
|
||||
|
||||
const model = String(modelWidget.value).toLowerCase()
|
||||
const resolution = String(resolutionWidget.value).toLowerCase()
|
||||
const seconds = parseFloat(String(durationWidget.value))
|
||||
const priceByModel: Record<string, Record<string, [number, number]>> = {
|
||||
'seedance-1-0-pro': {
|
||||
'480p': [0.23, 0.24],
|
||||
'720p': [0.51, 0.56],
|
||||
'1080p': [1.18, 1.22]
|
||||
},
|
||||
'seedance-1-0-lite': {
|
||||
'480p': [0.17, 0.18],
|
||||
'720p': [0.37, 0.41],
|
||||
'1080p': [0.85, 0.88]
|
||||
}
|
||||
}
|
||||
|
||||
const modelKey = model.includes('seedance-1-0-pro')
|
||||
? 'seedance-1-0-pro'
|
||||
: model.includes('seedance-1-0-lite')
|
||||
? 'seedance-1-0-lite'
|
||||
: ''
|
||||
|
||||
const resKey = resolution.includes('1080')
|
||||
? '1080p'
|
||||
: resolution.includes('720')
|
||||
? '720p'
|
||||
: resolution.includes('480')
|
||||
? '480p'
|
||||
: ''
|
||||
|
||||
const baseRange =
|
||||
modelKey && resKey ? priceByModel[modelKey]?.[resKey] : undefined
|
||||
if (!baseRange) return 'Token-based'
|
||||
|
||||
const [min10s, max10s] = baseRange
|
||||
const scale = seconds / 10
|
||||
const minCost = min10s * scale
|
||||
const maxCost = max10s * scale
|
||||
|
||||
const minStr = `$${minCost.toFixed(2)}/Run`
|
||||
const maxStr = `$${maxCost.toFixed(2)}/Run`
|
||||
|
||||
return minStr === maxStr
|
||||
? minStr
|
||||
: `$${minCost.toFixed(2)}-$${maxCost.toFixed(2)}/Run`
|
||||
}
|
||||
|
||||
/**
|
||||
* Static pricing data for API nodes, now supporting both strings and functions
|
||||
*/
|
||||
@@ -179,6 +239,12 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
const numImagesWidget = node.widgets?.find(
|
||||
(w) => w.name === 'num_images'
|
||||
) as IComboWidget
|
||||
const characterInput = node.inputs?.find(
|
||||
(i) => i.name === 'character_image'
|
||||
) as INodeInputSlot
|
||||
const hasCharacter =
|
||||
typeof characterInput?.link !== 'undefined' &&
|
||||
characterInput.link != null
|
||||
|
||||
if (!renderingSpeedWidget)
|
||||
return '$0.03-0.08 x num_images/Run (varies with rendering speed & num_images)'
|
||||
@@ -188,11 +254,23 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
|
||||
const renderingSpeed = String(renderingSpeedWidget.value)
|
||||
if (renderingSpeed.toLowerCase().includes('quality')) {
|
||||
basePrice = 0.09
|
||||
} else if (renderingSpeed.toLowerCase().includes('balanced')) {
|
||||
basePrice = 0.06
|
||||
if (hasCharacter) {
|
||||
basePrice = 0.2
|
||||
} else {
|
||||
basePrice = 0.09
|
||||
}
|
||||
} else if (renderingSpeed.toLowerCase().includes('default')) {
|
||||
if (hasCharacter) {
|
||||
basePrice = 0.15
|
||||
} else {
|
||||
basePrice = 0.06
|
||||
}
|
||||
} else if (renderingSpeed.toLowerCase().includes('turbo')) {
|
||||
basePrice = 0.03
|
||||
if (hasCharacter) {
|
||||
basePrice = 0.1
|
||||
} else {
|
||||
basePrice = 0.03
|
||||
}
|
||||
}
|
||||
|
||||
const totalCost = (basePrice * numImages).toFixed(2)
|
||||
@@ -395,7 +473,12 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
const modeValue = String(modeWidget.value)
|
||||
|
||||
// Same pricing matrix as KlingTextToVideoNode
|
||||
if (modeValue.includes('v2-master')) {
|
||||
if (modeValue.includes('v2-1')) {
|
||||
if (modeValue.includes('10s')) {
|
||||
return '$0.98/Run' // pro, 10s
|
||||
}
|
||||
return '$0.49/Run' // pro, 5s default
|
||||
} else if (modeValue.includes('v2-master')) {
|
||||
if (modeValue.includes('10s')) {
|
||||
return '$2.80/Run'
|
||||
}
|
||||
@@ -948,6 +1031,15 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
StabilityUpscaleFastNode: {
|
||||
displayPrice: '$0.01/Run'
|
||||
},
|
||||
StabilityTextToAudio: {
|
||||
displayPrice: '$0.20/Run'
|
||||
},
|
||||
StabilityAudioToAudio: {
|
||||
displayPrice: '$0.20/Run'
|
||||
},
|
||||
StabilityAudioInpaint: {
|
||||
displayPrice: '$0.20/Run'
|
||||
},
|
||||
VeoVideoGenerationNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const durationWidget = node.widgets?.find(
|
||||
@@ -970,7 +1062,7 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
) as IComboWidget
|
||||
|
||||
if (!modelWidget || !generateAudioWidget) {
|
||||
return '$2.00-6.00/Run (varies with model & audio generation)'
|
||||
return '$0.80-3.20/Run (varies with model & audio generation)'
|
||||
}
|
||||
|
||||
const model = String(modelWidget.value)
|
||||
@@ -978,13 +1070,13 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
String(generateAudioWidget.value).toLowerCase() === 'true'
|
||||
|
||||
if (model.includes('veo-3.0-fast-generate-001')) {
|
||||
return generateAudio ? '$3.20/Run' : '$2.00/Run'
|
||||
return generateAudio ? '$1.20/Run' : '$0.80/Run'
|
||||
} else if (model.includes('veo-3.0-generate-001')) {
|
||||
return generateAudio ? '$6.00/Run' : '$4.00/Run'
|
||||
return generateAudio ? '$3.20/Run' : '$1.60/Run'
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return '$2.00-6.00/Run'
|
||||
return '$0.80-3.20/Run'
|
||||
}
|
||||
},
|
||||
LumaImageNode: {
|
||||
@@ -1418,6 +1510,112 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
}
|
||||
return 'Token-based'
|
||||
}
|
||||
},
|
||||
ByteDanceSeedreamNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const sequentialGenerationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'sequential_image_generation'
|
||||
) as IComboWidget
|
||||
const maxImagesWidget = node.widgets?.find(
|
||||
(w) => w.name === 'max_images'
|
||||
) as IComboWidget
|
||||
|
||||
if (!sequentialGenerationWidget || !maxImagesWidget)
|
||||
return '$0.03/Run ($0.03 for one output image)'
|
||||
|
||||
if (
|
||||
String(sequentialGenerationWidget.value).toLowerCase() === 'disabled'
|
||||
) {
|
||||
return '$0.03/Run'
|
||||
}
|
||||
|
||||
const maxImages = Number(maxImagesWidget.value)
|
||||
if (maxImages === 1) {
|
||||
return '$0.03/Run'
|
||||
}
|
||||
const cost = (0.03 * maxImages).toFixed(2)
|
||||
return `$${cost}/Run ($0.03 for one output image)`
|
||||
}
|
||||
},
|
||||
ByteDanceTextToVideoNode: {
|
||||
displayPrice: byteDanceVideoPricingCalculator
|
||||
},
|
||||
ByteDanceImageToVideoNode: {
|
||||
displayPrice: byteDanceVideoPricingCalculator
|
||||
},
|
||||
ByteDanceFirstLastFrameNode: {
|
||||
displayPrice: byteDanceVideoPricingCalculator
|
||||
},
|
||||
ByteDanceImageReferenceNode: {
|
||||
displayPrice: byteDanceVideoPricingCalculator
|
||||
},
|
||||
WanTextToVideoApi: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const durationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'duration'
|
||||
) as IComboWidget
|
||||
const resolutionWidget = node.widgets?.find(
|
||||
(w) => w.name === 'size'
|
||||
) as IComboWidget
|
||||
|
||||
if (!durationWidget || !resolutionWidget) return '$0.05-0.15/second'
|
||||
|
||||
const seconds = parseFloat(String(durationWidget.value))
|
||||
const resolutionStr = String(resolutionWidget.value).toLowerCase()
|
||||
|
||||
const resKey = resolutionStr.includes('1080')
|
||||
? '1080p'
|
||||
: resolutionStr.includes('720')
|
||||
? '720p'
|
||||
: resolutionStr.includes('480')
|
||||
? '480p'
|
||||
: resolutionStr.match(/^\s*(\d{3,4}p)/)?.[1] ?? ''
|
||||
|
||||
const pricePerSecond: Record<string, number> = {
|
||||
'480p': 0.05,
|
||||
'720p': 0.1,
|
||||
'1080p': 0.15
|
||||
}
|
||||
|
||||
const pps = pricePerSecond[resKey]
|
||||
if (isNaN(seconds) || !pps) return '$0.05-0.15/second'
|
||||
|
||||
const cost = (pps * seconds).toFixed(2)
|
||||
return `$${cost}/Run`
|
||||
}
|
||||
},
|
||||
WanImageToVideoApi: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const durationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'duration'
|
||||
) as IComboWidget
|
||||
const resolutionWidget = node.widgets?.find(
|
||||
(w) => w.name === 'resolution'
|
||||
) as IComboWidget
|
||||
|
||||
if (!durationWidget || !resolutionWidget) return '$0.05-0.15/second'
|
||||
|
||||
const seconds = parseFloat(String(durationWidget.value))
|
||||
const resolution = String(resolutionWidget.value).trim().toLowerCase()
|
||||
|
||||
const pricePerSecond: Record<string, number> = {
|
||||
'480p': 0.05,
|
||||
'720p': 0.1,
|
||||
'1080p': 0.15
|
||||
}
|
||||
|
||||
const pps = pricePerSecond[resolution]
|
||||
if (isNaN(seconds) || !pps) return '$0.05-0.15/second'
|
||||
|
||||
const cost = (pps * seconds).toFixed(2)
|
||||
return `$${cost}/Run`
|
||||
}
|
||||
},
|
||||
WanTextToImageApi: {
|
||||
displayPrice: '$0.03/Run'
|
||||
},
|
||||
WanImageToImageApi: {
|
||||
displayPrice: '$0.03/Run'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1462,7 +1660,7 @@ export const useNodePricing = () => {
|
||||
OpenAIGPTImage1: ['quality', 'n'],
|
||||
IdeogramV1: ['num_images', 'turbo'],
|
||||
IdeogramV2: ['num_images', 'turbo'],
|
||||
IdeogramV3: ['rendering_speed', 'num_images'],
|
||||
IdeogramV3: ['rendering_speed', 'num_images', 'character_image'],
|
||||
FluxProKontextProNode: [],
|
||||
FluxProKontextMaxNode: [],
|
||||
VeoVideoGenerationNode: ['duration_seconds'],
|
||||
@@ -1508,7 +1706,18 @@ export const useNodePricing = () => {
|
||||
OpenAIChatNode: ['model'],
|
||||
// ByteDance
|
||||
ByteDanceImageNode: ['model'],
|
||||
ByteDanceImageEditNode: ['model']
|
||||
ByteDanceImageEditNode: ['model'],
|
||||
ByteDanceSeedreamNode: [
|
||||
'model',
|
||||
'sequential_image_generation',
|
||||
'max_images'
|
||||
],
|
||||
ByteDanceTextToVideoNode: ['model', 'duration', 'resolution'],
|
||||
ByteDanceImageToVideoNode: ['model', 'duration', 'resolution'],
|
||||
ByteDanceFirstLastFrameNode: ['model', 'duration', 'resolution'],
|
||||
ByteDanceImageReferenceNode: ['model', 'duration', 'resolution'],
|
||||
WanTextToVideoApi: ['duration', 'size'],
|
||||
WanImageToVideoApi: ['duration', 'resolution']
|
||||
}
|
||||
return widgetMap[nodeType] || []
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTextPreviewWidget } from '@/composables/widgets/useProgressTextWidget'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useTextPreviewWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useProgressTextWidget'
|
||||
|
||||
const TEXT_PREVIEW_WIDGET_NAME = '$$node-text-preview'
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { type ComputedRef, ref } from 'vue'
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
export interface UseComputedWithWidgetWatchOptions {
|
||||
interface UseComputedWithWidgetWatchOptions {
|
||||
/**
|
||||
* Names of widgets to observe for changes.
|
||||
* If not provided, all widgets will be observed.
|
||||
@@ -75,6 +75,29 @@ export const useComputedWithWidgetWatch = (
|
||||
}
|
||||
})
|
||||
})
|
||||
if (widgetNames && widgetNames.length > widgetsToObserve.length) {
|
||||
//Inputs have been included
|
||||
const indexesToObserve = widgetNames
|
||||
.map((name) =>
|
||||
widgetsToObserve.some((w) => w.name == name)
|
||||
? -1
|
||||
: node.inputs.findIndex((i) => i.name == name)
|
||||
)
|
||||
.filter((i) => i >= 0)
|
||||
node.onConnectionsChange = useChainCallback(
|
||||
node.onConnectionsChange,
|
||||
(_type: unknown, index: number, isConnected: boolean) => {
|
||||
if (!indexesToObserve.includes(index)) return
|
||||
widgetValues.value = {
|
||||
...widgetValues.value,
|
||||
[indexesToObserve[index]]: isConnected
|
||||
}
|
||||
if (triggerCanvasRedraw) {
|
||||
node.graph?.setDirtyCanvas(true, true)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Returns a function that creates a computed that responds to widget changes.
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { computed, onUnmounted } from 'vue'
|
||||
|
||||
import { useNodePacks } from '@/composables/nodePack/useNodePacks'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import { UseNodePacksOptions } from '@/types/comfyManagerTypes'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
export const useInstalledPacks = (options: UseNodePacksOptions = {}) => {
|
||||
const comfyManagerStore = useComfyManagerStore()
|
||||
|
||||
const installedPackIds = computed(() =>
|
||||
Array.from(comfyManagerStore.installedPacksIds)
|
||||
)
|
||||
|
||||
const { startFetch, cleanup, error, isLoading, nodePacks, isReady } =
|
||||
useNodePacks(installedPackIds, options)
|
||||
|
||||
const filterInstalledPack = (packs: components['schemas']['Node'][]) =>
|
||||
packs.filter((pack) => comfyManagerStore.isPackInstalled(pack.id))
|
||||
|
||||
const startFetchInstalled = async () => {
|
||||
await comfyManagerStore.refreshInstalledList()
|
||||
await startFetch()
|
||||
}
|
||||
|
||||
// When installedPackIds changes, we need to update the nodePacks
|
||||
whenever(installedPackIds, async () => {
|
||||
await startFetch()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
return {
|
||||
error,
|
||||
isLoading,
|
||||
isReady,
|
||||
installedPacks: nodePacks,
|
||||
startFetchInstalled,
|
||||
filterInstalledPack
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { groupBy } from 'es-toolkit/compat'
|
||||
import { computed, onMounted } from 'vue'
|
||||
|
||||
import { useWorkflowPacks } from '@/composables/nodePack/useWorkflowPacks'
|
||||
import { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import { collectAllNodes } from '@/utils/graphTraversalUtil'
|
||||
|
||||
/**
|
||||
* Composable to find missing NodePacks from workflow
|
||||
* Uses the same filtering approach as ManagerDialogContent.vue
|
||||
* Automatically fetches workflow pack data when initialized
|
||||
*/
|
||||
export const useMissingNodes = () => {
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const comfyManagerStore = useComfyManagerStore()
|
||||
const { workflowPacks, isLoading, error, startFetchWorkflowPacks } =
|
||||
useWorkflowPacks()
|
||||
|
||||
// Same filtering logic as ManagerDialogContent.vue
|
||||
const filterMissingPacks = (packs: components['schemas']['Node'][]) =>
|
||||
packs.filter((pack) => !comfyManagerStore.isPackInstalled(pack.id))
|
||||
|
||||
// Filter only uninstalled packs from workflow packs
|
||||
const missingNodePacks = computed(() => {
|
||||
if (!workflowPacks.value.length) return []
|
||||
return filterMissingPacks(workflowPacks.value)
|
||||
})
|
||||
|
||||
/**
|
||||
* Check if a pack is the ComfyUI builtin node pack (nodes that come pre-installed)
|
||||
* @param packId - The id of the pack to check
|
||||
* @returns True if the pack is the comfy-core pack, false otherwise
|
||||
*/
|
||||
const isCorePack = (packId: NodeProperty) => {
|
||||
return packId === 'comfy-core'
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node is a missing core node
|
||||
* A missing core node is a node that is in the workflow and originates from
|
||||
* the comfy-core pack (pre-installed) but not registered in the node def
|
||||
* store (the node def was not found on the server)
|
||||
* @param node - The node to check
|
||||
* @returns True if the node is a missing core node, false otherwise
|
||||
*/
|
||||
const isMissingCoreNode = (node: LGraphNode) => {
|
||||
const packId = node.properties?.cnr_id
|
||||
if (packId === undefined || !isCorePack(packId)) return false
|
||||
const nodeName = node.type
|
||||
const isRegisteredNodeDef = !!nodeDefStore.nodeDefsByName[nodeName]
|
||||
return !isRegisteredNodeDef
|
||||
}
|
||||
|
||||
const missingCoreNodes = computed<Record<string, LGraphNode[]>>(() => {
|
||||
const missingNodes = collectAllNodes(app.graph, isMissingCoreNode)
|
||||
return groupBy(missingNodes, (node) => String(node.properties?.ver || ''))
|
||||
})
|
||||
|
||||
// Automatically fetch workflow pack data when composable is used
|
||||
onMounted(async () => {
|
||||
if (!workflowPacks.value.length && !isLoading.value) {
|
||||
await startFetchWorkflowPacks()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
missingNodePacks,
|
||||
missingCoreNodes,
|
||||
isLoading,
|
||||
error
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { get, useAsyncState } from '@vueuse/core'
|
||||
import { Ref } from 'vue'
|
||||
|
||||
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
|
||||
import { UseNodePacksOptions } from '@/types/comfyManagerTypes'
|
||||
|
||||
/**
|
||||
* Handles fetching node packs from the registry given a list of node pack IDs
|
||||
*/
|
||||
export const useNodePacks = (
|
||||
packsIds: string[] | Ref<string[]>,
|
||||
options: UseNodePacksOptions = {}
|
||||
) => {
|
||||
const { immediate = false } = options
|
||||
const { getPacksByIds } = useComfyRegistryStore()
|
||||
|
||||
const fetchPacks = () => getPacksByIds.call(get(packsIds).filter(Boolean))
|
||||
|
||||
const {
|
||||
isReady,
|
||||
isLoading,
|
||||
error,
|
||||
execute,
|
||||
state: nodePacks
|
||||
} = useAsyncState(fetchPacks, [], {
|
||||
immediate
|
||||
})
|
||||
|
||||
const cleanup = () => {
|
||||
getPacksByIds.cancel()
|
||||
isReady.value = false
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
error,
|
||||
isLoading,
|
||||
isReady,
|
||||
nodePacks,
|
||||
startFetch: execute,
|
||||
cleanup
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import { compareVersions, isSemVer } from '@/utils/formatUtil'
|
||||
|
||||
export const usePackUpdateStatus = (
|
||||
nodePack: components['schemas']['Node']
|
||||
) => {
|
||||
const { isPackInstalled, getInstalledPackVersion } = useComfyManagerStore()
|
||||
|
||||
const isInstalled = computed(() => isPackInstalled(nodePack?.id))
|
||||
const installedVersion = computed(() =>
|
||||
getInstalledPackVersion(nodePack.id ?? '')
|
||||
)
|
||||
const latestVersion = computed(() => nodePack.latest_version?.version)
|
||||
|
||||
const isNightlyPack = computed(
|
||||
() => !!installedVersion.value && !isSemVer(installedVersion.value)
|
||||
)
|
||||
|
||||
const isUpdateAvailable = computed(() => {
|
||||
if (!isInstalled.value || isNightlyPack.value || !latestVersion.value) {
|
||||
return false
|
||||
}
|
||||
return compareVersions(latestVersion.value, installedVersion.value) > 0
|
||||
})
|
||||
|
||||
return {
|
||||
isUpdateAvailable,
|
||||
isNightlyPack,
|
||||
installedVersion,
|
||||
latestVersion
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { useNodePacks } from '@/composables/nodePack/useNodePacks'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
import { SelectedVersion, UseNodePacksOptions } from '@/types/comfyManagerTypes'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import { collectAllNodes } from '@/utils/graphTraversalUtil'
|
||||
|
||||
type WorkflowPack = {
|
||||
id:
|
||||
| ComfyWorkflowJSON['nodes'][number]['properties']['cnr_id']
|
||||
| ComfyWorkflowJSON['nodes'][number]['properties']['aux_id']
|
||||
version: ComfyWorkflowJSON['nodes'][number]['properties']['ver']
|
||||
}
|
||||
|
||||
const CORE_NODES_PACK_NAME = 'comfy-core'
|
||||
|
||||
/**
|
||||
* Handles parsing node pack metadata from nodes on the graph and fetching the
|
||||
* associated node packs from the registry
|
||||
*/
|
||||
export const useWorkflowPacks = (options: UseNodePacksOptions = {}) => {
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
const { inferPackFromNodeName } = useComfyRegistryStore()
|
||||
|
||||
const workflowPacks = ref<WorkflowPack[]>([])
|
||||
|
||||
const getWorkflowNodePackId = (node: LGraphNode): string | undefined => {
|
||||
if (typeof node.properties?.cnr_id === 'string') {
|
||||
return node.properties.cnr_id
|
||||
}
|
||||
if (typeof node.properties?.aux_id === 'string') {
|
||||
return node.properties.aux_id
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean the version string to be used in the registry search.
|
||||
* Removes the leading 'v' and trims whitespace and line terminators.
|
||||
*/
|
||||
const cleanVersionString = (version: string) =>
|
||||
version.replace(/^v/, '').trim()
|
||||
|
||||
/**
|
||||
* Infer the pack for a node by searching the registry for packs that have nodes
|
||||
* with the same name.
|
||||
*/
|
||||
const inferPack = async (
|
||||
node: LGraphNode
|
||||
): Promise<WorkflowPack | undefined> => {
|
||||
const nodeName = node.type
|
||||
|
||||
// Check if node is a core node
|
||||
const nodeDef = nodeDefStore.nodeDefsByName[nodeName]
|
||||
if (nodeDef?.nodeSource.type === 'core') {
|
||||
if (!systemStatsStore.systemStats) {
|
||||
await systemStatsStore.fetchSystemStats()
|
||||
}
|
||||
return {
|
||||
id: CORE_NODES_PACK_NAME,
|
||||
version:
|
||||
systemStatsStore.systemStats?.system?.comfyui_version ??
|
||||
SelectedVersion.NIGHTLY
|
||||
}
|
||||
}
|
||||
|
||||
// Query the registry to find which pack provides this node
|
||||
const pack = await inferPackFromNodeName.call(nodeName)
|
||||
|
||||
if (pack) {
|
||||
return {
|
||||
id: pack.id,
|
||||
version: pack.latest_version?.version ?? SelectedVersion.NIGHTLY
|
||||
}
|
||||
}
|
||||
|
||||
// No pack found - this node doesn't exist in the registry or couldn't be
|
||||
// extracted from the parent node pack successfully
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a workflow node to its pack using the node pack metadata.
|
||||
* If the node pack metadata is not available, fallback to searching the
|
||||
* registry for packs that have nodes with the same name.
|
||||
*/
|
||||
const workflowNodeToPack = async (
|
||||
node: LGraphNode
|
||||
): Promise<WorkflowPack | undefined> => {
|
||||
const packId = getWorkflowNodePackId(node)
|
||||
if (!packId) return inferPack(node) // Fallback
|
||||
if (packId === CORE_NODES_PACK_NAME) return undefined
|
||||
|
||||
const version =
|
||||
typeof node.properties.ver === 'string'
|
||||
? cleanVersionString(node.properties.ver)
|
||||
: undefined
|
||||
|
||||
return {
|
||||
id: packId,
|
||||
version
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the node packs for all nodes in the workflow (including subgraphs).
|
||||
*/
|
||||
const getWorkflowPacks = async () => {
|
||||
if (!app.graph) return []
|
||||
const allNodes = collectAllNodes(app.graph)
|
||||
if (!allNodes.length) return []
|
||||
const packs = await Promise.all(allNodes.map(workflowNodeToPack))
|
||||
workflowPacks.value = packs.filter((pack) => pack !== undefined)
|
||||
}
|
||||
|
||||
const packsToUniqueIds = (packs: WorkflowPack[]) =>
|
||||
packs.reduce((acc, pack) => {
|
||||
if (pack?.id) acc.add(pack.id)
|
||||
return acc
|
||||
}, new Set<string>())
|
||||
|
||||
const workflowPacksIds = computed(() =>
|
||||
Array.from(packsToUniqueIds(workflowPacks.value))
|
||||
)
|
||||
|
||||
const { startFetch, cleanup, error, isLoading, nodePacks, isReady } =
|
||||
useNodePacks(workflowPacksIds, options)
|
||||
|
||||
const isIdInWorkflow = (packId: string) =>
|
||||
workflowPacksIds.value.includes(packId)
|
||||
|
||||
const filterWorkflowPack = (packs: components['schemas']['Node'][]) =>
|
||||
packs.filter((pack) => !!pack.id && isIdInWorkflow(pack.id))
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
return {
|
||||
error,
|
||||
isLoading,
|
||||
isReady,
|
||||
workflowPacks: nodePacks,
|
||||
startFetchWorkflowPacks: async () => {
|
||||
await getWorkflowPacks() // Parse the packs from the workflow nodes
|
||||
await startFetch() // Fetch the packs infos from the registry
|
||||
},
|
||||
filterWorkflowPack
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { st } from '@/i18n'
|
||||
import {
|
||||
SettingTreeNode,
|
||||
getSettingInfo,
|
||||
useSettingStore
|
||||
} from '@/stores/settingStore'
|
||||
import { ISettingGroup, SettingParams } from '@/types/settingTypes'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
export function useSettingSearch() {
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const searchQuery = ref<string>('')
|
||||
const filteredSettingIds = ref<string[]>([])
|
||||
const searchInProgress = ref<boolean>(false)
|
||||
|
||||
watch(searchQuery, () => (searchInProgress.value = true))
|
||||
|
||||
/**
|
||||
* Settings categories that contains at least one setting in search results.
|
||||
*/
|
||||
const searchResultsCategories = computed<Set<string>>(() => {
|
||||
return new Set(
|
||||
filteredSettingIds.value.map(
|
||||
(id) => getSettingInfo(settingStore.settingsById[id]).category
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Check if the search query is empty
|
||||
*/
|
||||
const queryIsEmpty = computed(() => searchQuery.value.length === 0)
|
||||
|
||||
/**
|
||||
* Check if we're in search mode
|
||||
*/
|
||||
const inSearch = computed(
|
||||
() => !queryIsEmpty.value && !searchInProgress.value
|
||||
)
|
||||
|
||||
/**
|
||||
* Handle search functionality
|
||||
*/
|
||||
const handleSearch = (query: string) => {
|
||||
if (!query) {
|
||||
filteredSettingIds.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const queryLower = query.toLocaleLowerCase()
|
||||
const allSettings = Object.values(settingStore.settingsById)
|
||||
const filteredSettings = allSettings.filter((setting) => {
|
||||
// Filter out hidden and deprecated settings, just like in normal settings tree
|
||||
if (setting.type === 'hidden' || setting.deprecated) {
|
||||
return false
|
||||
}
|
||||
|
||||
const idLower = setting.id.toLowerCase()
|
||||
const nameLower = setting.name.toLowerCase()
|
||||
const translatedName = st(
|
||||
`settings.${normalizeI18nKey(setting.id)}.name`,
|
||||
setting.name
|
||||
).toLocaleLowerCase()
|
||||
const info = getSettingInfo(setting)
|
||||
const translatedCategory = st(
|
||||
`settingsCategories.${normalizeI18nKey(info.category)}`,
|
||||
info.category
|
||||
).toLocaleLowerCase()
|
||||
const translatedSubCategory = st(
|
||||
`settingsCategories.${normalizeI18nKey(info.subCategory)}`,
|
||||
info.subCategory
|
||||
).toLocaleLowerCase()
|
||||
|
||||
return (
|
||||
idLower.includes(queryLower) ||
|
||||
nameLower.includes(queryLower) ||
|
||||
translatedName.includes(queryLower) ||
|
||||
translatedCategory.includes(queryLower) ||
|
||||
translatedSubCategory.includes(queryLower)
|
||||
)
|
||||
})
|
||||
|
||||
filteredSettingIds.value = filteredSettings.map((x) => x.id)
|
||||
searchInProgress.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Get search results grouped by category
|
||||
*/
|
||||
const getSearchResults = (
|
||||
activeCategory: SettingTreeNode | null
|
||||
): ISettingGroup[] => {
|
||||
const groupedSettings: { [key: string]: SettingParams[] } = {}
|
||||
|
||||
filteredSettingIds.value.forEach((id) => {
|
||||
const setting = settingStore.settingsById[id]
|
||||
const info = getSettingInfo(setting)
|
||||
const groupLabel = info.subCategory
|
||||
|
||||
if (activeCategory === null || activeCategory.label === info.category) {
|
||||
if (!groupedSettings[groupLabel]) {
|
||||
groupedSettings[groupLabel] = []
|
||||
}
|
||||
groupedSettings[groupLabel].push(setting)
|
||||
}
|
||||
})
|
||||
|
||||
return Object.entries(groupedSettings).map(([label, settings]) => ({
|
||||
label,
|
||||
settings
|
||||
}))
|
||||
}
|
||||
|
||||
return {
|
||||
searchQuery,
|
||||
filteredSettingIds,
|
||||
searchInProgress,
|
||||
searchResultsCategories,
|
||||
queryIsEmpty,
|
||||
inSearch,
|
||||
handleSearch,
|
||||
getSearchResults
|
||||
}
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
import {
|
||||
type Component,
|
||||
computed,
|
||||
defineAsyncComponent,
|
||||
onMounted,
|
||||
ref
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { SettingTreeNode, useSettingStore } from '@/stores/settingStore'
|
||||
import type { SettingParams } from '@/types/settingTypes'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
import { buildTree } from '@/utils/treeUtil'
|
||||
|
||||
import { useCurrentUser } from '../auth/useCurrentUser'
|
||||
|
||||
interface SettingPanelItem {
|
||||
node: SettingTreeNode
|
||||
component: Component
|
||||
}
|
||||
|
||||
export function useSettingUI(
|
||||
defaultPanel?:
|
||||
| 'about'
|
||||
| 'keybinding'
|
||||
| 'extension'
|
||||
| 'server-config'
|
||||
| 'user'
|
||||
| 'credits'
|
||||
) {
|
||||
const { t } = useI18n()
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
const settingStore = useSettingStore()
|
||||
const activeCategory = ref<SettingTreeNode | null>(null)
|
||||
|
||||
const settingRoot = computed<SettingTreeNode>(() => {
|
||||
const root = buildTree(
|
||||
Object.values(settingStore.settingsById).filter(
|
||||
(setting: SettingParams) => setting.type !== 'hidden'
|
||||
),
|
||||
(setting: SettingParams) => setting.category || setting.id.split('.')
|
||||
)
|
||||
|
||||
const floatingSettings = (root.children ?? []).filter((node) => node.leaf)
|
||||
if (floatingSettings.length) {
|
||||
root.children = (root.children ?? []).filter((node) => !node.leaf)
|
||||
root.children.push({
|
||||
key: 'Other',
|
||||
label: 'Other',
|
||||
leaf: false,
|
||||
children: floatingSettings
|
||||
})
|
||||
}
|
||||
|
||||
return root
|
||||
})
|
||||
|
||||
const settingCategories = computed<SettingTreeNode[]>(
|
||||
() => settingRoot.value.children ?? []
|
||||
)
|
||||
|
||||
// Define panel items
|
||||
const aboutPanel: SettingPanelItem = {
|
||||
node: {
|
||||
key: 'about',
|
||||
label: 'About',
|
||||
children: []
|
||||
},
|
||||
component: defineAsyncComponent(
|
||||
() => import('@/components/dialog/content/setting/AboutPanel.vue')
|
||||
)
|
||||
}
|
||||
|
||||
const creditsPanel: SettingPanelItem = {
|
||||
node: {
|
||||
key: 'credits',
|
||||
label: 'Credits',
|
||||
children: []
|
||||
},
|
||||
component: defineAsyncComponent(
|
||||
() => import('@/components/dialog/content/setting/CreditsPanel.vue')
|
||||
)
|
||||
}
|
||||
|
||||
const userPanel: SettingPanelItem = {
|
||||
node: {
|
||||
key: 'user',
|
||||
label: 'User',
|
||||
children: []
|
||||
},
|
||||
component: defineAsyncComponent(
|
||||
() => import('@/components/dialog/content/setting/UserPanel.vue')
|
||||
)
|
||||
}
|
||||
|
||||
const keybindingPanel: SettingPanelItem = {
|
||||
node: {
|
||||
key: 'keybinding',
|
||||
label: 'Keybinding',
|
||||
children: []
|
||||
},
|
||||
component: defineAsyncComponent(
|
||||
() => import('@/components/dialog/content/setting/KeybindingPanel.vue')
|
||||
)
|
||||
}
|
||||
|
||||
const extensionPanel: SettingPanelItem = {
|
||||
node: {
|
||||
key: 'extension',
|
||||
label: 'Extension',
|
||||
children: []
|
||||
},
|
||||
component: defineAsyncComponent(
|
||||
() => import('@/components/dialog/content/setting/ExtensionPanel.vue')
|
||||
)
|
||||
}
|
||||
|
||||
const serverConfigPanel: SettingPanelItem = {
|
||||
node: {
|
||||
key: 'server-config',
|
||||
label: 'Server-Config',
|
||||
children: []
|
||||
},
|
||||
component: defineAsyncComponent(
|
||||
() => import('@/components/dialog/content/setting/ServerConfigPanel.vue')
|
||||
)
|
||||
}
|
||||
|
||||
const panels = computed<SettingPanelItem[]>(() =>
|
||||
[
|
||||
aboutPanel,
|
||||
creditsPanel,
|
||||
userPanel,
|
||||
keybindingPanel,
|
||||
extensionPanel,
|
||||
...(isElectron() ? [serverConfigPanel] : [])
|
||||
].filter((panel) => panel.component)
|
||||
)
|
||||
|
||||
/**
|
||||
* The default category to show when the dialog is opened.
|
||||
*/
|
||||
const defaultCategory = computed<SettingTreeNode>(() => {
|
||||
if (!defaultPanel) return settingCategories.value[0]
|
||||
// Search through all groups in groupedMenuTreeNodes
|
||||
for (const group of groupedMenuTreeNodes.value) {
|
||||
const found = group.children?.find((node) => node.key === defaultPanel)
|
||||
if (found) return found
|
||||
}
|
||||
return settingCategories.value[0]
|
||||
})
|
||||
|
||||
const translateCategory = (node: SettingTreeNode) => ({
|
||||
...node,
|
||||
translatedLabel: t(
|
||||
`settingsCategories.${normalizeI18nKey(node.label)}`,
|
||||
node.label
|
||||
)
|
||||
})
|
||||
|
||||
const groupedMenuTreeNodes = computed<SettingTreeNode[]>(() => [
|
||||
// Account settings - only show credits when user is authenticated
|
||||
{
|
||||
key: 'account',
|
||||
label: 'Account',
|
||||
children: [
|
||||
userPanel.node,
|
||||
...(isLoggedIn.value ? [creditsPanel.node] : [])
|
||||
].map(translateCategory)
|
||||
},
|
||||
// Normal settings stored in the settingStore
|
||||
{
|
||||
key: 'settings',
|
||||
label: 'Application Settings',
|
||||
children: settingCategories.value.map(translateCategory)
|
||||
},
|
||||
// Special settings such as about, keybinding, extension, server-config
|
||||
{
|
||||
key: 'specialSettings',
|
||||
label: 'Special Settings',
|
||||
children: [
|
||||
keybindingPanel.node,
|
||||
extensionPanel.node,
|
||||
aboutPanel.node,
|
||||
...(isElectron() ? [serverConfigPanel.node] : [])
|
||||
].map(translateCategory)
|
||||
}
|
||||
])
|
||||
|
||||
onMounted(() => {
|
||||
activeCategory.value = defaultCategory.value
|
||||
})
|
||||
|
||||
return {
|
||||
panels,
|
||||
activeCategory,
|
||||
defaultCategory,
|
||||
groupedMenuTreeNodes,
|
||||
settingCategories
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { markRaw } from 'vue'
|
||||
|
||||
import WorkflowsSidebarTab from '@/components/sidebar/tabs/WorkflowsSidebarTab.vue'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
import type { SidebarTabExtension } from '@/types/extensionTypes'
|
||||
|
||||
export const useWorkflowsSidebarTab = (): SidebarTabExtension => {
|
||||
const settingStore = useSettingStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
return {
|
||||
id: 'workflows',
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
iconBadge: () => {
|
||||
if (
|
||||
settingStore.get('Comfy.Workflow.WorkflowTabsPosition') !== 'Sidebar'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const value = workflowStore.openWorkflows.length.toString()
|
||||
return value === '0' ? null : value
|
||||
},
|
||||
title: 'sideToolbar.workflows',
|
||||
tooltip: 'sideToolbar.workflows',
|
||||
label: 'sideToolbar.labels.workflows',
|
||||
component: markRaw(WorkflowsSidebarTab),
|
||||
type: 'vue'
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,9 @@ import { useTitle } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
|
||||
const DEFAULT_TITLE = 'ComfyUI'
|
||||
const TITLE_SUFFIX = ' - ComfyUI'
|
||||
|
||||
@@ -4,7 +4,7 @@ import { paramsToCacheKey } from '@/utils/formatUtil'
|
||||
|
||||
const DEFAULT_MAX_SIZE = 50
|
||||
|
||||
export interface CachedRequestOptions {
|
||||
interface CachedRequestOptions {
|
||||
/**
|
||||
* Maximum number of items to store in the cache
|
||||
* @default 50
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import { Ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
|
||||
import { usePragmaticDroppable } from '@/composables/usePragmaticDragAndDrop'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useWorkflowService } from '@/services/workflowService'
|
||||
import { ComfyModelDef } from '@/stores/modelStore'
|
||||
import { ModelNodeProvider } from '@/stores/modelToNodeStore'
|
||||
import type { ModelNodeProvider } from '@/stores/modelToNodeStore'
|
||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { ComfyWorkflow } from '@/stores/workflowStore'
|
||||
import { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
|
||||
export const useCanvasDrop = (canvasRef: Ref<HTMLCanvasElement>) => {
|
||||
export const useCanvasDrop = (canvasRef: Ref<HTMLCanvasElement | null>) => {
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
const litegraphService = useLitegraphService()
|
||||
const workflowService = useWorkflowService()
|
||||
@@ -27,16 +28,19 @@ export const useCanvasDrop = (canvasRef: Ref<HTMLCanvasElement>) => {
|
||||
|
||||
if (dndData.type === 'tree-explorer-node') {
|
||||
const node = dndData.data as RenderedTreeExplorerNode
|
||||
const conv = useSharedCanvasPositionConversion()
|
||||
const basePos = conv.clientPosToCanvasPos([loc.clientX, loc.clientY])
|
||||
|
||||
if (node.data instanceof ComfyNodeDefImpl) {
|
||||
const nodeDef = node.data
|
||||
const pos = comfyApp.clientPosToCanvasPos([loc.clientX, loc.clientY])
|
||||
const pos = [...basePos]
|
||||
// Add an offset on y to make sure after adding the node, the cursor
|
||||
// is on the node (top left corner)
|
||||
pos[1] += LiteGraph.NODE_TITLE_HEIGHT
|
||||
litegraphService.addNodeOnGraph(nodeDef, { pos })
|
||||
} else if (node.data instanceof ComfyModelDef) {
|
||||
const model = node.data
|
||||
const pos = comfyApp.clientPosToCanvasPos([loc.clientX, loc.clientY])
|
||||
const pos = basePos
|
||||
const nodeAtPos = comfyApp.graph.getNodeOnPos(pos[0], pos[1])
|
||||
let targetProvider: ModelNodeProvider | null = null
|
||||
let targetGraphNode: LGraphNode | null = null
|
||||
@@ -73,11 +77,7 @@ export const useCanvasDrop = (canvasRef: Ref<HTMLCanvasElement>) => {
|
||||
}
|
||||
} else if (node.data instanceof ComfyWorkflow) {
|
||||
const workflow = node.data
|
||||
const position = comfyApp.clientPosToCanvasPos([
|
||||
loc.clientX,
|
||||
loc.clientY
|
||||
])
|
||||
await workflowService.insertWorkflow(workflow, { position })
|
||||
await workflowService.insertWorkflow(workflow, { position: basePos })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
/**
|
||||
* Adds a handler on copy that serializes selected nodes to JSON
|
||||
|
||||
@@ -13,22 +13,31 @@ import {
|
||||
LiteGraph,
|
||||
SubgraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { Point } from '@/lib/litegraph/src/litegraph'
|
||||
import type { Point } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import {
|
||||
type ComfyWorkflow,
|
||||
useWorkflowStore
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import {
|
||||
useCanvasStore,
|
||||
useTitleEditorStore
|
||||
} from '@/renderer/core/canvas/canvasStore'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { selectionBounds } from '@/renderer/core/layout/utils/layoutMath'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useWorkflowService } from '@/services/workflowService'
|
||||
import type { ComfyCommand } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useCanvasStore, useTitleEditorStore } from '@/stores/graphStore'
|
||||
import { useHelpCenterStore } from '@/stores/helpCenterStore'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { useQueueSettingsStore, useQueueStore } from '@/stores/queueStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
import { type ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
|
||||
@@ -38,6 +47,13 @@ import {
|
||||
getExecutionIdsForSelectedNodes
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
|
||||
import {
|
||||
ManagerUIState,
|
||||
useManagerState
|
||||
} from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
|
||||
import { useWorkflowTemplateSelectorDialog } from './useWorkflowTemplateSelectorDialog'
|
||||
|
||||
const moveSelectedNodesVersionAdded = '1.22.2'
|
||||
|
||||
@@ -109,6 +125,15 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
await workflowService.saveWorkflow(workflow)
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.PublishSubgraph',
|
||||
icon: 'pi pi-save',
|
||||
label: 'Publish Subgraph',
|
||||
menubarLabel: 'Publish',
|
||||
function: async () => {
|
||||
await useSubgraphStore().publishSubgraph()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.SaveWorkflowAs',
|
||||
icon: 'pi pi-save',
|
||||
@@ -178,8 +203,6 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
const subgraph = app.canvas.subgraph
|
||||
const nonIoNodes = getAllNonIoNodesInSubgraph(subgraph)
|
||||
nonIoNodes.forEach((node) => subgraph.remove(node))
|
||||
} else {
|
||||
app.graph.clear()
|
||||
}
|
||||
api.dispatchCustomEvent('graphCleared')
|
||||
}
|
||||
@@ -245,7 +268,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-folder-open',
|
||||
label: 'Browse Templates',
|
||||
function: () => {
|
||||
dialogService.showTemplateWorkflowsDialog()
|
||||
useWorkflowTemplateSelectorDialog().show()
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -276,6 +299,18 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
app.canvas.setDirty(true, true)
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Experimental.ToggleVueNodes',
|
||||
label: () =>
|
||||
`Experimental: ${
|
||||
useSettingStore().get('Comfy.VueNodes.Enabled') ? 'Disable' : 'Enable'
|
||||
} Vue Nodes`,
|
||||
function: async () => {
|
||||
const settingStore = useSettingStore()
|
||||
const current = settingStore.get('Comfy.VueNodes.Enabled') ?? false
|
||||
await settingStore.set('Comfy.VueNodes.Enabled', !current)
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Canvas.FitView',
|
||||
icon: 'pi pi-expand',
|
||||
@@ -283,15 +318,53 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
menubarLabel: 'Zoom to fit',
|
||||
category: 'view-controls' as const,
|
||||
function: () => {
|
||||
if (app.canvas.empty) {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('toastMessages.emptyCanvas'),
|
||||
life: 3000
|
||||
})
|
||||
return
|
||||
const vueNodesEnabled = useSettingStore().get('Comfy.VueNodes.Enabled')
|
||||
|
||||
if (vueNodesEnabled) {
|
||||
// Get nodes from Vue stores
|
||||
const canvasStore = useCanvasStore()
|
||||
const selectedNodeIds = canvasStore.selectedNodeIds
|
||||
const allNodes = layoutStore.getAllNodes().value
|
||||
|
||||
// Get nodes to fit - selected if any, otherwise all
|
||||
const nodesToFit =
|
||||
selectedNodeIds.size > 0
|
||||
? Array.from(selectedNodeIds)
|
||||
.map((id) => allNodes.get(id))
|
||||
.filter((node) => node != null)
|
||||
: Array.from(allNodes.values())
|
||||
|
||||
// Use Vue nodes bounds calculation
|
||||
const bounds = selectionBounds(nodesToFit)
|
||||
if (!bounds) {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('toastMessages.emptyCanvas'),
|
||||
life: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to LiteGraph format and animate
|
||||
const lgBounds = [
|
||||
bounds.x,
|
||||
bounds.y,
|
||||
bounds.width,
|
||||
bounds.height
|
||||
] as const
|
||||
const setDirty = () => app.canvas.setDirty(true, true)
|
||||
app.canvas.ds.animateToBounds(lgBounds, setDirty)
|
||||
} else {
|
||||
if (app.canvas.empty) {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('toastMessages.emptyCanvas'),
|
||||
life: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
app.canvas.fitViewToSelectionAnimated()
|
||||
}
|
||||
app.canvas.fitViewToSelectionAnimated()
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -446,6 +519,9 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
)
|
||||
group.resizeTo(canvas.selectedItems, padding)
|
||||
canvas.graph?.add(group)
|
||||
|
||||
group.recomputeInsideNodes()
|
||||
|
||||
useTitleEditorStore().titleEditorTarget = group
|
||||
}
|
||||
},
|
||||
@@ -676,36 +752,13 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
await workflowService.closeWorkflow(workflowStore.activeWorkflow)
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Feedback',
|
||||
icon: 'pi pi-megaphone',
|
||||
label: 'Give Feedback',
|
||||
versionAdded: '1.8.2',
|
||||
function: () => {
|
||||
dialogService.showIssueReportDialog({
|
||||
title: t('g.feedback'),
|
||||
subtitle: t('issueReport.feedbackTitle'),
|
||||
panelProps: {
|
||||
errorType: 'Feedback',
|
||||
defaultFields: ['SystemStats', 'Settings']
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.ContactSupport',
|
||||
icon: 'pi pi-question',
|
||||
label: 'Contact Support',
|
||||
versionAdded: '1.17.8',
|
||||
function: () => {
|
||||
dialogService.showIssueReportDialog({
|
||||
title: t('issueReport.contactSupportTitle'),
|
||||
subtitle: t('issueReport.contactSupportDescription'),
|
||||
panelProps: {
|
||||
errorType: 'ContactSupport',
|
||||
defaultFields: ['Workflow', 'Logs', 'SystemStats', 'Settings']
|
||||
}
|
||||
})
|
||||
window.open('https://support.comfy.org/', '_blank')
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -729,12 +782,52 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Manager.CustomNodesManager',
|
||||
id: 'Comfy.Manager.CustomNodesManager.ShowCustomNodesMenu',
|
||||
icon: 'pi pi-puzzle',
|
||||
label: 'Toggle the Custom Nodes Manager',
|
||||
label: 'Custom Nodes Manager',
|
||||
versionAdded: '1.12.10',
|
||||
function: () => {
|
||||
dialogService.toggleManagerDialog()
|
||||
function: async () => {
|
||||
await useManagerState().openManager({
|
||||
showToastOnLegacyError: true
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Manager.ShowUpdateAvailablePacks',
|
||||
icon: 'pi pi-sync',
|
||||
label: 'Check for Custom Node Updates',
|
||||
versionAdded: '1.17.0',
|
||||
function: async () => {
|
||||
const managerState = useManagerState()
|
||||
const state = managerState.managerUIState.value
|
||||
|
||||
// For DISABLED state, show error toast instead of opening settings
|
||||
if (state === ManagerUIState.DISABLED) {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('manager.notAvailable'),
|
||||
life: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await managerState.openManager({
|
||||
initialTab: ManagerTab.UpdateAvailable,
|
||||
showToastOnLegacyError: false
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Manager.ShowMissingPacks',
|
||||
icon: 'pi pi-exclamation-circle',
|
||||
label: 'Install Missing Custom Nodes',
|
||||
versionAdded: '1.17.0',
|
||||
function: async () => {
|
||||
await useManagerState().openManager({
|
||||
initialTab: ManagerTab.Missing,
|
||||
showToastOnLegacyError: false
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -813,6 +906,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const { node } = res
|
||||
canvas.select(node)
|
||||
canvasStore.updateSelectedItems()
|
||||
@@ -839,8 +933,11 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Comfy.OpenManagerDialog',
|
||||
icon: 'mdi mdi-puzzle-outline',
|
||||
label: 'Manager',
|
||||
function: () => {
|
||||
dialogService.showManagerDialog()
|
||||
function: async () => {
|
||||
await useManagerState().openManager({
|
||||
initialTab: ManagerTab.All,
|
||||
showToastOnLegacyError: false
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -898,6 +995,71 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
const modelSelectorDialog = useModelSelectorDialog()
|
||||
modelSelectorDialog.show()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Manager.CustomNodesManager.ShowLegacyCustomNodesMenu',
|
||||
icon: 'pi pi-bars',
|
||||
label: 'Custom Nodes (Legacy)',
|
||||
versionAdded: '1.16.4',
|
||||
function: async () => {
|
||||
await useManagerState().openManager({
|
||||
legacyCommand: 'Comfy.Manager.CustomNodesManager.ToggleVisibility',
|
||||
showToastOnLegacyError: true,
|
||||
isLegacyOnly: true
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Manager.ShowLegacyManagerMenu',
|
||||
icon: 'mdi mdi-puzzle',
|
||||
label: 'Manager Menu (Legacy)',
|
||||
versionAdded: '1.16.4',
|
||||
function: async () => {
|
||||
await useManagerState().openManager({
|
||||
showToastOnLegacyError: true,
|
||||
isLegacyOnly: true
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Memory.UnloadModels',
|
||||
icon: 'mdi mdi-vacuum-outline',
|
||||
label: 'Unload Models',
|
||||
versionAdded: '1.16.4',
|
||||
function: async () => {
|
||||
if (!useSettingStore().get('Comfy.Memory.AllowManualUnload')) {
|
||||
useToastStore().add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('g.commandProhibited', {
|
||||
command: 'Comfy.Memory.UnloadModels'
|
||||
}),
|
||||
life: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
await api.freeMemory({ freeExecutionCache: false })
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Memory.UnloadModelsAndExecutionCache',
|
||||
icon: 'mdi mdi-vacuum-outline',
|
||||
label: 'Unload Models and Execution Cache',
|
||||
versionAdded: '1.16.4',
|
||||
function: async () => {
|
||||
if (!useSettingStore().get('Comfy.Memory.AllowManualUnload')) {
|
||||
useToastStore().add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('g.commandProhibited', {
|
||||
command: 'Comfy.Memory.UnloadModelsAndExecutionCache'
|
||||
}),
|
||||
life: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
await api.freeMemory({ freeExecutionCache: true })
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { t } from '@/i18n'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
|
||||
export function useErrorHandling() {
|
||||
const toast = useToastStore()
|
||||
|
||||
37
src/composables/useFeatureFlags.ts
Normal file
37
src/composables/useFeatureFlags.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { computed, reactive, readonly } from 'vue'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
/**
|
||||
* Known server feature flags (top-level, not extensions)
|
||||
*/
|
||||
export enum ServerFeatureFlag {
|
||||
SUPPORTS_PREVIEW_METADATA = 'supports_preview_metadata',
|
||||
MAX_UPLOAD_SIZE = 'max_upload_size',
|
||||
MANAGER_SUPPORTS_V4 = 'extension.manager.supports_v4'
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for reactive access to server-side feature flags
|
||||
*/
|
||||
export function useFeatureFlags() {
|
||||
const flags = reactive({
|
||||
get supportsPreviewMetadata() {
|
||||
return api.getServerFeature(ServerFeatureFlag.SUPPORTS_PREVIEW_METADATA)
|
||||
},
|
||||
get maxUploadSize() {
|
||||
return api.getServerFeature(ServerFeatureFlag.MAX_UPLOAD_SIZE)
|
||||
},
|
||||
get supportsManagerV4() {
|
||||
return api.getServerFeature(ServerFeatureFlag.MANAGER_SUPPORTS_V4)
|
||||
}
|
||||
})
|
||||
|
||||
const featureFlag = <T = unknown>(featurePath: string, defaultValue?: T) =>
|
||||
computed(() => api.getServerFeature(featurePath, defaultValue))
|
||||
|
||||
return {
|
||||
flags: readonly(flags),
|
||||
featureFlag
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
import { useVersionCompatibilityStore } from '@/stores/versionCompatibilityStore'
|
||||
|
||||
export interface UseFrontendVersionMismatchWarningOptions {
|
||||
immediate?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for handling frontend version mismatch warnings.
|
||||
*
|
||||
* Displays toast notifications when the frontend version is incompatible with the backend,
|
||||
* either because the frontend is outdated or newer than the backend expects.
|
||||
* Automatically dismisses warnings when shown and persists dismissal state for 7 days.
|
||||
*
|
||||
* @param options - Configuration options
|
||||
* @param options.immediate - If true, automatically shows warning when version mismatch is detected
|
||||
* @returns Object with methods and computed properties for managing version warnings
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Show warning immediately when mismatch detected
|
||||
* const { showWarning, shouldShowWarning } = useFrontendVersionMismatchWarning({ immediate: true })
|
||||
*
|
||||
* // Manual control
|
||||
* const { showWarning } = useFrontendVersionMismatchWarning()
|
||||
* showWarning() // Call when needed
|
||||
* ```
|
||||
*/
|
||||
export function useFrontendVersionMismatchWarning(
|
||||
options: UseFrontendVersionMismatchWarningOptions = {}
|
||||
) {
|
||||
const { immediate = false } = options
|
||||
const { t } = useI18n()
|
||||
const toastStore = useToastStore()
|
||||
const versionCompatibilityStore = useVersionCompatibilityStore()
|
||||
|
||||
// Track if we've already shown the warning
|
||||
let hasShownWarning = false
|
||||
|
||||
const showWarning = () => {
|
||||
// Prevent showing the warning multiple times
|
||||
if (hasShownWarning) return
|
||||
|
||||
const message = versionCompatibilityStore.warningMessage
|
||||
if (!message) return
|
||||
|
||||
const detailMessage = t('g.frontendOutdated', {
|
||||
frontendVersion: message.frontendVersion,
|
||||
requiredVersion: message.requiredVersion
|
||||
})
|
||||
|
||||
const fullMessage = t('g.versionMismatchWarningMessage', {
|
||||
warning: t('g.versionMismatchWarning'),
|
||||
detail: detailMessage
|
||||
})
|
||||
|
||||
toastStore.addAlert(fullMessage)
|
||||
hasShownWarning = true
|
||||
|
||||
// Automatically dismiss the warning so it won't show again for 7 days
|
||||
versionCompatibilityStore.dismissWarning()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Only set up the watcher if immediate is true
|
||||
if (immediate) {
|
||||
whenever(
|
||||
() => versionCompatibilityStore.shouldShowWarning,
|
||||
() => {
|
||||
showWarning()
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
once: true
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
showWarning,
|
||||
shouldShowWarning: computed(
|
||||
() => versionCompatibilityStore.shouldShowWarning
|
||||
),
|
||||
dismissWarning: versionCompatibilityStore.dismissWarning,
|
||||
hasVersionMismatch: computed(
|
||||
() => versionCompatibilityStore.hasVersionMismatch
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { type Ref, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
export interface UseIntersectionObserverOptions
|
||||
extends IntersectionObserverInit {
|
||||
interface UseIntersectionObserverOptions extends IntersectionObserverInit {
|
||||
immediate?: boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type Ref, computed, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
export interface LazyPaginationOptions {
|
||||
interface LazyPaginationOptions {
|
||||
itemsPerPage?: number
|
||||
initialPage?: number
|
||||
}
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
import { watchEffect } from 'vue'
|
||||
|
||||
import {
|
||||
CanvasPointer,
|
||||
LGraphNode,
|
||||
LiteGraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
/**
|
||||
* Watch for changes in the setting store and update the LiteGraph settings accordingly.
|
||||
*/
|
||||
export const useLitegraphSettings = () => {
|
||||
const settingStore = useSettingStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
watchEffect(() => {
|
||||
const canvasInfoEnabled = settingStore.get('Comfy.Graph.CanvasInfo')
|
||||
if (canvasStore.canvas) {
|
||||
canvasStore.canvas.show_info = canvasInfoEnabled
|
||||
canvasStore.canvas.draw(false, true)
|
||||
}
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
const zoomSpeed = settingStore.get('Comfy.Graph.ZoomSpeed')
|
||||
if (canvasStore.canvas) {
|
||||
canvasStore.canvas.zoom_speed = zoomSpeed
|
||||
}
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
LiteGraph.snaps_for_comfy = settingStore.get(
|
||||
'Comfy.Node.AutoSnapLinkToSlot'
|
||||
)
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
LiteGraph.snap_highlights_node = settingStore.get(
|
||||
'Comfy.Node.SnapHighlightsNode'
|
||||
)
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
LGraphNode.keepAllLinksOnBypass = settingStore.get(
|
||||
'Comfy.Node.BypassAllLinksOnDelete'
|
||||
)
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
LiteGraph.middle_click_slot_add_default_node = settingStore.get(
|
||||
'Comfy.Node.MiddleClickRerouteNode'
|
||||
)
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
const linkRenderMode = settingStore.get('Comfy.LinkRenderMode')
|
||||
if (canvasStore.canvas) {
|
||||
canvasStore.canvas.links_render_mode = linkRenderMode
|
||||
canvasStore.canvas.setDirty(/* fg */ false, /* bg */ true)
|
||||
}
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
const lowQualityRenderingZoomThreshold = settingStore.get(
|
||||
'LiteGraph.Canvas.LowQualityRenderingZoomThreshold'
|
||||
)
|
||||
if (canvasStore.canvas) {
|
||||
canvasStore.canvas.low_quality_zoom_threshold =
|
||||
lowQualityRenderingZoomThreshold
|
||||
canvasStore.canvas.setDirty(/* fg */ true, /* bg */ true)
|
||||
}
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
const linkMarkerShape = settingStore.get('Comfy.Graph.LinkMarkers')
|
||||
const { canvas } = canvasStore
|
||||
if (canvas) {
|
||||
canvas.linkMarkerShape = linkMarkerShape
|
||||
canvas.setDirty(false, true)
|
||||
}
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
const maximumFps = settingStore.get('LiteGraph.Canvas.MaximumFps')
|
||||
const { canvas } = canvasStore
|
||||
if (canvas) canvas.maximumFps = maximumFps
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
const dragZoomEnabled = settingStore.get('Comfy.Graph.CtrlShiftZoom')
|
||||
const { canvas } = canvasStore
|
||||
if (canvas) canvas.dragZoomEnabled = dragZoomEnabled
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
CanvasPointer.doubleClickTime = settingStore.get(
|
||||
'Comfy.Pointer.DoubleClickTime'
|
||||
)
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
CanvasPointer.bufferTime = settingStore.get('Comfy.Pointer.ClickBufferTime')
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
CanvasPointer.maxClickDrift = settingStore.get('Comfy.Pointer.ClickDrift')
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
LiteGraph.CANVAS_GRID_SIZE = settingStore.get('Comfy.SnapToGrid.GridSize')
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
LiteGraph.alwaysSnapToGrid = settingStore.get('pysssss.SnapToGrid')
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
LiteGraph.context_menu_scaling = settingStore.get(
|
||||
'LiteGraph.ContextMenu.Scaling'
|
||||
)
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
LiteGraph.Reroute.maxSplineOffset = settingStore.get(
|
||||
'LiteGraph.Reroute.SplineOffset'
|
||||
)
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
const navigationMode = settingStore.get('Comfy.Canvas.NavigationMode') as
|
||||
| 'standard'
|
||||
| 'legacy'
|
||||
|
||||
LiteGraph.canvasNavigationMode = navigationMode
|
||||
LiteGraph.macTrackpadGestures = navigationMode === 'standard'
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
LiteGraph.saveViewportWithGraph = settingStore.get(
|
||||
'Comfy.EnableWorkflowViewRestore'
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -2,15 +2,15 @@ import { ref, toRaw, watch } from 'vue'
|
||||
|
||||
import Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import {
|
||||
import type {
|
||||
CameraType,
|
||||
MaterialMode,
|
||||
UpDirection
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import { t } from '@/i18n'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useLoad3dService } from '@/services/load3dService'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
|
||||
interface Load3dViewerState {
|
||||
backgroundColor: string
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import { useEventListener, whenever } from '@vueuse/core'
|
||||
import { computed, readonly, ref } from 'vue'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
import { ManagerWsQueueStatus } from '@/types/comfyManagerTypes'
|
||||
|
||||
type QueuedTask<T> = {
|
||||
task: () => Promise<T>
|
||||
onComplete?: () => void
|
||||
}
|
||||
|
||||
const MANAGER_WS_MSG_TYPE = 'cm-queue-status'
|
||||
|
||||
export const useManagerQueue = () => {
|
||||
const clientQueueItems = ref<QueuedTask<unknown>[]>([])
|
||||
const clientQueueLength = computed(() => clientQueueItems.value.length)
|
||||
const onCompletedQueue = ref<((() => void) | undefined)[]>([])
|
||||
const onCompleteWaitingCount = ref(0)
|
||||
const uncompletedCount = computed(
|
||||
() => clientQueueLength.value + onCompleteWaitingCount.value
|
||||
)
|
||||
|
||||
const serverQueueStatus = ref<ManagerWsQueueStatus>(ManagerWsQueueStatus.DONE)
|
||||
const isServerIdle = computed(
|
||||
() => serverQueueStatus.value === ManagerWsQueueStatus.DONE
|
||||
)
|
||||
|
||||
const allTasksDone = computed(
|
||||
() => isServerIdle.value && clientQueueLength.value === 0
|
||||
)
|
||||
const nextTaskReady = computed(
|
||||
() => isServerIdle.value && clientQueueLength.value > 0
|
||||
)
|
||||
|
||||
const cleanupListener = useEventListener(
|
||||
api,
|
||||
MANAGER_WS_MSG_TYPE,
|
||||
(event: CustomEvent<{ status: ManagerWsQueueStatus }>) => {
|
||||
if (event?.type === MANAGER_WS_MSG_TYPE && event.detail?.status) {
|
||||
serverQueueStatus.value = event.detail.status
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const startNextTask = () => {
|
||||
const nextTask = clientQueueItems.value.shift()
|
||||
if (!nextTask) return
|
||||
|
||||
const { task, onComplete } = nextTask
|
||||
if (onComplete) {
|
||||
// Set the task's onComplete to be executed the next time the server is idle
|
||||
onCompletedQueue.value.push(onComplete)
|
||||
onCompleteWaitingCount.value++
|
||||
}
|
||||
|
||||
task().catch((e) => {
|
||||
const message = `Error enqueuing task for ComfyUI Manager: ${e}`
|
||||
console.error(message)
|
||||
})
|
||||
}
|
||||
|
||||
const enqueueTask = <T>(task: QueuedTask<T>): void => {
|
||||
clientQueueItems.value.push(task)
|
||||
}
|
||||
|
||||
const clearQueue = () => {
|
||||
clientQueueItems.value = []
|
||||
onCompletedQueue.value = []
|
||||
onCompleteWaitingCount.value = 0
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
clearQueue()
|
||||
cleanupListener()
|
||||
}
|
||||
|
||||
whenever(nextTaskReady, startNextTask)
|
||||
whenever(isServerIdle, () => {
|
||||
if (onCompletedQueue.value?.length) {
|
||||
while (
|
||||
onCompleteWaitingCount.value > 0 &&
|
||||
onCompletedQueue.value.length > 0
|
||||
) {
|
||||
const onComplete = onCompletedQueue.value.shift()
|
||||
onComplete?.()
|
||||
onCompleteWaitingCount.value--
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
allTasksDone,
|
||||
statusMessage: readonly(serverQueueStatus),
|
||||
queueLength: clientQueueLength,
|
||||
uncompletedCount,
|
||||
|
||||
enqueueTask,
|
||||
clearQueue,
|
||||
cleanup
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import ModelSelector from '@/components/widget/ModelSelector.vue'
|
||||
import SampleModelSelector from '@/components/widget/SampleModelSelector.vue'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
@@ -15,7 +15,7 @@ export const useModelSelectorDialog = () => {
|
||||
function show() {
|
||||
dialogService.showLayoutDialog({
|
||||
key: DIALOG_KEY,
|
||||
component: ModelSelector,
|
||||
component: SampleModelSelector,
|
||||
props: {
|
||||
onClose: hide
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ import { useEventListener } from '@vueuse/core'
|
||||
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isAudioNode, isImageNode, isVideoNode } from '@/utils/litegraphUtil'
|
||||
|
||||
|
||||
30
src/composables/usePopoverSizing.ts
Normal file
30
src/composables/usePopoverSizing.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { type CSSProperties, type ComputedRef, computed } from 'vue'
|
||||
|
||||
interface PopoverSizeOptions {
|
||||
minWidth?: string
|
||||
maxWidth?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for managing popover sizing styles
|
||||
* @param options Popover size configuration
|
||||
* @returns Computed style object for popover sizing
|
||||
*/
|
||||
export function usePopoverSizing(
|
||||
options: PopoverSizeOptions
|
||||
): ComputedRef<CSSProperties> {
|
||||
return computed(() => {
|
||||
const { minWidth, maxWidth } = options
|
||||
const style: CSSProperties = {}
|
||||
|
||||
if (minWidth) {
|
||||
style.minWidth = minWidth
|
||||
}
|
||||
|
||||
if (maxWidth) {
|
||||
style.maxWidth = maxWidth
|
||||
}
|
||||
|
||||
return style
|
||||
})
|
||||
}
|
||||
@@ -2,19 +2,17 @@ import {
|
||||
draggable,
|
||||
dropTargetForElements
|
||||
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
|
||||
import { onBeforeUnmount, onMounted } from 'vue'
|
||||
import { toValue } from 'vue'
|
||||
import { type MaybeRefOrGetter, onBeforeUnmount, onMounted } from 'vue'
|
||||
|
||||
export function usePragmaticDroppable(
|
||||
dropTargetElement: HTMLElement | (() => HTMLElement),
|
||||
dropTargetElement: MaybeRefOrGetter<HTMLElement | null>,
|
||||
options: Omit<Parameters<typeof dropTargetForElements>[0], 'element'>
|
||||
) {
|
||||
let cleanup = () => {}
|
||||
|
||||
onMounted(() => {
|
||||
const element =
|
||||
typeof dropTargetElement === 'function'
|
||||
? dropTargetElement()
|
||||
: dropTargetElement
|
||||
const element = toValue(dropTargetElement)
|
||||
|
||||
if (!element) {
|
||||
return
|
||||
@@ -32,16 +30,13 @@ export function usePragmaticDroppable(
|
||||
}
|
||||
|
||||
export function usePragmaticDraggable(
|
||||
draggableElement: HTMLElement | (() => HTMLElement),
|
||||
draggableElement: MaybeRefOrGetter<HTMLElement | null>,
|
||||
options: Omit<Parameters<typeof draggable>[0], 'element'>
|
||||
) {
|
||||
let cleanup = () => {}
|
||||
|
||||
onMounted(() => {
|
||||
const element =
|
||||
typeof draggableElement === 'function'
|
||||
? draggableElement()
|
||||
: draggableElement
|
||||
const element = toValue(draggableElement)
|
||||
|
||||
if (!element) {
|
||||
return
|
||||
@@ -51,6 +46,7 @@ export function usePragmaticDraggable(
|
||||
element,
|
||||
...options
|
||||
})
|
||||
// TODO: Change to onScopeDispose
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||
|
||||
interface RefreshableItem {
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { orderBy } from 'es-toolkit/compat'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { DEFAULT_PAGE_SIZE } from '@/constants/searchConstants'
|
||||
import { useRegistrySearchGateway } from '@/services/gateway/registrySearchGateway'
|
||||
import type { SearchAttribute } from '@/types/algoliaTypes'
|
||||
import { SortableAlgoliaField } from '@/types/comfyManagerTypes'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import type { QuerySuggestion, SearchMode } from '@/types/searchServiceTypes'
|
||||
|
||||
type RegistryNodePack = components['schemas']['Node']
|
||||
|
||||
const SEARCH_DEBOUNCE_TIME = 320
|
||||
const DEFAULT_SORT_FIELD = SortableAlgoliaField.Downloads // Set in the index configuration
|
||||
|
||||
/**
|
||||
* Composable for managing UI state of Comfy Node Registry search.
|
||||
*/
|
||||
export function useRegistrySearch(
|
||||
options: {
|
||||
initialSortField?: string
|
||||
initialSearchMode?: SearchMode
|
||||
initialSearchQuery?: string
|
||||
initialPageNumber?: number
|
||||
} = {}
|
||||
) {
|
||||
const {
|
||||
initialSortField = DEFAULT_SORT_FIELD,
|
||||
initialSearchMode = 'packs',
|
||||
initialSearchQuery = '',
|
||||
initialPageNumber = 0
|
||||
} = options
|
||||
|
||||
const isLoading = ref(false)
|
||||
const sortField = ref<string>(initialSortField)
|
||||
const searchMode = ref<SearchMode>(initialSearchMode)
|
||||
const pageSize = ref(DEFAULT_PAGE_SIZE)
|
||||
const pageNumber = ref(initialPageNumber)
|
||||
const searchQuery = ref(initialSearchQuery)
|
||||
const searchResults = ref<RegistryNodePack[]>([])
|
||||
const suggestions = ref<QuerySuggestion[]>([])
|
||||
|
||||
const searchAttributes = computed<SearchAttribute[]>(() =>
|
||||
searchMode.value === 'nodes' ? ['comfy_nodes'] : ['name', 'description']
|
||||
)
|
||||
|
||||
const searchGateway = useRegistrySearchGateway()
|
||||
|
||||
const { searchPacks, clearSearchCache, getSortValue, getSortableFields } =
|
||||
searchGateway
|
||||
|
||||
const updateSearchResults = async (options: { append?: boolean }) => {
|
||||
isLoading.value = true
|
||||
if (!options.append) {
|
||||
pageNumber.value = 0
|
||||
}
|
||||
const { nodePacks, querySuggestions } = await searchPacks(
|
||||
searchQuery.value,
|
||||
{
|
||||
pageSize: pageSize.value,
|
||||
pageNumber: pageNumber.value,
|
||||
restrictSearchableAttributes: searchAttributes.value
|
||||
}
|
||||
)
|
||||
|
||||
let sortedPacks = nodePacks
|
||||
|
||||
// Results are sorted by the default field to begin with -- so don't manually sort again
|
||||
if (sortField.value && sortField.value !== DEFAULT_SORT_FIELD) {
|
||||
// Get the sort direction from the provider's sortable fields
|
||||
const sortableFields = getSortableFields()
|
||||
const fieldConfig = sortableFields.find((f) => f.id === sortField.value)
|
||||
const direction = fieldConfig?.direction || 'desc'
|
||||
|
||||
sortedPacks = orderBy(
|
||||
nodePacks,
|
||||
[(pack) => getSortValue(pack, sortField.value)],
|
||||
[direction]
|
||||
)
|
||||
}
|
||||
|
||||
if (options.append && searchResults.value?.length) {
|
||||
searchResults.value = searchResults.value.concat(sortedPacks)
|
||||
} else {
|
||||
searchResults.value = sortedPacks
|
||||
}
|
||||
suggestions.value = querySuggestions
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
const onQueryChange = () => updateSearchResults({ append: false })
|
||||
const onPageChange = () => updateSearchResults({ append: true })
|
||||
|
||||
watch([sortField, searchMode], onQueryChange)
|
||||
watch(pageNumber, onPageChange)
|
||||
watchDebounced(searchQuery, onQueryChange, {
|
||||
debounce: SEARCH_DEBOUNCE_TIME,
|
||||
immediate: true
|
||||
})
|
||||
|
||||
const sortOptions = computed(() => {
|
||||
return getSortableFields()
|
||||
})
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
pageNumber,
|
||||
pageSize,
|
||||
sortField,
|
||||
searchMode,
|
||||
searchQuery,
|
||||
suggestions,
|
||||
searchResults,
|
||||
sortOptions,
|
||||
clearCache: clearSearchCache
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,35 @@
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
|
||||
import { LogsWsMessage } from '@/schemas/apiSchema'
|
||||
import type { LogsWsMessage } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import type { components } from '@/workbench/extensions/manager/types/generatedManagerTypes'
|
||||
|
||||
const LOGS_MESSAGE_TYPE = 'logs'
|
||||
const MANAGER_WS_TASK_DONE_NAME = 'cm-task-completed'
|
||||
const MANAGER_WS_TASK_STARTED_NAME = 'cm-task-started'
|
||||
|
||||
type ManagerWsTaskDoneMsg = components['schemas']['MessageTaskDone']
|
||||
type ManagerWsTaskStartedMsg = components['schemas']['MessageTaskStarted']
|
||||
|
||||
interface UseServerLogsOptions {
|
||||
ui_id?: string
|
||||
immediate?: boolean
|
||||
messageFilter?: (message: string) => boolean
|
||||
}
|
||||
|
||||
export const useServerLogs = (options: UseServerLogsOptions = {}) => {
|
||||
const {
|
||||
ui_id,
|
||||
immediate = false,
|
||||
messageFilter = (msg: string) => Boolean(msg.trim())
|
||||
} = options
|
||||
|
||||
const logs = ref<string[]>([])
|
||||
let stop: ReturnType<typeof useEventListener> | null = null
|
||||
const isTaskStarted = ref(!ui_id) // If no ui_id, capture all logs immediately
|
||||
let stopLogs: ReturnType<typeof useEventListener> | null = null
|
||||
let stopTaskDone: ReturnType<typeof useEventListener> | null = null
|
||||
let stopTaskStarted: ReturnType<typeof useEventListener> | null = null
|
||||
|
||||
const isValidLogEvent = (event: CustomEvent<LogsWsMessage>) =>
|
||||
event?.type === LOGS_MESSAGE_TYPE && event.detail?.entries?.length > 0
|
||||
@@ -27,19 +38,54 @@ export const useServerLogs = (options: UseServerLogsOptions = {}) => {
|
||||
event.detail.entries.map((e) => e.m).filter(messageFilter)
|
||||
|
||||
const handleLogMessage = (event: CustomEvent<LogsWsMessage>) => {
|
||||
// Only capture logs if this task has started
|
||||
if (!isTaskStarted.value) return
|
||||
|
||||
if (isValidLogEvent(event)) {
|
||||
logs.value.push(...parseLogMessage(event))
|
||||
const messages = parseLogMessage(event)
|
||||
if (messages.length > 0) {
|
||||
logs.value.push(...messages)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleTaskStarted = (event: CustomEvent<ManagerWsTaskStartedMsg>) => {
|
||||
if (ui_id && event?.detail?.ui_id === ui_id) {
|
||||
isTaskStarted.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const handleTaskDone = (event: CustomEvent<ManagerWsTaskDoneMsg>) => {
|
||||
if (ui_id && event?.detail?.ui_id === ui_id) {
|
||||
isTaskStarted.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const start = async () => {
|
||||
await api.subscribeLogs(true)
|
||||
stop = useEventListener(api, LOGS_MESSAGE_TYPE, handleLogMessage)
|
||||
stopLogs = useEventListener(api, LOGS_MESSAGE_TYPE, handleLogMessage)
|
||||
|
||||
if (ui_id) {
|
||||
stopTaskStarted = useEventListener(
|
||||
api,
|
||||
MANAGER_WS_TASK_STARTED_NAME,
|
||||
handleTaskStarted
|
||||
)
|
||||
stopTaskDone = useEventListener(
|
||||
api,
|
||||
MANAGER_WS_TASK_DONE_NAME,
|
||||
handleTaskDone
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const stopListening = async () => {
|
||||
stop?.()
|
||||
stop = null
|
||||
stopLogs?.()
|
||||
stopTaskStarted?.()
|
||||
stopTaskDone?.()
|
||||
stopLogs = null
|
||||
stopTaskStarted = null
|
||||
stopTaskDone = null
|
||||
await api.subscribeLogs(false)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,56 +1,213 @@
|
||||
import { refDebounced } from '@vueuse/core'
|
||||
import Fuse from 'fuse.js'
|
||||
import { type Ref, computed, ref } from 'vue'
|
||||
|
||||
import type { TemplateInfo } from '@/types/workflowTemplateTypes'
|
||||
|
||||
export interface TemplateFilterOptions {
|
||||
searchQuery?: string
|
||||
}
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
|
||||
export function useTemplateFiltering(
|
||||
templates: Ref<TemplateInfo[]> | TemplateInfo[]
|
||||
) {
|
||||
const searchQuery = ref('')
|
||||
const selectedModels = ref<string[]>([])
|
||||
const selectedUseCases = ref<string[]>([])
|
||||
const selectedLicenses = ref<string[]>([])
|
||||
const sortBy = ref<
|
||||
| 'default'
|
||||
| 'alphabetical'
|
||||
| 'newest'
|
||||
| 'vram-low-to-high'
|
||||
| 'model-size-low-to-high'
|
||||
>('newest')
|
||||
|
||||
const templatesArray = computed(() => {
|
||||
const templateData = 'value' in templates ? templates.value : templates
|
||||
return Array.isArray(templateData) ? templateData : []
|
||||
})
|
||||
|
||||
const filteredTemplates = computed(() => {
|
||||
const templateData = templatesArray.value
|
||||
if (templateData.length === 0) {
|
||||
return []
|
||||
// Fuse.js configuration for fuzzy search
|
||||
const fuseOptions = {
|
||||
keys: [
|
||||
{ name: 'name', weight: 0.3 },
|
||||
{ name: 'title', weight: 0.3 },
|
||||
{ name: 'description', weight: 0.2 },
|
||||
{ name: 'tags', weight: 0.1 },
|
||||
{ name: 'models', weight: 0.1 }
|
||||
],
|
||||
threshold: 0.4,
|
||||
includeScore: true,
|
||||
includeMatches: true
|
||||
}
|
||||
|
||||
const fuse = computed(() => new Fuse(templatesArray.value, fuseOptions))
|
||||
|
||||
const availableModels = computed(() => {
|
||||
const modelSet = new Set<string>()
|
||||
templatesArray.value.forEach((template) => {
|
||||
if (Array.isArray(template.models)) {
|
||||
template.models.forEach((model) => modelSet.add(model))
|
||||
}
|
||||
})
|
||||
return Array.from(modelSet).sort()
|
||||
})
|
||||
|
||||
const availableUseCases = computed(() => {
|
||||
const tagSet = new Set<string>()
|
||||
templatesArray.value.forEach((template) => {
|
||||
if (template.tags && Array.isArray(template.tags)) {
|
||||
template.tags.forEach((tag) => tagSet.add(tag))
|
||||
}
|
||||
})
|
||||
return Array.from(tagSet).sort()
|
||||
})
|
||||
|
||||
const availableLicenses = computed(() => {
|
||||
return ['Open Source', 'Closed Source (API Nodes)']
|
||||
})
|
||||
|
||||
const debouncedSearchQuery = refDebounced(searchQuery, 50)
|
||||
|
||||
const filteredBySearch = computed(() => {
|
||||
if (!debouncedSearchQuery.value.trim()) {
|
||||
return templatesArray.value
|
||||
}
|
||||
|
||||
if (!searchQuery.value.trim()) {
|
||||
return templateData
|
||||
const results = fuse.value.search(debouncedSearchQuery.value)
|
||||
return results.map((result) => result.item)
|
||||
})
|
||||
|
||||
const filteredByModels = computed(() => {
|
||||
if (selectedModels.value.length === 0) {
|
||||
return filteredBySearch.value
|
||||
}
|
||||
|
||||
const query = searchQuery.value.toLowerCase().trim()
|
||||
return templateData.filter((template) => {
|
||||
const searchableText = [
|
||||
template.name,
|
||||
template.description,
|
||||
template.sourceModule
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
|
||||
return searchableText.includes(query)
|
||||
return filteredBySearch.value.filter((template) => {
|
||||
if (!template.models || !Array.isArray(template.models)) {
|
||||
return false
|
||||
}
|
||||
return selectedModels.value.some((selectedModel) =>
|
||||
template.models?.includes(selectedModel)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const filteredByUseCases = computed(() => {
|
||||
if (selectedUseCases.value.length === 0) {
|
||||
return filteredByModels.value
|
||||
}
|
||||
|
||||
return filteredByModels.value.filter((template) => {
|
||||
if (!template.tags || !Array.isArray(template.tags)) {
|
||||
return false
|
||||
}
|
||||
return selectedUseCases.value.some((selectedTag) =>
|
||||
template.tags?.includes(selectedTag)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const filteredByLicenses = computed(() => {
|
||||
if (selectedLicenses.value.length === 0) {
|
||||
return filteredByUseCases.value
|
||||
}
|
||||
|
||||
return filteredByUseCases.value.filter((template) => {
|
||||
// Check if template has API in its tags or name (indicating it's a closed source API node)
|
||||
const isApiTemplate =
|
||||
template.tags?.includes('API') ||
|
||||
template.name?.toLowerCase().includes('api_')
|
||||
|
||||
return selectedLicenses.value.some((selectedLicense) => {
|
||||
if (selectedLicense === 'Closed Source (API Nodes)') {
|
||||
return isApiTemplate
|
||||
} else if (selectedLicense === 'Open Source') {
|
||||
return !isApiTemplate
|
||||
}
|
||||
return false
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const sortedTemplates = computed(() => {
|
||||
const templates = [...filteredByLicenses.value]
|
||||
|
||||
switch (sortBy.value) {
|
||||
case 'alphabetical':
|
||||
return templates.sort((a, b) => {
|
||||
const nameA = a.title || a.name || ''
|
||||
const nameB = b.title || b.name || ''
|
||||
return nameA.localeCompare(nameB)
|
||||
})
|
||||
case 'newest':
|
||||
return templates.sort((a, b) => {
|
||||
const dateA = new Date(a.date || '1970-01-01')
|
||||
const dateB = new Date(b.date || '1970-01-01')
|
||||
return dateB.getTime() - dateA.getTime()
|
||||
})
|
||||
case 'vram-low-to-high':
|
||||
// TODO: Implement VRAM sorting when VRAM data is available
|
||||
// For now, keep original order
|
||||
return templates
|
||||
case 'model-size-low-to-high':
|
||||
return templates.sort((a: any, b: any) => {
|
||||
const sizeA =
|
||||
typeof a.size === 'number' ? a.size : Number.POSITIVE_INFINITY
|
||||
const sizeB =
|
||||
typeof b.size === 'number' ? b.size : Number.POSITIVE_INFINITY
|
||||
if (sizeA === sizeB) return 0
|
||||
return sizeA - sizeB
|
||||
})
|
||||
case 'default':
|
||||
default:
|
||||
// Keep original order (default order)
|
||||
return templates
|
||||
}
|
||||
})
|
||||
|
||||
const filteredTemplates = computed(() => sortedTemplates.value)
|
||||
|
||||
const resetFilters = () => {
|
||||
searchQuery.value = ''
|
||||
selectedModels.value = []
|
||||
selectedUseCases.value = []
|
||||
selectedLicenses.value = []
|
||||
sortBy.value = 'default'
|
||||
}
|
||||
|
||||
const removeModelFilter = (model: string) => {
|
||||
selectedModels.value = selectedModels.value.filter((m) => m !== model)
|
||||
}
|
||||
|
||||
const removeUseCaseFilter = (tag: string) => {
|
||||
selectedUseCases.value = selectedUseCases.value.filter((t) => t !== tag)
|
||||
}
|
||||
|
||||
const removeLicenseFilter = (license: string) => {
|
||||
selectedLicenses.value = selectedLicenses.value.filter((l) => l !== license)
|
||||
}
|
||||
|
||||
const filteredCount = computed(() => filteredTemplates.value.length)
|
||||
const totalCount = computed(() => templatesArray.value.length)
|
||||
|
||||
return {
|
||||
// State
|
||||
searchQuery,
|
||||
selectedModels,
|
||||
selectedUseCases,
|
||||
selectedLicenses,
|
||||
sortBy,
|
||||
|
||||
// Computed
|
||||
filteredTemplates,
|
||||
availableModels,
|
||||
availableUseCases,
|
||||
availableLicenses,
|
||||
filteredCount,
|
||||
resetFilters
|
||||
totalCount,
|
||||
|
||||
// Methods
|
||||
resetFilters,
|
||||
removeModelFilter,
|
||||
removeUseCaseFilter,
|
||||
removeLicenseFilter
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useWorkflowTemplatesStore } from '@/stores/workflowTemplatesStore'
|
||||
import type {
|
||||
TemplateGroup,
|
||||
TemplateInfo,
|
||||
WorkflowTemplates
|
||||
} from '@/types/workflowTemplateTypes'
|
||||
|
||||
export function useTemplateWorkflows() {
|
||||
const { t } = useI18n()
|
||||
const workflowTemplatesStore = useWorkflowTemplatesStore()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
// State
|
||||
const selectedTemplate = ref<WorkflowTemplates | null>(null)
|
||||
const loadingTemplateId = ref<string | null>(null)
|
||||
|
||||
// Computed
|
||||
const isTemplatesLoaded = computed(() => workflowTemplatesStore.isLoaded)
|
||||
const allTemplateGroups = computed<TemplateGroup[]>(
|
||||
() => workflowTemplatesStore.groupedTemplates
|
||||
)
|
||||
|
||||
/**
|
||||
* Loads all template workflows from the API
|
||||
*/
|
||||
const loadTemplates = async () => {
|
||||
if (!workflowTemplatesStore.isLoaded) {
|
||||
await workflowTemplatesStore.loadWorkflowTemplates()
|
||||
}
|
||||
return workflowTemplatesStore.isLoaded
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the first template category as default
|
||||
*/
|
||||
const selectFirstTemplateCategory = () => {
|
||||
if (allTemplateGroups.value.length > 0) {
|
||||
const firstCategory = allTemplateGroups.value[0].modules[0]
|
||||
selectTemplateCategory(firstCategory)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects a template category
|
||||
*/
|
||||
const selectTemplateCategory = (category: WorkflowTemplates | null) => {
|
||||
selectedTemplate.value = category
|
||||
return category !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets template thumbnail URL
|
||||
*/
|
||||
const getTemplateThumbnailUrl = (
|
||||
template: TemplateInfo,
|
||||
sourceModule: string,
|
||||
index = ''
|
||||
) => {
|
||||
const basePath =
|
||||
sourceModule === 'default'
|
||||
? api.fileURL(`/templates/${template.name}`)
|
||||
: api.apiURL(`/workflow_templates/${sourceModule}/${template.name}`)
|
||||
|
||||
const indexSuffix = sourceModule === 'default' && index ? `-${index}` : ''
|
||||
return `${basePath}${indexSuffix}.${template.mediaSubtype}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets formatted template title
|
||||
*/
|
||||
const getTemplateTitle = (template: TemplateInfo, sourceModule: string) => {
|
||||
const fallback =
|
||||
template.title ?? template.name ?? `${sourceModule} Template`
|
||||
return sourceModule === 'default'
|
||||
? template.localizedTitle ?? fallback
|
||||
: fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets formatted template description
|
||||
*/
|
||||
const getTemplateDescription = (
|
||||
template: TemplateInfo,
|
||||
sourceModule: string
|
||||
) => {
|
||||
return sourceModule === 'default'
|
||||
? template.localizedDescription ?? ''
|
||||
: template.description?.replace(/[-_]/g, ' ').trim() ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a workflow template
|
||||
*/
|
||||
const loadWorkflowTemplate = async (id: string, sourceModule: string) => {
|
||||
if (!isTemplatesLoaded.value) return false
|
||||
|
||||
loadingTemplateId.value = id
|
||||
let json
|
||||
|
||||
try {
|
||||
// Handle "All" category as a special case
|
||||
if (sourceModule === 'all') {
|
||||
// Find "All" category in the ComfyUI Examples group
|
||||
const comfyExamplesGroup = allTemplateGroups.value.find(
|
||||
(g) =>
|
||||
g.label ===
|
||||
t('templateWorkflows.category.ComfyUI Examples', 'ComfyUI Examples')
|
||||
)
|
||||
const allCategory = comfyExamplesGroup?.modules.find(
|
||||
(m) => m.moduleName === 'all'
|
||||
)
|
||||
const template = allCategory?.templates.find((t) => t.name === id)
|
||||
|
||||
if (!template || !template.sourceModule) return false
|
||||
|
||||
// Use the stored source module for loading
|
||||
const actualSourceModule = template.sourceModule
|
||||
json = await fetchTemplateJson(id, actualSourceModule)
|
||||
|
||||
// Use source module for name
|
||||
const workflowName =
|
||||
actualSourceModule === 'default'
|
||||
? t(`templateWorkflows.template.${id}`, id)
|
||||
: id
|
||||
|
||||
dialogStore.closeDialog()
|
||||
await app.loadGraphData(json, true, true, workflowName)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Regular case for normal categories
|
||||
json = await fetchTemplateJson(id, sourceModule)
|
||||
|
||||
const workflowName =
|
||||
sourceModule === 'default'
|
||||
? t(`templateWorkflows.template.${id}`, id)
|
||||
: id
|
||||
|
||||
dialogStore.closeDialog()
|
||||
await app.loadGraphData(json, true, true, workflowName)
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Error loading workflow template:', error)
|
||||
return false
|
||||
} finally {
|
||||
loadingTemplateId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches template JSON from the appropriate endpoint
|
||||
*/
|
||||
const fetchTemplateJson = async (id: string, sourceModule: string) => {
|
||||
if (sourceModule === 'default') {
|
||||
// Default templates provided by frontend are served on this separate endpoint
|
||||
return fetch(api.fileURL(`/templates/${id}.json`)).then((r) => r.json())
|
||||
} else {
|
||||
return fetch(
|
||||
api.apiURL(`/workflow_templates/${sourceModule}/${id}.json`)
|
||||
).then((r) => r.json())
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
selectedTemplate,
|
||||
loadingTemplateId,
|
||||
|
||||
// Computed
|
||||
isTemplatesLoaded,
|
||||
allTemplateGroups,
|
||||
|
||||
// Methods
|
||||
loadTemplates,
|
||||
selectFirstTemplateCategory,
|
||||
selectTemplateCategory,
|
||||
getTemplateThumbnailUrl,
|
||||
getTemplateTitle,
|
||||
getTemplateDescription,
|
||||
loadWorkflowTemplate
|
||||
}
|
||||
}
|
||||
48
src/composables/useTransformCompatOverlayProps.ts
Normal file
48
src/composables/useTransformCompatOverlayProps.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { HintedString } from '@primevue/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
/**
|
||||
* Options for configuring transform-compatible overlay props
|
||||
*/
|
||||
interface TransformCompatOverlayOptions {
|
||||
/**
|
||||
* Where to append the overlay. 'self' keeps overlay within component
|
||||
* for proper transform inheritance, 'body' teleports to document body
|
||||
*/
|
||||
appendTo?: HintedString<'body' | 'self'> | undefined | HTMLElement
|
||||
// Future: other props needed for transform compatibility
|
||||
// scrollTarget?: string | HTMLElement
|
||||
// autoZIndex?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable that provides props to make PrimeVue overlay components
|
||||
* compatible with CSS-transformed parent elements.
|
||||
*
|
||||
* Vue nodes use CSS transforms for positioning/scaling. PrimeVue overlay
|
||||
* components (Select, MultiSelect, TreeSelect, etc.) teleport to document
|
||||
* body by default, breaking transform inheritance. This composable provides
|
||||
* the necessary props to keep overlays within their component elements.
|
||||
*
|
||||
* @param overrides - Optional overrides for specific use cases
|
||||
* @returns Computed props object to spread on PrimeVue overlay components
|
||||
*
|
||||
* @example
|
||||
* ```vue
|
||||
* <template>
|
||||
* <Select v-bind="overlayProps" />
|
||||
* </template>
|
||||
*
|
||||
* <script setup>
|
||||
* const overlayProps = useTransformCompatOverlayProps()
|
||||
* </script>
|
||||
* ```
|
||||
*/
|
||||
export function useTransformCompatOverlayProps(
|
||||
overrides: TransformCompatOverlayOptions = {}
|
||||
) {
|
||||
return computed(() => ({
|
||||
appendTo: 'self' as const,
|
||||
...overrides
|
||||
}))
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import type { TreeNode } from '@/types/treeExplorerTypes'
|
||||
|
||||
|
||||
39
src/composables/useVueFeatureFlags.ts
Normal file
39
src/composables/useVueFeatureFlags.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Vue-related feature flags composable
|
||||
* Manages local settings-driven flags and LiteGraph integration
|
||||
*/
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
import { LiteGraph } from '../lib/litegraph/src/litegraph'
|
||||
|
||||
function useVueFeatureFlagsIndividual() {
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const shouldRenderVueNodes = computed(() => {
|
||||
try {
|
||||
return settingStore.get('Comfy.VueNodes.Enabled') ?? false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for changes and update LiteGraph immediately
|
||||
watch(
|
||||
shouldRenderVueNodes,
|
||||
() => {
|
||||
LiteGraph.vueNodesMode = shouldRenderVueNodes.value
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
return {
|
||||
shouldRenderVueNodes
|
||||
}
|
||||
}
|
||||
|
||||
export const useVueFeatureFlags = createSharedComposable(
|
||||
useVueFeatureFlagsIndividual
|
||||
)
|
||||
@@ -1,95 +0,0 @@
|
||||
import { computed, onUnmounted, watch } from 'vue'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
import { useWorkflowService } from '@/services/workflowService'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
|
||||
export function useWorkflowAutoSave() {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const settingStore = useSettingStore()
|
||||
const workflowService = useWorkflowService()
|
||||
|
||||
// Use computed refs to cache autosave settings
|
||||
const autoSaveSetting = computed(() =>
|
||||
settingStore.get('Comfy.Workflow.AutoSave')
|
||||
)
|
||||
const autoSaveDelay = computed(() =>
|
||||
settingStore.get('Comfy.Workflow.AutoSaveDelay')
|
||||
)
|
||||
|
||||
let autoSaveTimeout: NodeJS.Timeout | null = null
|
||||
let isSaving = false
|
||||
let needsAutoSave = false
|
||||
|
||||
const scheduleAutoSave = () => {
|
||||
// Clear any existing timeout
|
||||
if (autoSaveTimeout) {
|
||||
clearTimeout(autoSaveTimeout)
|
||||
autoSaveTimeout = null
|
||||
}
|
||||
|
||||
// If autosave is enabled
|
||||
if (autoSaveSetting.value === 'after delay') {
|
||||
// If a save is in progress, mark that we need an autosave after saving
|
||||
if (isSaving) {
|
||||
needsAutoSave = true
|
||||
return
|
||||
}
|
||||
const delay = autoSaveDelay.value
|
||||
autoSaveTimeout = setTimeout(async () => {
|
||||
const activeWorkflow = workflowStore.activeWorkflow
|
||||
if (activeWorkflow?.isModified && activeWorkflow.isPersisted) {
|
||||
try {
|
||||
isSaving = true
|
||||
await workflowService.saveWorkflow(activeWorkflow)
|
||||
} catch (err) {
|
||||
console.error('Auto save failed:', err)
|
||||
} finally {
|
||||
isSaving = false
|
||||
if (needsAutoSave) {
|
||||
needsAutoSave = false
|
||||
scheduleAutoSave()
|
||||
}
|
||||
}
|
||||
}
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for autosave setting changes
|
||||
watch(
|
||||
autoSaveSetting,
|
||||
(newSetting) => {
|
||||
// Clear any existing timeout when settings change
|
||||
if (autoSaveTimeout) {
|
||||
clearTimeout(autoSaveTimeout)
|
||||
autoSaveTimeout = null
|
||||
}
|
||||
|
||||
// If there's an active modified workflow and autosave is enabled, schedule a save
|
||||
if (
|
||||
newSetting === 'after delay' &&
|
||||
workflowStore.activeWorkflow?.isModified
|
||||
) {
|
||||
scheduleAutoSave()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Listen for graph changes and schedule autosave when they occur
|
||||
const onGraphChanged = () => {
|
||||
scheduleAutoSave()
|
||||
}
|
||||
|
||||
api.addEventListener('graphChanged', onGraphChanged)
|
||||
|
||||
onUnmounted(() => {
|
||||
if (autoSaveTimeout) {
|
||||
clearTimeout(autoSaveTimeout)
|
||||
autoSaveTimeout = null
|
||||
}
|
||||
api.removeEventListener('graphChanged', onGraphChanged)
|
||||
})
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
import { tryOnScopeDispose } from '@vueuse/core'
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { getStorageValue, setStorageValue } from '@/scripts/utils'
|
||||
import { useWorkflowService } from '@/services/workflowService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
|
||||
export function useWorkflowPersistence() {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const workflowPersistenceEnabled = computed(() =>
|
||||
settingStore.get('Comfy.Workflow.Persist')
|
||||
)
|
||||
|
||||
const persistCurrentWorkflow = () => {
|
||||
if (!workflowPersistenceEnabled.value) return
|
||||
const workflow = JSON.stringify(comfyApp.graph.serialize())
|
||||
localStorage.setItem('workflow', workflow)
|
||||
if (api.clientId) {
|
||||
sessionStorage.setItem(`workflow:${api.clientId}`, workflow)
|
||||
}
|
||||
}
|
||||
|
||||
const loadWorkflowFromStorage = async (
|
||||
json: string | null,
|
||||
workflowName: string | null
|
||||
) => {
|
||||
if (!json) return false
|
||||
const workflow = JSON.parse(json)
|
||||
await comfyApp.loadGraphData(workflow, true, true, workflowName)
|
||||
return true
|
||||
}
|
||||
|
||||
const loadPreviousWorkflowFromStorage = async () => {
|
||||
const workflowName = getStorageValue('Comfy.PreviousWorkflow')
|
||||
const clientId = api.initialClientId ?? api.clientId
|
||||
|
||||
// Try loading from session storage first
|
||||
if (clientId) {
|
||||
const sessionWorkflow = sessionStorage.getItem(`workflow:${clientId}`)
|
||||
if (await loadWorkflowFromStorage(sessionWorkflow, workflowName)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to local storage
|
||||
const localWorkflow = localStorage.getItem('workflow')
|
||||
return await loadWorkflowFromStorage(localWorkflow, workflowName)
|
||||
}
|
||||
|
||||
const loadDefaultWorkflow = async () => {
|
||||
if (!settingStore.get('Comfy.TutorialCompleted')) {
|
||||
await settingStore.set('Comfy.TutorialCompleted', true)
|
||||
await useWorkflowService().loadBlankWorkflow()
|
||||
await useCommandStore().execute('Comfy.BrowseTemplates')
|
||||
} else {
|
||||
await comfyApp.loadGraphData()
|
||||
}
|
||||
}
|
||||
|
||||
const restorePreviousWorkflow = async () => {
|
||||
if (!workflowPersistenceEnabled.value) return
|
||||
try {
|
||||
const restored = await loadPreviousWorkflowFromStorage()
|
||||
if (!restored) {
|
||||
await loadDefaultWorkflow()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading previous workflow', err)
|
||||
await loadDefaultWorkflow()
|
||||
}
|
||||
}
|
||||
|
||||
// Setup watchers
|
||||
watch(
|
||||
() => workflowStore.activeWorkflow?.key,
|
||||
(activeWorkflowKey) => {
|
||||
if (!activeWorkflowKey) return
|
||||
setStorageValue('Comfy.PreviousWorkflow', activeWorkflowKey)
|
||||
// When the activeWorkflow changes, the graph has already been loaded.
|
||||
// Saving the current state of the graph to the localStorage.
|
||||
persistCurrentWorkflow()
|
||||
}
|
||||
)
|
||||
api.addEventListener('graphChanged', persistCurrentWorkflow)
|
||||
|
||||
// Clean up event listener when component unmounts
|
||||
tryOnScopeDispose(() => {
|
||||
api.removeEventListener('graphChanged', persistCurrentWorkflow)
|
||||
})
|
||||
|
||||
// Restore workflow tabs states
|
||||
const openWorkflows = computed(() => workflowStore.openWorkflows)
|
||||
const activeWorkflow = computed(() => workflowStore.activeWorkflow)
|
||||
const restoreState = computed<{ paths: string[]; activeIndex: number }>(
|
||||
() => {
|
||||
if (!openWorkflows.value || !activeWorkflow.value) {
|
||||
return { paths: [], activeIndex: -1 }
|
||||
}
|
||||
|
||||
const paths = openWorkflows.value
|
||||
.filter((workflow) => workflow?.isPersisted && !workflow.isModified)
|
||||
.map((workflow) => workflow.path)
|
||||
const activeIndex = openWorkflows.value.findIndex(
|
||||
(workflow) => workflow.path === activeWorkflow.value?.path
|
||||
)
|
||||
|
||||
return { paths, activeIndex }
|
||||
}
|
||||
)
|
||||
|
||||
// Get storage values before setting watchers
|
||||
const storedWorkflows = JSON.parse(
|
||||
getStorageValue('Comfy.OpenWorkflowsPaths') || '[]'
|
||||
)
|
||||
const storedActiveIndex = JSON.parse(
|
||||
getStorageValue('Comfy.ActiveWorkflowIndex') || '-1'
|
||||
)
|
||||
|
||||
watch(restoreState, ({ paths, activeIndex }) => {
|
||||
if (workflowPersistenceEnabled.value) {
|
||||
setStorageValue('Comfy.OpenWorkflowsPaths', JSON.stringify(paths))
|
||||
setStorageValue('Comfy.ActiveWorkflowIndex', JSON.stringify(activeIndex))
|
||||
}
|
||||
})
|
||||
|
||||
const restoreWorkflowTabsState = () => {
|
||||
if (!workflowPersistenceEnabled.value) return
|
||||
const isRestorable = storedWorkflows?.length > 0 && storedActiveIndex >= 0
|
||||
if (isRestorable) {
|
||||
workflowStore.openWorkflowsInBackground({
|
||||
left: storedWorkflows.slice(0, storedActiveIndex),
|
||||
right: storedWorkflows.slice(storedActiveIndex)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
restorePreviousWorkflow,
|
||||
restoreWorkflowTabsState
|
||||
}
|
||||
}
|
||||
38
src/composables/useWorkflowTemplateSelectorDialog.ts
Normal file
38
src/composables/useWorkflowTemplateSelectorDialog.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import WorkflowTemplateSelectorDialog from '@/components/custom/widget/WorkflowTemplateSelectorDialog.vue'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const DIALOG_KEY = 'global-workflow-template-selector'
|
||||
|
||||
export const useWorkflowTemplateSelectorDialog = () => {
|
||||
const dialogService = useDialogService()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
function hide() {
|
||||
dialogStore.closeDialog({ key: DIALOG_KEY })
|
||||
}
|
||||
|
||||
function show() {
|
||||
dialogService.showLayoutDialog({
|
||||
key: DIALOG_KEY,
|
||||
component: WorkflowTemplateSelectorDialog,
|
||||
props: {
|
||||
onClose: hide
|
||||
},
|
||||
dialogComponentProps: {
|
||||
pt: {
|
||||
content: { class: '!px-0 overflow-hidden h-full !py-0' },
|
||||
root: {
|
||||
style:
|
||||
'width: 90vw; height: 85vh; max-width: 1400px; display: flex;'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
show,
|
||||
hide
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import type { ISerialisedGraph } from '@/lib/litegraph/src/types/serialisation'
|
||||
import type { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
|
||||
import { validateComfyWorkflow } from '@/schemas/comfyWorkflowSchema'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
import { fixBadLinks } from '@/utils/linkFixer'
|
||||
|
||||
export interface ValidationResult {
|
||||
graphData: ComfyWorkflowJSON | null
|
||||
}
|
||||
|
||||
export function useWorkflowValidation() {
|
||||
const toastStore = useToastStore()
|
||||
|
||||
function tryFixLinks(
|
||||
graphData: ComfyWorkflowJSON,
|
||||
options: { silent?: boolean } = {}
|
||||
) {
|
||||
const { silent = false } = options
|
||||
|
||||
// Collect all logs in an array
|
||||
const logs: string[] = []
|
||||
// Then validate and fix links if schema validation passed
|
||||
const linkValidation = fixBadLinks(
|
||||
graphData as unknown as ISerialisedGraph,
|
||||
{
|
||||
fix: true,
|
||||
silent,
|
||||
logger: {
|
||||
log: (message: string) => {
|
||||
logs.push(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (!silent && logs.length > 0) {
|
||||
toastStore.add({
|
||||
severity: 'warn',
|
||||
summary: 'Workflow Validation',
|
||||
detail: logs.join('\n')
|
||||
})
|
||||
}
|
||||
|
||||
// If links were fixed, notify the user
|
||||
if (linkValidation.fixed) {
|
||||
if (!silent) {
|
||||
toastStore.add({
|
||||
severity: 'success',
|
||||
summary: 'Workflow Links Fixed',
|
||||
detail: `Fixed ${linkValidation.patched} node connections and removed ${linkValidation.deleted} invalid links.`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return linkValidation.graph as unknown as ComfyWorkflowJSON
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a workflow, including link validation and schema validation
|
||||
*/
|
||||
async function validateWorkflow(
|
||||
graphData: ComfyWorkflowJSON,
|
||||
options: {
|
||||
silent?: boolean
|
||||
} = {}
|
||||
): Promise<ValidationResult> {
|
||||
const { silent = false } = options
|
||||
|
||||
let validatedData: ComfyWorkflowJSON | null = null
|
||||
|
||||
// First do schema validation
|
||||
const validatedGraphData = await validateComfyWorkflow(
|
||||
graphData,
|
||||
/* onError=*/ (err) => {
|
||||
if (!silent) {
|
||||
toastStore.addAlert(err)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (validatedGraphData) {
|
||||
try {
|
||||
validatedData = tryFixLinks(validatedGraphData, { silent })
|
||||
} catch (err) {
|
||||
// Link fixer itself is throwing an error
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
graphData: validatedData
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
validateWorkflow
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
type InputSpec,
|
||||
isBooleanInputSpec
|
||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
|
||||
export const useBooleanWidget = () => {
|
||||
const widgetConstructor: ComfyWidgetConstructorV2 = (
|
||||
node: LGraphNode,
|
||||
inputSpec: InputSpec
|
||||
) => {
|
||||
if (!isBooleanInputSpec(inputSpec)) {
|
||||
throw new Error(`Invalid input data: ${inputSpec}`)
|
||||
}
|
||||
|
||||
const defaultVal = inputSpec.default ?? false
|
||||
const options = {
|
||||
on: inputSpec.label_on,
|
||||
off: inputSpec.label_off
|
||||
}
|
||||
|
||||
return node.addWidget(
|
||||
'toggle',
|
||||
inputSpec.name,
|
||||
defaultVal,
|
||||
() => {},
|
||||
options
|
||||
)
|
||||
}
|
||||
|
||||
return widgetConstructor
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ChatHistoryWidget from '@/components/graph/widgets/ChatHistoryWidget.vue'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import {
|
||||
ComponentWidgetImpl,
|
||||
type ComponentWidgetStandardProps,
|
||||
addWidget
|
||||
} from '@/scripts/domWidget'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
|
||||
type ChatHistoryCustomProps = Omit<
|
||||
InstanceType<typeof ChatHistoryWidget>['$props'],
|
||||
ComponentWidgetStandardProps
|
||||
>
|
||||
|
||||
const PADDING = 16
|
||||
|
||||
export const useChatHistoryWidget = (
|
||||
options: {
|
||||
props?: ChatHistoryCustomProps
|
||||
} = {}
|
||||
) => {
|
||||
const widgetConstructor: ComfyWidgetConstructorV2 = (
|
||||
node: LGraphNode,
|
||||
inputSpec: InputSpec
|
||||
) => {
|
||||
const widgetValue = ref<string>('')
|
||||
const widget = new ComponentWidgetImpl<
|
||||
string | object,
|
||||
ChatHistoryCustomProps
|
||||
>({
|
||||
node,
|
||||
name: inputSpec.name,
|
||||
component: ChatHistoryWidget,
|
||||
props: options.props,
|
||||
inputSpec,
|
||||
options: {
|
||||
getValue: () => widgetValue.value,
|
||||
setValue: (value: string | object) => {
|
||||
widgetValue.value = typeof value === 'string' ? value : String(value)
|
||||
},
|
||||
getMinHeight: () => 400 + PADDING
|
||||
}
|
||||
})
|
||||
addWidget(node, widget)
|
||||
return widget
|
||||
}
|
||||
|
||||
return widgetConstructor
|
||||
}
|
||||
@@ -1,414 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import MultiSelectWidget from '@/components/graph/widgets/MultiSelectWidget.vue'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
|
||||
import {
|
||||
ComboInputSpec,
|
||||
type InputSpec,
|
||||
isComboInputSpec
|
||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import {
|
||||
type BaseDOMWidget,
|
||||
ComponentWidgetImpl,
|
||||
addWidget
|
||||
} from '@/scripts/domWidget'
|
||||
import {
|
||||
type ComfyWidgetConstructorV2,
|
||||
addValueControlWidgets
|
||||
} from '@/scripts/widgets'
|
||||
import { fileNameMappingService } from '@/services/fileNameMappingService'
|
||||
|
||||
import { useRemoteWidget } from './useRemoteWidget'
|
||||
|
||||
// Extended interface for widgets with filename mapping
|
||||
interface IFilenameMappingWidget extends IComboWidget {
|
||||
serializeValue?: () => any
|
||||
getRawValues?: () => string[]
|
||||
refreshMappings?: () => void
|
||||
incrementValue?: (options: any) => void
|
||||
decrementValue?: (options: any) => void
|
||||
setValue?: (value: any, options?: any) => void
|
||||
_displayValue?: string
|
||||
computedDisabled?: boolean
|
||||
}
|
||||
|
||||
// Common media file extensions (images, videos, audio)
|
||||
const FILE_EXTENSIONS = [
|
||||
// Image formats
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.png',
|
||||
'.gif',
|
||||
'.webp',
|
||||
'.bmp',
|
||||
'.tiff',
|
||||
'.svg',
|
||||
// Video formats
|
||||
'.mp4',
|
||||
'.avi',
|
||||
'.mov',
|
||||
'.webm',
|
||||
'.mkv',
|
||||
'.flv',
|
||||
'.wmv',
|
||||
// Audio formats
|
||||
'.mp3',
|
||||
'.wav',
|
||||
'.flac',
|
||||
'.aac',
|
||||
'.ogg',
|
||||
'.m4a',
|
||||
'.wma'
|
||||
]
|
||||
|
||||
/**
|
||||
* Check if options contain filename-like values (UUIDs or legacy hashes)
|
||||
*/
|
||||
function hasFilenameOptions(options: any[]): boolean {
|
||||
return options.some((opt: any) => {
|
||||
if (typeof opt !== 'string') return false
|
||||
|
||||
// Check for UUID format (new system)
|
||||
const isUUID =
|
||||
/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(
|
||||
opt
|
||||
)
|
||||
|
||||
// Check for common file extensions (legacy)
|
||||
const hasExtension = FILE_EXTENSIONS.some((ext) =>
|
||||
opt.toLowerCase().endsWith(ext)
|
||||
)
|
||||
|
||||
// Check for hash-like filenames (legacy ComfyUI hashed files)
|
||||
const isHashLike = /^[a-f0-9]{8,}\./i.test(opt)
|
||||
|
||||
return isUUID || hasExtension || isHashLike
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply filename mapping to a widget using a simplified approach
|
||||
*/
|
||||
function applyFilenameMappingToWidget(
|
||||
widget: IComboWidget,
|
||||
node: LGraphNode,
|
||||
_inputSpec: ComboInputSpec
|
||||
) {
|
||||
// Validate widget exists
|
||||
if (!widget) {
|
||||
return
|
||||
}
|
||||
|
||||
// Simple approach: just override _displayValue for text display
|
||||
// Leave all widget functionality intact
|
||||
|
||||
// Cast to extended interface for type safety
|
||||
const mappingWidget = widget as IFilenameMappingWidget
|
||||
|
||||
// Override serializeValue to ensure asset ID is used for API
|
||||
mappingWidget.serializeValue = function () {
|
||||
// Always return the actual widget value (asset ID) for serialization
|
||||
return mappingWidget.value
|
||||
}
|
||||
|
||||
// Override _displayValue to show human-readable names
|
||||
try {
|
||||
Object.defineProperty(mappingWidget, '_displayValue', {
|
||||
get() {
|
||||
if (mappingWidget.computedDisabled) return ''
|
||||
|
||||
// Get current asset ID value
|
||||
const assetId = mappingWidget.value
|
||||
if (typeof assetId !== 'string') return String(assetId)
|
||||
|
||||
// Try to get human-readable name from cache (deduplicated for display)
|
||||
const mapping = fileNameMappingService.getCachedMapping('input', true)
|
||||
const humanName = mapping[assetId]
|
||||
|
||||
// Return human name for display, fallback to asset ID
|
||||
return humanName || assetId
|
||||
},
|
||||
configurable: true
|
||||
})
|
||||
} catch (error) {
|
||||
// Property might be non-configurable, continue without override
|
||||
}
|
||||
|
||||
// Also override the options.values to show human names in dropdown
|
||||
const originalOptions = mappingWidget.options as any
|
||||
|
||||
// Store original values array - maintain the same array reference
|
||||
const rawValues = Array.isArray(originalOptions.values)
|
||||
? originalOptions.values
|
||||
: []
|
||||
|
||||
// Create a computed property that returns mapped values
|
||||
if (mappingWidget.options) {
|
||||
try {
|
||||
Object.defineProperty(mappingWidget.options, 'values', {
|
||||
get() {
|
||||
if (!Array.isArray(rawValues)) return rawValues
|
||||
|
||||
// Map values to human-readable names (deduplicated for dropdown display)
|
||||
const mapping = fileNameMappingService.getCachedMapping('input', true)
|
||||
const mapped = rawValues.map((value: any) => {
|
||||
if (typeof value === 'string') {
|
||||
const humanName = mapping[value]
|
||||
if (humanName) {
|
||||
return humanName
|
||||
}
|
||||
}
|
||||
return value
|
||||
})
|
||||
return mapped
|
||||
},
|
||||
set(newValues) {
|
||||
// Update raw values array in place to maintain reference
|
||||
rawValues.length = 0
|
||||
if (Array.isArray(newValues)) {
|
||||
rawValues.push(...newValues)
|
||||
}
|
||||
// Trigger UI update
|
||||
node.setDirtyCanvas?.(true, true)
|
||||
node.graph?.setDirtyCanvas?.(true, true)
|
||||
},
|
||||
configurable: true,
|
||||
enumerable: true
|
||||
})
|
||||
} catch (error) {
|
||||
// Property might be non-configurable, continue without override
|
||||
}
|
||||
}
|
||||
|
||||
// Add helper methods for managing the raw values
|
||||
mappingWidget.getRawValues = function () {
|
||||
return rawValues
|
||||
}
|
||||
|
||||
// Add a method to force refresh the dropdown
|
||||
mappingWidget.refreshMappings = function () {
|
||||
// Force litegraph to re-read the values and trigger UI update
|
||||
node.setDirtyCanvas?.(true, true)
|
||||
node.graph?.setDirtyCanvas?.(true, true)
|
||||
}
|
||||
|
||||
// Override incrementValue and decrementValue for arrow key navigation
|
||||
mappingWidget.incrementValue = function (options: any) {
|
||||
// Get the current human-readable value (deduplicated)
|
||||
const mapping = fileNameMappingService.getCachedMapping('input', true)
|
||||
const currentHumanName = mapping[mappingWidget.value] || mappingWidget.value
|
||||
|
||||
// Get the values array (which contains human names through our proxy)
|
||||
const rawValues = mappingWidget.options?.values
|
||||
if (!rawValues || typeof rawValues === 'function') return
|
||||
|
||||
const values = Array.isArray(rawValues)
|
||||
? rawValues
|
||||
: Object.values(rawValues)
|
||||
const currentIndex = values.indexOf(currentHumanName as any)
|
||||
|
||||
if (currentIndex >= 0 && currentIndex < values.length - 1) {
|
||||
// Get next value and set it (setValue will handle conversion)
|
||||
const nextValue = values[currentIndex + 1]
|
||||
mappingWidget.setValue?.(nextValue, options)
|
||||
}
|
||||
}
|
||||
mappingWidget.decrementValue = function (options: any) {
|
||||
// Get the current human-readable value (deduplicated)
|
||||
const mapping = fileNameMappingService.getCachedMapping('input', true)
|
||||
const currentHumanName = mapping[mappingWidget.value] || mappingWidget.value
|
||||
|
||||
// Get the values array (which contains human names through our proxy)
|
||||
const rawValues = mappingWidget.options?.values
|
||||
if (!rawValues || typeof rawValues === 'function') return
|
||||
|
||||
const values = Array.isArray(rawValues)
|
||||
? rawValues
|
||||
: Object.values(rawValues)
|
||||
const currentIndex = values.indexOf(currentHumanName as any)
|
||||
|
||||
if (currentIndex > 0) {
|
||||
// Get previous value and set it (setValue will handle conversion)
|
||||
const prevValue = values[currentIndex - 1]
|
||||
mappingWidget.setValue?.(prevValue, options)
|
||||
}
|
||||
}
|
||||
|
||||
// Override setValue to handle human name selection from dropdown
|
||||
const originalSetValue = mappingWidget.setValue
|
||||
mappingWidget.setValue = function (selectedValue: any, options?: any) {
|
||||
if (typeof selectedValue === 'string') {
|
||||
// Check if this is a human-readable name that needs reverse mapping
|
||||
// Use deduplicated reverse mapping to handle suffixed names
|
||||
const reverseMapping = fileNameMappingService.getCachedReverseMapping(
|
||||
'input',
|
||||
true
|
||||
)
|
||||
const assetId = reverseMapping[selectedValue] || selectedValue
|
||||
|
||||
// Set the asset ID
|
||||
mappingWidget.value = assetId
|
||||
|
||||
// Call original setValue with asset ID if it exists
|
||||
if (originalSetValue) {
|
||||
originalSetValue.call(mappingWidget, assetId, options)
|
||||
}
|
||||
|
||||
// Trigger callback with asset ID
|
||||
if (mappingWidget.callback) {
|
||||
mappingWidget.callback.call(mappingWidget, assetId)
|
||||
}
|
||||
} else {
|
||||
mappingWidget.value = selectedValue
|
||||
if (originalSetValue) {
|
||||
originalSetValue.call(mappingWidget, selectedValue, options)
|
||||
}
|
||||
if (mappingWidget.callback) {
|
||||
mappingWidget.callback.call(mappingWidget, selectedValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Override callback to handle human name selection
|
||||
const originalCallback = mappingWidget.callback
|
||||
if (mappingWidget.callback) {
|
||||
mappingWidget.callback = function (selectedValue: any) {
|
||||
if (typeof selectedValue === 'string') {
|
||||
// Check if this is a human-readable name that needs reverse mapping
|
||||
// Use deduplicated reverse mapping to handle suffixed names
|
||||
const reverseMapping = fileNameMappingService.getCachedReverseMapping(
|
||||
'input',
|
||||
true
|
||||
)
|
||||
const assetId = reverseMapping[selectedValue] || selectedValue
|
||||
|
||||
// Set the asset ID
|
||||
mappingWidget.value = assetId
|
||||
|
||||
// Call original callback with asset ID
|
||||
if (originalCallback) {
|
||||
originalCallback.call(mappingWidget, assetId)
|
||||
}
|
||||
} else {
|
||||
mappingWidget.value = selectedValue
|
||||
if (originalCallback) {
|
||||
originalCallback.call(mappingWidget, selectedValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger async load of mappings and update display when ready
|
||||
fileNameMappingService
|
||||
.getMapping('input')
|
||||
.then(() => {
|
||||
// Mappings loaded, trigger redraw to update display
|
||||
node.setDirtyCanvas?.(true, true)
|
||||
node.graph?.setDirtyCanvas?.(true, true)
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently fail - will show hash values as fallback
|
||||
})
|
||||
}
|
||||
|
||||
const getDefaultValue = (inputSpec: ComboInputSpec) => {
|
||||
if (inputSpec.default) return inputSpec.default
|
||||
if (inputSpec.options?.length) return inputSpec.options[0]
|
||||
if (inputSpec.remote) return 'Loading...'
|
||||
return undefined
|
||||
}
|
||||
|
||||
const addMultiSelectWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
|
||||
const widgetValue = ref<string[]>([])
|
||||
const widget = new ComponentWidgetImpl({
|
||||
node,
|
||||
name: inputSpec.name,
|
||||
component: MultiSelectWidget,
|
||||
inputSpec,
|
||||
options: {
|
||||
getValue: () => widgetValue.value,
|
||||
setValue: (value: string[]) => {
|
||||
widgetValue.value = value
|
||||
}
|
||||
}
|
||||
})
|
||||
addWidget(node, widget as BaseDOMWidget<object | string>)
|
||||
// TODO: Add remote support to multi-select widget
|
||||
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/3003
|
||||
return widget
|
||||
}
|
||||
|
||||
const addComboWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
|
||||
const defaultValue = getDefaultValue(inputSpec)
|
||||
const comboOptions = inputSpec.options ?? []
|
||||
const widget = node.addWidget(
|
||||
'combo',
|
||||
inputSpec.name,
|
||||
defaultValue,
|
||||
() => {},
|
||||
{
|
||||
values: comboOptions
|
||||
}
|
||||
) as IComboWidget
|
||||
|
||||
if (inputSpec.remote) {
|
||||
const remoteWidget = useRemoteWidget({
|
||||
remoteConfig: inputSpec.remote,
|
||||
defaultValue,
|
||||
node,
|
||||
widget
|
||||
})
|
||||
if (inputSpec.remote.refresh_button) remoteWidget.addRefreshButton()
|
||||
|
||||
const origOptions = widget.options
|
||||
widget.options = new Proxy(origOptions, {
|
||||
get(target, prop) {
|
||||
// Assertion: Proxy handler passthrough
|
||||
return prop !== 'values'
|
||||
? target[prop as keyof typeof target]
|
||||
: remoteWidget.getValue()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (inputSpec.control_after_generate) {
|
||||
widget.linkedWidgets = addValueControlWidgets(
|
||||
node,
|
||||
widget,
|
||||
undefined,
|
||||
undefined,
|
||||
transformInputSpecV2ToV1(inputSpec)
|
||||
)
|
||||
}
|
||||
|
||||
// For non-remote combo widgets, check if they contain filenames and apply mapping
|
||||
if (!inputSpec.remote && inputSpec.options) {
|
||||
// Check if options contain filename-like values
|
||||
const hasFilenames = hasFilenameOptions(inputSpec.options)
|
||||
|
||||
if (hasFilenames) {
|
||||
// Apply filename mapping for display
|
||||
applyFilenameMappingToWidget(widget, node, inputSpec)
|
||||
}
|
||||
}
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
export const useComboWidget = () => {
|
||||
const widgetConstructor: ComfyWidgetConstructorV2 = (
|
||||
node: LGraphNode,
|
||||
inputSpec: InputSpec
|
||||
) => {
|
||||
if (!isComboInputSpec(inputSpec)) {
|
||||
throw new Error(`Invalid input data: ${inputSpec}`)
|
||||
}
|
||||
return inputSpec.multi_select
|
||||
? addMultiSelectWidget(node, inputSpec)
|
||||
: addComboWidget(node, inputSpec)
|
||||
}
|
||||
|
||||
return widgetConstructor
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import _ from 'es-toolkit/compat'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import {
|
||||
type InputSpec,
|
||||
isFloatInputSpec
|
||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
function onFloatValueChange(this: INumericWidget, v: number) {
|
||||
const round = this.options.round
|
||||
if (round) {
|
||||
const precision =
|
||||
this.options.precision ?? Math.max(0, -Math.floor(Math.log10(round)))
|
||||
const rounded = Math.round(v / round) * round
|
||||
this.value = _.clamp(
|
||||
Number(rounded.toFixed(precision)),
|
||||
this.options.min ?? -Infinity,
|
||||
this.options.max ?? Infinity
|
||||
)
|
||||
} else {
|
||||
this.value = v
|
||||
}
|
||||
}
|
||||
|
||||
export const _for_testing = {
|
||||
onFloatValueChange
|
||||
}
|
||||
|
||||
export const useFloatWidget = () => {
|
||||
const widgetConstructor: ComfyWidgetConstructorV2 = (
|
||||
node: LGraphNode,
|
||||
inputSpec: InputSpec
|
||||
) => {
|
||||
if (!isFloatInputSpec(inputSpec)) {
|
||||
throw new Error(`Invalid input data: ${inputSpec}`)
|
||||
}
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const sliderEnabled = !settingStore.get('Comfy.DisableSliders')
|
||||
|
||||
const display_type = inputSpec.display
|
||||
const widgetType =
|
||||
sliderEnabled && display_type == 'slider'
|
||||
? 'slider'
|
||||
: display_type == 'knob'
|
||||
? 'knob'
|
||||
: 'number'
|
||||
|
||||
const step = inputSpec.step ?? 0.5
|
||||
const precision =
|
||||
settingStore.get('Comfy.FloatRoundingPrecision') ||
|
||||
Math.max(0, -Math.floor(Math.log10(step)))
|
||||
const enableRounding = !settingStore.get('Comfy.DisableFloatRounding')
|
||||
|
||||
/** Assertion {@link inputSpec.default} */
|
||||
const defaultValue = (inputSpec.default as number | undefined) ?? 0
|
||||
return node.addWidget(
|
||||
widgetType,
|
||||
inputSpec.name,
|
||||
defaultValue,
|
||||
onFloatValueChange,
|
||||
{
|
||||
min: inputSpec.min ?? 0,
|
||||
max: inputSpec.max ?? 2048,
|
||||
round:
|
||||
enableRounding && precision && !inputSpec.round
|
||||
? Math.pow(10, -precision)
|
||||
: (inputSpec.round as number),
|
||||
/** @deprecated Use step2 instead. The 10x value is a legacy implementation. */
|
||||
step: step * 10.0,
|
||||
step2: step,
|
||||
precision
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return widgetConstructor
|
||||
}
|
||||
@@ -1,317 +0,0 @@
|
||||
import {
|
||||
BaseWidget,
|
||||
type CanvasPointer,
|
||||
type LGraphNode,
|
||||
LiteGraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
IWidgetOptions
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { app } from '@/scripts/app'
|
||||
import { calculateImageGrid } from '@/scripts/ui/imagePreview'
|
||||
import { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { is_all_same_aspect_ratio } from '@/utils/imageUtil'
|
||||
|
||||
const renderPreview = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
node: LGraphNode,
|
||||
shiftY: number
|
||||
) => {
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
const mouse = canvas.graph_mouse
|
||||
|
||||
if (!canvas.pointer_is_down && node.pointerDown) {
|
||||
if (
|
||||
mouse[0] === node.pointerDown.pos[0] &&
|
||||
mouse[1] === node.pointerDown.pos[1]
|
||||
) {
|
||||
node.imageIndex = node.pointerDown.index
|
||||
}
|
||||
node.pointerDown = null
|
||||
}
|
||||
|
||||
const imgs = node.imgs ?? []
|
||||
let { imageIndex } = node
|
||||
const numImages = imgs.length
|
||||
if (numImages === 1 && !imageIndex) {
|
||||
// This skips the thumbnail render section below
|
||||
node.imageIndex = imageIndex = 0
|
||||
}
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const allowImageSizeDraw = settingStore.get('Comfy.Node.AllowImageSizeDraw')
|
||||
const IMAGE_TEXT_SIZE_TEXT_HEIGHT = allowImageSizeDraw ? 15 : 0
|
||||
const dw = node.size[0]
|
||||
const dh = node.size[1] - shiftY - IMAGE_TEXT_SIZE_TEXT_HEIGHT
|
||||
|
||||
if (imageIndex == null) {
|
||||
// No image selected; draw thumbnails of all
|
||||
let cellWidth: number
|
||||
let cellHeight: number
|
||||
let shiftX: number
|
||||
let cell_padding: number
|
||||
let cols: number
|
||||
|
||||
const compact_mode = is_all_same_aspect_ratio(imgs)
|
||||
if (!compact_mode) {
|
||||
// use rectangle cell style and border line
|
||||
cell_padding = 2
|
||||
// Prevent infinite canvas2d scale-up
|
||||
const largestDimension = imgs.reduce(
|
||||
(acc, current) =>
|
||||
Math.max(acc, current.naturalWidth, current.naturalHeight),
|
||||
0
|
||||
)
|
||||
const fakeImgs = []
|
||||
fakeImgs.length = imgs.length
|
||||
fakeImgs[0] = {
|
||||
naturalWidth: largestDimension,
|
||||
naturalHeight: largestDimension
|
||||
}
|
||||
;({ cellWidth, cellHeight, cols, shiftX } = calculateImageGrid(
|
||||
fakeImgs,
|
||||
dw,
|
||||
dh
|
||||
))
|
||||
} else {
|
||||
cell_padding = 0
|
||||
;({ cellWidth, cellHeight, cols, shiftX } = calculateImageGrid(
|
||||
imgs,
|
||||
dw,
|
||||
dh
|
||||
))
|
||||
}
|
||||
|
||||
let anyHovered = false
|
||||
node.imageRects = []
|
||||
for (let i = 0; i < numImages; i++) {
|
||||
const img = imgs[i]
|
||||
const row = Math.floor(i / cols)
|
||||
const col = i % cols
|
||||
const x = col * cellWidth + shiftX
|
||||
const y = row * cellHeight + shiftY
|
||||
if (!anyHovered) {
|
||||
anyHovered = LiteGraph.isInsideRectangle(
|
||||
mouse[0],
|
||||
mouse[1],
|
||||
x + node.pos[0],
|
||||
y + node.pos[1],
|
||||
cellWidth,
|
||||
cellHeight
|
||||
)
|
||||
if (anyHovered) {
|
||||
node.overIndex = i
|
||||
let value = 110
|
||||
if (canvas.pointer_is_down) {
|
||||
if (!node.pointerDown || node.pointerDown.index !== i) {
|
||||
node.pointerDown = { index: i, pos: [...mouse] }
|
||||
}
|
||||
value = 125
|
||||
}
|
||||
ctx.filter = `contrast(${value}%) brightness(${value}%)`
|
||||
canvas.canvas.style.cursor = 'pointer'
|
||||
}
|
||||
}
|
||||
node.imageRects.push([x, y, cellWidth, cellHeight])
|
||||
|
||||
const wratio = cellWidth / img.width
|
||||
const hratio = cellHeight / img.height
|
||||
const ratio = Math.min(wratio, hratio)
|
||||
|
||||
const imgHeight = ratio * img.height
|
||||
const imgY = row * cellHeight + shiftY + (cellHeight - imgHeight) / 2
|
||||
const imgWidth = ratio * img.width
|
||||
const imgX = col * cellWidth + shiftX + (cellWidth - imgWidth) / 2
|
||||
|
||||
ctx.drawImage(
|
||||
img,
|
||||
imgX + cell_padding,
|
||||
imgY + cell_padding,
|
||||
imgWidth - cell_padding * 2,
|
||||
imgHeight - cell_padding * 2
|
||||
)
|
||||
if (!compact_mode) {
|
||||
// rectangle cell and border line style
|
||||
ctx.strokeStyle = '#8F8F8F'
|
||||
ctx.lineWidth = 1
|
||||
ctx.strokeRect(
|
||||
x + cell_padding,
|
||||
y + cell_padding,
|
||||
cellWidth - cell_padding * 2,
|
||||
cellHeight - cell_padding * 2
|
||||
)
|
||||
}
|
||||
|
||||
ctx.filter = 'none'
|
||||
}
|
||||
|
||||
if (!anyHovered) {
|
||||
node.pointerDown = null
|
||||
node.overIndex = null
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
// Draw individual
|
||||
const img = imgs[imageIndex]
|
||||
let w = img.naturalWidth
|
||||
let h = img.naturalHeight
|
||||
|
||||
const scaleX = dw / w
|
||||
const scaleY = dh / h
|
||||
const scale = Math.min(scaleX, scaleY, 1)
|
||||
|
||||
w *= scale
|
||||
h *= scale
|
||||
|
||||
const x = (dw - w) / 2
|
||||
const y = (dh - h) / 2 + shiftY
|
||||
ctx.drawImage(img, x, y, w, h)
|
||||
|
||||
// Draw image size text below the image
|
||||
if (allowImageSizeDraw) {
|
||||
ctx.fillStyle = LiteGraph.NODE_TEXT_COLOR
|
||||
ctx.textAlign = 'center'
|
||||
ctx.font = '10px sans-serif'
|
||||
const sizeText = `${Math.round(img.naturalWidth)} × ${Math.round(img.naturalHeight)}`
|
||||
const textY = y + h + 10
|
||||
ctx.fillText(sizeText, x + w / 2, textY)
|
||||
}
|
||||
|
||||
const drawButton = (
|
||||
x: number,
|
||||
y: number,
|
||||
sz: number,
|
||||
text: string
|
||||
): boolean => {
|
||||
const hovered = LiteGraph.isInsideRectangle(
|
||||
mouse[0],
|
||||
mouse[1],
|
||||
x + node.pos[0],
|
||||
y + node.pos[1],
|
||||
sz,
|
||||
sz
|
||||
)
|
||||
let fill = '#333'
|
||||
let textFill = '#fff'
|
||||
let isClicking = false
|
||||
if (hovered) {
|
||||
canvas.canvas.style.cursor = 'pointer'
|
||||
if (canvas.pointer_is_down) {
|
||||
fill = '#1e90ff'
|
||||
isClicking = true
|
||||
} else {
|
||||
fill = '#eee'
|
||||
textFill = '#000'
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fillStyle = fill
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(x, y, sz, sz, [4])
|
||||
ctx.fill()
|
||||
ctx.fillStyle = textFill
|
||||
ctx.font = '12px Arial'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(text, x + 15, y + 20)
|
||||
|
||||
return isClicking
|
||||
}
|
||||
|
||||
if (!(numImages > 1)) return
|
||||
|
||||
const imageNum = (node.imageIndex ?? 0) + 1
|
||||
if (drawButton(dw - 40, dh + shiftY - 40, 30, `${imageNum}/${numImages}`)) {
|
||||
const i = imageNum >= numImages ? 0 : imageNum
|
||||
if (!node.pointerDown || node.pointerDown.index !== i) {
|
||||
node.pointerDown = { index: i, pos: [...mouse] }
|
||||
}
|
||||
}
|
||||
|
||||
if (drawButton(dw - 40, shiftY + 10, 30, `x`)) {
|
||||
if (!node.pointerDown || node.pointerDown.index !== null) {
|
||||
node.pointerDown = { index: null, pos: [...mouse] }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ImagePreviewWidget extends BaseWidget {
|
||||
constructor(
|
||||
node: LGraphNode,
|
||||
name: string,
|
||||
options: IWidgetOptions<string | object>
|
||||
) {
|
||||
const widget: IBaseWidget = {
|
||||
name,
|
||||
options,
|
||||
type: 'custom',
|
||||
/** Dummy value to satisfy type requirements. */
|
||||
value: '',
|
||||
y: 0
|
||||
}
|
||||
super(widget, node)
|
||||
|
||||
// Don't serialize the widget value
|
||||
this.serialize = false
|
||||
}
|
||||
|
||||
override drawWidget(ctx: CanvasRenderingContext2D): void {
|
||||
renderPreview(ctx, this.node, this.y)
|
||||
}
|
||||
|
||||
override onPointerDown(pointer: CanvasPointer, node: LGraphNode): boolean {
|
||||
pointer.onDragStart = () => {
|
||||
const { canvas } = app
|
||||
const { graph } = canvas
|
||||
canvas.emitBeforeChange()
|
||||
graph?.beforeChange()
|
||||
// Ensure that dragging is properly cleaned up, on success or failure.
|
||||
pointer.finally = () => {
|
||||
canvas.isDragging = false
|
||||
graph?.afterChange()
|
||||
canvas.emitAfterChange()
|
||||
}
|
||||
|
||||
canvas.processSelect(node, pointer.eDown)
|
||||
canvas.isDragging = true
|
||||
}
|
||||
|
||||
pointer.onDragEnd = (e) => {
|
||||
const { canvas } = app
|
||||
if (e.shiftKey || LiteGraph.alwaysSnapToGrid)
|
||||
canvas.graph?.snapToGrid(canvas.selectedItems)
|
||||
|
||||
canvas.setDirty(true, true)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override onClick(): void {}
|
||||
|
||||
override computeLayoutSize() {
|
||||
return {
|
||||
minHeight: 220,
|
||||
minWidth: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const useImagePreviewWidget = () => {
|
||||
const widgetConstructor: ComfyWidgetConstructorV2 = (
|
||||
node: LGraphNode,
|
||||
inputSpec: InputSpec
|
||||
) => {
|
||||
return node.addCustomWidget(
|
||||
new ImagePreviewWidget(node, inputSpec.name, {
|
||||
serialize: false
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return widgetConstructor
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
|
||||
import { useNodeImageUpload } from '@/composables/node/useNodeImageUpload'
|
||||
import { useValueTransform } from '@/composables/useValueTransform'
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { IComboWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { ResultItem, ResultItemType } from '@/schemas/apiSchema'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
|
||||
import { fileNameMappingService } from '@/services/fileNameMappingService'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { isImageUploadInput } from '@/types/nodeDefAugmentation'
|
||||
import { createAnnotatedPath } from '@/utils/formatUtil'
|
||||
import { addToComboValues } from '@/utils/litegraphUtil'
|
||||
|
||||
const ACCEPTED_IMAGE_TYPES = 'image/png,image/jpeg,image/webp'
|
||||
const ACCEPTED_VIDEO_TYPES = 'video/webm,video/mp4'
|
||||
|
||||
type InternalFile = string | ResultItem
|
||||
type InternalValue = InternalFile | InternalFile[]
|
||||
type ExposedValue = string | string[]
|
||||
|
||||
const isImageFile = (file: File) => file.type.startsWith('image/')
|
||||
const isVideoFile = (file: File) => file.type.startsWith('video/')
|
||||
|
||||
const findFileComboWidget = (node: LGraphNode, inputName: string) =>
|
||||
node.widgets!.find((w) => w.name === inputName) as IComboWidget & {
|
||||
value: ExposedValue
|
||||
}
|
||||
|
||||
export const useImageUploadWidget = () => {
|
||||
const widgetConstructor: ComfyWidgetConstructor = (
|
||||
node: LGraphNode,
|
||||
inputName: string,
|
||||
inputData: InputSpec
|
||||
) => {
|
||||
if (!isImageUploadInput(inputData)) {
|
||||
throw new Error(
|
||||
'Image upload widget requires imageInputName augmentation'
|
||||
)
|
||||
}
|
||||
|
||||
const inputOptions = inputData[1]
|
||||
const { imageInputName, allow_batch, image_folder = 'input' } = inputOptions
|
||||
const folder: ResultItemType | undefined = image_folder
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
|
||||
const isAnimated = !!inputOptions.animated_image_upload
|
||||
const isVideo = !!inputOptions.video_upload
|
||||
const accept = isVideo ? ACCEPTED_VIDEO_TYPES : ACCEPTED_IMAGE_TYPES
|
||||
const { showPreview } = isVideo ? useNodeVideo(node) : useNodeImage(node)
|
||||
|
||||
const fileFilter = isVideo ? isVideoFile : isImageFile
|
||||
const fileComboWidget = findFileComboWidget(node, imageInputName)
|
||||
const initialFile = `${fileComboWidget.value}`
|
||||
const formatPath = (value: InternalFile) =>
|
||||
createAnnotatedPath(value, { rootFolder: image_folder })
|
||||
|
||||
const transform = (internalValue: InternalValue): ExposedValue => {
|
||||
if (!internalValue) return initialFile
|
||||
if (Array.isArray(internalValue))
|
||||
return allow_batch
|
||||
? internalValue.map(formatPath)
|
||||
: formatPath(internalValue[0])
|
||||
return formatPath(internalValue)
|
||||
}
|
||||
|
||||
Object.defineProperty(
|
||||
fileComboWidget,
|
||||
'value',
|
||||
useValueTransform(transform, initialFile)
|
||||
)
|
||||
|
||||
// Setup file upload handling
|
||||
const { openFileSelection } = useNodeImageUpload(node, {
|
||||
allow_batch,
|
||||
fileFilter,
|
||||
accept,
|
||||
folder,
|
||||
onUploadComplete: async (output) => {
|
||||
// CRITICAL: Refresh mappings FIRST before updating dropdown
|
||||
// This ensures new hash→human mappings are available when dropdown renders
|
||||
try {
|
||||
await fileNameMappingService.refreshMapping('input')
|
||||
} catch (error) {
|
||||
// Continue anyway - will show hash values as fallback
|
||||
}
|
||||
|
||||
// Now add the files to dropdown - addToComboValues will trigger refreshMappings
|
||||
output.forEach((path) => {
|
||||
addToComboValues(fileComboWidget, path)
|
||||
})
|
||||
|
||||
// Set the widget value to the newly uploaded files
|
||||
// Use the last uploaded file for single selection widgets
|
||||
const selectedValue = allow_batch ? output : output[output.length - 1]
|
||||
|
||||
// @ts-expect-error litegraph combo value type does not support arrays yet
|
||||
fileComboWidget.value = selectedValue
|
||||
fileComboWidget.callback?.(selectedValue)
|
||||
|
||||
// Force one more refresh to ensure UI is in sync
|
||||
if (typeof (fileComboWidget as any).refreshMappings === 'function') {
|
||||
;(fileComboWidget as any).refreshMappings()
|
||||
}
|
||||
|
||||
// Trigger UI update to show human-readable names
|
||||
node.setDirtyCanvas?.(true, true)
|
||||
node.graph?.setDirtyCanvas?.(true, true)
|
||||
}
|
||||
})
|
||||
|
||||
// Create the button widget for selecting the files
|
||||
const uploadWidget = node.addWidget(
|
||||
'button',
|
||||
inputName,
|
||||
'image',
|
||||
() => openFileSelection(),
|
||||
{
|
||||
serialize: false
|
||||
}
|
||||
)
|
||||
uploadWidget.label = t('g.choose_file_to_upload')
|
||||
|
||||
// Add our own callback to the combo widget to render an image when it changes
|
||||
fileComboWidget.callback = function () {
|
||||
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
|
||||
isAnimated
|
||||
})
|
||||
node.graph?.setDirtyCanvas(true)
|
||||
}
|
||||
|
||||
// On load if we have a value then render the image
|
||||
// The value isnt set immediately so we need to wait a moment
|
||||
// No change callbacks seem to be fired on initial setting of the value
|
||||
requestAnimationFrame(() => {
|
||||
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
|
||||
isAnimated
|
||||
})
|
||||
showPreview({ block: false })
|
||||
})
|
||||
|
||||
return { widget: uploadWidget }
|
||||
}
|
||||
|
||||
return widgetConstructor
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
|
||||
import {
|
||||
type InputSpec,
|
||||
isIntInputSpec
|
||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import {
|
||||
type ComfyWidgetConstructorV2,
|
||||
addValueControlWidget
|
||||
} from '@/scripts/widgets'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
function onValueChange(this: INumericWidget, v: number) {
|
||||
// For integers, always round to the nearest step
|
||||
// step === 0 is invalid, assign 1 if options.step is 0
|
||||
const step = this.options.step2 || 1
|
||||
|
||||
if (step === 1) {
|
||||
// Simple case: round to nearest integer
|
||||
this.value = Math.round(v)
|
||||
} else {
|
||||
// Round to nearest multiple of step
|
||||
// First, determine if min value creates an offset
|
||||
const min = this.options.min ?? 0
|
||||
const offset = min % step
|
||||
|
||||
// Round to nearest step, accounting for offset
|
||||
this.value = Math.round((v - offset) / step) * step + offset
|
||||
}
|
||||
}
|
||||
|
||||
export const _for_testing = {
|
||||
onValueChange
|
||||
}
|
||||
|
||||
export const useIntWidget = () => {
|
||||
const widgetConstructor: ComfyWidgetConstructorV2 = (
|
||||
node: LGraphNode,
|
||||
inputSpec: InputSpec
|
||||
) => {
|
||||
if (!isIntInputSpec(inputSpec)) {
|
||||
throw new Error(`Invalid input data: ${inputSpec}`)
|
||||
}
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const sliderEnabled = !settingStore.get('Comfy.DisableSliders')
|
||||
const display_type = inputSpec.display
|
||||
const widgetType =
|
||||
sliderEnabled && display_type == 'slider'
|
||||
? 'slider'
|
||||
: display_type == 'knob'
|
||||
? 'knob'
|
||||
: 'number'
|
||||
|
||||
const step = inputSpec.step ?? 1
|
||||
/** Assertion {@link inputSpec.default} */
|
||||
const defaultValue = (inputSpec.default as number | undefined) ?? 0
|
||||
const widget = node.addWidget(
|
||||
widgetType,
|
||||
inputSpec.name,
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
{
|
||||
min: inputSpec.min ?? 0,
|
||||
max: inputSpec.max ?? 2048,
|
||||
/** @deprecated Use step2 instead. The 10x value is a legacy implementation. */
|
||||
step: step * 10,
|
||||
step2: step,
|
||||
precision: 0
|
||||
}
|
||||
)
|
||||
|
||||
const controlAfterGenerate =
|
||||
inputSpec.control_after_generate ??
|
||||
/**
|
||||
* Compatibility with legacy node convention. Int input with name
|
||||
* 'seed' or 'noise_seed' get automatically added a control widget.
|
||||
*/
|
||||
['seed', 'noise_seed'].includes(inputSpec.name)
|
||||
|
||||
if (controlAfterGenerate) {
|
||||
const seedControl = addValueControlWidget(
|
||||
node,
|
||||
widget,
|
||||
'randomize',
|
||||
undefined,
|
||||
undefined,
|
||||
transformInputSpecV2ToV1(inputSpec)
|
||||
)
|
||||
widget.linkedWidgets = [seedControl]
|
||||
}
|
||||
|
||||
return widget
|
||||
}
|
||||
return widgetConstructor
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import { Editor as TiptapEditor } from '@tiptap/core'
|
||||
import TiptapLink from '@tiptap/extension-link'
|
||||
import TiptapTable from '@tiptap/extension-table'
|
||||
import TiptapTableCell from '@tiptap/extension-table-cell'
|
||||
import TiptapTableHeader from '@tiptap/extension-table-header'
|
||||
import TiptapTableRow from '@tiptap/extension-table-row'
|
||||
import TiptapStarterKit from '@tiptap/starter-kit'
|
||||
import { Markdown as TiptapMarkdown } from 'tiptap-markdown'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { type InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { app } from '@/scripts/app'
|
||||
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
|
||||
function addMarkdownWidget(
|
||||
node: LGraphNode,
|
||||
name: string,
|
||||
opts: { defaultVal: string }
|
||||
) {
|
||||
TiptapMarkdown.configure({
|
||||
html: false,
|
||||
breaks: true,
|
||||
transformPastedText: true
|
||||
})
|
||||
const editor = new TiptapEditor({
|
||||
extensions: [
|
||||
TiptapStarterKit,
|
||||
TiptapMarkdown,
|
||||
TiptapLink,
|
||||
TiptapTable,
|
||||
TiptapTableCell,
|
||||
TiptapTableHeader,
|
||||
TiptapTableRow
|
||||
],
|
||||
content: opts.defaultVal,
|
||||
editable: false
|
||||
})
|
||||
|
||||
const inputEl = editor.options.element as HTMLElement
|
||||
inputEl.classList.add('comfy-markdown')
|
||||
const textarea = document.createElement('textarea')
|
||||
inputEl.append(textarea)
|
||||
|
||||
const widget = node.addDOMWidget(name, 'MARKDOWN', inputEl, {
|
||||
getValue(): string {
|
||||
return textarea.value
|
||||
},
|
||||
setValue(v: string) {
|
||||
textarea.value = v
|
||||
editor.commands.setContent(v)
|
||||
}
|
||||
})
|
||||
widget.inputEl = inputEl
|
||||
widget.options.minNodeSize = [400, 200]
|
||||
|
||||
inputEl.addEventListener('pointerdown', (event: PointerEvent) => {
|
||||
if (event.button !== 0) {
|
||||
app.canvas.processMouseDown(event)
|
||||
return
|
||||
}
|
||||
if (event.target instanceof HTMLAnchorElement) {
|
||||
return
|
||||
}
|
||||
inputEl.classList.add('editing')
|
||||
setTimeout(() => {
|
||||
textarea.focus()
|
||||
}, 0)
|
||||
})
|
||||
|
||||
textarea.addEventListener('blur', () => {
|
||||
inputEl.classList.remove('editing')
|
||||
})
|
||||
|
||||
textarea.addEventListener('change', () => {
|
||||
editor.commands.setContent(textarea.value)
|
||||
widget.callback?.(widget.value)
|
||||
})
|
||||
|
||||
inputEl.addEventListener('keydown', (event: KeyboardEvent) => {
|
||||
event.stopPropagation()
|
||||
})
|
||||
|
||||
inputEl.addEventListener('pointerdown', (event: PointerEvent) => {
|
||||
if (event.button === 1) {
|
||||
app.canvas.processMouseDown(event)
|
||||
}
|
||||
})
|
||||
|
||||
inputEl.addEventListener('pointermove', (event: PointerEvent) => {
|
||||
if ((event.buttons & 4) === 4) {
|
||||
app.canvas.processMouseMove(event)
|
||||
}
|
||||
})
|
||||
|
||||
inputEl.addEventListener('pointerup', (event: PointerEvent) => {
|
||||
if (event.button === 1) {
|
||||
app.canvas.processMouseUp(event)
|
||||
}
|
||||
})
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
export const useMarkdownWidget = () => {
|
||||
const widgetConstructor: ComfyWidgetConstructorV2 = (
|
||||
node: LGraphNode,
|
||||
inputSpec: InputSpec
|
||||
) => {
|
||||
return addMarkdownWidget(node, inputSpec.name, {
|
||||
defaultVal: inputSpec.default ?? ''
|
||||
})
|
||||
}
|
||||
|
||||
return widgetConstructor
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import TextPreviewWidget from '@/components/graph/widgets/TextPreviewWidget.vue'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import {
|
||||
ComponentWidgetImpl,
|
||||
type ComponentWidgetStandardProps,
|
||||
addWidget
|
||||
} from '@/scripts/domWidget'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
|
||||
type TextPreviewCustomProps = Omit<
|
||||
InstanceType<typeof TextPreviewWidget>['$props'],
|
||||
ComponentWidgetStandardProps
|
||||
>
|
||||
|
||||
const PADDING = 16
|
||||
|
||||
export const useTextPreviewWidget = (
|
||||
options: {
|
||||
minHeight?: number
|
||||
} = {}
|
||||
) => {
|
||||
const widgetConstructor: ComfyWidgetConstructorV2 = (
|
||||
node: LGraphNode,
|
||||
inputSpec: InputSpec
|
||||
) => {
|
||||
const widgetValue = ref<string>('')
|
||||
const widget = new ComponentWidgetImpl<
|
||||
string | object,
|
||||
TextPreviewCustomProps
|
||||
>({
|
||||
node,
|
||||
name: inputSpec.name,
|
||||
component: TextPreviewWidget,
|
||||
inputSpec,
|
||||
props: {
|
||||
nodeId: node.id
|
||||
},
|
||||
options: {
|
||||
getValue: () => widgetValue.value,
|
||||
setValue: (value: string | object) => {
|
||||
widgetValue.value = typeof value === 'string' ? value : String(value)
|
||||
},
|
||||
getMinHeight: () => options.minHeight ?? 42 + PADDING,
|
||||
serialize: false
|
||||
}
|
||||
})
|
||||
addWidget(node, widget)
|
||||
return widget
|
||||
}
|
||||
|
||||
return widgetConstructor
|
||||
}
|
||||
@@ -1,286 +0,0 @@
|
||||
import axios from 'axios'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { IWidget } from '@/lib/litegraph/src/litegraph'
|
||||
import type { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
|
||||
const MAX_RETRIES = 5
|
||||
const TIMEOUT = 4096
|
||||
|
||||
export interface CacheEntry<T> {
|
||||
data: T
|
||||
timestamp?: number
|
||||
error?: Error | null
|
||||
fetchPromise?: Promise<T>
|
||||
controller?: AbortController
|
||||
lastErrorTime?: number
|
||||
retryCount?: number
|
||||
failed?: boolean
|
||||
}
|
||||
|
||||
const dataCache = new Map<string, CacheEntry<any>>()
|
||||
|
||||
const createCacheKey = (config: RemoteWidgetConfig): string => {
|
||||
const { route, query_params = {}, refresh = 0 } = config
|
||||
|
||||
const paramsKey = Object.entries(query_params)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join('&')
|
||||
|
||||
return [route, `r=${refresh}`, paramsKey].join(';')
|
||||
}
|
||||
|
||||
const getBackoff = (retryCount: number) =>
|
||||
Math.min(1000 * Math.pow(2, retryCount), 512)
|
||||
|
||||
const isInitialized = (entry: CacheEntry<unknown> | undefined) =>
|
||||
entry?.data && entry?.timestamp && entry.timestamp > 0
|
||||
|
||||
const isStale = (entry: CacheEntry<unknown> | undefined, ttl: number) =>
|
||||
entry?.timestamp && Date.now() - entry.timestamp >= ttl
|
||||
|
||||
const isFetching = (entry: CacheEntry<unknown> | undefined) =>
|
||||
entry?.fetchPromise !== undefined
|
||||
|
||||
const isFailed = (entry: CacheEntry<unknown> | undefined) =>
|
||||
entry?.failed === true
|
||||
|
||||
const isBackingOff = (entry: CacheEntry<unknown> | undefined) =>
|
||||
entry?.error &&
|
||||
entry?.lastErrorTime &&
|
||||
Date.now() - entry.lastErrorTime < getBackoff(entry.retryCount || 0)
|
||||
|
||||
const fetchData = async (
|
||||
config: RemoteWidgetConfig,
|
||||
controller: AbortController
|
||||
) => {
|
||||
const { route, response_key, query_params, timeout = TIMEOUT } = config
|
||||
|
||||
// Get auth header from Firebase
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const authHeader = await authStore.getAuthHeader()
|
||||
|
||||
const headers: Record<string, string> = {}
|
||||
if (authHeader) {
|
||||
Object.assign(headers, authHeader)
|
||||
}
|
||||
|
||||
const res = await axios.get(route, {
|
||||
params: query_params,
|
||||
signal: controller.signal,
|
||||
timeout,
|
||||
headers
|
||||
})
|
||||
return response_key ? res.data[response_key] : res.data
|
||||
}
|
||||
|
||||
export function useRemoteWidget<
|
||||
T extends string | number | boolean | object
|
||||
>(options: {
|
||||
remoteConfig: RemoteWidgetConfig
|
||||
defaultValue: T
|
||||
node: LGraphNode
|
||||
widget: IWidget
|
||||
}) {
|
||||
const { remoteConfig, defaultValue, node, widget } = options
|
||||
const { refresh = 0, max_retries = MAX_RETRIES } = remoteConfig
|
||||
const isPermanent = refresh <= 0
|
||||
const cacheKey = createCacheKey(remoteConfig)
|
||||
let isLoaded = false
|
||||
let refreshQueued = false
|
||||
|
||||
const setSuccess = (entry: CacheEntry<T>, data: T) => {
|
||||
entry.retryCount = 0
|
||||
entry.lastErrorTime = 0
|
||||
entry.error = null
|
||||
entry.timestamp = Date.now()
|
||||
entry.data = data ?? defaultValue
|
||||
}
|
||||
|
||||
const setError = (entry: CacheEntry<T>, error: Error | unknown) => {
|
||||
entry.retryCount = (entry.retryCount || 0) + 1
|
||||
entry.lastErrorTime = Date.now()
|
||||
entry.error = error instanceof Error ? error : new Error(String(error))
|
||||
entry.data ??= defaultValue
|
||||
entry.fetchPromise = undefined
|
||||
if (entry.retryCount >= max_retries) {
|
||||
setFailed(entry)
|
||||
}
|
||||
}
|
||||
|
||||
const setFailed = (entry: CacheEntry<T>) => {
|
||||
dataCache.set(cacheKey, {
|
||||
data: entry.data ?? defaultValue,
|
||||
failed: true
|
||||
})
|
||||
}
|
||||
|
||||
const isFirstLoad = () => {
|
||||
return !isLoaded && isInitialized(dataCache.get(cacheKey))
|
||||
}
|
||||
|
||||
const onFirstLoad = (data: T[]) => {
|
||||
isLoaded = true
|
||||
widget.value = data[0]
|
||||
widget.callback?.(widget.value)
|
||||
node.graph?.setDirtyCanvas(true)
|
||||
}
|
||||
|
||||
const fetchValue = async () => {
|
||||
const entry = dataCache.get(cacheKey)
|
||||
|
||||
if (isFailed(entry)) return entry!.data
|
||||
|
||||
const isValid =
|
||||
isInitialized(entry) && (isPermanent || !isStale(entry, refresh))
|
||||
if (isValid || isBackingOff(entry) || isFetching(entry)) return entry!.data
|
||||
|
||||
const currentEntry: CacheEntry<T> = entry || { data: defaultValue }
|
||||
dataCache.set(cacheKey, currentEntry)
|
||||
|
||||
try {
|
||||
currentEntry.controller = new AbortController()
|
||||
currentEntry.fetchPromise = fetchData(
|
||||
remoteConfig,
|
||||
currentEntry.controller
|
||||
)
|
||||
const data = await currentEntry.fetchPromise
|
||||
|
||||
setSuccess(currentEntry, data)
|
||||
return currentEntry.data
|
||||
} catch (err) {
|
||||
setError(currentEntry, err)
|
||||
return currentEntry.data
|
||||
} finally {
|
||||
currentEntry.fetchPromise = undefined
|
||||
currentEntry.controller = undefined
|
||||
}
|
||||
}
|
||||
|
||||
const onRefresh = () => {
|
||||
if (remoteConfig.control_after_refresh) {
|
||||
const data = getCachedValue()
|
||||
if (!Array.isArray(data)) return // control_after_refresh is only supported for array values
|
||||
|
||||
switch (remoteConfig.control_after_refresh) {
|
||||
case 'first':
|
||||
widget.value = data[0] ?? defaultValue
|
||||
break
|
||||
case 'last':
|
||||
widget.value = data.at(-1) ?? defaultValue
|
||||
break
|
||||
}
|
||||
widget.callback?.(widget.value)
|
||||
node.graph?.setDirtyCanvas(true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the widget's cached value, forcing a refresh on next access (e.g., a new render)
|
||||
*/
|
||||
const clearCachedValue = () => {
|
||||
const entry = dataCache.get(cacheKey)
|
||||
if (!entry) return
|
||||
if (entry.fetchPromise) entry.controller?.abort() // Abort in-flight request
|
||||
dataCache.delete(cacheKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cached value of the widget without starting a new fetch.
|
||||
* @returns the most recently computed value of the widget.
|
||||
*/
|
||||
function getCachedValue() {
|
||||
return dataCache.get(cacheKey)?.data as T
|
||||
}
|
||||
|
||||
/**
|
||||
* Getter of the remote property of the widget (e.g., options.values, value, etc.).
|
||||
* Starts the fetch process then returns the cached value immediately.
|
||||
* @returns the most recent value of the widget.
|
||||
*/
|
||||
function getValue(onFulfilled?: () => void) {
|
||||
void fetchValue()
|
||||
.then((data) => {
|
||||
if (isFirstLoad()) onFirstLoad(data)
|
||||
if (refreshQueued && data !== defaultValue) {
|
||||
onRefresh()
|
||||
refreshQueued = false
|
||||
}
|
||||
onFulfilled?.()
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err)
|
||||
})
|
||||
return getCachedValue() ?? defaultValue
|
||||
}
|
||||
|
||||
/**
|
||||
* Force the widget to refresh its value
|
||||
*/
|
||||
widget.refresh = function () {
|
||||
refreshQueued = true
|
||||
clearCachedValue()
|
||||
getValue()
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a refresh button to the node that, when clicked, will force the widget to refresh
|
||||
*/
|
||||
function addRefreshButton() {
|
||||
node.addWidget('button', 'refresh', 'refresh', widget.refresh)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add auto-refresh toggle widget and execution success listener
|
||||
*/
|
||||
function addAutoRefreshToggle() {
|
||||
let autoRefreshEnabled = false
|
||||
|
||||
// Handler for execution success
|
||||
const handleExecutionSuccess = () => {
|
||||
if (autoRefreshEnabled && widget.refresh) {
|
||||
widget.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
// Add toggle widget
|
||||
const autoRefreshWidget = node.addWidget(
|
||||
'toggle',
|
||||
'Auto-refresh after generation',
|
||||
false,
|
||||
(value: boolean) => {
|
||||
autoRefreshEnabled = value
|
||||
},
|
||||
{
|
||||
serialize: false
|
||||
}
|
||||
)
|
||||
|
||||
// Register event listener
|
||||
api.addEventListener('execution_success', handleExecutionSuccess)
|
||||
|
||||
// Cleanup on node removal
|
||||
node.onRemoved = useChainCallback(node.onRemoved, function () {
|
||||
api.removeEventListener('execution_success', handleExecutionSuccess)
|
||||
})
|
||||
|
||||
return autoRefreshWidget
|
||||
}
|
||||
|
||||
// Always add auto-refresh toggle for remote widgets
|
||||
addAutoRefreshToggle()
|
||||
|
||||
return {
|
||||
getCachedValue,
|
||||
getValue,
|
||||
refreshValue: widget.refresh,
|
||||
addRefreshButton,
|
||||
getCacheEntry: () => dataCache.get(cacheKey),
|
||||
|
||||
cacheKey
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
type InputSpec,
|
||||
isStringInputSpec
|
||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { app } from '@/scripts/app'
|
||||
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
const TRACKPAD_DETECTION_THRESHOLD = 50
|
||||
|
||||
function addMultilineWidget(
|
||||
node: LGraphNode,
|
||||
name: string,
|
||||
opts: { defaultVal: string; placeholder?: string }
|
||||
) {
|
||||
const inputEl = document.createElement('textarea')
|
||||
inputEl.className = 'comfy-multiline-input'
|
||||
inputEl.value = opts.defaultVal
|
||||
inputEl.placeholder = opts.placeholder || name
|
||||
inputEl.spellcheck = useSettingStore().get('Comfy.TextareaWidget.Spellcheck')
|
||||
|
||||
const widget = node.addDOMWidget(name, 'customtext', inputEl, {
|
||||
getValue(): string {
|
||||
return inputEl.value
|
||||
},
|
||||
setValue(v: string) {
|
||||
inputEl.value = v
|
||||
}
|
||||
})
|
||||
|
||||
widget.inputEl = inputEl
|
||||
widget.options.minNodeSize = [400, 200]
|
||||
|
||||
inputEl.addEventListener('input', () => {
|
||||
widget.callback?.(widget.value)
|
||||
})
|
||||
|
||||
// Allow middle mouse button panning
|
||||
inputEl.addEventListener('pointerdown', (event: PointerEvent) => {
|
||||
if (event.button === 1) {
|
||||
app.canvas.processMouseDown(event)
|
||||
}
|
||||
})
|
||||
|
||||
inputEl.addEventListener('pointermove', (event: PointerEvent) => {
|
||||
if ((event.buttons & 4) === 4) {
|
||||
app.canvas.processMouseMove(event)
|
||||
}
|
||||
})
|
||||
|
||||
inputEl.addEventListener('pointerup', (event: PointerEvent) => {
|
||||
if (event.button === 1) {
|
||||
app.canvas.processMouseUp(event)
|
||||
}
|
||||
})
|
||||
|
||||
inputEl.addEventListener('wheel', (event: WheelEvent) => {
|
||||
const gesturesEnabled = useSettingStore().get(
|
||||
'LiteGraph.Pointer.TrackpadGestures'
|
||||
)
|
||||
const deltaX = event.deltaX
|
||||
const deltaY = event.deltaY
|
||||
|
||||
const canScrollY = inputEl.scrollHeight > inputEl.clientHeight
|
||||
const isHorizontal = Math.abs(deltaX) > Math.abs(deltaY)
|
||||
|
||||
// Prevent pinch zoom from zooming the page
|
||||
if (event.ctrlKey) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
app.canvas.processMouseWheel(event)
|
||||
return
|
||||
}
|
||||
|
||||
// Detect if this is likely a trackpad gesture vs mouse wheel
|
||||
// Trackpads usually have deltaX or smaller deltaY values (< TRACKPAD_DETECTION_THRESHOLD)
|
||||
// Mouse wheels typically have larger discrete deltaY values (>= TRACKPAD_DETECTION_THRESHOLD)
|
||||
const isLikelyTrackpad =
|
||||
Math.abs(deltaX) > 0 || Math.abs(deltaY) < TRACKPAD_DETECTION_THRESHOLD
|
||||
|
||||
// Trackpad gestures: when enabled, trackpad panning goes to canvas
|
||||
if (gesturesEnabled && isLikelyTrackpad) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
app.canvas.processMouseWheel(event)
|
||||
return
|
||||
}
|
||||
|
||||
// When gestures disabled: horizontal always goes to canvas (no horizontal scroll in textarea)
|
||||
if (isHorizontal) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
app.canvas.processMouseWheel(event)
|
||||
return
|
||||
}
|
||||
|
||||
// Vertical scrolling when gestures disabled: let textarea scroll if scrollable
|
||||
if (canScrollY) {
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
|
||||
// If textarea can't scroll vertically, pass to canvas
|
||||
event.preventDefault()
|
||||
app.canvas.processMouseWheel(event)
|
||||
})
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
export const useStringWidget = () => {
|
||||
const widgetConstructor: ComfyWidgetConstructorV2 = (
|
||||
node: LGraphNode,
|
||||
inputSpec: InputSpec
|
||||
) => {
|
||||
if (!isStringInputSpec(inputSpec)) {
|
||||
throw new Error(`Invalid input data: ${inputSpec}`)
|
||||
}
|
||||
|
||||
const defaultVal = inputSpec.default ?? ''
|
||||
const multiline = inputSpec.multiline
|
||||
|
||||
const widget = multiline
|
||||
? addMultilineWidget(node, inputSpec.name, {
|
||||
defaultVal,
|
||||
placeholder: inputSpec.placeholder
|
||||
})
|
||||
: node.addWidget('text', inputSpec.name, defaultVal, () => {}, {})
|
||||
|
||||
if (typeof inputSpec.dynamicPrompts === 'boolean') {
|
||||
widget.dynamicPrompts = inputSpec.dynamicPrompts
|
||||
}
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
return widgetConstructor
|
||||
}
|
||||
Reference in New Issue
Block a user