diff --git a/.gitignore b/.gitignore
index 5473190ea9..32e1b6624c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -44,6 +44,7 @@ components.d.ts
tests-ui/data/*
tests-ui/ComfyUI_examples
tests-ui/workflows/examples
+coverage/
# Browser tests
/test-results/
diff --git a/eslint.config.ts b/eslint.config.ts
index 94f8bb5f20..3073948f2e 100644
--- a/eslint.config.ts
+++ b/eslint.config.ts
@@ -83,6 +83,13 @@ export default defineConfig([
'vue/no-restricted-class': ['error', '/^dark:/'],
'vue/multi-word-component-names': 'off', // TODO: fix
'vue/no-template-shadow': 'off', // TODO: fix
+ /* Toggle on to do additional until we can clean up existing violations.
+ 'vue/no-unused-emit-declarations': 'error',
+ 'vue/no-unused-properties': 'error',
+ 'vue/no-unused-refs': 'error',
+ 'vue/no-use-v-else-with-v-for': 'error',
+ 'vue/no-useless-v-bind': 'error',
+ // */
'vue/one-component-per-file': 'off', // TODO: fix
'vue/require-default-prop': 'off', // TODO: fix -- this one is very worthwhile
// Restrict deprecated PrimeVue components
diff --git a/package.json b/package.json
index 770ef7e04f..923f04b7e0 100644
--- a/package.json
+++ b/package.json
@@ -27,6 +27,8 @@
"preview": "nx preview",
"lint": "eslint src --cache",
"lint:fix": "eslint src --cache --fix",
+ "lint:unstaged": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache",
+ "lint:unstaged:fix": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache --fix",
"lint:no-cache": "eslint src",
"lint:fix:no-cache": "eslint src --fix",
"knip": "knip --cache",
@@ -94,6 +96,7 @@
"vite-plugin-html": "^3.2.2",
"vite-plugin-vue-devtools": "^7.7.6",
"vitest": "^3.2.4",
+ "vue-component-type-helpers": "^3.0.7",
"vue-eslint-parser": "^10.2.0",
"vue-tsc": "^3.0.7",
"zip-dir": "^2.0.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 75fc42327e..6ce1f4d341 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -339,6 +339,9 @@ importers:
vitest:
specifier: ^3.2.4
version: 3.2.4(@types/debug@4.1.12)(@types/node@20.14.10)(@vitest/ui@3.2.4)(happy-dom@15.11.0)(jsdom@26.1.0)(lightningcss@1.30.1)(terser@5.39.2)
+ vue-component-type-helpers:
+ specifier: ^3.0.7
+ version: 3.0.7
vue-eslint-parser:
specifier: ^10.2.0
version: 10.2.0(eslint@9.35.0(jiti@2.4.2))
diff --git a/src/base/common/async.ts b/src/base/common/async.ts
new file mode 100644
index 0000000000..a97f6f1bd0
--- /dev/null
+++ b/src/base/common/async.ts
@@ -0,0 +1,98 @@
+/**
+ * Cross-browser async utilities for scheduling tasks during browser idle time
+ * with proper fallbacks for browsers that don't support requestIdleCallback.
+ *
+ * Implementation based on:
+ * https://github.com/microsoft/vscode/blob/main/src/vs/base/common/async.ts
+ */
+
+interface IdleDeadline {
+ didTimeout: boolean
+ timeRemaining(): number
+}
+
+interface IDisposable {
+ dispose(): void
+}
+
+/**
+ * Internal implementation function that handles the actual scheduling logic.
+ * Uses feature detection to determine whether to use native requestIdleCallback
+ * or fall back to setTimeout-based implementation.
+ */
+let _runWhenIdle: (
+ targetWindow: any,
+ callback: (idle: IdleDeadline) => void,
+ timeout?: number
+) => IDisposable
+
+/**
+ * Execute the callback during the next browser idle period.
+ * Falls back to setTimeout-based scheduling in browsers without native support.
+ */
+export let runWhenGlobalIdle: (
+ callback: (idle: IdleDeadline) => void,
+ timeout?: number
+ ) => IDisposable
+
+ // Self-invoking function to set up the idle callback implementation
+;(function () {
+ const safeGlobal: any = globalThis
+
+ if (
+ typeof safeGlobal.requestIdleCallback !== 'function' ||
+ typeof safeGlobal.cancelIdleCallback !== 'function'
+ ) {
+ // Fallback implementation for browsers without native support (e.g., Safari)
+ _runWhenIdle = (_targetWindow, runner, _timeout?) => {
+ setTimeout(() => {
+ if (disposed) {
+ return
+ }
+
+ // Simulate IdleDeadline - give 15ms window (one frame at ~64fps)
+ const end = Date.now() + 15
+ const deadline: IdleDeadline = {
+ didTimeout: true,
+ timeRemaining() {
+ return Math.max(0, end - Date.now())
+ }
+ }
+
+ runner(Object.freeze(deadline))
+ })
+
+ let disposed = false
+ return {
+ dispose() {
+ if (disposed) {
+ return
+ }
+ disposed = true
+ }
+ }
+ }
+ } else {
+ // Native requestIdleCallback implementation
+ _runWhenIdle = (targetWindow: typeof safeGlobal, runner, timeout?) => {
+ const handle: number = targetWindow.requestIdleCallback(
+ runner,
+ typeof timeout === 'number' ? { timeout } : undefined
+ )
+
+ let disposed = false
+ return {
+ dispose() {
+ if (disposed) {
+ return
+ }
+ disposed = true
+ targetWindow.cancelIdleCallback(handle)
+ }
+ }
+ }
+ }
+
+ runWhenGlobalIdle = (runner, timeout) =>
+ _runWhenIdle(globalThis, runner, timeout)
+})()
diff --git a/src/components/dialog/content/MissingCoreNodesMessage.vue b/src/components/dialog/content/MissingCoreNodesMessage.vue
index cf81441f19..10030a9e93 100644
--- a/src/components/dialog/content/MissingCoreNodesMessage.vue
+++ b/src/components/dialog/content/MissingCoreNodesMessage.vue
@@ -43,11 +43,11 @@
diff --git a/src/platform/assets/components/AssetCard.vue b/src/platform/assets/components/AssetCard.vue
index e379099c19..be7c45ca54 100644
--- a/src/platform/assets/components/AssetCard.vue
+++ b/src/platform/assets/components/AssetCard.vue
@@ -14,7 +14,7 @@
'bg-ivory-100 border border-gray-300 dark-theme:bg-charcoal-400 dark-theme:border-charcoal-600',
'hover:transform hover:-translate-y-0.5 hover:shadow-lg hover:shadow-black/10 hover:border-gray-400',
'dark-theme:hover:shadow-lg dark-theme:hover:shadow-black/30 dark-theme:hover:border-charcoal-700',
- 'focus:outline-none focus:ring-2 focus:ring-blue-500 dark-theme:focus:ring-blue-400'
+ 'focus:outline-none focus:transform focus:-translate-y-0.5 focus:shadow-lg focus:shadow-black/10 dark-theme:focus:shadow-black/30'
],
// Div-specific styles
!interactive && [
diff --git a/src/platform/assets/components/AssetFilterBar.vue b/src/platform/assets/components/AssetFilterBar.vue
index 1f3295b439..904ce3e82e 100644
--- a/src/platform/assets/components/AssetFilterBar.vue
+++ b/src/platform/assets/components/AssetFilterBar.vue
@@ -3,7 +3,7 @@
void
+ ): Promise {
if (import.meta.env.DEV) {
- console.log('Asset selected:', asset.id, asset.name)
+ console.debug('Asset selected:', assetId)
+ }
+
+ if (!onSelect) {
+ return
+ }
+
+ try {
+ const detailAsset = await assetService.getAssetDetails(assetId)
+ const filename = detailAsset.user_metadata?.filename
+ const validatedFilename = assetFilenameSchema.safeParse(filename)
+ if (!validatedFilename.success) {
+ console.error(
+ 'Invalid asset filename:',
+ validatedFilename.error.errors,
+ 'for asset:',
+ assetId
+ )
+ return
+ }
+
+ onSelect(validatedFilename.data)
+ } catch (error) {
+ console.error(`Failed to fetch asset details for ${assetId}:`, error)
}
- return asset.id
}
return {
@@ -182,7 +212,6 @@ export function useAssetBrowser(assets: AssetItem[] = []) {
filteredAssets,
// Actions
- selectAsset,
- transformAssetForDisplay
+ selectAssetWithCallback
}
}
diff --git a/src/platform/assets/composables/useAssetBrowserDialog.ts b/src/platform/assets/composables/useAssetBrowserDialog.ts
index e5f63eead3..31f75c3539 100644
--- a/src/platform/assets/composables/useAssetBrowserDialog.ts
+++ b/src/platform/assets/composables/useAssetBrowserDialog.ts
@@ -1,5 +1,7 @@
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
-import { useDialogStore } from '@/stores/dialogStore'
+import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
+import { assetService } from '@/platform/assets/services/assetService'
+import { type DialogComponentProps, useDialogStore } from '@/stores/dialogStore'
interface AssetBrowserDialogProps {
/** ComfyUI node type for context (e.g., 'CheckpointLoaderSimple') */
@@ -8,36 +10,29 @@ interface AssetBrowserDialogProps {
inputName: string
/** Current selected asset value */
currentValue?: string
- /** Callback for when an asset is selected */
- onAssetSelected?: (assetPath: string) => void
+ /**
+ * Callback for when an asset is selected
+ * @param {string} filename - The validated filename from user_metadata.filename
+ */
+ onAssetSelected?: (filename: string) => void
}
export const useAssetBrowserDialog = () => {
const dialogStore = useDialogStore()
const dialogKey = 'global-asset-browser'
- function hide() {
- dialogStore.closeDialog({ key: dialogKey })
- }
-
- function show(props: AssetBrowserDialogProps) {
- const handleAssetSelected = (assetPath: string) => {
- props.onAssetSelected?.(assetPath)
- hide() // Auto-close on selection
+ async function show(props: AssetBrowserDialogProps) {
+ const handleAssetSelected = (filename: string) => {
+ props.onAssetSelected?.(filename)
+ dialogStore.closeDialog({ key: dialogKey })
}
-
- const handleClose = () => {
- hide()
- }
-
- // Default dialog configuration for AssetBrowserModal
- const dialogComponentProps = {
+ const dialogComponentProps: DialogComponentProps = {
headless: true,
modal: true,
- closable: false,
+ closable: true,
pt: {
root: {
- class: 'rounded-2xl overflow-hidden'
+ class: 'rounded-2xl overflow-hidden asset-browser-dialog'
},
header: {
class: 'p-0 hidden'
@@ -48,6 +43,17 @@ export const useAssetBrowserDialog = () => {
}
}
+ const assets: AssetItem[] = await assetService
+ .getAssetsForNodeType(props.nodeType)
+ .catch((error) => {
+ console.error(
+ 'Failed to fetch assets for node type:',
+ props.nodeType,
+ error
+ )
+ return []
+ })
+
dialogStore.showDialog({
key: dialogKey,
component: AssetBrowserModal,
@@ -55,12 +61,13 @@ export const useAssetBrowserDialog = () => {
nodeType: props.nodeType,
inputName: props.inputName,
currentValue: props.currentValue,
+ assets,
onSelect: handleAssetSelected,
- onClose: handleClose
+ onClose: () => dialogStore.closeDialog({ key: dialogKey })
},
dialogComponentProps
})
}
- return { show, hide }
+ return { show }
}
diff --git a/src/platform/assets/schemas/assetSchema.ts b/src/platform/assets/schemas/assetSchema.ts
index fab41649af..2c051a30de 100644
--- a/src/platform/assets/schemas/assetSchema.ts
+++ b/src/platform/assets/schemas/assetSchema.ts
@@ -4,13 +4,13 @@ import { z } from 'zod'
const zAsset = z.object({
id: z.string(),
name: z.string(),
- asset_hash: z.string(),
+ asset_hash: z.string().nullable(),
size: z.number(),
- mime_type: z.string(),
+ mime_type: z.string().nullable(),
tags: z.array(z.string()),
preview_url: z.string().optional(),
created_at: z.string(),
- updated_at: z.string(),
+ updated_at: z.string().optional(),
last_access_time: z.string(),
user_metadata: z.record(z.unknown()).optional(), // API allows arbitrary key-value pairs
preview_id: z.string().nullable().optional()
@@ -33,6 +33,14 @@ const zModelFile = z.object({
pathIndex: z.number()
})
+// Filename validation schema
+export const assetFilenameSchema = z
+ .string()
+ .min(1, 'Filename cannot be empty')
+ .regex(/^[^\\:*?"<>|]+$/, 'Invalid filename characters') // Allow forward slashes, block backslashes and other unsafe chars
+ .regex(/^(?!\/|.*\.\.)/, 'Path must not start with / or contain ..') // Prevent absolute paths and directory traversal
+ .trim()
+
// Export schemas following repository patterns
export const assetResponseSchema = zAssetResponse
diff --git a/src/platform/assets/services/assetService.ts b/src/platform/assets/services/assetService.ts
index 74b20a753a..7d0f82cbb4 100644
--- a/src/platform/assets/services/assetService.ts
+++ b/src/platform/assets/services/assetService.ts
@@ -1,6 +1,7 @@
import { fromZodError } from 'zod-validation-error'
import {
+ type AssetItem,
type AssetResponse,
type ModelFile,
type ModelFolder,
@@ -127,10 +128,75 @@ function createAssetService() {
)
}
+ /**
+ * Gets assets for a specific node type by finding the matching category
+ * and fetching all assets with that category tag
+ *
+ * @param nodeType - The ComfyUI node type (e.g., 'CheckpointLoaderSimple')
+ * @returns Promise - Full asset objects with preserved metadata
+ */
+ async function getAssetsForNodeType(nodeType: string): Promise {
+ if (!nodeType || typeof nodeType !== 'string') {
+ return []
+ }
+
+ // Find the category for this node type using efficient O(1) lookup
+ const modelToNodeStore = useModelToNodeStore()
+ const category = modelToNodeStore.getCategoryForNodeType(nodeType)
+
+ if (!category) {
+ return []
+ }
+
+ // Fetch assets for this category using same API pattern as getAssetModels
+ const data = await handleAssetRequest(
+ `${ASSETS_ENDPOINT}?include_tags=${MODELS_TAG},${category}`,
+ `assets for ${nodeType}`
+ )
+
+ // Return full AssetItem[] objects (don't strip like getAssetModels does)
+ return (
+ data?.assets?.filter(
+ (asset) =>
+ !asset.tags.includes(MISSING_TAG) && asset.tags.includes(category)
+ ) ?? []
+ )
+ }
+
+ /**
+ * Gets complete details for a specific asset by ID
+ * Calls the detail endpoint which includes user_metadata and all fields
+ *
+ * @param id - The asset ID
+ * @returns Promise - Complete asset object with user_metadata
+ */
+ async function getAssetDetails(id: string): Promise {
+ const res = await api.fetchApi(`${ASSETS_ENDPOINT}/${id}`)
+ if (!res.ok) {
+ throw new Error(
+ `Unable to load asset details for ${id}: Server returned ${res.status}. Please try again.`
+ )
+ }
+ const data = await res.json()
+
+ // Validate the single asset response against our schema
+ const result = assetResponseSchema.safeParse({ assets: [data] })
+ if (result.success && result.data.assets?.[0]) {
+ return result.data.assets[0]
+ }
+
+ const error = result.error
+ ? fromZodError(result.error)
+ : 'Unknown validation error'
+ throw new Error(`Invalid asset response against zod schema:\n${error}`)
+ }
+
return {
getAssetModelFolders,
getAssetModels,
- isAssetBrowserEligible
+ isAssetBrowserEligible,
+ getAssetsForNodeType,
+ getAssetDetails
}
}
diff --git a/src/platform/settings/settingStore.ts b/src/platform/settings/settingStore.ts
index 2d94e38e57..5a1573efbf 100644
--- a/src/platform/settings/settingStore.ts
+++ b/src/platform/settings/settingStore.ts
@@ -1,5 +1,6 @@
import _ from 'es-toolkit/compat'
import { defineStore } from 'pinia'
+import { compare, valid } from 'semver'
import { ref } from 'vue'
import type { SettingParams } from '@/platform/settings/types'
@@ -7,7 +8,6 @@ import type { Settings } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import type { TreeNode } from '@/types/treeExplorerTypes'
-import { compareVersions, isSemVer } from '@/utils/formatUtil'
export const getSettingInfo = (setting: SettingParams) => {
const parts = setting.category || setting.id.split('.')
@@ -132,20 +132,25 @@ export const useSettingStore = defineStore('setting', () => {
if (installedVersion) {
const sortedVersions = Object.keys(defaultsByInstallVersion).sort(
- (a, b) => compareVersions(b, a)
+ (a, b) => compare(b, a)
)
for (const version of sortedVersions) {
// Ensure the version is in a valid format before comparing
- if (!isSemVer(version)) {
+ if (!valid(version)) {
continue
}
- if (compareVersions(installedVersion, version) >= 0) {
- const versionedDefault = defaultsByInstallVersion[version]
- return typeof versionedDefault === 'function'
- ? versionedDefault()
- : versionedDefault
+ if (compare(installedVersion, version) >= 0) {
+ const versionedDefault =
+ defaultsByInstallVersion[
+ version as keyof typeof defaultsByInstallVersion
+ ]
+ if (versionedDefault !== undefined) {
+ return typeof versionedDefault === 'function'
+ ? versionedDefault()
+ : versionedDefault
+ }
}
}
}
diff --git a/src/platform/updates/common/releaseStore.ts b/src/platform/updates/common/releaseStore.ts
index f34e525f56..470c922721 100644
--- a/src/platform/updates/common/releaseStore.ts
+++ b/src/platform/updates/common/releaseStore.ts
@@ -1,11 +1,12 @@
import { until } from '@vueuse/core'
import { defineStore } from 'pinia'
+import { compare } from 'semver'
import { computed, ref } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import { isElectron } from '@/utils/envUtil'
-import { compareVersions, stringToLocale } from '@/utils/formatUtil'
+import { stringToLocale } from '@/utils/formatUtil'
import { type ReleaseNote, useReleaseService } from './releaseService'
@@ -56,16 +57,19 @@ export const useReleaseStore = defineStore('release', () => {
const isNewVersionAvailable = computed(
() =>
!!recentRelease.value &&
- compareVersions(
+ compare(
recentRelease.value.version,
- currentComfyUIVersion.value
+ currentComfyUIVersion.value || '0.0.0'
) > 0
)
const isLatestVersion = computed(
() =>
!!recentRelease.value &&
- !compareVersions(recentRelease.value.version, currentComfyUIVersion.value)
+ compare(
+ recentRelease.value.version,
+ currentComfyUIVersion.value || '0.0.0'
+ ) === 0
)
const hasMediumOrHighAttention = computed(() =>
diff --git a/src/platform/updates/common/versionCompatibilityStore.ts b/src/platform/updates/common/versionCompatibilityStore.ts
index 46b25cf330..cc85f945b5 100644
--- a/src/platform/updates/common/versionCompatibilityStore.ts
+++ b/src/platform/updates/common/versionCompatibilityStore.ts
@@ -1,6 +1,6 @@
import { until, useStorage } from '@vueuse/core'
import { defineStore } from 'pinia'
-import * as semver from 'semver'
+import { gt, valid } from 'semver'
import { computed } from 'vue'
import config from '@/config'
@@ -26,13 +26,13 @@ export const useVersionCompatibilityStore = defineStore(
if (
!frontendVersion.value ||
!requiredFrontendVersion.value ||
- !semver.valid(frontendVersion.value) ||
- !semver.valid(requiredFrontendVersion.value)
+ !valid(frontendVersion.value) ||
+ !valid(requiredFrontendVersion.value)
) {
return false
}
// Returns true if required version is greater than frontend version
- return semver.gt(requiredFrontendVersion.value, frontendVersion.value)
+ return gt(requiredFrontendVersion.value, frontendVersion.value)
})
const isFrontendNewer = computed(() => {
diff --git a/src/renderer/core/canvas/canvasStore.ts b/src/renderer/core/canvas/canvasStore.ts
index 6e09d95a3a..ec38940fe9 100644
--- a/src/renderer/core/canvas/canvasStore.ts
+++ b/src/renderer/core/canvas/canvasStore.ts
@@ -1,8 +1,10 @@
+import { useEventListener, whenever } from '@vueuse/core'
import { defineStore } from 'pinia'
import { type Raw, computed, markRaw, ref, shallowRef } from 'vue'
import type { Point, Positionable } from '@/lib/litegraph/src/interfaces'
import type {
+ LGraph,
LGraphCanvas,
LGraphGroup,
LGraphNode
@@ -94,9 +96,43 @@ export const useCanvasStore = defineStore('canvas', () => {
appScalePercentage.value = Math.round(newScale * 100)
}
+ const currentGraph = shallowRef(null)
+ const isInSubgraph = ref(false)
+
+ // Provide selection state to all Vue nodes
+ const selectedNodeIds = computed(
+ () =>
+ new Set(
+ selectedItems.value
+ .filter((item) => item.id !== undefined)
+ .map((item) => String(item.id))
+ )
+ )
+
+ whenever(
+ () => canvas.value,
+ (newCanvas) => {
+ useEventListener(
+ newCanvas.canvas,
+ 'litegraph:set-graph',
+ (event: CustomEvent<{ newGraph: LGraph; oldGraph: LGraph }>) => {
+ const newGraph = event.detail?.newGraph || app.canvas?.graph
+ currentGraph.value = newGraph
+ isInSubgraph.value = Boolean(app.canvas?.subgraph)
+ }
+ )
+
+ useEventListener(newCanvas.canvas, 'subgraph-opened', () => {
+ isInSubgraph.value = true
+ })
+ },
+ { immediate: true }
+ )
+
return {
canvas,
selectedItems,
+ selectedNodeIds,
nodeSelected,
groupSelected,
rerouteSelected,
@@ -105,6 +141,8 @@ export const useCanvasStore = defineStore('canvas', () => {
getCanvas,
setAppZoomFromPercentage,
initScaleSync,
- cleanupScaleSync
+ cleanupScaleSync,
+ currentGraph,
+ isInSubgraph
}
})
diff --git a/src/renderer/core/canvas/injectionKeys.ts b/src/renderer/core/canvas/injectionKeys.ts
index 5c850c100b..9c0d25733e 100644
--- a/src/renderer/core/canvas/injectionKeys.ts
+++ b/src/renderer/core/canvas/injectionKeys.ts
@@ -2,13 +2,6 @@ import type { InjectionKey, Ref } from 'vue'
import type { NodeProgressState } from '@/schemas/apiSchema'
-/**
- * Injection key for providing selected node IDs to Vue node components.
- * Contains a reactive Set of selected node IDs (as strings).
- */
-export const SelectedNodeIdsKey: InjectionKey[>> =
- Symbol('selectedNodeIds')
-
/**
* Injection key for providing executing node IDs to Vue node components.
* Contains a reactive Set of currently executing node IDs (as strings).
diff --git a/src/renderer/extensions/minimap/composables/useMinimapGraph.ts b/src/renderer/extensions/minimap/composables/useMinimapGraph.ts
index 4f1c0dd8ed..c5bf9aa6c9 100644
--- a/src/renderer/extensions/minimap/composables/useMinimapGraph.ts
+++ b/src/renderer/extensions/minimap/composables/useMinimapGraph.ts
@@ -1,11 +1,13 @@
import { useThrottleFn } from '@vueuse/core'
-import { ref } from 'vue'
+import { ref, watch } from 'vue'
import type { Ref } from 'vue'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
+import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { api } from '@/scripts/api'
+import { MinimapDataSourceFactory } from '../data/MinimapDataSourceFactory'
import type { UpdateFlags } from '../types'
interface GraphCallbacks {
@@ -28,6 +30,9 @@ export function useMinimapGraph(
viewport: false
})
+ // Track LayoutStore version for change detection
+ const layoutStoreVersion = layoutStore.getVersion()
+
// Map to store original callbacks per graph ID
const originalCallbacksMap = new Map()
@@ -96,28 +101,30 @@ export function useMinimapGraph(
let positionChanged = false
let connectionChanged = false
- if (g._nodes.length !== lastNodeCount.value) {
+ // Use unified data source for change detection
+ const dataSource = MinimapDataSourceFactory.create(g)
+
+ // Check for node count changes
+ const currentNodeCount = dataSource.getNodeCount()
+ if (currentNodeCount !== lastNodeCount.value) {
structureChanged = true
- lastNodeCount.value = g._nodes.length
+ lastNodeCount.value = currentNodeCount
}
- for (const node of g._nodes) {
- const key = node.id
- const currentState = `${node.pos[0]},${node.pos[1]},${node.size[0]},${node.size[1]}`
+ // Check for node position/size changes
+ const nodes = dataSource.getNodes()
+ for (const node of nodes) {
+ const nodeId = node.id
+ const currentState = `${node.x},${node.y},${node.width},${node.height}`
- if (nodeStatesCache.get(key) !== currentState) {
+ if (nodeStatesCache.get(nodeId) !== currentState) {
positionChanged = true
- nodeStatesCache.set(key, currentState)
+ nodeStatesCache.set(nodeId, currentState)
}
}
- const currentLinks = JSON.stringify(g.links || {})
- if (currentLinks !== linksCache.value) {
- connectionChanged = true
- linksCache.value = currentLinks
- }
-
- const currentNodeIds = new Set(g._nodes.map((n: LGraphNode) => n.id))
+ // Clean up removed nodes from cache
+ const currentNodeIds = new Set(nodes.map((n) => n.id))
for (const [nodeId] of nodeStatesCache) {
if (!currentNodeIds.has(nodeId)) {
nodeStatesCache.delete(nodeId)
@@ -125,6 +132,13 @@ export function useMinimapGraph(
}
}
+ // TODO: update when Layoutstore tracks links
+ const currentLinks = JSON.stringify(g.links || {})
+ if (currentLinks !== linksCache.value) {
+ connectionChanged = true
+ linksCache.value = currentLinks
+ }
+
if (structureChanged || positionChanged) {
updateFlags.value.bounds = true
updateFlags.value.nodes = true
@@ -140,6 +154,10 @@ export function useMinimapGraph(
const init = () => {
setupEventListeners()
api.addEventListener('graphChanged', handleGraphChangedThrottled)
+
+ watch(layoutStoreVersion, () => {
+ void handleGraphChangedThrottled()
+ })
}
const destroy = () => {
diff --git a/src/renderer/extensions/minimap/composables/useMinimapViewport.ts b/src/renderer/extensions/minimap/composables/useMinimapViewport.ts
index da67e5a7ca..6f947a3077 100644
--- a/src/renderer/extensions/minimap/composables/useMinimapViewport.ts
+++ b/src/renderer/extensions/minimap/composables/useMinimapViewport.ts
@@ -5,9 +5,9 @@ import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformS
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import {
calculateMinimapScale,
- calculateNodeBounds,
enforceMinimumBounds
} from '@/renderer/core/spatial/boundsCalculator'
+import { MinimapDataSourceFactory } from '@/renderer/extensions/minimap/data/MinimapDataSourceFactory'
import type { MinimapBounds, MinimapCanvas, ViewportTransform } from '../types'
@@ -53,17 +53,15 @@ export function useMinimapViewport(
}
const calculateGraphBounds = (): MinimapBounds => {
- const g = graph.value
- if (!g || !g._nodes || g._nodes.length === 0) {
+ // Use unified data source
+ const dataSource = MinimapDataSourceFactory.create(graph.value)
+
+ if (!dataSource.hasData()) {
return { minX: 0, minY: 0, maxX: 100, maxY: 100, width: 100, height: 100 }
}
- const bounds = calculateNodeBounds(g._nodes)
- if (!bounds) {
- return { minX: 0, minY: 0, maxX: 100, maxY: 100, width: 100, height: 100 }
- }
-
- return enforceMinimumBounds(bounds)
+ const sourceBounds = dataSource.getBounds()
+ return enforceMinimumBounds(sourceBounds)
}
const calculateScale = () => {
diff --git a/src/renderer/extensions/minimap/data/AbstractMinimapDataSource.ts b/src/renderer/extensions/minimap/data/AbstractMinimapDataSource.ts
new file mode 100644
index 0000000000..4aae340b4e
--- /dev/null
+++ b/src/renderer/extensions/minimap/data/AbstractMinimapDataSource.ts
@@ -0,0 +1,95 @@
+import type { LGraph } from '@/lib/litegraph/src/litegraph'
+import { calculateNodeBounds } from '@/renderer/core/spatial/boundsCalculator'
+
+import type {
+ IMinimapDataSource,
+ MinimapBounds,
+ MinimapGroupData,
+ MinimapLinkData,
+ MinimapNodeData
+} from '../types'
+
+/**
+ * Abstract base class for minimap data sources
+ * Provides common functionality and shared implementation
+ */
+export abstract class AbstractMinimapDataSource implements IMinimapDataSource {
+ constructor(protected graph: LGraph | null) {}
+
+ // Abstract methods that must be implemented by subclasses
+ abstract getNodes(): MinimapNodeData[]
+ abstract getNodeCount(): number
+ abstract hasData(): boolean
+
+ // Shared implementation using calculateNodeBounds
+ getBounds(): MinimapBounds {
+ const nodes = this.getNodes()
+ if (nodes.length === 0) {
+ return { minX: 0, minY: 0, maxX: 100, maxY: 100, width: 100, height: 100 }
+ }
+
+ // Convert MinimapNodeData to the format expected by calculateNodeBounds
+ const compatibleNodes = nodes.map((node) => ({
+ pos: [node.x, node.y],
+ size: [node.width, node.height]
+ }))
+
+ const bounds = calculateNodeBounds(compatibleNodes)
+ if (!bounds) {
+ return { minX: 0, minY: 0, maxX: 100, maxY: 100, width: 100, height: 100 }
+ }
+
+ return bounds
+ }
+
+ // Shared implementation for groups
+ getGroups(): MinimapGroupData[] {
+ if (!this.graph?._groups) return []
+ return this.graph._groups.map((group) => ({
+ x: group.pos[0],
+ y: group.pos[1],
+ width: group.size[0],
+ height: group.size[1],
+ color: group.color
+ }))
+ }
+
+ // TODO: update when Layoutstore supports links
+ getLinks(): MinimapLinkData[] {
+ if (!this.graph) return []
+ return this.extractLinksFromGraph(this.graph)
+ }
+
+ protected extractLinksFromGraph(graph: LGraph): MinimapLinkData[] {
+ const links: MinimapLinkData[] = []
+ const nodeMap = new Map(this.getNodes().map((n) => [n.id, n]))
+
+ for (const node of graph._nodes) {
+ if (!node.outputs) continue
+
+ const sourceNodeData = nodeMap.get(String(node.id))
+ if (!sourceNodeData) continue
+
+ for (const output of node.outputs) {
+ if (!output.links) continue
+
+ for (const linkId of output.links) {
+ const link = graph.links[linkId]
+ if (!link) continue
+
+ const targetNodeData = nodeMap.get(String(link.target_id))
+ if (!targetNodeData) continue
+
+ links.push({
+ sourceNode: sourceNodeData,
+ targetNode: targetNodeData,
+ sourceSlot: link.origin_slot,
+ targetSlot: link.target_slot
+ })
+ }
+ }
+ }
+
+ return links
+ }
+}
diff --git a/src/renderer/extensions/minimap/data/LayoutStoreDataSource.ts b/src/renderer/extensions/minimap/data/LayoutStoreDataSource.ts
new file mode 100644
index 0000000000..c0daf7030f
--- /dev/null
+++ b/src/renderer/extensions/minimap/data/LayoutStoreDataSource.ts
@@ -0,0 +1,42 @@
+import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
+
+import type { MinimapNodeData } from '../types'
+import { AbstractMinimapDataSource } from './AbstractMinimapDataSource'
+
+/**
+ * Layout Store data source implementation
+ */
+export class LayoutStoreDataSource extends AbstractMinimapDataSource {
+ getNodes(): MinimapNodeData[] {
+ const allNodes = layoutStore.getAllNodes().value
+ if (allNodes.size === 0) return []
+
+ const nodes: MinimapNodeData[] = []
+
+ for (const [nodeId, layout] of allNodes) {
+ // Find corresponding LiteGraph node for additional properties
+ const graphNode = this.graph?._nodes?.find((n) => String(n.id) === nodeId)
+
+ nodes.push({
+ id: nodeId,
+ x: layout.position.x,
+ y: layout.position.y,
+ width: layout.size.width,
+ height: layout.size.height,
+ bgcolor: graphNode?.bgcolor,
+ mode: graphNode?.mode,
+ hasErrors: graphNode?.has_errors
+ })
+ }
+
+ return nodes
+ }
+
+ getNodeCount(): number {
+ return layoutStore.getAllNodes().value.size
+ }
+
+ hasData(): boolean {
+ return this.getNodeCount() > 0
+ }
+}
diff --git a/src/renderer/extensions/minimap/data/LiteGraphDataSource.ts b/src/renderer/extensions/minimap/data/LiteGraphDataSource.ts
new file mode 100644
index 0000000000..8e1048e750
--- /dev/null
+++ b/src/renderer/extensions/minimap/data/LiteGraphDataSource.ts
@@ -0,0 +1,30 @@
+import type { MinimapNodeData } from '../types'
+import { AbstractMinimapDataSource } from './AbstractMinimapDataSource'
+
+/**
+ * LiteGraph data source implementation
+ */
+export class LiteGraphDataSource extends AbstractMinimapDataSource {
+ getNodes(): MinimapNodeData[] {
+ if (!this.graph?._nodes) return []
+
+ return this.graph._nodes.map((node) => ({
+ id: String(node.id),
+ x: node.pos[0],
+ y: node.pos[1],
+ width: node.size[0],
+ height: node.size[1],
+ bgcolor: node.bgcolor,
+ mode: node.mode,
+ hasErrors: node.has_errors
+ }))
+ }
+
+ getNodeCount(): number {
+ return this.graph?._nodes?.length ?? 0
+ }
+
+ hasData(): boolean {
+ return this.getNodeCount() > 0
+ }
+}
diff --git a/src/renderer/extensions/minimap/data/MinimapDataSourceFactory.ts b/src/renderer/extensions/minimap/data/MinimapDataSourceFactory.ts
new file mode 100644
index 0000000000..49b15ed9e3
--- /dev/null
+++ b/src/renderer/extensions/minimap/data/MinimapDataSourceFactory.ts
@@ -0,0 +1,22 @@
+import type { LGraph } from '@/lib/litegraph/src/litegraph'
+import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
+
+import type { IMinimapDataSource } from '../types'
+import { LayoutStoreDataSource } from './LayoutStoreDataSource'
+import { LiteGraphDataSource } from './LiteGraphDataSource'
+
+/**
+ * Factory for creating the appropriate data source
+ */
+export class MinimapDataSourceFactory {
+ static create(graph: LGraph | null): IMinimapDataSource {
+ // Check if LayoutStore has data
+ const layoutStoreHasData = layoutStore.getAllNodes().value.size > 0
+
+ if (layoutStoreHasData) {
+ return new LayoutStoreDataSource(graph)
+ }
+
+ return new LiteGraphDataSource(graph)
+ }
+}
diff --git a/src/renderer/extensions/minimap/minimapCanvasRenderer.ts b/src/renderer/extensions/minimap/minimapCanvasRenderer.ts
index 2e0790ca96..3e547ce689 100644
--- a/src/renderer/extensions/minimap/minimapCanvasRenderer.ts
+++ b/src/renderer/extensions/minimap/minimapCanvasRenderer.ts
@@ -3,7 +3,12 @@ import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
-import type { MinimapRenderContext } from './types'
+import { MinimapDataSourceFactory } from './data/MinimapDataSourceFactory'
+import type {
+ IMinimapDataSource,
+ MinimapNodeData,
+ MinimapRenderContext
+} from './types'
/**
* Get theme-aware colors for the minimap
@@ -25,24 +30,49 @@ function getMinimapColors() {
}
}
+/**
+ * Get node color based on settings and node properties (Single Responsibility)
+ */
+function getNodeColor(
+ node: MinimapNodeData,
+ settings: MinimapRenderContext['settings'],
+ colors: ReturnType
+): string {
+ if (settings.renderBypass && node.mode === LGraphEventMode.BYPASS) {
+ return colors.bypassColor
+ }
+
+ if (settings.nodeColors) {
+ if (node.bgcolor) {
+ return colors.isLightTheme
+ ? adjustColor(node.bgcolor, { lightness: 0.5 })
+ : node.bgcolor
+ }
+ return colors.nodeColorDefault
+ }
+
+ return colors.nodeColor
+}
+
/**
* Render groups on the minimap
*/
function renderGroups(
ctx: CanvasRenderingContext2D,
- graph: LGraph,
+ dataSource: IMinimapDataSource,
offsetX: number,
offsetY: number,
context: MinimapRenderContext,
colors: ReturnType
) {
- if (!graph._groups || graph._groups.length === 0) return
+ const groups = dataSource.getGroups()
+ if (groups.length === 0) return
- for (const group of graph._groups) {
- const x = (group.pos[0] - context.bounds.minX) * context.scale + offsetX
- const y = (group.pos[1] - context.bounds.minY) * context.scale + offsetY
- const w = group.size[0] * context.scale
- const h = group.size[1] * context.scale
+ for (const group of groups) {
+ const x = (group.x - context.bounds.minX) * context.scale + offsetX
+ const y = (group.y - context.bounds.minY) * context.scale + offsetY
+ const w = group.width * context.scale
+ const h = group.height * context.scale
let color = colors.groupColor
@@ -64,45 +94,34 @@ function renderGroups(
*/
function renderNodes(
ctx: CanvasRenderingContext2D,
- graph: LGraph,
+ dataSource: IMinimapDataSource,
offsetX: number,
offsetY: number,
context: MinimapRenderContext,
colors: ReturnType
) {
- if (!graph._nodes || graph._nodes.length === 0) return
+ const nodes = dataSource.getNodes()
+ if (nodes.length === 0) return
- // Group nodes by color for batch rendering
+ // Group nodes by color for batch rendering (performance optimization)
const nodesByColor = new Map<
string,
Array<{ x: number; y: number; w: number; h: number; hasErrors?: boolean }>
>()
- for (const node of graph._nodes) {
- const x = (node.pos[0] - context.bounds.minX) * context.scale + offsetX
- const y = (node.pos[1] - context.bounds.minY) * context.scale + offsetY
- const w = node.size[0] * context.scale
- const h = node.size[1] * context.scale
+ for (const node of nodes) {
+ const x = (node.x - context.bounds.minX) * context.scale + offsetX
+ const y = (node.y - context.bounds.minY) * context.scale + offsetY
+ const w = node.width * context.scale
+ const h = node.height * context.scale
- let color = colors.nodeColor
-
- if (context.settings.renderBypass && node.mode === LGraphEventMode.BYPASS) {
- color = colors.bypassColor
- } else if (context.settings.nodeColors) {
- color = colors.nodeColorDefault
-
- if (node.bgcolor) {
- color = colors.isLightTheme
- ? adjustColor(node.bgcolor, { lightness: 0.5 })
- : node.bgcolor
- }
- }
+ const color = getNodeColor(node, context.settings, colors)
if (!nodesByColor.has(color)) {
nodesByColor.set(color, [])
}
- nodesByColor.get(color)!.push({ x, y, w, h, hasErrors: node.has_errors })
+ nodesByColor.get(color)!.push({ x, y, w, h, hasErrors: node.hasErrors })
}
// Batch render nodes by color
@@ -132,13 +151,14 @@ function renderNodes(
*/
function renderConnections(
ctx: CanvasRenderingContext2D,
- graph: LGraph,
+ dataSource: IMinimapDataSource,
offsetX: number,
offsetY: number,
context: MinimapRenderContext,
colors: ReturnType
) {
- if (!graph || !graph._nodes) return
+ const links = dataSource.getLinks()
+ if (links.length === 0) return
ctx.strokeStyle = colors.linkColor
ctx.lineWidth = 0.3
@@ -151,41 +171,28 @@ function renderConnections(
y2: number
}> = []
- for (const node of graph._nodes) {
- if (!node.outputs) continue
+ for (const link of links) {
+ const x1 =
+ (link.sourceNode.x - context.bounds.minX) * context.scale + offsetX
+ const y1 =
+ (link.sourceNode.y - context.bounds.minY) * context.scale + offsetY
+ const x2 =
+ (link.targetNode.x - context.bounds.minX) * context.scale + offsetX
+ const y2 =
+ (link.targetNode.y - context.bounds.minY) * context.scale + offsetY
- const x1 = (node.pos[0] - context.bounds.minX) * context.scale + offsetX
- const y1 = (node.pos[1] - context.bounds.minY) * context.scale + offsetY
+ const outputX = x1 + link.sourceNode.width * context.scale
+ const outputY = y1 + link.sourceNode.height * context.scale * 0.2
+ const inputX = x2
+ const inputY = y2 + link.targetNode.height * context.scale * 0.2
- for (const output of node.outputs) {
- if (!output.links) continue
+ // Draw connection line
+ ctx.beginPath()
+ ctx.moveTo(outputX, outputY)
+ ctx.lineTo(inputX, inputY)
+ ctx.stroke()
- for (const linkId of output.links) {
- const link = graph.links[linkId]
- if (!link) continue
-
- const targetNode = graph.getNodeById(link.target_id)
- if (!targetNode) continue
-
- const x2 =
- (targetNode.pos[0] - context.bounds.minX) * context.scale + offsetX
- const y2 =
- (targetNode.pos[1] - context.bounds.minY) * context.scale + offsetY
-
- const outputX = x1 + node.size[0] * context.scale
- const outputY = y1 + node.size[1] * context.scale * 0.2
- const inputX = x2
- const inputY = y2 + targetNode.size[1] * context.scale * 0.2
-
- // Draw connection line
- ctx.beginPath()
- ctx.moveTo(outputX, outputY)
- ctx.lineTo(inputX, inputY)
- ctx.stroke()
-
- connections.push({ x1: outputX, y1: outputY, x2: inputX, y2: inputY })
- }
- }
+ connections.push({ x1: outputX, y1: outputY, x2: inputX, y2: inputY })
}
// Render connection slots on top
@@ -217,8 +224,11 @@ export function renderMinimapToCanvas(
// Clear canvas
ctx.clearRect(0, 0, context.width, context.height)
+ // Create unified data source (Dependency Inversion)
+ const dataSource = MinimapDataSourceFactory.create(graph)
+
// Fast path for empty graph
- if (!graph || !graph._nodes || graph._nodes.length === 0) {
+ if (!dataSource.hasData()) {
return
}
@@ -228,12 +238,12 @@ export function renderMinimapToCanvas(
// Render in correct order: groups -> links -> nodes
if (context.settings.showGroups) {
- renderGroups(ctx, graph, offsetX, offsetY, context, colors)
+ renderGroups(ctx, dataSource, offsetX, offsetY, context, colors)
}
if (context.settings.showLinks) {
- renderConnections(ctx, graph, offsetX, offsetY, context, colors)
+ renderConnections(ctx, dataSource, offsetX, offsetY, context, colors)
}
- renderNodes(ctx, graph, offsetX, offsetY, context, colors)
+ renderNodes(ctx, dataSource, offsetX, offsetY, context, colors)
}
diff --git a/src/renderer/extensions/minimap/types.ts b/src/renderer/extensions/minimap/types.ts
index fbea21c83e..b458718ea8 100644
--- a/src/renderer/extensions/minimap/types.ts
+++ b/src/renderer/extensions/minimap/types.ts
@@ -2,6 +2,7 @@
* Minimap-specific type definitions
*/
import type { LGraph } from '@/lib/litegraph/src/litegraph'
+import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
/**
* Minimal interface for what the minimap needs from the canvas
@@ -66,3 +67,50 @@ export type MinimapSettingsKey =
| 'Comfy.Minimap.ShowGroups'
| 'Comfy.Minimap.RenderBypassState'
| 'Comfy.Minimap.RenderErrorState'
+
+/**
+ * Node data required for minimap rendering
+ */
+export interface MinimapNodeData {
+ id: NodeId
+ x: number
+ y: number
+ width: number
+ height: number
+ bgcolor?: string
+ mode?: number
+ hasErrors?: boolean
+}
+
+/**
+ * Link data required for minimap rendering
+ */
+export interface MinimapLinkData {
+ sourceNode: MinimapNodeData
+ targetNode: MinimapNodeData
+ sourceSlot: number
+ targetSlot: number
+}
+
+/**
+ * Group data required for minimap rendering
+ */
+export interface MinimapGroupData {
+ x: number
+ y: number
+ width: number
+ height: number
+ color?: string
+}
+
+/**
+ * Interface for minimap data sources (Dependency Inversion Principle)
+ */
+export interface IMinimapDataSource {
+ getNodes(): MinimapNodeData[]
+ getLinks(): MinimapLinkData[]
+ getGroups(): MinimapGroupData[]
+ getBounds(): MinimapBounds
+ getNodeCount(): number
+ hasData(): boolean
+}
diff --git a/src/renderer/extensions/vueNodes/components/LGraphNode.vue b/src/renderer/extensions/vueNodes/components/LGraphNode.vue
index 478326c83b..ce318e82e0 100644
--- a/src/renderer/extensions/vueNodes/components/LGraphNode.vue
+++ b/src/renderer/extensions/vueNodes/components/LGraphNode.vue
@@ -55,6 +55,7 @@
:collapsed="isCollapsed"
@collapse="handleCollapse"
@update:title="handleTitleUpdate"
+ @enter-subgraph="handleEnterSubgraph"
/>
]
@@ -138,12 +139,12 @@
diff --git a/src/renderer/extensions/vueNodes/components/NodeHeader.vue b/src/renderer/extensions/vueNodes/components/NodeHeader.vue
index 0c6ebfc207..3bd75024c6 100644
--- a/src/renderer/extensions/vueNodes/components/NodeHeader.vue
+++ b/src/renderer/extensions/vueNodes/components/NodeHeader.vue
@@ -4,7 +4,7 @@
+
+
+
+
+
+
+
diff --git a/src/renderer/extensions/vueNodes/composables/useNodeEventHandlers.ts b/src/renderer/extensions/vueNodes/composables/useNodeEventHandlers.ts
index 97653ee0df..1d090af843 100644
--- a/src/renderer/extensions/vueNodes/composables/useNodeEventHandlers.ts
+++ b/src/renderer/extensions/vueNodes/composables/useNodeEventHandlers.ts
@@ -8,19 +8,17 @@
* - Layout mutations for visual feedback
* - Integration with LiteGraph canvas selection system
*/
-import type { Ref } from 'vue'
+import { createSharedComposable } from '@vueuse/core'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
+import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
-interface NodeManager {
- getNode: (id: string) => any
-}
-
-export function useNodeEventHandlers(nodeManager: Ref) {
+function useNodeEventHandlersIndividual() {
const canvasStore = useCanvasStore()
+ const { nodeManager } = useVueNodeLifecycle()
const { bringNodeToFront } = useNodeZIndex()
const { shouldHandleNodePointerEvents } = useCanvasInteractions()
@@ -237,3 +235,7 @@ export function useNodeEventHandlers(nodeManager: Ref) {
deselectNodes
}
}
+
+export const useNodeEventHandlers = createSharedComposable(
+ useNodeEventHandlersIndividual
+)
diff --git a/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.ts b/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.ts
new file mode 100644
index 0000000000..f5ba083742
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/composables/useNodePointerInteractions.ts
@@ -0,0 +1,93 @@
+import { type MaybeRefOrGetter, computed, ref, toValue } from 'vue'
+
+import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
+import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
+import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
+import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
+
+// Treat tiny pointer jitter as a click, not a drag
+const DRAG_THRESHOLD_PX = 4
+
+export function useNodePointerInteractions(
+ nodeDataMaybe: MaybeRefOrGetter,
+ onPointerUp: (
+ event: PointerEvent,
+ nodeData: VueNodeData,
+ wasDragging: boolean
+ ) => void
+) {
+ const nodeData = toValue(nodeDataMaybe)
+
+ const { startDrag, endDrag, handleDrag } = useNodeLayout(nodeData.id)
+ // Use canvas interactions for proper wheel event handling and pointer event capture control
+ const { forwardEventToCanvas, shouldHandleNodePointerEvents } =
+ useCanvasInteractions()
+
+ // Drag state for styling
+ const isDragging = ref(false)
+ const dragStyle = computed(() => ({
+ cursor: isDragging.value ? 'grabbing' : 'grab'
+ }))
+ const lastX = ref(0)
+ const lastY = ref(0)
+
+ const handlePointerDown = (event: PointerEvent) => {
+ if (!nodeData) {
+ console.warn(
+ 'LGraphNode: nodeData is null/undefined in handlePointerDown'
+ )
+ return
+ }
+
+ // Don't handle pointer events when canvas is in panning mode - forward to canvas instead
+ if (!shouldHandleNodePointerEvents.value) {
+ forwardEventToCanvas(event)
+ return
+ }
+
+ // Start drag using layout system
+ isDragging.value = true
+
+ // Set Vue node dragging state for selection toolbox
+ layoutStore.isDraggingVueNodes.value = true
+
+ startDrag(event)
+ lastY.value = event.clientY
+ lastX.value = event.clientX
+ }
+
+ const handlePointerMove = (event: PointerEvent) => {
+ if (isDragging.value) {
+ void handleDrag(event)
+ }
+ }
+
+ const handlePointerUp = (event: PointerEvent) => {
+ if (isDragging.value) {
+ isDragging.value = false
+ void endDrag(event)
+
+ // Clear Vue node dragging state for selection toolbox
+ layoutStore.isDraggingVueNodes.value = false
+ }
+
+ // Don't emit node-click when canvas is in panning mode - forward to canvas instead
+ if (!shouldHandleNodePointerEvents.value) {
+ forwardEventToCanvas(event)
+ return
+ }
+
+ // Emit node-click for selection handling in GraphCanvas
+ const dx = event.clientX - lastX.value
+ const dy = event.clientY - lastY.value
+ const wasDragging = Math.hypot(dx, dy) > DRAG_THRESHOLD_PX
+ onPointerUp(event, nodeData, wasDragging)
+ }
+ return {
+ isDragging,
+ dragStyle,
+ handlePointerMove,
+ handlePointerDown,
+ handlePointerUp
+ }
+}
diff --git a/src/renderer/extensions/vueNodes/layout/useNodeLayout.ts b/src/renderer/extensions/vueNodes/layout/useNodeLayout.ts
index 18a085641e..3274d342d2 100644
--- a/src/renderer/extensions/vueNodes/layout/useNodeLayout.ts
+++ b/src/renderer/extensions/vueNodes/layout/useNodeLayout.ts
@@ -1,3 +1,4 @@
+import { storeToRefs } from 'pinia'
/**
* Composable for individual Vue node components
*
@@ -6,7 +7,7 @@
*/
import { computed, inject } from 'vue'
-import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
+import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
@@ -17,14 +18,14 @@ import { LayoutSource, type Point } from '@/renderer/core/layout/types'
* Uses customRef for shared write access with Canvas renderer
*/
export function useNodeLayout(nodeId: string) {
- const store = layoutStore
const mutations = useLayoutMutations()
+ const { selectedNodeIds } = storeToRefs(useCanvasStore())
// Get transform utilities from TransformPane if available
const transformState = inject(TransformStateKey)
// Get the customRef for this node (shared write access)
- const layoutRef = store.getNodeLayoutRef(nodeId)
+ const layoutRef = layoutStore.getNodeLayoutRef(nodeId)
// Computed properties for easy access
const position = computed(() => {
@@ -53,8 +54,6 @@ export function useNodeLayout(nodeId: string) {
let dragStartMouse: Point | null = null
let otherSelectedNodesStartPositions: Map | null = null
- const selectedNodeIds = inject(SelectedNodeIdsKey, null)
-
/**
* Start dragging the node
*/
diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useComboWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useComboWidget.ts
index 2387fc59ca..59705458cf 100644
--- a/src/renderer/extensions/vueNodes/widgets/composables/useComboWidget.ts
+++ b/src/renderer/extensions/vueNodes/widgets/composables/useComboWidget.ts
@@ -3,10 +3,9 @@ import { ref } from 'vue'
import MultiSelectWidget from '@/components/graph/widgets/MultiSelectWidget.vue'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
-import type {
- IBaseWidget,
- IComboWidget
-} from '@/lib/litegraph/src/types/widgets'
+import { isAssetWidget, isComboWidget } from '@/lib/litegraph/src/litegraph'
+import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
+import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
import { assetService } from '@/platform/assets/services/assetService'
import { useSettingStore } from '@/platform/settings/settingStore'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
@@ -73,11 +72,29 @@ const addComboWidget = (
const currentValue = getDefaultValue(inputSpec)
const displayLabel = currentValue ?? t('widgets.selectModel')
- const widget = node.addWidget('asset', inputSpec.name, displayLabel, () => {
- console.log(
- `Asset Browser would open here for:\nNode: ${node.type}\nWidget: ${inputSpec.name}\nCurrent Value:${currentValue}`
- )
- })
+ const assetBrowserDialog = useAssetBrowserDialog()
+
+ const widget = node.addWidget(
+ 'asset',
+ inputSpec.name,
+ displayLabel,
+ async () => {
+ if (!isAssetWidget(widget)) {
+ throw new Error(`Expected asset widget but received ${widget.type}`)
+ }
+ await assetBrowserDialog.show({
+ nodeType: node.comfyClass || '',
+ inputName: inputSpec.name,
+ currentValue: widget.value,
+ onAssetSelected: (filename: string) => {
+ const oldValue = widget.value
+ widget.value = filename
+ // Using onWidgetChanged prevents a callback race where asset selection could reopen the dialog
+ node.onWidgetChanged?.(widget.name, filename, oldValue, widget)
+ }
+ })
+ }
+ )
return widget
}
@@ -96,11 +113,14 @@ const addComboWidget = (
)
if (inputSpec.remote) {
+ if (!isComboWidget(widget)) {
+ throw new Error(`Expected combo widget but received ${widget.type}`)
+ }
const remoteWidget = useRemoteWidget({
remoteConfig: inputSpec.remote,
defaultValue,
node,
- widget: widget as IComboWidget
+ widget
})
if (inputSpec.remote.refresh_button) remoteWidget.addRefreshButton()
@@ -116,16 +136,19 @@ const addComboWidget = (
}
if (inputSpec.control_after_generate) {
+ if (!isComboWidget(widget)) {
+ throw new Error(`Expected combo widget but received ${widget.type}`)
+ }
widget.linkedWidgets = addValueControlWidgets(
node,
- widget as IComboWidget,
+ widget,
undefined,
undefined,
transformInputSpecV2ToV1(inputSpec)
)
}
- return widget as IBaseWidget
+ return widget
}
export const useComboWidget = () => {
diff --git a/src/stores/modelToNodeStore.ts b/src/stores/modelToNodeStore.ts
index f6c15e91ad..4f79252942 100644
--- a/src/stores/modelToNodeStore.ts
+++ b/src/stores/modelToNodeStore.ts
@@ -33,12 +33,43 @@ export const useModelToNodeStore = defineStore('modelToNode', () => {
)
})
+ /** Internal computed for efficient reverse lookup: nodeType -> category */
+ const nodeTypeToCategory = computed(() => {
+ const lookup: Record = {}
+ for (const [category, providers] of Object.entries(modelToNodeMap.value)) {
+ for (const provider of providers) {
+ // Only store the first category for each node type (matches current assetService behavior)
+ if (!lookup[provider.nodeDef.name]) {
+ lookup[provider.nodeDef.name] = category
+ }
+ }
+ }
+ return lookup
+ })
+
/** Get set of all registered node types for efficient lookup */
function getRegisteredNodeTypes(): Set {
registerDefaults()
return registeredNodeTypes.value
}
+ /**
+ * Get the category for a given node type.
+ * Performs efficient O(1) lookup using cached reverse map.
+ * @param nodeType The node type name to find the category for
+ * @returns The category name, or undefined if not found
+ */
+ function getCategoryForNodeType(nodeType: string): string | undefined {
+ registerDefaults()
+
+ // Handle invalid input gracefully
+ if (!nodeType || typeof nodeType !== 'string') {
+ return undefined
+ }
+
+ return nodeTypeToCategory.value[nodeType]
+ }
+
/**
* Get the node provider for the given model type name.
* @param modelType The name of the model type to get the node provider for.
@@ -109,6 +140,7 @@ export const useModelToNodeStore = defineStore('modelToNode', () => {
return {
modelToNodeMap,
getRegisteredNodeTypes,
+ getCategoryForNodeType,
getNodeProvider,
getAllNodeProviders,
registerNodeProvider,
diff --git a/src/utils/formatUtil.ts b/src/utils/formatUtil.ts
index 2a0b127d7e..9d4f0c2702 100644
--- a/src/utils/formatUtil.ts
+++ b/src/utils/formatUtil.ts
@@ -364,39 +364,6 @@ export const downloadUrlToHfRepoUrl = (url: string): string => {
}
}
-export const isSemVer = (
- version: string
-): version is `${number}.${number}.${number}` => {
- const regex = /^\d+\.\d+\.\d+$/
- return regex.test(version)
-}
-
-const normalizeVersion = (version: string) =>
- version
- .split(/[+.-]/)
- .map(Number)
- .filter((part) => !Number.isNaN(part))
-
-export function compareVersions(
- versionA: string | undefined,
- versionB: string | undefined
-): number {
- versionA ??= '0.0.0'
- versionB ??= '0.0.0'
-
- const aParts = normalizeVersion(versionA)
- const bParts = normalizeVersion(versionB)
-
- for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
- const aPart = aParts[i] ?? 0
- const bPart = bParts[i] ?? 0
- if (aPart < bPart) return -1
- if (aPart > bPart) return 1
- }
-
- return 0
-}
-
/**
* Converts Metronome's integer amount back to a formatted currency string.
* For USD, converts from cents to dollars.
diff --git a/src/utils/graphTraversalUtil.ts b/src/utils/graphTraversalUtil.ts
index 72f6f37333..4a573ed24c 100644
--- a/src/utils/graphTraversalUtil.ts
+++ b/src/utils/graphTraversalUtil.ts
@@ -8,6 +8,23 @@ import { parseNodeLocatorId } from '@/types/nodeIdentification'
import { isSubgraphIoNode } from './typeGuardUtil'
+interface NodeWithId {
+ id: string | number
+ subgraphId?: string | null
+}
+
+/**
+ * Constructs a locator ID from node data with optional subgraph context.
+ *
+ * @param nodeData - Node data containing id and optional subgraphId
+ * @returns The locator ID string
+ */
+export function getLocatorIdFromNodeData(nodeData: NodeWithId): string {
+ return nodeData.subgraphId
+ ? `${nodeData.subgraphId}:${String(nodeData.id)}`
+ : String(nodeData.id)
+}
+
/**
* Parses an execution ID into its component parts.
*
diff --git a/src/utils/versionUtil.ts b/src/utils/versionUtil.ts
index 61337b2aa8..423d52b5fe 100644
--- a/src/utils/versionUtil.ts
+++ b/src/utils/versionUtil.ts
@@ -1,4 +1,4 @@
-import * as semver from 'semver'
+import { clean, satisfies } from 'semver'
import type {
ConflictDetail,
@@ -11,7 +11,7 @@ import type {
* @returns Cleaned version string or original if cleaning fails
*/
export function cleanVersion(version: string): string {
- return semver.clean(version) || version
+ return clean(version) || version
}
/**
@@ -23,7 +23,7 @@ export function cleanVersion(version: string): string {
export function satisfiesVersion(version: string, range: string): boolean {
try {
const cleanedVersion = cleanVersion(version)
- return semver.satisfies(cleanedVersion, range)
+ return satisfies(cleanedVersion, range)
} catch {
return false
}
diff --git a/src/views/GraphView.vue b/src/views/GraphView.vue
index a23d4fecf9..bbfca6ea0f 100644
--- a/src/views/GraphView.vue
+++ b/src/views/GraphView.vue
@@ -33,6 +33,7 @@ import {
} from 'vue'
import { useI18n } from 'vue-i18n'
+import { runWhenGlobalIdle } from '@/base/common/async'
import MenuHamburger from '@/components/MenuHamburger.vue'
import UnloadWindowConfirmDialog from '@/components/dialog/UnloadWindowConfirmDialog.vue'
import GraphCanvas from '@/components/graph/GraphCanvas.vue'
@@ -253,33 +254,30 @@ void nextTick(() => {
})
const onGraphReady = () => {
- requestIdleCallback(
- () => {
- // Setting values now available after comfyApp.setup.
- // Load keybindings.
- wrapWithErrorHandling(useKeybindingService().registerUserKeybindings)()
+ runWhenGlobalIdle(() => {
+ // Setting values now available after comfyApp.setup.
+ // Load keybindings.
+ wrapWithErrorHandling(useKeybindingService().registerUserKeybindings)()
- // Load server config
- wrapWithErrorHandling(useServerConfigStore().loadServerConfig)(
- SERVER_CONFIG_ITEMS,
- settingStore.get('Comfy.Server.ServerConfigValues')
- )
+ // Load server config
+ wrapWithErrorHandling(useServerConfigStore().loadServerConfig)(
+ SERVER_CONFIG_ITEMS,
+ settingStore.get('Comfy.Server.ServerConfigValues')
+ )
- // Load model folders
- void wrapWithErrorHandlingAsync(useModelStore().loadModelFolders)()
+ // Load model folders
+ void wrapWithErrorHandlingAsync(useModelStore().loadModelFolders)()
- // Non-blocking load of node frequencies
- void wrapWithErrorHandlingAsync(
- useNodeFrequencyStore().loadNodeFrequencies
- )()
+ // Non-blocking load of node frequencies
+ void wrapWithErrorHandlingAsync(
+ useNodeFrequencyStore().loadNodeFrequencies
+ )()
- // Node defs now available after comfyApp.setup.
- // Explicitly initialize nodeSearchService to avoid indexing delay when
- // node search is triggered
- useNodeDefStore().nodeSearchService.searchNode('')
- },
- { timeout: 1000 }
- )
+ // Node defs now available after comfyApp.setup.
+ // Explicitly initialize nodeSearchService to avoid indexing delay when
+ // node search is triggered
+ useNodeDefStore().nodeSearchService.searchNode('')
+ }, 1000)
}
diff --git a/src/workbench/extensions/manager/components/manager/PackVersionBadge.vue b/src/workbench/extensions/manager/components/manager/PackVersionBadge.vue
index baf724a206..204b2a78ec 100644
--- a/src/workbench/extensions/manager/components/manager/PackVersionBadge.vue
+++ b/src/workbench/extensions/manager/components/manager/PackVersionBadge.vue
@@ -43,11 +43,11 @@