Compare commits

...

19 Commits

Author SHA1 Message Date
Benjamin Lu
fe63d89ef2 Revert "[refactor] Use getSlotPosition for Vue nodes in link rendering"
This reverts commit 881ff67fe2.
2025-09-08 19:07:30 -07:00
Benjamin Lu
06469bebed empty commit for ci 2025-09-08 16:46:50 -07:00
Benjamin Lu
0d4ecf801f empty commit for ci 2025-09-08 16:46:36 -07:00
Benjamin Lu
aecbdced12 yagni mm? [skip ci] 2025-09-08 14:38:38 -07:00
Benjamin Lu
8b5c7ffb60 Use update instead 2025-09-08 14:38:38 -07:00
Benjamin Lu
8586e684a6 Fix init issue 2025-09-08 14:38:38 -07:00
Benjamin Lu
cf42355a9f Add offset 2025-09-08 14:38:38 -07:00
Benjamin Lu
881ff67fe2 [refactor] Use getSlotPosition for Vue nodes in link rendering
Replace direct node position calls with getSlotPosition utility when Vue nodes mode is enabled. This ensures consistent slot positioning across the canvas rendering system.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-08 14:38:38 -07:00
Benjamin Lu
aa7f8912a7 [refactor] Use getSlotPosition for Vue nodes in link rendering (#5400)
* Remove COMFY_VUE_NODE_DIMENSIONS

* [refactor] Use getSlotPosition for Vue nodes in link rendering

Replace direct node position calls with getSlotPosition utility when Vue nodes mode is enabled. This ensures consistent slot positioning across the canvas rendering system.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix getSlotPosition readonly return value (#5433)

* Update accordingly to new type

* Fix canvas/screen conversion formulas in useTransformState (#5406)

* Fix conversion formulas

* update test expectations

* Remove unused type import

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
2025-09-08 14:27:55 -07:00
Christian Byrne
76bfc9e678 [refactor] Centralize type assertions from yjs data (#5385)
* switch to schema interface and remove assertions at callsites

* [refactor] improve type safety and code organization - addresses @DrJKL's review feedback

- Remove unnecessary type assertions from REROUTE_DEFAULTS
- Use safer Omit<RerouteData, 'id'  < /dev/null |  'parentId'> pattern for defaults to prevent hardcoded ID bugs
- Extract asRerouteId and asLinkId utility functions to module scope as pure functions
- Update getRerouteField to handle partial defaults safely

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* [fix] revert to clean defaults pattern - removes any type usage

Reverted the overcomplicated Omit pattern back to the simple, working approach.
The original pattern was cleaner and didn't introduce unnecessary complexity.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-08 12:55:31 -07:00
Benjamin Lu
0e44a4a354 Remove COMFY_VUE_NODE_DIMENSIONS constant (#5398)
* Remove COMFY_VUE_NODE_DIMENSIONS

* Update litegraph snapshot test
2025-09-08 12:53:42 -07:00
Alexander Brown
9a89869517 lint: Fix missing defaults for Props with Defaults (#5439) 2025-09-08 12:05:49 -07:00
Arjan Singh
551af4c0e0 [feat] Implement AssetService behind settings flag (#5404)
* [feat] add Comfy.Assets.UseAssetAPI to CORE_SETTINGS

* [feat] create AssetService

1. Add service for accessing new Asset API
2. Add fallback model paths logic so empty model directories appear for
   the user.
3. Copious tests for them all.

Co-Authored-By: Claude <noreply@anthropic.com>

* [feat] switch between assets and file paths for model data

* [feat] ignore assets with "missing" tag

* [fix] formatting and style

* [fix] call assets API with the correct filters

* [feat] elminate unused modelPath code

* [fix] remove stray comment

* [fix] model manager api was not parsed correctly

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-08 11:49:55 -07:00
filtered
76f21b9975 Fix bypass slot check only works for precise types (#5431) 2025-09-08 11:26:28 -07:00
Christian Byrne
9f42f3dfb9 fix unnecessary any type (#5428) 2025-09-08 04:06:43 -07:00
Dr.Lt.Data
d7a7ed6d93 refine locales/ko (#5432) 2025-09-08 04:03:28 -07:00
Christian Byrne
713ad134cf Implement selection state management in Vue Nodes (#5421)
* let canvas continue to own selection state management

* fix merge error

* refactor: use computed instead of watcher for selectedNodeIds

Replace watcher pattern with computed for better Vue idioms:
- More reactive and efficient
- Automatically recomputes when dependencies change
- Simpler, more declarative code

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: improve injection error handling for selectedNodeIds

Replace silent fallback with explicit error when SelectedNodeIds
is not provided:
- Fail fast instead of silently using empty Set
- Clear error message for debugging
- Prevents nodes appearing unselected due to missing provider

Addresses DrJKL's concern about injection default behavior.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* test: improve mocking patterns using vi.mockObject

Replace manual mock interfaces with vi.mockObject for better type safety:
- Use Vitest's built-in mocking utilities instead of manual interfaces
- Properly configure mock return values
- Remove unnecessary type assertions

Addresses DrJKL's feedback on test mocking patterns.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* test: extract repeated nodeData for clarity

Extract common test nodeData object to reduce duplication:
- Move repeated VueNodeData object to describe scope
- Replace 6 instances of identical nodeData declarations
- Maintain different nodeData for specific test cases

Addresses DrJKL's suggestion to extract repeated test data.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* add type safety to mocks

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-08 00:43:30 -07:00
Christian Byrne
1328da0fbd fix: Wait until graph configured to initialize Vue nodes (#5415)
* don't register vue nodes until after tasks flushed

* wait if configuring graph
2025-09-07 18:46:06 -07:00
Alexander Brown
6c7adf954a fix: Prevent pointer events on widgets from propagating to the containing node. (#5424) 2025-09-07 18:45:45 -07:00
34 changed files with 1086 additions and 344 deletions

View File

@@ -44,7 +44,6 @@
:node-data="nodeData"
:position="nodePositions.get(nodeData.id)"
:size="nodeSizes.get(nodeData.id)"
:selected="nodeData.selected"
:readonly="false"
:executing="executionStore.executingNodeId === nodeData.id"
:error="
@@ -79,6 +78,7 @@ import {
computed,
onMounted,
onUnmounted,
provide,
ref,
shallowRef,
watch,
@@ -112,6 +112,7 @@ import { useWorkflowPersistence } from '@/composables/useWorkflowPersistence'
import { CORE_SETTINGS } from '@/constants/coreSettings'
import { i18n, t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
import TransformPane from '@/renderer/core/layout/TransformPane.vue'
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
import VueGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
@@ -189,6 +190,17 @@ const handleNodeSelect = nodeEventHandlers.handleNodeSelect
const handleNodeCollapse = nodeEventHandlers.handleNodeCollapse
const handleNodeTitleUpdate = nodeEventHandlers.handleNodeTitleUpdate
// Provide selection state to all Vue nodes
const selectedNodeIds = computed(
() =>
new Set(
canvasStore.selectedItems
.filter((item) => item.id !== undefined)
.map((item) => String(item.id))
)
)
provide(SelectedNodeIdsKey, selectedNodeIds)
watchEffect(() => {
nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated')
})

View File

@@ -4,6 +4,7 @@
*/
import { nextTick, reactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import { type Bounds, QuadTree } from '@/renderer/core/spatial/QuadTree'
@@ -595,6 +596,7 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
/**
* Handles node addition to the graph - sets up Vue state and spatial indexing
* Defers position extraction until after potential configure() calls
*/
const handleNodeAdded = (
node: LGraphNode,
@@ -618,27 +620,48 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
lastUpdate: performance.now(),
culled: false
})
nodePositions.set(id, { x: node.pos[0], y: node.pos[1] })
nodeSizes.set(id, { width: node.size[0], height: node.size[1] })
attachMetadata(node)
// Add to spatial index for viewport culling
const bounds: Bounds = {
x: node.pos[0],
y: node.pos[1],
width: node.size[0],
height: node.size[1]
const initializeVueNodeLayout = () => {
// Extract actual positions after configure() has potentially updated them
const nodePosition = { x: node.pos[0], y: node.pos[1] }
const nodeSize = { width: node.size[0], height: node.size[1] }
nodePositions.set(id, nodePosition)
nodeSizes.set(id, nodeSize)
attachMetadata(node)
// Add to spatial index for viewport culling with final positions
const nodeBounds: Bounds = {
x: nodePosition.x,
y: nodePosition.y,
width: nodeSize.width,
height: nodeSize.height
}
spatialIndex.insert(id, nodeBounds, id)
// Add node to layout store with final positions
setSource(LayoutSource.Canvas)
void createNode(id, {
position: nodePosition,
size: nodeSize,
zIndex: node.order || 0,
visible: true
})
}
spatialIndex.insert(id, bounds, id)
// Add node to layout store
setSource(LayoutSource.Canvas)
void createNode(id, {
position: { x: node.pos[0], y: node.pos[1] },
size: { width: node.size[0], height: node.size[1] },
zIndex: node.order || 0,
visible: true
})
// Check if we're in the middle of configuring the graph (workflow loading)
if (window.app?.configuringGraph) {
// During workflow loading - defer layout initialization until configure completes
// Chain our callback with any existing onAfterGraphConfigured callback
node.onAfterGraphConfigured = useChainCallback(
node.onAfterGraphConfigured,
initializeVueNodeLayout
)
} else {
// Not during workflow loading - initialize layout immediately
// This handles individual node additions during normal operation
initializeVueNodeLayout()
}
// Call original callback if provided
if (originalCallback) {

View File

@@ -33,12 +33,20 @@ export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
const node = nodeManager.value.getNode(nodeData.id)
if (!node) return
// Handle multi-select with Ctrl/Cmd key
if (!event.ctrlKey && !event.metaKey) {
canvasStore.canvas.deselectAllNodes()
}
const isMultiSelect = event.ctrlKey || event.metaKey
canvasStore.canvas.selectNode(node)
if (isMultiSelect) {
// Ctrl/Cmd+click -> toggle selection
if (node.selected) {
canvasStore.canvas.deselect(node)
} else {
canvasStore.canvas.select(node)
}
} else {
// Regular click -> single select
canvasStore.canvas.deselectAll()
canvasStore.canvas.select(node)
}
// Bring node to front when clicked (similar to LiteGraph behavior)
// Skip if node is pinned to avoid unwanted movement
@@ -47,9 +55,6 @@ export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
layoutMutations.bringNodeToFront(nodeData.id)
}
// Ensure node selection state is set
node.selected = true
// Update canvas selection tracking
canvasStore.updateSelectedItems()
}

View File

@@ -972,5 +972,14 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: false,
experimental: true,
versionAdded: '1.27.1'
},
{
id: 'Comfy.Assets.UseAssetAPI',
name: 'Use Asset API for model library',
type: 'boolean',
tooltip:
'Use new asset API instead of experiment endpoints for model browsing',
defaultValue: false,
experimental: true
}
]

View File

@@ -6,6 +6,7 @@ import {
type LinkRenderContext,
LitegraphLinkAdapter
} from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { CanvasPointer } from './CanvasPointer'
@@ -5559,7 +5560,9 @@ export class LGraphCanvas
const link = graph._links.get(link_id)
if (!link) continue
const endPos = node.getInputPos(i)
const endPos: Point = LiteGraph.vueNodesMode // TODO: still use LG get pos if vue nodes is off until stable
? getSlotPosition(node, i, true)
: node.getInputPos(i)
// find link info
const start_node = graph.getNodeById(link.origin_id)
@@ -5569,7 +5572,9 @@ export class LGraphCanvas
const startPos: Point =
outputId === -1
? [start_node.pos[0] + 10, start_node.pos[1] + 10]
: start_node.getOutputPos(outputId)
: LiteGraph.vueNodesMode // TODO: still use LG get pos if vue nodes is off until stable
? getSlotPosition(start_node, outputId, false)
: start_node.getOutputPos(outputId)
const output = start_node.outputs[outputId]
if (!output) continue

View File

@@ -3833,33 +3833,12 @@ export class LGraphNode
? this.getInputPos(slotIndex)
: this.getOutputPos(slotIndex)
if (LiteGraph.vueNodesMode) {
// Vue-based slot dimensions
const dimensions = LiteGraph.COMFY_VUE_NODE_DIMENSIONS.components
if (slot.isWidgetInputSlot) {
// Widget slots have a 20x20 clickable area centered at the position
slot.boundingRect[0] = pos[0] - 10
slot.boundingRect[1] = pos[1] - 10
slot.boundingRect[2] = 20
slot.boundingRect[3] = 20
} else {
// Regular slots have a 20x20 clickable area for the connector
// but the full slot height for vertical spacing
slot.boundingRect[0] = pos[0] - 10
slot.boundingRect[1] = pos[1] - dimensions.SLOT_HEIGHT / 2
slot.boundingRect[2] = 20
slot.boundingRect[3] = dimensions.SLOT_HEIGHT
}
} else {
// Traditional LiteGraph dimensions
slot.boundingRect[0] = pos[0] - LiteGraph.NODE_SLOT_HEIGHT * 0.5
slot.boundingRect[1] = pos[1] - LiteGraph.NODE_SLOT_HEIGHT * 0.5
slot.boundingRect[2] = slot.isWidgetInputSlot
? BaseWidget.margin
: LiteGraph.NODE_SLOT_HEIGHT
slot.boundingRect[3] = LiteGraph.NODE_SLOT_HEIGHT
}
slot.boundingRect[0] = pos[0] - LiteGraph.NODE_SLOT_HEIGHT * 0.5
slot.boundingRect[1] = pos[1] - LiteGraph.NODE_SLOT_HEIGHT * 0.5
slot.boundingRect[2] = slot.isWidgetInputSlot
? BaseWidget.margin
: LiteGraph.NODE_SLOT_HEIGHT
slot.boundingRect[3] = LiteGraph.NODE_SLOT_HEIGHT
}
#measureSlots(): ReadOnlyRect | null {

View File

@@ -24,26 +24,6 @@ import {
} from './types/globalEnums'
import { createUuidv4 } from './utils/uuid'
/**
* Vue node dimensions configuration for the contract between LiteGraph and Vue components.
* These values ensure both systems can independently calculate node, slot, and widget positions
* to place them in identical locations.
*
* IMPORTANT: These values must match the actual rendered dimensions of Vue components
* for the positioning contract to work correctly.
*/
export const COMFY_VUE_NODE_DIMENSIONS = {
spacing: {
BETWEEN_SLOTS_AND_BODY: 8,
BETWEEN_WIDGETS: 8
},
components: {
HEADER_HEIGHT: 34, // 18 header + 16 padding
SLOT_HEIGHT: 24,
STANDARD_WIDGET_HEIGHT: 30
}
} as const
/**
* The Global Scope. It contains all the registered node classes.
*/
@@ -95,14 +75,6 @@ export class LiteGraphGlobal {
WIDGET_SECONDARY_TEXT_COLOR = '#999'
WIDGET_DISABLED_TEXT_COLOR = '#666'
/**
* Vue node dimensions configuration for the contract between LiteGraph and Vue components.
* These values ensure both systems can independently calculate node, slot, and widget positions
* to place them in identical locations.
*/
// WARNING THIS WILL BE REMOVED IN FAVOR OF THE SLOTS LAYOUT TREE useDomSlotRegistration
COMFY_VUE_NODE_DIMENSIONS = COMFY_VUE_NODE_DIMENSIONS
LINK_COLOR = '#9A9'
EVENT_LINK_COLOR = '#A86'
CONNECTING_LINK_COLOR = '#AFA'

View File

@@ -104,7 +104,6 @@ export { BadgePosition, LGraphBadge } from './LGraphBadge'
export { LGraphCanvas } from './LGraphCanvas'
export { LGraphGroup } from './LGraphGroup'
export { LGraphNode, type NodeId } from './LGraphNode'
export { COMFY_VUE_NODE_DIMENSIONS } from './LiteGraphGlobal'
export { LLink } from './LLink'
export { createBounds } from './measure'
export { Reroute, type RerouteId } from './Reroute'

View File

@@ -272,7 +272,7 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
const matchingIndex = this.#getBypassSlotIndex(slot, type)
// No input types match
if (matchingIndex === undefined) {
if (matchingIndex === -1) {
console.debug(
`[ExecutableNodeDTO.resolveOutput] No input types match type [${type}] for id [${this.id}] slot [${slot}]`,
this
@@ -331,7 +331,7 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
* Used when bypassing nodes.
* @param slot The output slot index on this node
* @param type The type of the final target input (so type list matches are accurate)
* @returns The index of the input slot on this node, otherwise `undefined`.
* @returns The index of the input slot on this node, otherwise `-1`.
*/
#getBypassSlotIndex(slot: number, type: ISlotType) {
const { inputs } = this
@@ -352,15 +352,15 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
return slot
}
// Preserve legacy behaviour; use exact match first.
const exactMatch = inputs.findIndex((input) => input.type === type)
if (exactMatch !== -1) return exactMatch
// Find first matching slot - prefer exact type
return (
// Preserve legacy behaviour; use exact match first.
inputs.findIndex((input) => input.type === type) ??
inputs.findIndex(
(input) =>
LiteGraph.isValidConnection(input.type, outputType) &&
LiteGraph.isValidConnection(input.type, type)
)
return inputs.findIndex(
(input) =>
LiteGraph.isValidConnection(input.type, outputType) &&
LiteGraph.isValidConnection(input.type, type)
)
}

View File

@@ -3,7 +3,7 @@
"label": "업데이트 확인"
},
"Comfy-Desktop_Folders_OpenCustomNodesFolder": {
"label": "사용자 정의 노드 폴더 열기"
"label": "커스텀 노드 폴더 열기"
},
"Comfy-Desktop_Folders_OpenInputsFolder": {
"label": "입력 폴더 열기"
@@ -129,7 +129,7 @@
"label": "선택 영역을 서브그래프로 변환"
},
"Comfy_Graph_ExitSubgraph": {
"label": "서브그래프 종료"
"label": "서브그래프 나가기"
},
"Comfy_Graph_FitGroupToContents": {
"label": "그룹을 내용에 맞게 맞추기"
@@ -138,7 +138,7 @@
"label": "선택한 노드 그룹화"
},
"Comfy_Graph_UnpackSubgraph": {
"label": "선택한 서브그래프 풀기"
"label": "선택한 서브그래프 묶음 풀기"
},
"Comfy_GroupNode_ConvertSelectedNodesToGroupNode": {
"label": "선택한 노드를 그룹 노드로 변환"
@@ -171,13 +171,13 @@
"label": "기본 워크플로 로드"
},
"Comfy_Manager_CustomNodesManager_ShowCustomNodesMenu": {
"label": "사용자 정의 노드 (베타)"
"label": "커스텀 노드 (베타)"
},
"Comfy_Manager_CustomNodesManager_ShowLegacyCustomNodesMenu": {
"label": "커스텀 노드 (레거시)"
"label": "커스텀 노드 (구버전)"
},
"Comfy_Manager_ShowLegacyManagerMenu": {
"label": "매니저 메뉴 (레거시)"
"label": "매니저 메뉴 (구버전)"
},
"Comfy_Manager_ShowMissingPacks": {
"label": "누락된 팩 설치"

View File

@@ -287,7 +287,7 @@
"color": "색상",
"comingSoon": "곧 출시 예정",
"command": "명령",
"commandProhibited": "명령 {command} 금지되었습니다. 자세한 정보는 관리자에게 문의하십시오.",
"commandProhibited": "{command} 금지된 명령입니다. 자세한 정보는 관리자에게 문의하십시오.",
"community": "커뮤니티",
"completed": "완료됨",
"confirm": "확인",
@@ -326,7 +326,7 @@
"findIssues": "문제 찾기",
"firstTimeUIMessage": "새 UI를 처음 사용합니다. \"메뉴 > 새 메뉴 사용 > 비활성화\"를 선택하여 이전 UI로 복원하세요.",
"frontendNewer": "프론트엔드 버전 {frontendVersion}이(가) 백엔드 버전 {backendVersion}과(와) 호환되지 않을 수 있습니다.",
"frontendOutdated": "프론트엔드 버전 {frontendVersion}이(가) 오래되었습니다. 백엔드는 {requiredVersion} 이상이 필요합니다.",
"frontendOutdated": "프론트엔드 버전 {frontendVersion}이(가) 오래된 버전입니다. 백엔드는 {requiredVersion} 이상 버전이 필요합니다.",
"goToNode": "노드로 이동",
"help": "도움말",
"icon": "아이콘",
@@ -353,7 +353,7 @@
"micPermissionDenied": "마이크 권한이 거부되었습니다",
"migrate": "이전(migrate)",
"missing": "누락됨",
"moreWorkflows": "더 많은 워크플로",
"moreWorkflows": "더 많은 워크플로",
"name": "이름",
"newFolder": "새 폴더",
"next": "다음",
@@ -464,8 +464,8 @@
"appPathLocationTooltip": "ComfyUI의 앱 에셋 디렉토리. ComfyUI 코드 및 에셋을 저장합니다.",
"cannotWrite": "선택한 경로에 쓸 수 없습니다",
"chooseInstallationLocation": "설치 위치 선택",
"customNodes": "사용자 정의 노드",
"customNodesDescription": "기존 ComfyUI 설치에서 사용자 정의 노드를 다시 설치합니다.",
"customNodes": "커스텀 노드",
"customNodesDescription": "기존 ComfyUI 설치에서 커스텀 노드를 다시 설치합니다.",
"desktopAppSettings": "데스크탑 앱 설정",
"desktopAppSettingsDescription": "ComfyUI가 데스크탑에서 어떻게 작동하는지 구성하세요. 이 설정은 나중에 변경할 수 있습니다.",
"desktopSettings": "데스크탑 설정",
@@ -489,7 +489,7 @@
"helpImprove": "ComfyUI 개선에 도움을 주세요",
"installLocation": "설치 위치",
"installLocationDescription": "ComfyUI의 사용자 데이터 디렉토리를 선택하십시오. 선택한 위치에 Python 환경이 설치됩니다. 선택한 디스크에 충분한 공간(~15GB)이 남아 있는지 확인하십시오.",
"installLocationTooltip": "ComfyUI의 사용자 데이터 디렉토리. 저장소:\n- Python 환경\n- 모델\n- 사용자 정의 노드\n",
"installLocationTooltip": "ComfyUI의 사용자 데이터 디렉토리. 저장소:\n- Python 환경\n- 모델\n- 커스텀 노드\n",
"insufficientFreeSpace": "공간이 부족합니다 - 최소한의 여유 공간",
"isOneDrive": "OneDrive에 설치하면 문제가 발생할 수 있습니다. OneDrive가 아닌 위치에 설치하는 것을 강력히 권장합니다.",
"manualConfiguration": {
@@ -633,9 +633,9 @@
"installationQueue": "설치 대기열",
"lastUpdated": "마지막 업데이트",
"latestVersion": "최신",
"legacyManagerUI": "레거시 UI 사용",
"legacyManagerUIDescription": "레거시 매니저 UI를 사용하려면, ComfyUI를 --enable-manager-legacy-ui로 시작하세요",
"legacyMenuNotAvailable": "이 버전의 ComfyUI에서는 레거시 매니저 메뉴를 사용할 수 없습니다. 대신 새로운 매니저 메뉴를 사용하십시오.",
"legacyManagerUI": "구버전 매니저 UI 사용",
"legacyManagerUIDescription": "구버전 매니저 UI를 사용하려면, ComfyUI를 --enable-manager-legacy-ui로 시작하세요",
"legacyMenuNotAvailable": "이 버전의 ComfyUI에서는 구버전 매니저 메뉴를 사용할 수 없습니다. 대신 새로운 매니저 메뉴를 사용하십시오.",
"license": "라이선스",
"loadingVersions": "버전 로딩 중...",
"nightlyVersion": "최신 테스트 버전(nightly)",
@@ -663,7 +663,7 @@
"pending": "대기 중",
"unknown": "알 수 없음"
},
"title": "사용자 정의 노드 관리자",
"title": "커스텀 노드 관리자",
"totalNodes": "총 노드",
"tryAgainLater": "나중에 다시 시도해 주세요.",
"tryDifferentSearch": "다른 검색어를 시도해 주세요.",
@@ -750,8 +750,8 @@
"Contact Support": "고객 지원 문의",
"Convert Selection to Subgraph": "선택 영역을 서브그래프로 변환",
"Convert selected nodes to group node": "선택한 노드를 그룹 노드로 변환",
"Custom Nodes (Legacy)": "커스텀 노드(레거시)",
"Custom Nodes Manager": "사용자 정의 노드 관리자",
"Custom Nodes (Legacy)": "커스텀 노드(구버전)",
"Custom Nodes Manager": "커스텀 노드 관리자",
"Decrease Brush Size in MaskEditor": "마스크 편집기에서 브러시 크기 줄이기",
"Delete Selected Items": "선택한 항목 삭제",
"Desktop User Guide": "데스크톱 사용자 가이드",
@@ -770,7 +770,7 @@
"Load Default Workflow": "기본 워크플로 불러오기",
"Manage group nodes": "그룹 노드 관리",
"Manager": "매니저",
"Manager Menu (Legacy)": "매니저 메뉴(레거시)",
"Manager Menu (Legacy)": "매니저 메뉴(구버전)",
"Move Selected Nodes Down": "선택한 노드 아래로 이동",
"Move Selected Nodes Left": "선택한 노드 왼쪽으로 이동",
"Move Selected Nodes Right": "선택한 노드 오른쪽으로 이동",
@@ -779,7 +779,7 @@
"New": "새로 만들기",
"Next Opened Workflow": "다음 열린 워크플로",
"Open": "열기",
"Open Custom Nodes Folder": "사용자 정의 노드 폴더 열기",
"Open Custom Nodes Folder": "커스텀 노드 폴더 열기",
"Open DevTools": "개발자 도구 열기",
"Open Inputs Folder": "입력 폴더 열기",
"Open Logs Folder": "로그 폴더 열기",
@@ -814,13 +814,13 @@
"Toggle Model Library Sidebar": "모델 라이브러리 사이드바 전환",
"Toggle Node Library Sidebar": "노드 라이브러리 사이드바 전환",
"Toggle Queue Sidebar": "대기열 사이드바 전환",
"Toggle Workflows Sidebar": "워크플로 사이드바 전환",
"Toggle Workflows Sidebar": "워크플로 사이드바 전환",
"Toggle View Controls Bottom Panel": "뷰 컨트롤 하단 패널 전환",
"Toggle Search Box": "검색 상자 전환",
"Toggle Terminal Bottom Panel": "터미널 하단 패널 전환",
"Toggle Theme (Dark/Light)": "테마 전환 (어두운/밝은)",
"Toggle View Controls Bottom Panel": "뷰 컨트롤 하단 패널 전환",
"Toggle Workflows Sidebar": "워크플로 사이드바 전환",
"Toggle Workflows Sidebar": "워크플로 사이드바 전환",
"Toggle the Custom Nodes Manager": "커스텀 노드 매니저 전환",
"Toggle the Custom Nodes Manager Progress Bar": "커스텀 노드 매니저 진행률 표시줄 전환",
"Undo": "실행 취소",
@@ -828,10 +828,10 @@
"Unload Models": "모델 언로드",
"Unload Models and Execution Cache": "모델 및 실행 캐시 언로드",
"Unlock Canvas": "캔버스 잠금 해제",
"Unpack the selected Subgraph": "선택한 서브그래프 풀기",
"Unpack the selected Subgraph": "선택한 서브그래프 묶음 풀기",
"View": "보기",
"Workflow": "워크플로",
"Workflows": "워크플로",
"Workflows": "워크플로",
"Zoom In": "확대",
"Zoom Out": "축소",
"Zoom to fit": "화면에 맞추기"
@@ -842,10 +842,10 @@
"renderErrorState": "에러 상태 렌더링",
"showGroups": "프레임/그룹 표시",
"showLinks": "링크 표시",
"sideToolbar_modelLibrary": "sideToolbar.모델 라이브러리",
"sideToolbar_nodeLibrary": "sideToolbar.노드 라이브러리",
"sideToolbar_queue": "sideToolbar.대기열",
"sideToolbar_workflows": "sideToolbar.워크플로"
"sideToolbar_modelLibrary": "사이드툴바.모델 라이브러리",
"sideToolbar_nodeLibrary": "사이드툴바.노드 라이브러리",
"sideToolbar_queue": "사이드툴바.대기열",
"sideToolbar_workflows": "사이드툴바.워크플로"
},
"missingModelsDialog": {
"doNotAskAgain": "다시 보지 않기",
@@ -1011,7 +1011,7 @@
"name": "DirectML 장치 번호"
},
"disable-all-custom-nodes": {
"name": "모든 사용자 정의 노드 로드 비활성화."
"name": "모든 커스텀 노드 로드 비활성화."
},
"disable-ipex-optimize": {
"name": "IPEX 최적화 비활성화"
@@ -1247,7 +1247,7 @@
"Basics": "기본",
"ComfyUI Examples": "ComfyUI 예시",
"ControlNet": "컨트롤넷",
"Custom Nodes": "사용자 정의 노드",
"Custom Nodes": "커스텀 노드",
"Flux": "FLUX",
"Image": "이미지",
"Image API": "이미지 API",
@@ -1636,7 +1636,7 @@
"versionMismatchWarning": {
"dismiss": "닫기",
"frontendNewer": "프론트엔드 버전 {frontendVersion}이(가) 백엔드 버전 {backendVersion}과(와) 호환되지 않을 수 있습니다.",
"frontendOutdated": "프론트엔드 버전 {frontendVersion}이(가) 오래되었습니다. 백엔드는 {requiredVersion} 이상 버전 필요합니다.",
"frontendOutdated": "프론트엔드 버전 {frontendVersion}이(가) 오래된 버전입니다. 백엔드는 {requiredVersion} 이상 버전 필요합니다.",
"title": "버전 호환성 경고",
"updateFrontend": "프론트엔드 업데이트"
},

View File

@@ -0,0 +1,8 @@
import type { InjectionKey, Ref } from 'vue'
/**
* 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<Ref<Set<string>>> =
Symbol('selectedNodeIds')

View File

@@ -9,9 +9,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type {
INodeInputSlot,
INodeOutputSlot,
INodeSlot,
Point,
ReadOnlyPoint
Point
} from '@/lib/litegraph/src/interfaces'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { isWidgetInputSlot } from '@/lib/litegraph/src/node/slotUtils'
@@ -79,21 +77,6 @@ export function calculateInputSlotPosFromSlot(
const { pos } = input
if (pos) return [nodeX + pos[0], nodeY + pos[1]]
// Check if we should use Vue positioning
if (LiteGraph.vueNodesMode) {
if (isWidgetInputSlot(input)) {
// Widget slot - pass the slot object
return calculateVueSlotPosition(context, true, input, -1)
} else {
// Regular slot - find its index in default vertical inputs
const defaultVerticalInputs = getDefaultVerticalInputs(context)
const slotIndex = defaultVerticalInputs.indexOf(input)
if (slotIndex !== -1) {
return calculateVueSlotPosition(context, true, input, slotIndex)
}
}
}
// Default vertical slots
const offsetX = LiteGraph.NODE_SLOT_HEIGHT * 0.5
const nodeOffsetY = context.slotStartY || 0
@@ -131,15 +114,6 @@ export function calculateOutputSlotPos(
const outputPos = outputSlot.pos
if (outputPos) return [nodeX + outputPos[0], nodeY + outputPos[1]]
// Check if we should use Vue positioning
if (LiteGraph.vueNodesMode) {
const defaultVerticalOutputs = getDefaultVerticalOutputs(context)
const slotIndex = defaultVerticalOutputs.indexOf(outputSlot)
if (slotIndex !== -1) {
return calculateVueSlotPosition(context, false, outputSlot, slotIndex)
}
}
// Default vertical slots
const offsetX = LiteGraph.NODE_SLOT_HEIGHT * 0.5
const nodeOffsetY = context.slotStartY || 0
@@ -163,7 +137,7 @@ export function getSlotPosition(
node: LGraphNode,
slotIndex: number,
isInput: boolean
): ReadOnlyPoint {
): Point {
// Try to get precise position from slot layout (DOM-registered)
const slotKey = getSlotKey(String(node.id), slotIndex, isInput)
const slotLayout = layoutStore.getSlotLayout(slotKey)
@@ -195,8 +169,23 @@ export function getSlotPosition(
: calculateOutputSlotPos(context, slotIndex)
}
// Fallback to node's own methods if layout not available
return isInput ? node.getInputPos(slotIndex) : node.getOutputPos(slotIndex)
// Fallback: calculate directly from node properties if layout not available
const context: SlotPositionContext = {
nodeX: node.pos[0],
nodeY: node.pos[1],
nodeWidth: node.size[0],
nodeHeight: node.size[1],
collapsed: node.flags.collapsed || false,
collapsedWidth: node._collapsed_width,
slotStartY: node.constructor.slot_start_y,
inputs: node.inputs,
outputs: node.outputs,
widgets: node.widgets
}
return isInput
? calculateInputSlotPos(context, slotIndex)
: calculateOutputSlotPos(context, slotIndex)
}
/**
@@ -218,66 +207,3 @@ function getDefaultVerticalOutputs(
): INodeOutputSlot[] {
return context.outputs.filter((slot) => !slot.pos)
}
/**
* Calculate slot position using Vue node dimensions.
* This method uses the COMFY_VUE_NODE_DIMENSIONS constants to match Vue component rendering.
* @param context Node context
* @param isInput Whether this is an input slot (true) or output slot (false)
* @param slot The slot object (for widget detection)
* @param slotIndex The index of the slot in the appropriate array
* @returns The [x, y] position of the slot center in graph coordinates
*/
function calculateVueSlotPosition(
context: SlotPositionContext,
isInput: boolean,
slot: INodeSlot,
slotIndex: number
): Point {
const { nodeX, nodeY, nodeWidth, widgets } = context
const dimensions = LiteGraph.COMFY_VUE_NODE_DIMENSIONS.components
const spacing = LiteGraph.COMFY_VUE_NODE_DIMENSIONS.spacing
let slotCenterY: number
// IMPORTANT: LiteGraph's node position (nodeY) is at the TOP of the body (below the header)
// The header is rendered ABOVE this position at negative Y coordinates
// So we need to adjust for the difference between LiteGraph's header (30px) and Vue's header (34px)
const headerDifference =
dimensions.HEADER_HEIGHT - LiteGraph.NODE_TITLE_HEIGHT
if (isInput && isWidgetInputSlot(slot as INodeInputSlot)) {
// Widget input slot - calculate based on widget position
// Count regular (non-widget) input slots
const regularInputCount = getDefaultVerticalInputs(context).length
// Find widget index
const widgetIndex =
widgets?.findIndex(
(w) => w.name === (slot as INodeInputSlot).widget?.name
) ?? 0
// Y position relative to the node body top (not the header)
slotCenterY =
headerDifference +
regularInputCount * dimensions.SLOT_HEIGHT +
(regularInputCount > 0 ? spacing.BETWEEN_SLOTS_AND_BODY : 0) +
widgetIndex *
(dimensions.STANDARD_WIDGET_HEIGHT + spacing.BETWEEN_WIDGETS) +
dimensions.STANDARD_WIDGET_HEIGHT / 2
} else {
// Regular slot (input or output)
// Slots start at the top of the body, but we need to account for Vue's larger header
slotCenterY =
headerDifference +
slotIndex * dimensions.SLOT_HEIGHT +
dimensions.SLOT_HEIGHT / 2
}
// Calculate X position
// Input slots: 10px from left edge (center of 20x20 connector)
// Output slots: 10px from right edge (center of 20x20 connector)
const slotCenterX = isInput ? 10 : nodeWidth - 10
return [nodeX + slotCenterX, nodeY + slotCenterY]
}

View File

@@ -0,0 +1,52 @@
/**
* Canvas Rect Cache (VueUse-based)
*
* Tracks the client-origin and size of the graph canvas container using
* useElementBounding, and exposes a small API to read the rect and
* subscribe to changes.
*
* We assume no document scrolling (body is overflow: hidden). Layout
* changes are driven by window resize and container/splitter changes.
*/
import { useElementBounding } from '@vueuse/core'
import { shallowRef, watch } from 'vue'
// Target container element (covers the canvas fully and shares its origin)
const containerRef = shallowRef<HTMLElement | null>(null)
// Bind bounding measurement once; element may be resolved later
const { x, y, width, height, update } = useElementBounding(containerRef, {
// Track layout changes from resize; scrolling is disabled globally
windowResize: true,
windowScroll: false,
immediate: true
})
// Listener registry for external subscribers
const listeners = new Set<() => void>()
function ensureContainer() {
if (!containerRef.value) {
containerRef.value = document.getElementById(
'graph-canvas-container'
) as HTMLElement | null
// Force an immediate measurement once the element is resolved
if (containerRef.value) update()
}
}
// Notify subscribers when the bounding rect changes
watch([x, y, width, height], () => {
if (listeners.size) listeners.forEach((cb) => cb())
})
export function onCanvasRectChange(cb: () => void): () => void {
ensureContainer()
listeners.add(cb)
return () => listeners.delete(cb)
}
export function getCanvasClientOrigin() {
ensureContainer()
return { left: x.value || 0, top: y.value || 0 }
}

View File

@@ -21,6 +21,10 @@ import {
} from 'vue'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import {
getCanvasClientOrigin,
onCanvasRectChange
} from '@/renderer/core/layout/dom/canvasRectCache'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { Point as LayoutPoint } from '@/renderer/core/layout/types'
@@ -52,7 +56,7 @@ const cleanupFunctions = new WeakMap<
Ref<HTMLElement | null>,
{
stopWatcher?: WatchStopHandle
handleResize?: () => void
unsubscribeRectChange?: () => void
}
>()
@@ -90,6 +94,8 @@ export function useDomSlotRegistration(options: SlotRegistrationOptions) {
if (!el || !transform?.screenToCanvas) return
const rect = el.getBoundingClientRect()
// Normalize to canvas-relative screen coordinates (CSS pixels)
const { left: canvasLeft, top: canvasTop } = getCanvasClientOrigin()
// Skip if bounds haven't changed significantly (within 0.5px)
if (lastMeasuredBounds.value) {
@@ -106,10 +112,10 @@ export function useDomSlotRegistration(options: SlotRegistrationOptions) {
lastMeasuredBounds.value = rect
// Center of the visual connector (dot) in screen coords
// Center of the visual connector (dot) in canvas-relative screen coords
const centerScreen = {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2
x: rect.left + rect.width / 2 - canvasLeft,
y: rect.top + rect.height / 2 - canvasTop
}
const centerCanvas = transform.screenToCanvas(centerScreen)
@@ -192,12 +198,11 @@ export function useDomSlotRegistration(options: SlotRegistrationOptions) {
const cleanup = cleanupFunctions.get(elRef) || {}
cleanup.stopWatcher = stopWatcher
// Window resize - remeasure as viewport changed
const handleResize = () => {
// Subscribe to canvas rect changes (covers window resize and layout changes)
const unsubscribe = onCanvasRectChange(() =>
scheduleMeasurement(measureAndCacheOffset)
}
window.addEventListener('resize', handleResize, { passive: true })
cleanup.handleResize = handleResize
)
cleanup.unsubscribeRectChange = unsubscribe
cleanupFunctions.set(elRef, cleanup)
})
@@ -209,9 +214,7 @@ export function useDomSlotRegistration(options: SlotRegistrationOptions) {
const cleanup = cleanupFunctions.get(elRef)
if (cleanup) {
if (cleanup.stopWatcher) cleanup.stopWatcher()
if (cleanup.handleResize) {
window.removeEventListener('resize', cleanup.handleResize)
}
if (cleanup.unsubscribeRectChange) cleanup.unsubscribeRectChange()
cleanupFunctions.delete(elRef)
}

View File

@@ -35,6 +35,7 @@ import {
type Point,
type RerouteId,
type RerouteLayout,
type Size,
type SlotLayout
} from '@/renderer/core/layout/types'
import { SpatialIndexManager } from '@/renderer/core/spatial/SpatialIndex'
@@ -49,7 +50,62 @@ const logger = log.getLogger('LayoutStore')
// Constants
const REROUTE_RADIUS = 8
// Utility functions
function asRerouteId(id: string | number): RerouteId {
return Number(id)
}
function asLinkId(id: string | number): LinkId {
return Number(id)
}
interface NodeLayoutData {
id: NodeId
position: Point
size: Size
zIndex: number
visible: boolean
bounds: Bounds
}
interface LinkData {
id: LinkId
sourceNodeId: NodeId
targetNodeId: NodeId
sourceSlot: number
targetSlot: number
}
interface RerouteData {
id: RerouteId
position: Point
parentId: LinkId
linkIds: LinkId[]
}
// Generic typed Y.Map interface
interface TypedYMap<T> {
get<K extends keyof T>(key: K): T[K] | undefined
get<K extends keyof T>(key: K, defaultValue: T[K]): T[K]
}
class LayoutStoreImpl implements LayoutStore {
private static readonly NODE_DEFAULTS: NodeLayoutData = {
id: 'unknown-node',
position: { x: 0, y: 0 },
size: { width: 100, height: 50 },
zIndex: 0,
visible: true,
bounds: { x: 0, y: 0, width: 100, height: 50 }
}
private static readonly REROUTE_DEFAULTS: RerouteData = {
id: 0,
position: { x: 0, y: 0 },
parentId: 0,
linkIds: []
}
// Yjs document and shared data structures
private ydoc = new Y.Doc()
private ynodes: Y.Map<Y.Map<unknown>> // Maps nodeId -> Y.Map containing NodeLayout data
@@ -127,6 +183,34 @@ class LayoutStoreImpl implements LayoutStore {
})
}
private getNodeField<K extends keyof NodeLayoutData>(
ynode: Y.Map<unknown>,
field: K,
defaultValue: NodeLayoutData[K] = LayoutStoreImpl.NODE_DEFAULTS[field]
): NodeLayoutData[K] {
const typedNode = ynode as TypedYMap<NodeLayoutData>
const value = typedNode.get(field)
return value ?? defaultValue
}
private getLinkField<K extends keyof LinkData>(
ylink: Y.Map<unknown>,
field: K
): LinkData[K] | undefined {
const typedLink = ylink as TypedYMap<LinkData>
return typedLink.get(field)
}
private getRerouteField<K extends keyof RerouteData>(
yreroute: Y.Map<unknown>,
field: K,
defaultValue: RerouteData[K] = LayoutStoreImpl.REROUTE_DEFAULTS[field]
): RerouteData[K] {
const typedReroute = yreroute as TypedYMap<RerouteData>
const value = typedReroute.get(field)
return value ?? defaultValue
}
/**
* Get or create a customRef for a node layout
*/
@@ -678,7 +762,7 @@ class LayoutStoreImpl implements LayoutStore {
// Check precise distance for candidates
for (const rerouteKey of candidateRerouteKeys) {
const rerouteId = Number(rerouteKey) as RerouteId // Convert string key back to numeric
const rerouteId = asRerouteId(rerouteKey)
const rerouteLayout = this.rerouteLayouts.get(rerouteId)
if (rerouteLayout) {
const dx = point.x - rerouteLayout.position.x
@@ -723,7 +807,7 @@ class LayoutStoreImpl implements LayoutStore {
slots: this.slotSpatialIndex.query(bounds),
reroutes: this.rerouteSpatialIndex
.query(bounds)
.map((key) => Number(key) as RerouteId) // Convert string keys to numeric
.map((key) => asRerouteId(key))
}
}
@@ -902,7 +986,7 @@ class LayoutStoreImpl implements LayoutStore {
return
}
const size = ynode.get('size') as { width: number; height: number }
const size = this.getNodeField(ynode, 'size')
const newBounds = {
x: operation.position.x,
y: operation.position.y,
@@ -931,7 +1015,7 @@ class LayoutStoreImpl implements LayoutStore {
const ynode = this.ynodes.get(operation.nodeId)
if (!ynode) return
const position = ynode.get('position') as Point
const position = this.getNodeField(ynode, 'position')
const newBounds = {
x: position.x,
y: position.y,
@@ -1120,9 +1204,9 @@ class LayoutStoreImpl implements LayoutStore {
private findLinksConnectedToNode(nodeId: NodeId): LinkId[] {
const connectedLinks: LinkId[] = []
this.ylinks.forEach((linkData: Y.Map<unknown>, linkIdStr: string) => {
const linkId = Number(linkIdStr) as LinkId
const sourceNodeId = linkData.get('sourceNodeId') as NodeId
const targetNodeId = linkData.get('targetNodeId') as NodeId
const linkId = asLinkId(linkIdStr)
const sourceNodeId = this.getLinkField(linkData, 'sourceNodeId')
const targetNodeId = this.getLinkField(linkData, 'targetNodeId')
if (sourceNodeId === nodeId || targetNodeId === nodeId) {
connectedLinks.push(linkId)
@@ -1136,7 +1220,7 @@ class LayoutStoreImpl implements LayoutStore {
*/
private handleLinkChange(change: YEventChange, linkIdStr: string): void {
if (change.action === 'delete') {
const linkId = Number(linkIdStr) as LinkId
const linkId = asLinkId(linkIdStr)
this.cleanupLinkData(linkId)
}
// Link was added or updated - geometry will be computed separately
@@ -1175,7 +1259,7 @@ class LayoutStoreImpl implements LayoutStore {
change: YEventChange,
rerouteIdStr: string
): void {
const rerouteId = Number(rerouteIdStr) as RerouteId
const rerouteId = asRerouteId(rerouteIdStr)
if (change.action === 'delete') {
this.handleRerouteDelete(rerouteId)
@@ -1199,7 +1283,7 @@ class LayoutStoreImpl implements LayoutStore {
const rerouteData = this.yreroutes.get(String(rerouteId))
if (!rerouteData) return
const position = rerouteData.get('position') as Point
const position = this.getRerouteField(rerouteData, 'position')
if (!position) return
const layout = this.createRerouteLayout(rerouteId, position)
@@ -1263,12 +1347,12 @@ class LayoutStoreImpl implements LayoutStore {
private yNodeToLayout(ynode: Y.Map<unknown>): NodeLayout {
return {
id: ynode.get('id') as string,
position: ynode.get('position') as Point,
size: ynode.get('size') as { width: number; height: number },
zIndex: ynode.get('zIndex') as number,
visible: ynode.get('visible') as boolean,
bounds: ynode.get('bounds') as Bounds
id: this.getNodeField(ynode, 'id'),
position: this.getNodeField(ynode, 'position'),
size: this.getNodeField(ynode, 'size'),
zIndex: this.getNodeField(ynode, 'zIndex'),
visible: this.getNodeField(ynode, 'visible'),
bounds: this.getNodeField(ynode, 'bounds')
}
}

View File

@@ -74,6 +74,10 @@ export const useTransformState = () => {
// Computed transform string for CSS
const transformStyle = computed(() => ({
// Match LiteGraph DragAndScale.toCanvasContext():
// ctx.scale(scale); ctx.translate(offset)
// CSS applies right-to-left, so "scale() translate()" -> translate first, then scale
// Effective mapping: screen = (canvas + offset) * scale
transform: `scale(${camera.z}) translate(${camera.x}px, ${camera.y}px)`,
transformOrigin: '0 0'
}))
@@ -103,15 +107,15 @@ export const useTransformState = () => {
* Applies the same transform that LiteGraph uses for rendering.
* Essential for positioning Vue components to align with canvas elements.
*
* Formula: screen = canvas * scale + offset
* Formula: screen = (canvas + offset) * scale
*
* @param point - Point in canvas coordinate system
* @returns Point in screen coordinate system
*/
const canvasToScreen = (point: Point): Point => {
return {
x: point.x * camera.z + camera.x,
y: point.y * camera.z + camera.y
x: (point.x + camera.x) * camera.z,
y: (point.y + camera.y) * camera.z
}
}
@@ -121,15 +125,15 @@ export const useTransformState = () => {
* Inverse of canvasToScreen. Useful for hit testing and converting
* mouse events back to canvas space.
*
* Formula: canvas = (screen - offset) / scale
* Formula: canvas = screen / scale - offset
*
* @param point - Point in screen coordinate system
* @returns Point in canvas coordinate system
*/
const screenToCanvas = (point: Point): Point => {
return {
x: (point.x - camera.x) / camera.z,
y: (point.y - camera.y) / camera.z
x: point.x / camera.z - camera.x,
y: point.y / camera.z - camera.y
}
}

View File

@@ -2,7 +2,7 @@
<div v-if="renderError" class="node-error p-1 text-red-500 text-xs"></div>
<div
v-else
class="lg-slot lg-slot--input flex items-center cursor-crosshair group rounded-r-lg"
class="lg-slot lg-slot--input flex items-center cursor-crosshair group rounded-r-lg h-6"
:class="{
'opacity-70': readonly,
'lg-slot--connected': connected,
@@ -10,9 +10,6 @@
'lg-slot--dot-only': dotOnly,
'pr-6 hover:bg-black/5 hover:dark:bg-white/5': !dotOnly
}"
:style="{
height: slotHeight + 'px'
}"
>
<!-- Connection Dot -->
<SlotConnectionDot
@@ -43,11 +40,7 @@ import {
import { useErrorHandling } from '@/composables/useErrorHandling'
import { getSlotColor } from '@/constants/slotColors'
import {
COMFY_VUE_NODE_DIMENSIONS,
INodeSlot,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import { INodeSlot, LGraphNode } from '@/lib/litegraph/src/litegraph'
// DOM-based slot registration for arbitrary positioning
import {
type TransformState,
@@ -82,9 +75,6 @@ onErrorCaptured((error) => {
// Get slot color based on type
const slotColor = computed(() => getSlotColor(props.slotData.type))
// Get slot height from litegraph constants
const slotHeight = COMFY_VUE_NODE_DIMENSIONS.components.SLOT_HEIGHT
const transformState = inject<TransformState | undefined>(
'transformState',
undefined

View File

@@ -10,10 +10,13 @@
'bg-white dark-theme:bg-[#15161A]',
'min-w-[445px]',
'lg-node absolute border border-solid rounded-2xl',
'outline outline-transparent outline-2 hover:outline-black dark-theme:hover:outline-white',
'outline outline-transparent outline-2',
{
'border-blue-500 ring-2 ring-blue-300': selected,
'border-[#e1ded5] dark-theme:border-[#292A30]': !selected,
'outline-black dark-theme:outline-white': isSelected
},
{
'border-blue-500 ring-2 ring-blue-300': isSelected,
'border-[#e1ded5] dark-theme:border-[#292A30]': !isSelected,
'animate-pulse': executing,
'opacity-50': nodeData.mode === 4,
'border-red-500 bg-red-50': error,
@@ -107,12 +110,13 @@
</template>
<script setup lang="ts">
import { computed, onErrorCaptured, ref, toRef, watch } from 'vue'
import { computed, inject, onErrorCaptured, ref, toRef, watch } from 'vue'
// Import the VueNodeData type
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
import { LODLevel, useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
import { cn } from '@/utils/tailwindUtil'
@@ -129,7 +133,6 @@ interface LGraphNodeProps {
position?: { x: number; y: number }
size?: { width: number; height: number }
readonly?: boolean
selected?: boolean
executing?: boolean
progress?: number
error?: string | null
@@ -150,6 +153,19 @@ const emit = defineEmits<{
'update:title': [nodeId: string, newTitle: string]
}>()
// Inject selection state from parent
const selectedNodeIds = inject(SelectedNodeIdsKey)
if (!selectedNodeIds) {
throw new Error(
'SelectedNodeIds not provided - LGraphNode must be used within a component that provides selection state'
)
}
// Computed selection state - only this node re-evaluates when its selection changes
const isSelected = computed(() => {
return selectedNodeIds.value.has(props.nodeData.id)
})
// LOD (Level of Detail) system based on zoom level
const zoomRef = toRef(() => props.zoomLevel ?? 1)
const {
@@ -193,7 +209,7 @@ const isCollapsed = ref(props.nodeData.flags?.collapsed ?? false)
// Watch for external changes to the collapsed state
watch(
() => props.nodeData.flags?.collapsed,
(newCollapsed) => {
(newCollapsed: boolean | undefined) => {
if (newCollapsed !== undefined && newCollapsed !== isCollapsed.value) {
isCollapsed.value = newCollapsed
}

View File

@@ -2,7 +2,7 @@
<div v-if="renderError" class="node-error p-1 text-red-500 text-xs"></div>
<div
v-else
class="lg-slot lg-slot--output flex items-center cursor-crosshair justify-end group rounded-l-lg"
class="lg-slot lg-slot--output flex items-center cursor-crosshair justify-end group rounded-l-lg h-6"
:class="{
'opacity-70': readonly,
'lg-slot--connected': connected,
@@ -11,9 +11,6 @@
'pl-6 hover:bg-black/5 hover:dark:bg-white/5': !dotOnly,
'justify-center': dotOnly
}"
:style="{
height: slotHeight + 'px'
}"
>
<!-- Slot Name -->
<span
@@ -45,7 +42,6 @@ import {
import { useErrorHandling } from '@/composables/useErrorHandling'
import { getSlotColor } from '@/constants/slotColors'
import type { INodeSlot, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { COMFY_VUE_NODE_DIMENSIONS } from '@/lib/litegraph/src/litegraph'
// DOM-based slot registration for arbitrary positioning
import {
type TransformState,
@@ -81,9 +77,6 @@ onErrorCaptured((error) => {
// Get slot color based on type
const slotColor = computed(() => getSlotColor(props.slotData.type))
// Get slot height from litegraph constants
const slotHeight = COMFY_VUE_NODE_DIMENSIONS.components.SLOT_HEIGHT
const transformState = inject<TransformState | undefined>(
'transformState',
undefined

View File

@@ -57,7 +57,9 @@ interface Emits {
}
const props = withDefaults(defineProps<Props>(), {
disabled: false
disabled: false,
optionLabel: 'label',
optionValue: 'value'
})
const emit = defineEmits<Emits>()
@@ -65,8 +67,7 @@ const emit = defineEmits<Emits>()
// handle both string/number arrays and object arrays with PrimeVue compatibility
const getOptionValue = (option: T, index: number): string => {
if (typeof option === 'object' && option !== null) {
// Use PrimeVue optionValue prop if provided, otherwise fallback to common fields
const valueField = props.optionValue ?? 'value'
const valueField = props.optionValue
const value =
(option as any)[valueField] ??
(option as any).value ??
@@ -81,8 +82,7 @@ const getOptionValue = (option: T, index: number): string => {
// for display with PrimeVue compatibility
const getOptionLabel = (option: T): string => {
if (typeof option === 'object' && option !== null) {
// Use PrimeVue optionLabel prop if provided, otherwise fallback to common fields
const labelField = props.optionLabel ?? 'label'
const labelField = props.optionLabel
return (
(option as any)[labelField] ??
(option as any).label ??

View File

@@ -1,19 +1,16 @@
<script setup lang="ts">
import { COMFY_VUE_NODE_DIMENSIONS } from '@/lib/litegraph/src/litegraph'
import { noop } from 'es-toolkit'
import { SimplifiedWidget } from '@/types/simplifiedWidget'
defineProps<{
widget: Pick<SimplifiedWidget<string | number | undefined>, 'name'>
}>()
// Get widget height from litegraph constants
const widgetHeight = COMFY_VUE_NODE_DIMENSIONS.components.STANDARD_WIDGET_HEIGHT
</script>
<template>
<div
class="flex items-center justify-between gap-2"
:style="{ height: widgetHeight + 'px' }"
class="flex items-center justify-between gap-2 h-[30px] overscroll-contain"
>
<p
v-if="widget.name"
@@ -21,7 +18,12 @@ const widgetHeight = COMFY_VUE_NODE_DIMENSIONS.components.STANDARD_WIDGET_HEIGHT
>
{{ widget.name }}
</p>
<div class="w-75">
<div
class="w-75"
@pointerdown.stop="noop"
@pointermove.stop="noop"
@pointerup.stop="noop"
>
<slot />
</div>
</div>

View File

@@ -467,6 +467,7 @@ const zSettings = z.object({
'Comfy.Minimap.RenderErrorState': z.boolean(),
'Comfy.Canvas.NavigationMode': z.string(),
'Comfy.VueNodes.Enabled': z.boolean(),
'Comfy.Assets.UseAssetAPI': z.boolean(),
'Comfy-Desktop.AutoUpdate': z.boolean(),
'Comfy-Desktop.SendStatistics': z.boolean(),
'Comfy-Desktop.WindowStyle': z.string(),

View File

@@ -682,7 +682,8 @@ export class ComfyApi extends EventTarget {
}
const folderBlacklist = ['configs', 'custom_nodes']
return (await res.json()).filter(
(folder: string) => !folderBlacklist.includes(folder)
(folder: { name: string; folders: string[] }) =>
!folderBlacklist.includes(folder.name)
)
}
@@ -1019,7 +1020,13 @@ export class ComfyApi extends EventTarget {
}
async getFolderPaths(): Promise<Record<string, string[]>> {
return (await axios.get(this.internalURL('/folder_paths'))).data
const response = await axios
.get(this.internalURL('/folder_paths'))
.catch(() => null)
if (!response) {
return {} // Fallback: no filesystem paths known when API unavailable
}
return response.data
}
/* Frees memory by unloading models and optionally freeing execution cache

View File

@@ -0,0 +1,151 @@
import { api } from '@/scripts/api'
const ASSETS_ENDPOINT = '/assets'
const MODELS_TAG = 'models'
const MISSING_TAG = 'missing'
// Types for asset API responses
interface AssetResponse {
assets?: Asset[]
total?: number
has_more?: boolean
}
interface Asset {
id: string
name: string
tags: string[]
size: number
created_at?: string
}
/**
* Type guard for validating asset structure
*/
function isValidAsset(asset: unknown): asset is Asset {
return (
asset !== null &&
typeof asset === 'object' &&
'id' in asset &&
'name' in asset &&
'tags' in asset &&
Array.isArray((asset as Asset).tags)
)
}
/**
* Creates predicate for filtering assets by folder and excluding missing ones
*/
function createAssetFolderFilter(folder?: string) {
return (asset: unknown): asset is Asset => {
if (!isValidAsset(asset) || asset.tags.includes(MISSING_TAG)) {
return false
}
if (folder && !asset.tags.includes(folder)) {
return false
}
return true
}
}
/**
* Creates predicate for filtering folder assets (requires name)
*/
function createFolderAssetFilter(folder: string) {
return (asset: unknown): asset is Asset => {
if (!isValidAsset(asset) || !asset.name) {
return false
}
return asset.tags.includes(folder) && !asset.tags.includes(MISSING_TAG)
}
}
/**
* Private service for asset-related network requests
* Not exposed globally - used internally by ComfyApi
*/
function createAssetService() {
/**
* Handles API response with consistent error handling
*/
async function handleAssetRequest(
url: string,
context: string
): Promise<AssetResponse> {
const res = await api.fetchApi(url)
if (!res.ok) {
throw new Error(
`Unable to load ${context}: Server returned ${res.status}. Please try again.`
)
}
return await res.json()
}
/**
* Gets a list of model folder keys from the asset API
*
* Logic:
* 1. Extract directory names directly from asset tags
* 2. Filter out blacklisted directories
* 3. Return alphabetically sorted directories with assets
*
* @returns The list of model folder keys
*/
async function getAssetModelFolders(): Promise<
{ name: string; folders: string[] }[]
> {
const data = await handleAssetRequest(
`${ASSETS_ENDPOINT}?include_tags=${MODELS_TAG}`,
'model folders'
)
// Blacklist directories we don't want to show
const blacklistedDirectories = ['configs']
// Extract directory names from assets that actually exist, exclude missing assets
const discoveredFolders = new Set<string>()
if (data?.assets) {
const directoryTags = data.assets
.filter(createAssetFolderFilter())
.flatMap((asset) => asset.tags)
.filter(
(tag) => tag !== MODELS_TAG && !blacklistedDirectories.includes(tag)
)
for (const tag of directoryTags) {
discoveredFolders.add(tag)
}
}
// Return only discovered folders in alphabetical order
const sortedFolders = Array.from(discoveredFolders).sort()
return sortedFolders.map((name) => ({ name, folders: [] }))
}
/**
* Gets a list of models in the specified folder from the asset API
* @param folder The folder to list models from, such as 'checkpoints'
* @returns The list of model filenames within the specified folder
*/
async function getAssetModels(
folder: string
): Promise<{ name: string; pathIndex: number }[]> {
const data = await handleAssetRequest(
`${ASSETS_ENDPOINT}?include_tags=${MODELS_TAG},${folder}`,
`models for ${folder}`
)
return data?.assets
? data.assets.filter(createFolderAssetFilter(folder)).map((asset) => ({
name: asset.name,
pathIndex: 0
}))
: []
}
return {
getAssetModelFolders,
getAssetModels
}
}
export const assetService = createAssetService()

View File

@@ -2,6 +2,8 @@ import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { api } from '@/scripts/api'
import { assetService } from '@/services/assetService'
import { useSettingStore } from '@/stores/settingStore'
/** (Internal helper) finds a value in a metadata object from any of a list of keys. */
function _findInMetadata(metadata: any, ...keys: string[]): string | null {
@@ -153,7 +155,12 @@ export class ModelFolder {
models: Record<string, ComfyModelDef> = {}
state: ResourceState = ResourceState.Uninitialized
constructor(public directory: string) {}
constructor(
public directory: string,
private getModelsFunc: (
folder: string
) => Promise<{ name: string; pathIndex: number }[]>
) {}
get key(): string {
return this.directory + '/'
@@ -167,7 +174,7 @@ export class ModelFolder {
return this
}
this.state = ResourceState.Loading
const models = await api.getModels(this.directory)
const models = await this.getModelsFunc(this.directory)
for (const model of models) {
this.models[`${model.pathIndex}/${model.name}`] = new ComfyModelDef(
model.name,
@@ -182,6 +189,7 @@ export class ModelFolder {
/** Model store handler, wraps individual per-folder model stores */
export const useModelStore = defineStore('models', () => {
const settingStore = useSettingStore()
const modelFolderNames = ref<string[]>([])
const modelFolderByName = ref<Record<string, ModelFolder>>({})
const modelFolders = computed<ModelFolder[]>(() =>
@@ -197,11 +205,22 @@ export const useModelStore = defineStore('models', () => {
* Loads the model folders from the server
*/
async function loadModelFolders() {
const resData = await api.getModelFolders()
const useAssetAPI: boolean = settingStore.get('Comfy.Assets.UseAssetAPI')
const resData = useAssetAPI
? await assetService.getAssetModelFolders()
: await api.getModelFolders()
modelFolderNames.value = resData.map((folder) => folder.name)
modelFolderByName.value = {}
for (const folderName of modelFolderNames.value) {
modelFolderByName.value[folderName] = new ModelFolder(folderName)
const getModelsFunc = useAssetAPI
? (folder: string) => assetService.getAssetModels(folder)
: (folder: string) => api.getModels(folder)
modelFolderByName.value[folderName] = new ModelFolder(
folderName,
getModelsFunc
)
}
}

View File

@@ -0,0 +1,30 @@
import axios from 'axios'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '@/scripts/api'
vi.mock('axios')
describe('getFolderPaths', () => {
beforeEach(() => {
vi.resetAllMocks()
})
it('returns legacy API response when available', async () => {
const mockResponse = { checkpoints: ['/test/checkpoints'] }
vi.mocked(axios.get).mockResolvedValueOnce({ data: mockResponse })
const result = await api.getFolderPaths()
expect(result).toEqual(mockResponse)
})
it('returns empty object when legacy API unavailable (dynamic discovery)', async () => {
vi.mocked(axios.get).mockRejectedValueOnce(new Error())
const result = await api.getFolderPaths()
// With dynamic discovery, we don't pre-generate directories when API is unavailable
expect(result).toEqual({})
})
})

View File

@@ -121,24 +121,24 @@ describe('useTransformState', () => {
const canvasPoint = { x: 10, y: 20 }
const screenPoint = canvasToScreen(canvasPoint)
// screen = canvas * scale + offset
// x: 10 * 2 + 100 = 120
// y: 20 * 2 + 50 = 90
expect(screenPoint).toEqual({ x: 120, y: 90 })
// screen = (canvas + offset) * scale
// x: (10 + 100) * 2 = 220
// y: (20 + 50) * 2 = 140
expect(screenPoint).toEqual({ x: 220, y: 140 })
})
it('should handle zero coordinates', () => {
const { canvasToScreen } = transformState
const screenPoint = canvasToScreen({ x: 0, y: 0 })
expect(screenPoint).toEqual({ x: 100, y: 50 })
expect(screenPoint).toEqual({ x: 200, y: 100 })
})
it('should handle negative coordinates', () => {
const { canvasToScreen } = transformState
const screenPoint = canvasToScreen({ x: -10, y: -20 })
expect(screenPoint).toEqual({ x: 80, y: 10 })
expect(screenPoint).toEqual({ x: 180, y: 60 })
})
})
@@ -146,12 +146,12 @@ describe('useTransformState', () => {
it('should convert screen coordinates to canvas coordinates', () => {
const { screenToCanvas } = transformState
const screenPoint = { x: 120, y: 90 }
const screenPoint = { x: 220, y: 140 }
const canvasPoint = screenToCanvas(screenPoint)
// canvas = (screen - offset) / scale
// x: (120 - 100) / 2 = 10
// y: (90 - 50) / 2 = 20
// canvas = screen / scale - offset
// x: 220 / 2 - 100 = 10
// y: 140 / 2 - 50 = 20
expect(canvasPoint).toEqual({ x: 10, y: 20 })
})
@@ -183,11 +183,11 @@ describe('useTransformState', () => {
const nodeSize = [200, 100]
const bounds = getNodeScreenBounds(nodePos, nodeSize)
// Top-left: canvasToScreen(10, 20) = (120, 90)
// Top-left: canvasToScreen(10, 20) = (220, 140)
// Width: 200 * 2 = 400
// Height: 100 * 2 = 200
expect(bounds.x).toBe(120)
expect(bounds.y).toBe(90)
expect(bounds.x).toBe(220)
expect(bounds.y).toBe(140)
expect(bounds.width).toBe(400)
expect(bounds.height).toBe(200)
})
@@ -288,14 +288,14 @@ describe('useTransformState', () => {
// topLeft in screen: (-200, -120)
// bottomRight in screen: (1200, 720)
// Convert to canvas coordinates:
// topLeft: ((-200 - 100) / 2, (-120 - 50) / 2) = (-150, -85)
// bottomRight: ((1200 - 100) / 2, (720 - 50) / 2) = (550, 335)
// Convert to canvas coordinates (canvas = screen / scale - offset):
// topLeft: (-200 / 2 - 100, -120 / 2 - 50) = (-200, -110)
// bottomRight: (1200 / 2 - 100, 720 / 2 - 50) = (500, 310)
expect(bounds.x).toBe(-150)
expect(bounds.y).toBe(-85)
expect(bounds.width).toBe(700) // 550 - (-150)
expect(bounds.height).toBe(420) // 335 - (-85)
expect(bounds.x).toBe(-200)
expect(bounds.y).toBe(-110)
expect(bounds.width).toBe(700) // 500 - (-200)
expect(bounds.height).toBe(420) // 310 - (-110)
})
it('should handle zero margin', () => {
@@ -305,8 +305,8 @@ describe('useTransformState', () => {
const bounds = getViewportBounds(viewport, 0)
// No margin, so viewport bounds are exact
expect(bounds.x).toBe(-50) // (0 - 100) / 2
expect(bounds.y).toBe(-25) // (0 - 50) / 2
expect(bounds.x).toBe(-100) // 0 / 2 - 100
expect(bounds.y).toBe(-50) // 0 / 2 - 50
expect(bounds.width).toBe(500) // 1000 / 2
expect(bounds.height).toBe(300) // 600 / 2
})

View File

@@ -0,0 +1,238 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type {
VueNodeData,
useGraphNodeManager
} from '@/composables/graph/useGraphNodeManager'
import { useNodeEventHandlers } from '@/composables/graph/useNodeEventHandlers'
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { useCanvasStore } from '@/stores/graphStore'
vi.mock('@/stores/graphStore', () => ({
useCanvasStore: vi.fn()
}))
vi.mock('@/renderer/core/layout/operations/layoutMutations', () => ({
useLayoutMutations: vi.fn()
}))
function createMockCanvas(): Pick<
LGraphCanvas,
'select' | 'deselect' | 'deselectAll'
> {
return {
select: vi.fn(),
deselect: vi.fn(),
deselectAll: vi.fn()
}
}
function createMockNode(): Pick<LGraphNode, 'id' | 'selected' | 'flags'> {
return {
id: 'node-1',
selected: false,
flags: { pinned: false }
}
}
function createMockNodeManager(
node: Pick<LGraphNode, 'id' | 'selected' | 'flags'>
) {
return {
getNode: vi.fn().mockReturnValue(node) as ReturnType<
typeof useGraphNodeManager
>['getNode']
}
}
function createMockCanvasStore(
canvas: Pick<LGraphCanvas, 'select' | 'deselect' | 'deselectAll'>
): Pick<
ReturnType<typeof useCanvasStore>,
'canvas' | 'selectedItems' | 'updateSelectedItems'
> {
return {
canvas: canvas as LGraphCanvas,
selectedItems: [],
updateSelectedItems: vi.fn()
}
}
function createMockLayoutMutations(): Pick<
ReturnType<typeof useLayoutMutations>,
'setSource' | 'bringNodeToFront'
> {
return {
setSource: vi.fn(),
bringNodeToFront: vi.fn()
}
}
describe('useNodeEventHandlers', () => {
let mockCanvas: ReturnType<typeof createMockCanvas>
let mockNode: ReturnType<typeof createMockNode>
let mockNodeManager: ReturnType<typeof createMockNodeManager>
let mockCanvasStore: ReturnType<typeof createMockCanvasStore>
let mockLayoutMutations: ReturnType<typeof createMockLayoutMutations>
const testNodeData: VueNodeData = {
id: 'node-1',
title: 'Test Node',
type: 'test',
mode: 0,
selected: false,
executing: false
}
beforeEach(async () => {
mockNode = createMockNode()
mockCanvas = createMockCanvas()
mockNodeManager = createMockNodeManager(mockNode)
mockCanvasStore = createMockCanvasStore(mockCanvas)
mockLayoutMutations = createMockLayoutMutations()
vi.mocked(useCanvasStore).mockReturnValue(
mockCanvasStore as ReturnType<typeof useCanvasStore>
)
vi.mocked(useLayoutMutations).mockReturnValue(
mockLayoutMutations as ReturnType<typeof useLayoutMutations>
)
})
describe('handleNodeSelect', () => {
it('should select single node on regular click', () => {
const nodeManager = ref(mockNodeManager)
const { handleNodeSelect } = useNodeEventHandlers(nodeManager)
const event = new PointerEvent('pointerdown', {
bubbles: true,
ctrlKey: false,
metaKey: false
})
handleNodeSelect(event, testNodeData)
expect(mockCanvas.deselectAll).toHaveBeenCalledOnce()
expect(mockCanvas.select).toHaveBeenCalledWith(mockNode)
expect(mockCanvasStore.updateSelectedItems).toHaveBeenCalledOnce()
})
it('should toggle selection on ctrl+click', () => {
const nodeManager = ref(mockNodeManager)
const { handleNodeSelect } = useNodeEventHandlers(nodeManager)
// Test selecting unselected node with ctrl
mockNode.selected = false
const ctrlClickEvent = new PointerEvent('pointerdown', {
bubbles: true,
ctrlKey: true,
metaKey: false
})
handleNodeSelect(ctrlClickEvent, testNodeData)
expect(mockCanvas.deselectAll).not.toHaveBeenCalled()
expect(mockCanvas.select).toHaveBeenCalledWith(mockNode)
})
it('should deselect on ctrl+click of selected node', () => {
const nodeManager = ref(mockNodeManager)
const { handleNodeSelect } = useNodeEventHandlers(nodeManager)
// Test deselecting selected node with ctrl
mockNode.selected = true
const ctrlClickEvent = new PointerEvent('pointerdown', {
bubbles: true,
ctrlKey: true,
metaKey: false
})
handleNodeSelect(ctrlClickEvent, testNodeData)
expect(mockCanvas.deselect).toHaveBeenCalledWith(mockNode)
expect(mockCanvas.select).not.toHaveBeenCalled()
})
it('should handle meta key (Cmd) on Mac', () => {
const nodeManager = ref(mockNodeManager)
const { handleNodeSelect } = useNodeEventHandlers(nodeManager)
mockNode.selected = false
const metaClickEvent = new PointerEvent('pointerdown', {
bubbles: true,
ctrlKey: false,
metaKey: true
})
handleNodeSelect(metaClickEvent, testNodeData)
expect(mockCanvas.select).toHaveBeenCalledWith(mockNode)
expect(mockCanvas.deselectAll).not.toHaveBeenCalled()
})
it('should bring node to front when not pinned', () => {
const nodeManager = ref(mockNodeManager)
const { handleNodeSelect } = useNodeEventHandlers(nodeManager)
mockNode.flags.pinned = false
const event = new PointerEvent('pointerdown')
handleNodeSelect(event, testNodeData)
expect(mockLayoutMutations.bringNodeToFront).toHaveBeenCalledWith(
'node-1'
)
})
it('should not bring pinned node to front', () => {
const nodeManager = ref(mockNodeManager)
const { handleNodeSelect } = useNodeEventHandlers(nodeManager)
mockNode.flags.pinned = true
const event = new PointerEvent('pointerdown')
handleNodeSelect(event, testNodeData)
expect(mockLayoutMutations.bringNodeToFront).not.toHaveBeenCalled()
})
it('should handle missing canvas gracefully', () => {
const nodeManager = ref(mockNodeManager)
const { handleNodeSelect } = useNodeEventHandlers(nodeManager)
mockCanvasStore.canvas = null
const event = new PointerEvent('pointerdown')
expect(() => {
handleNodeSelect(event, testNodeData)
}).not.toThrow()
expect(mockCanvas.select).not.toHaveBeenCalled()
})
it('should handle missing node gracefully', () => {
const nodeManager = ref(mockNodeManager)
const { handleNodeSelect } = useNodeEventHandlers(nodeManager)
vi.mocked(mockNodeManager.getNode).mockReturnValue(undefined)
const event = new PointerEvent('pointerdown')
const nodeData = {
id: 'missing-node',
title: 'Missing Node',
type: 'test'
} as any
expect(() => {
handleNodeSelect(event, nodeData)
}).not.toThrow()
expect(mockCanvas.select).not.toHaveBeenCalled()
})
})
})

View File

@@ -11,17 +11,6 @@ LiteGraphGlobal {
"CARD_SHAPE": 4,
"CENTER": 5,
"CIRCLE_SHAPE": 3,
"COMFY_VUE_NODE_DIMENSIONS": {
"components": {
"HEADER_HEIGHT": 34,
"SLOT_HEIGHT": 24,
"STANDARD_WIDGET_HEIGHT": 30,
},
"spacing": {
"BETWEEN_SLOTS_AND_BODY": 8,
"BETWEEN_WIDGETS": 8,
},
},
"CONNECTING_LINK_COLOR": "#AFA",
"Classes": {
"InputIndicators": [Function],

View File

@@ -1,7 +1,11 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource, type NodeLayout } from '@/renderer/core/layout/types'
import {
type LayoutChange,
LayoutSource,
type NodeLayout
} from '@/renderer/core/layout/types'
describe('layoutStore CRDT operations', () => {
beforeEach(() => {
@@ -145,7 +149,7 @@ describe('layoutStore CRDT operations', () => {
layoutStore.setActor('user-123')
// Track change notifications AFTER setting source/actor
const changes: any[] = []
const changes: LayoutChange[] = []
const unsubscribe = layoutStore.onChange((change) => {
changes.push(change)
})

View File

@@ -0,0 +1,150 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '@/scripts/api'
import { assetService } from '@/services/assetService'
// Test data constants
const MOCK_ASSETS = {
checkpoints: {
id: 'uuid-1',
name: 'model1.safetensors',
tags: ['models', 'checkpoints'],
size: 123456
},
loras: {
id: 'uuid-2',
name: 'model2.safetensors',
tags: ['models', 'loras'],
size: 654321
},
vae: {
id: 'uuid-3',
name: 'vae1.safetensors',
tags: ['models', 'vae'],
size: 789012
}
} as const
// Helper functions
function mockApiResponse(assets: any[], options = {}) {
const response = {
assets,
total: assets.length,
has_more: false,
...options
}
vi.mocked(api.fetchApi).mockResolvedValueOnce(Response.json(response))
return response
}
function mockApiError(status: number, statusText = 'Error') {
vi.mocked(api.fetchApi).mockResolvedValueOnce(
new Response(null, { status, statusText })
)
}
describe('assetService', () => {
beforeEach(() => {
vi.resetAllMocks()
vi.spyOn(api, 'fetchApi')
})
describe('getAssetModelFolders', () => {
it('should extract directory names from asset tags and filter blacklisted ones', async () => {
const assets = [
{
id: 'uuid-1',
name: 'checkpoint1.safetensors',
tags: ['models', 'checkpoints'],
size: 123456
},
{
id: 'uuid-2',
name: 'config.yaml',
tags: ['models', 'configs'], // Blacklisted
size: 654321
},
{
id: 'uuid-3',
name: 'vae1.safetensors',
tags: ['models', 'vae'],
size: 789012
}
]
mockApiResponse(assets)
const result = await assetService.getAssetModelFolders()
expect(api.fetchApi).toHaveBeenCalledWith('/assets?include_tags=models')
expect(result).toHaveLength(2)
const folderNames = result.map((f) => f.name)
expect(folderNames).toEqual(['checkpoints', 'vae'])
expect(folderNames).not.toContain('configs')
})
it('should handle errors and empty responses', async () => {
// Empty response
mockApiResponse([])
const emptyResult = await assetService.getAssetModelFolders()
expect(emptyResult).toHaveLength(0)
// Network error
vi.mocked(api.fetchApi).mockRejectedValueOnce(new Error('Network error'))
await expect(assetService.getAssetModelFolders()).rejects.toThrow(
'Network error'
)
// HTTP error
mockApiError(500)
await expect(assetService.getAssetModelFolders()).rejects.toThrow(
'Unable to load model folders: Server returned 500. Please try again.'
)
})
})
describe('getAssetModels', () => {
it('should return filtered models for folder', async () => {
const assets = [
{ ...MOCK_ASSETS.checkpoints, name: 'valid.safetensors' },
{ ...MOCK_ASSETS.checkpoints, name: undefined }, // Invalid name
{ ...MOCK_ASSETS.loras, name: 'lora.safetensors' }, // Wrong tag
{
id: 'uuid-4',
name: 'missing-model.safetensors',
tags: ['models', 'checkpoints', 'missing'], // Has missing tag
size: 654321
}
]
mockApiResponse(assets)
const result = await assetService.getAssetModels('checkpoints')
expect(api.fetchApi).toHaveBeenCalledWith(
'/assets?include_tags=models,checkpoints'
)
expect(result).toEqual([
expect.objectContaining({ name: 'valid.safetensors', pathIndex: 0 })
])
})
it('should handle errors and empty responses', async () => {
// Empty response
mockApiResponse([])
const emptyResult = await assetService.getAssetModels('nonexistent')
expect(emptyResult).toEqual([])
// Network error
vi.mocked(api.fetchApi).mockRejectedValueOnce(new Error('Network error'))
await expect(assetService.getAssetModels('checkpoints')).rejects.toThrow(
'Network error'
)
// HTTP error
mockApiError(404)
await expect(assetService.getAssetModels('checkpoints')).rejects.toThrow(
'Unable to load models for checkpoints: Server returned 404. Please try again.'
)
})
})
})

View File

@@ -2,7 +2,9 @@ import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '@/scripts/api'
import { assetService } from '@/services/assetService'
import { useModelStore } from '@/stores/modelStore'
import { useSettingStore } from '@/stores/settingStore'
// Mock the api
vi.mock('@/scripts/api', () => ({
@@ -13,7 +15,32 @@ vi.mock('@/scripts/api', () => ({
}
}))
function enableMocks() {
// Mock the assetService
vi.mock('@/services/assetService', () => ({
assetService: {
getAssetModelFolders: vi.fn(),
getAssetModels: vi.fn()
}
}))
// Mock the settingStore
vi.mock('@/stores/settingStore', () => ({
useSettingStore: vi.fn()
}))
function enableMocks(useAssetAPI = false) {
// Mock settingStore to return the useAssetAPI setting
const mockSettingStore = {
get: vi.fn().mockImplementation((key: string) => {
if (key === 'Comfy.Assets.UseAssetAPI') {
return useAssetAPI
}
return false
})
}
vi.mocked(useSettingStore).mockReturnValue(mockSettingStore as any)
// Mock experimental API - returns objects with name and folders properties
vi.mocked(api.getModels).mockResolvedValue([
{ name: 'sdxl.safetensors', pathIndex: 0 },
{ name: 'sdv15.safetensors', pathIndex: 0 },
@@ -23,6 +50,18 @@ function enableMocks() {
{ name: 'checkpoints', folders: ['/path/to/checkpoints'] },
{ name: 'vae', folders: ['/path/to/vae'] }
])
// Mock asset API - also returns objects with name and folders properties
vi.mocked(assetService.getAssetModelFolders).mockResolvedValue([
{ name: 'checkpoints', folders: ['/path/to/checkpoints'] },
{ name: 'vae', folders: ['/path/to/vae'] }
])
vi.mocked(assetService.getAssetModels).mockResolvedValue([
{ name: 'sdxl.safetensors', pathIndex: 0 },
{ name: 'sdv15.safetensors', pathIndex: 0 },
{ name: 'noinfo.safetensors', pathIndex: 0 }
])
vi.mocked(api.viewMetadata).mockImplementation((_, model) => {
if (model === 'noinfo.safetensors') {
return Promise.resolve({})
@@ -46,26 +85,25 @@ describe('useModelStore', () => {
beforeEach(async () => {
setActivePinia(createPinia())
store = useModelStore()
vi.resetAllMocks()
})
it('should load models', async () => {
enableMocks()
store = useModelStore()
await store.loadModelFolders()
const folderStore = await store.getLoadedModelFolder('checkpoints')
expect(folderStore).not.toBeNull()
if (!folderStore) return
expect(Object.keys(folderStore.models).length).toBe(3)
expect(folderStore).toBeDefined()
expect(Object.keys(folderStore!.models)).toHaveLength(3)
})
it('should load model metadata', async () => {
enableMocks()
store = useModelStore()
await store.loadModelFolders()
const folderStore = await store.getLoadedModelFolder('checkpoints')
expect(folderStore).not.toBeNull()
if (!folderStore) return
const model = folderStore.models['0/sdxl.safetensors']
expect(folderStore).toBeDefined()
const model = folderStore!.models['0/sdxl.safetensors']
await model.load()
expect(model.title).toBe('Title of sdxl.safetensors')
expect(model.architecture_id).toBe('stable-diffusion-xl-base-v1')
@@ -79,11 +117,11 @@ describe('useModelStore', () => {
it('should handle no metadata', async () => {
enableMocks()
store = useModelStore()
await store.loadModelFolders()
const folderStore = await store.getLoadedModelFolder('checkpoints')
expect(folderStore).not.toBeNull()
if (!folderStore) return
const model = folderStore.models['0/noinfo.safetensors']
expect(folderStore).toBeDefined()
const model = folderStore!.models['0/noinfo.safetensors']
await model.load()
expect(model.file_name).toBe('noinfo.safetensors')
expect(model.title).toBe('noinfo')
@@ -95,6 +133,7 @@ describe('useModelStore', () => {
it('should cache model information', async () => {
enableMocks()
store = useModelStore()
await store.loadModelFolders()
expect(api.getModels).toHaveBeenCalledTimes(0)
await store.getLoadedModelFolder('checkpoints')
@@ -102,4 +141,36 @@ describe('useModelStore', () => {
await store.getLoadedModelFolder('checkpoints')
expect(api.getModels).toHaveBeenCalledTimes(1)
})
describe('API switching functionality', () => {
it('should use experimental API for complete workflow when UseAssetAPI setting is false', async () => {
enableMocks(false) // useAssetAPI = false
store = useModelStore()
await store.loadModelFolders()
const folderStore = await store.getLoadedModelFolder('checkpoints')
// Both APIs return objects with .name property, modelStore extracts folder.name in both cases
expect(api.getModelFolders).toHaveBeenCalledTimes(1)
expect(api.getModels).toHaveBeenCalledWith('checkpoints')
expect(assetService.getAssetModelFolders).toHaveBeenCalledTimes(0)
expect(assetService.getAssetModels).toHaveBeenCalledTimes(0)
expect(folderStore).toBeDefined()
expect(Object.keys(folderStore!.models)).toHaveLength(3)
})
it('should use asset API for complete workflow when UseAssetAPI setting is true', async () => {
enableMocks(true) // useAssetAPI = true
store = useModelStore()
await store.loadModelFolders()
const folderStore = await store.getLoadedModelFolder('checkpoints')
// Both APIs return objects with .name property, modelStore extracts folder.name in both cases
expect(assetService.getAssetModelFolders).toHaveBeenCalledTimes(1)
expect(assetService.getAssetModels).toHaveBeenCalledWith('checkpoints')
expect(api.getModelFolders).toHaveBeenCalledTimes(0)
expect(api.getModels).toHaveBeenCalledTimes(0)
expect(folderStore).toBeDefined()
expect(Object.keys(folderStore!.models)).toHaveLength(3)
})
})
})

View File

@@ -113,7 +113,7 @@ describe('useSubgraphStore', () => {
})
it('should allow subgraphs to be edited', async () => {
await mockFetch({ 'test.json': mockGraph })
store.editBlueprint(store.typePrefix + 'test')
await store.editBlueprint(store.typePrefix + 'test')
//check active graph
expect(comfyApp.loadGraphData).toHaveBeenCalled()
})