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 @@
@@ -36,17 +36,39 @@ @cancel="handleTitleCancel" />
+ + +
+ + + +
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 @@