merge main into rh-test

This commit is contained in:
bymyself
2025-09-28 15:33:29 -07:00
parent 1c0f151d02
commit ff0c15b119
1317 changed files with 85439 additions and 18373 deletions

View File

@@ -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/).

View File

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

View File

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

View File

@@ -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[]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 */

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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] || []
}

View File

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

View File

@@ -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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 })
}
}
]

View File

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

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

View File

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

View File

@@ -1,7 +1,6 @@
import { type Ref, onBeforeUnmount, ref, watch } from 'vue'
export interface UseIntersectionObserverOptions
extends IntersectionObserverInit {
interface UseIntersectionObserverOptions extends IntersectionObserverInit {
immediate?: boolean
}

View File

@@ -1,6 +1,6 @@
import { type Ref, computed, ref, shallowRef, watch } from 'vue'
export interface LazyPaginationOptions {
interface LazyPaginationOptions {
itemsPerPage?: number
initialPage?: number
}

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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(() => {

View File

@@ -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 {

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -1,4 +1,4 @@
import { Ref } from 'vue'
import type { Ref } from 'vue'
import type { TreeNode } from '@/types/treeExplorerTypes'

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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