mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-20 20:37:33 +00:00
Compare commits
6 Commits
add-bypass
...
remove-flo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8e1f494fa | ||
|
|
c82c3c24f7 | ||
|
|
fffa81c9b5 | ||
|
|
db35e0b7d2 | ||
|
|
0c6eeb0632 | ||
|
|
fca95ad07e |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -44,7 +44,6 @@ components.d.ts
|
||||
tests-ui/data/*
|
||||
tests-ui/ComfyUI_examples
|
||||
tests-ui/workflows/examples
|
||||
coverage/
|
||||
|
||||
# Browser tests
|
||||
/test-results/
|
||||
|
||||
70
CODEOWNERS
70
CODEOWNERS
@@ -1,61 +1,17 @@
|
||||
# Desktop/Electron
|
||||
/src/types/desktop/ @webfiltered
|
||||
/src/constants/desktopDialogs.ts @webfiltered
|
||||
/src/constants/desktopMaintenanceTasks.ts @webfiltered
|
||||
/src/stores/electronDownloadStore.ts @webfiltered
|
||||
/src/extensions/core/electronAdapter.ts @webfiltered
|
||||
/src/views/DesktopDialogView.vue @webfiltered
|
||||
/src/components/install/ @webfiltered
|
||||
/src/components/maintenance/ @webfiltered
|
||||
/vite.electron.config.mts @webfiltered
|
||||
# Admins
|
||||
* @Comfy-Org/comfy_frontend_devs
|
||||
|
||||
# Common UI Components
|
||||
/src/components/chip/ @viva-jinyi
|
||||
/src/components/card/ @viva-jinyi
|
||||
/src/components/button/ @viva-jinyi
|
||||
/src/components/input/ @viva-jinyi
|
||||
# Maintainers
|
||||
*.md @Comfy-Org/comfy_maintainer
|
||||
/tests-ui/ @Comfy-Org/comfy_maintainer
|
||||
/browser_tests/ @Comfy-Org/comfy_maintainer
|
||||
/.env_example @Comfy-Org/comfy_maintainer
|
||||
|
||||
# Topbar
|
||||
/src/components/topbar/ @pythongosssss
|
||||
# Translations (AIGODLIKE team + shinshin86)
|
||||
/src/locales/ @Yorha4D @KarryCharon @DorotaLuna @shinshin86 @Comfy-Org/comfy_maintainer
|
||||
|
||||
# Thumbnail
|
||||
/src/renderer/core/thumbnail/ @pythongosssss
|
||||
# Load 3D extension
|
||||
/src/extensions/core/load3d.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
|
||||
# Legacy UI
|
||||
/scripts/ui/ @pythongosssss
|
||||
|
||||
# Link rendering
|
||||
/src/renderer/core/canvas/links/ @benceruleanlu
|
||||
|
||||
# Node help system
|
||||
/src/utils/nodeHelpUtil.ts @benceruleanlu
|
||||
/src/stores/workspace/nodeHelpStore.ts @benceruleanlu
|
||||
/src/services/nodeHelpService.ts @benceruleanlu
|
||||
|
||||
# Selection toolbox
|
||||
/src/components/graph/selectionToolbox/ @Myestery
|
||||
|
||||
# Minimap
|
||||
/src/renderer/extensions/minimap/ @jtydhr88
|
||||
|
||||
# Assets
|
||||
/src/platform/assets/ @arjansingh
|
||||
|
||||
# Workflow Templates
|
||||
/src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki
|
||||
/src/components/templates/ @Myestery @christian-byrne @comfyui-wiki
|
||||
|
||||
# Mask Editor
|
||||
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp
|
||||
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp
|
||||
/src/extensions/core/maskEditorOld.ts @trsommer @brucew4yn3rp
|
||||
|
||||
# 3D
|
||||
/src/extensions/core/load3d.ts @jtydhr88
|
||||
/src/components/load3d/ @jtydhr88
|
||||
|
||||
# Manager
|
||||
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
|
||||
|
||||
# Translations
|
||||
/src/locales/ @Yorha4D @KarryCharon @shinshin86 @Comfy-Org/comfy_maintainer
|
||||
# Mask Editor extension
|
||||
/src/extensions/core/maskeditor.ts @brucew4yn3rp @trsommer @Comfy-Org/comfy_frontend_devs
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Vue Node Selection', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
const modifiers = [
|
||||
{ key: 'Control', name: 'ctrl' },
|
||||
{ key: 'Shift', name: 'shift' }
|
||||
] as const
|
||||
|
||||
for (const { key: modifier, name } of modifiers) {
|
||||
test(`should allow selecting multiple nodes with ${name}+click`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.getByText('Load Checkpoint').click()
|
||||
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
|
||||
|
||||
await comfyPage.page.getByText('Empty Latent Image').click({
|
||||
modifiers: [modifier]
|
||||
})
|
||||
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(2)
|
||||
|
||||
await comfyPage.page.getByText('KSampler').click({
|
||||
modifiers: [modifier]
|
||||
})
|
||||
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(3)
|
||||
})
|
||||
|
||||
test(`should allow de-selecting nodes with ${name}+click`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.getByText('Load Checkpoint').click()
|
||||
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
|
||||
|
||||
await comfyPage.page.getByText('Load Checkpoint').click({
|
||||
modifiers: [modifier]
|
||||
})
|
||||
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(0)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -1,49 +0,0 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../fixtures/ComfyPage'
|
||||
|
||||
const BYPASS_HOTKEY = 'Control+b'
|
||||
const BYPASS_CLASS = /before:bg-bypass\/60/
|
||||
|
||||
test.describe('Vue Node Bypass', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test('should allow toggling bypass on a selected node with hotkey', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const checkpointNode = comfyPage.page.locator('[data-node-id]').filter({
|
||||
hasText: 'Load Checkpoint'
|
||||
})
|
||||
await checkpointNode.getByText('Load Checkpoint').click()
|
||||
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
|
||||
await expect(checkpointNode).toHaveClass(BYPASS_CLASS)
|
||||
|
||||
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
|
||||
await expect(checkpointNode).not.toHaveClass(BYPASS_CLASS)
|
||||
})
|
||||
|
||||
test('should allow toggling bypass on multiple selected nodes with hotkey', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const checkpointNode = comfyPage.page.locator('[data-node-id]').filter({
|
||||
hasText: 'Load Checkpoint'
|
||||
})
|
||||
const ksamplerNode = comfyPage.page.locator('[data-node-id]').filter({
|
||||
hasText: 'KSampler'
|
||||
})
|
||||
|
||||
await checkpointNode.getByText('Load Checkpoint').click()
|
||||
await ksamplerNode.getByText('KSampler').click({ modifiers: ['Control'] })
|
||||
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
|
||||
await expect(checkpointNode).toHaveClass(BYPASS_CLASS)
|
||||
await expect(ksamplerNode).toHaveClass(BYPASS_CLASS)
|
||||
|
||||
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
|
||||
await expect(checkpointNode).not.toHaveClass(BYPASS_CLASS)
|
||||
await expect(ksamplerNode).not.toHaveClass(BYPASS_CLASS)
|
||||
})
|
||||
})
|
||||
@@ -77,25 +77,12 @@ export default defineConfig([
|
||||
'@typescript-eslint/prefer-as-const': 'off',
|
||||
'@typescript-eslint/consistent-type-imports': 'error',
|
||||
'@typescript-eslint/no-import-type-side-effects': 'error',
|
||||
'@typescript-eslint/no-empty-object-type': [
|
||||
'error',
|
||||
{
|
||||
allowInterfaces: 'always'
|
||||
}
|
||||
],
|
||||
'unused-imports/no-unused-imports': 'error',
|
||||
'vue/no-v-html': 'off',
|
||||
// Enforce dark-theme: instead of dark: prefix
|
||||
'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
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
<link rel="stylesheet" type="text/css" href="user.css" />
|
||||
<link rel="stylesheet" type="text/css" href="api/userdata/user.css" />
|
||||
|
||||
<!-- Fullscreen mode on mobile browsers -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<!-- Fullscreen mode on iOS -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<!-- Status bar style (eg. black or transparent) -->
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
|
||||
|
||||
@@ -27,8 +27,6 @@
|
||||
"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",
|
||||
@@ -96,7 +94,6 @@
|
||||
"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",
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -339,9 +339,6 @@ 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))
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
/**
|
||||
* 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)
|
||||
})()
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
<!-- TransformPane for Vue node rendering -->
|
||||
<TransformPane
|
||||
v-if="shouldRenderVueNodes && comfyApp.canvas && comfyAppReady"
|
||||
v-if="isVueNodesEnabled && comfyApp.canvas && comfyAppReady"
|
||||
:canvas="comfyApp.canvas"
|
||||
@transform-update="handleTransformUpdate"
|
||||
@wheel.capture="canvasInteractions.forwardEventToCanvas"
|
||||
@@ -53,6 +53,9 @@
|
||||
"
|
||||
:zoom-level="canvasStore.canvas?.ds?.scale || 1"
|
||||
:data-node-id="nodeData.id"
|
||||
@node-click="handleNodeSelect"
|
||||
@update:collapsed="handleNodeCollapse"
|
||||
@update:title="handleNodeTitleUpdate"
|
||||
/>
|
||||
</TransformPane>
|
||||
|
||||
@@ -76,6 +79,7 @@ import {
|
||||
nextTick,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
provide,
|
||||
ref,
|
||||
shallowRef,
|
||||
watch,
|
||||
@@ -113,11 +117,14 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
|
||||
import { useWorkflowAutoSave } from '@/platform/workflow/persistence/composables/useWorkflowAutoSave'
|
||||
import { useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistence'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
|
||||
import { attachSlotLinkPreviewRenderer } from '@/renderer/core/canvas/links/slotLinkPreviewRenderer'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
|
||||
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
|
||||
import VueGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
|
||||
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
|
||||
import { useExecutionStateProvider } from '@/renderer/extensions/vueNodes/execution/useExecutionStateProvider'
|
||||
import { UnauthorizedError, api } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
@@ -164,13 +171,20 @@ const minimapEnabled = computed(() => settingStore.get('Comfy.Minimap.Visible'))
|
||||
|
||||
// Feature flags
|
||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
const isVueNodesEnabled = computed(() => shouldRenderVueNodes.value)
|
||||
|
||||
// Vue node system
|
||||
const vueNodeLifecycle = useVueNodeLifecycle()
|
||||
const viewportCulling = useViewportCulling()
|
||||
const vueNodeLifecycle = useVueNodeLifecycle(isVueNodesEnabled)
|
||||
const viewportCulling = useViewportCulling(
|
||||
isVueNodesEnabled,
|
||||
vueNodeLifecycle.vueNodeData,
|
||||
vueNodeLifecycle.nodeDataTrigger,
|
||||
vueNodeLifecycle.nodeManager
|
||||
)
|
||||
const nodeEventHandlers = useNodeEventHandlers(vueNodeLifecycle.nodeManager)
|
||||
|
||||
const handleVueNodeLifecycleReset = async () => {
|
||||
if (shouldRenderVueNodes.value) {
|
||||
if (isVueNodesEnabled.value) {
|
||||
vueNodeLifecycle.disposeNodeManagerAndSyncs()
|
||||
await nextTick()
|
||||
vueNodeLifecycle.initializeNodeManager()
|
||||
@@ -198,6 +212,23 @@ const handleTransformUpdate = () => {
|
||||
// TODO: Fix paste position sync in separate PR
|
||||
vueNodeLifecycle.detectChangesInRAF.value()
|
||||
}
|
||||
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)
|
||||
|
||||
// Provide execution state to all Vue nodes
|
||||
useExecutionStateProvider()
|
||||
|
||||
watchEffect(() => {
|
||||
nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated')
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
:style="`backgroundColor: ${containerStyles.backgroundColor};`"
|
||||
:pt="{
|
||||
header: 'hidden',
|
||||
content: 'p-1 h-10 flex flex-row gap-1'
|
||||
content: 'px-1 py-1 h-10 px-1 flex flex-row gap-1'
|
||||
}"
|
||||
@wheel="canvasInteractions.handleWheel"
|
||||
>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { t } from '@/i18n'
|
||||
@@ -34,8 +33,19 @@ export const useCurrentUser = () => {
|
||||
return null
|
||||
})
|
||||
|
||||
const onUserResolved = (callback: (user: AuthUserInfo) => void) =>
|
||||
whenever(resolvedUserInfo, callback, { immediate: true })
|
||||
const onUserResolved = (callback: (user: AuthUserInfo) => void) => {
|
||||
if (resolvedUserInfo.value) {
|
||||
callback(resolvedUserInfo.value)
|
||||
}
|
||||
|
||||
const stop = watch(resolvedUserInfo, (value) => {
|
||||
if (value) {
|
||||
callback(value)
|
||||
}
|
||||
})
|
||||
|
||||
return () => stop()
|
||||
}
|
||||
|
||||
const userDisplayName = computed(() => {
|
||||
if (isApiKeyLogin.value) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Ref } from 'vue'
|
||||
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
|
||||
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
|
||||
import type { Rect } from '@/lib/litegraph/src/interfaces'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
@@ -71,7 +71,7 @@ export function useSelectionToolboxPosition(
|
||||
visible.value = true
|
||||
|
||||
// Get bounds for all selected items
|
||||
const allBounds: ReadOnlyRect[] = []
|
||||
const allBounds: Rect[] = []
|
||||
for (const item of selectableItems) {
|
||||
// Skip items without valid IDs
|
||||
if (item.id == null) continue
|
||||
|
||||
@@ -68,7 +68,7 @@ interface SpatialMetrics {
|
||||
nodesInIndex: number
|
||||
}
|
||||
|
||||
export interface GraphNodeManager {
|
||||
interface GraphNodeManager {
|
||||
// Reactive state - safe data extracted from LiteGraph nodes
|
||||
vueNodeData: ReadonlyMap<string, VueNodeData>
|
||||
nodeState: ReadonlyMap<string, NodeState>
|
||||
|
||||
@@ -6,20 +6,26 @@
|
||||
* 2. Set display none on element to avoid cascade resolution overhead
|
||||
* 3. Only run when transform changes (event driven)
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import { type Ref, computed } from 'vue'
|
||||
|
||||
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
|
||||
export function useViewportCulling() {
|
||||
interface NodeManager {
|
||||
getNode: (id: string) => any
|
||||
}
|
||||
|
||||
export function useViewportCulling(
|
||||
isVueNodesEnabled: Ref<boolean>,
|
||||
vueNodeData: Ref<ReadonlyMap<string, VueNodeData>>,
|
||||
nodeDataTrigger: Ref<number>,
|
||||
nodeManager: Ref<NodeManager | null>
|
||||
) {
|
||||
const canvasStore = useCanvasStore()
|
||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
const { vueNodeData, nodeDataTrigger, nodeManager } = useVueNodeLifecycle()
|
||||
|
||||
const allNodes = computed(() => {
|
||||
if (!shouldRenderVueNodes.value) return []
|
||||
if (!isVueNodesEnabled.value) return []
|
||||
void nodeDataTrigger.value // Force re-evaluation when nodeManager initializes
|
||||
return Array.from(vueNodeData.value.values())
|
||||
})
|
||||
@@ -78,7 +84,7 @@ export function useViewportCulling() {
|
||||
* Uses RAF to batch updates for smooth performance
|
||||
*/
|
||||
const handleTransformUpdate = () => {
|
||||
if (!shouldRenderVueNodes.value) return
|
||||
if (!isVueNodesEnabled.value) return
|
||||
|
||||
// Cancel previous RAF if still pending
|
||||
if (rafId !== null) {
|
||||
|
||||
@@ -8,16 +8,13 @@
|
||||
* - Reactive state management for node data, positions, and sizes
|
||||
* - Memory management and proper cleanup
|
||||
*/
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
import { computed, readonly, ref, shallowRef, watch } from 'vue'
|
||||
import { type Ref, computed, readonly, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
||||
import type {
|
||||
GraphNodeManager,
|
||||
NodeState,
|
||||
VueNodeData
|
||||
} from '@/composables/graph/useGraphNodeManager'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
@@ -27,12 +24,13 @@ import { useLinkLayoutSync } from '@/renderer/core/layout/sync/useLinkLayoutSync
|
||||
import { useSlotLayoutSync } from '@/renderer/core/layout/sync/useSlotLayoutSync'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
|
||||
function useVueNodeLifecycleIndividual() {
|
||||
export function useVueNodeLifecycle(isVueNodesEnabled: Ref<boolean>) {
|
||||
const canvasStore = useCanvasStore()
|
||||
const layoutMutations = useLayoutMutations()
|
||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
|
||||
const nodeManager = shallowRef<GraphNodeManager | null>(null)
|
||||
const nodeManager = shallowRef<ReturnType<typeof useGraphNodeManager> | null>(
|
||||
null
|
||||
)
|
||||
const cleanupNodeManager = shallowRef<(() => void) | null>(null)
|
||||
|
||||
// Sync management
|
||||
@@ -147,7 +145,7 @@ function useVueNodeLifecycleIndividual() {
|
||||
// Watch for Vue nodes enabled state changes
|
||||
watch(
|
||||
() =>
|
||||
shouldRenderVueNodes.value &&
|
||||
isVueNodesEnabled.value &&
|
||||
Boolean(comfyApp.canvas?.graph || comfyApp.graph),
|
||||
(enabled) => {
|
||||
if (enabled) {
|
||||
@@ -161,7 +159,7 @@ function useVueNodeLifecycleIndividual() {
|
||||
|
||||
// Consolidated watch for slot layout sync management
|
||||
watch(
|
||||
[() => canvasStore.canvas, () => shouldRenderVueNodes.value],
|
||||
[() => canvasStore.canvas, () => isVueNodesEnabled.value],
|
||||
([canvas, vueMode], [, oldVueMode]) => {
|
||||
const modeChanged = vueMode !== oldVueMode
|
||||
|
||||
@@ -193,7 +191,7 @@ function useVueNodeLifecycleIndividual() {
|
||||
// Handle case where Vue nodes are enabled but graph starts empty
|
||||
const setupEmptyGraphListener = () => {
|
||||
if (
|
||||
shouldRenderVueNodes.value &&
|
||||
isVueNodesEnabled.value &&
|
||||
comfyApp.graph &&
|
||||
!nodeManager.value &&
|
||||
comfyApp.graph._nodes.length === 0
|
||||
@@ -204,7 +202,7 @@ function useVueNodeLifecycleIndividual() {
|
||||
comfyApp.graph.onNodeAdded = originalOnNodeAdded
|
||||
|
||||
// Initialize node manager if needed
|
||||
if (shouldRenderVueNodes.value && !nodeManager.value) {
|
||||
if (isVueNodesEnabled.value && !nodeManager.value) {
|
||||
initializeNodeManager()
|
||||
}
|
||||
|
||||
@@ -250,7 +248,3 @@ function useVueNodeLifecycleIndividual() {
|
||||
cleanup
|
||||
}
|
||||
}
|
||||
|
||||
export const useVueNodeLifecycle = createSharedComposable(
|
||||
useVueNodeLifecycleIndividual
|
||||
)
|
||||
|
||||
@@ -1548,71 +1548,6 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
},
|
||||
ByteDanceImageReferenceNode: {
|
||||
displayPrice: byteDanceVideoPricingCalculator
|
||||
},
|
||||
WanTextToVideoApi: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const durationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'duration'
|
||||
) as IComboWidget
|
||||
const resolutionWidget = node.widgets?.find(
|
||||
(w) => w.name === 'size'
|
||||
) as IComboWidget
|
||||
|
||||
if (!durationWidget || !resolutionWidget) return '$0.05-0.15/second'
|
||||
|
||||
const seconds = parseFloat(String(durationWidget.value))
|
||||
const resolutionStr = String(resolutionWidget.value).toLowerCase()
|
||||
|
||||
const resKey = resolutionStr.includes('1080')
|
||||
? '1080p'
|
||||
: resolutionStr.includes('720')
|
||||
? '720p'
|
||||
: resolutionStr.includes('480')
|
||||
? '480p'
|
||||
: resolutionStr.match(/^\s*(\d{3,4}p)/)?.[1] ?? ''
|
||||
|
||||
const pricePerSecond: Record<string, number> = {
|
||||
'480p': 0.05,
|
||||
'720p': 0.1,
|
||||
'1080p': 0.15
|
||||
}
|
||||
|
||||
const pps = pricePerSecond[resKey]
|
||||
if (isNaN(seconds) || !pps) return '$0.05-0.15/second'
|
||||
|
||||
const cost = (pps * seconds).toFixed(2)
|
||||
return `$${cost}/Run`
|
||||
}
|
||||
},
|
||||
WanImageToVideoApi: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const durationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'duration'
|
||||
) as IComboWidget
|
||||
const resolutionWidget = node.widgets?.find(
|
||||
(w) => w.name === 'resolution'
|
||||
) as IComboWidget
|
||||
|
||||
if (!durationWidget || !resolutionWidget) return '$0.05-0.15/second'
|
||||
|
||||
const seconds = parseFloat(String(durationWidget.value))
|
||||
const resolution = String(resolutionWidget.value).trim().toLowerCase()
|
||||
|
||||
const pricePerSecond: Record<string, number> = {
|
||||
'480p': 0.05,
|
||||
'720p': 0.1,
|
||||
'1080p': 0.15
|
||||
}
|
||||
|
||||
const pps = pricePerSecond[resolution]
|
||||
if (isNaN(seconds) || !pps) return '$0.05-0.15/second'
|
||||
|
||||
const cost = (pps * seconds).toFixed(2)
|
||||
return `$${cost}/Run`
|
||||
}
|
||||
},
|
||||
WanTextToImageApi: {
|
||||
displayPrice: '$0.03/Run'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1712,9 +1647,7 @@ export const useNodePricing = () => {
|
||||
ByteDanceTextToVideoNode: ['model', 'duration', 'resolution'],
|
||||
ByteDanceImageToVideoNode: ['model', 'duration', 'resolution'],
|
||||
ByteDanceFirstLastFrameNode: ['model', 'duration', 'resolution'],
|
||||
ByteDanceImageReferenceNode: ['model', 'duration', 'resolution'],
|
||||
WanTextToVideoApi: ['duration', 'size'],
|
||||
WanImageToVideoApi: ['duration', 'resolution']
|
||||
ByteDanceImageReferenceNode: ['model', 'duration', 'resolution']
|
||||
}
|
||||
return widgetMap[nodeType] || []
|
||||
}
|
||||
|
||||
@@ -2,17 +2,16 @@
|
||||
* Vue-related feature flags composable
|
||||
* Manages local settings-driven flags and LiteGraph integration
|
||||
*/
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
import { LiteGraph } from '../lib/litegraph/src/litegraph'
|
||||
|
||||
function useVueFeatureFlagsIndividual() {
|
||||
export const useVueFeatureFlags = () => {
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const shouldRenderVueNodes = computed(() => {
|
||||
const isVueNodesEnabled = computed(() => {
|
||||
try {
|
||||
return settingStore.get('Comfy.VueNodes.Enabled') ?? false
|
||||
} catch {
|
||||
@@ -20,20 +19,20 @@ function useVueFeatureFlagsIndividual() {
|
||||
}
|
||||
})
|
||||
|
||||
// Whether Vue nodes should render
|
||||
const shouldRenderVueNodes = computed(() => isVueNodesEnabled.value)
|
||||
|
||||
// Sync the Vue nodes flag with LiteGraph global settings
|
||||
const syncVueNodesFlag = () => {
|
||||
LiteGraph.vueNodesMode = isVueNodesEnabled.value
|
||||
}
|
||||
|
||||
// Watch for changes and update LiteGraph immediately
|
||||
watch(
|
||||
shouldRenderVueNodes,
|
||||
() => {
|
||||
LiteGraph.vueNodesMode = shouldRenderVueNodes.value
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
watch(isVueNodesEnabled, syncVueNodesFlag, { immediate: true })
|
||||
|
||||
return {
|
||||
shouldRenderVueNodes
|
||||
isVueNodesEnabled,
|
||||
shouldRenderVueNodes,
|
||||
syncVueNodesFlag
|
||||
}
|
||||
}
|
||||
|
||||
export const useVueFeatureFlags = createSharedComposable(
|
||||
useVueFeatureFlagsIndividual
|
||||
)
|
||||
|
||||
@@ -122,14 +122,14 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
|
||||
key: '.'
|
||||
},
|
||||
commandId: 'Comfy.Canvas.FitView',
|
||||
targetElementId: 'graph-canvas-container'
|
||||
targetElementId: 'graph-canvas'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
key: 'p'
|
||||
},
|
||||
commandId: 'Comfy.Canvas.ToggleSelected.Pin',
|
||||
targetElementId: 'graph-canvas-container'
|
||||
targetElementId: 'graph-canvas'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
@@ -137,7 +137,7 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
|
||||
alt: true
|
||||
},
|
||||
commandId: 'Comfy.Canvas.ToggleSelectedNodes.Collapse',
|
||||
targetElementId: 'graph-canvas-container'
|
||||
targetElementId: 'graph-canvas'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
@@ -145,7 +145,7 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
|
||||
ctrl: true
|
||||
},
|
||||
commandId: 'Comfy.Canvas.ToggleSelectedNodes.Bypass',
|
||||
targetElementId: 'graph-canvas-container'
|
||||
targetElementId: 'graph-canvas'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
@@ -153,7 +153,7 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
|
||||
ctrl: true
|
||||
},
|
||||
commandId: 'Comfy.Canvas.ToggleSelectedNodes.Mute',
|
||||
targetElementId: 'graph-canvas-container'
|
||||
targetElementId: 'graph-canvas'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
|
||||
@@ -76,7 +76,7 @@ export const i18n = createI18n({
|
||||
})
|
||||
|
||||
/** Convenience shorthand: i18n.global */
|
||||
export const { t, te, d } = i18n.global
|
||||
export const { t, te } = i18n.global
|
||||
|
||||
/**
|
||||
* Safe translation function that returns the fallback message if the key is not found.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Point, ReadOnlyRect, Rect } from './interfaces'
|
||||
import type { Point, Rect } from './interfaces'
|
||||
import { EaseFunction, Rectangle } from './litegraph'
|
||||
|
||||
export interface DragAndScaleState {
|
||||
@@ -188,10 +188,7 @@ export class DragAndScale {
|
||||
* Fits the view to the specified bounds.
|
||||
* @param bounds The bounds to fit the view to, defined by a rectangle.
|
||||
*/
|
||||
fitToBounds(
|
||||
bounds: ReadOnlyRect,
|
||||
{ zoom = 0.75 }: { zoom?: number } = {}
|
||||
): void {
|
||||
fitToBounds(bounds: Rect, { zoom = 0.75 }: { zoom?: number } = {}): void {
|
||||
const cw = this.element.width / window.devicePixelRatio
|
||||
const ch = this.element.height / window.devicePixelRatio
|
||||
let targetScale = this.scale
|
||||
@@ -223,7 +220,7 @@ export class DragAndScale {
|
||||
* @param bounds The bounds to animate the view to, defined by a rectangle.
|
||||
*/
|
||||
animateToBounds(
|
||||
bounds: ReadOnlyRect,
|
||||
bounds: Rect | Rectangle,
|
||||
setDirty: () => void,
|
||||
{
|
||||
duration = 350,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
SUBGRAPH_INPUT_ID,
|
||||
SUBGRAPH_OUTPUT_ID
|
||||
} from '@/lib/litegraph/src/constants'
|
||||
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import { createUuidv4, zeroUuid } from '@/lib/litegraph/src/utils/uuid'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
@@ -1707,7 +1708,12 @@ export class LGraph
|
||||
...subgraphNode.subgraph.groups
|
||||
].map((p: { pos: Point; size?: Size }): HasBoundingRect => {
|
||||
return {
|
||||
boundingRect: [p.pos[0], p.pos[1], p.size?.[0] ?? 0, p.size?.[1] ?? 0]
|
||||
boundingRect: new Rectangle(
|
||||
p.pos[0],
|
||||
p.pos[1],
|
||||
p.size?.[0] ?? 0,
|
||||
p.size?.[1] ?? 0
|
||||
)
|
||||
}
|
||||
})
|
||||
const bounds = createBounds(positionables) ?? [0, 0, 0, 0]
|
||||
|
||||
@@ -47,8 +47,6 @@ import type {
|
||||
NullableProperties,
|
||||
Point,
|
||||
Positionable,
|
||||
ReadOnlyPoint,
|
||||
ReadOnlyRect,
|
||||
Rect,
|
||||
Size
|
||||
} from './interfaces'
|
||||
@@ -236,11 +234,11 @@ export class LGraphCanvas
|
||||
implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
{
|
||||
// Optimised buffers used during rendering
|
||||
static #temp = new Float32Array(4)
|
||||
static #temp_vec2 = new Float32Array(2)
|
||||
static #tmp_area = new Float32Array(4)
|
||||
static #margin_area = new Float32Array(4)
|
||||
static #link_bounding = new Float32Array(4)
|
||||
static #temp = [0, 0, 0, 0] satisfies Rect
|
||||
static #temp_vec2 = [0, 0] satisfies Point
|
||||
static #tmp_area = [0, 0, 0, 0] satisfies Rect
|
||||
static #margin_area = [0, 0, 0, 0] satisfies Rect
|
||||
static #link_bounding = [0, 0, 0, 0] satisfies Rect
|
||||
|
||||
static DEFAULT_BACKGROUND_IMAGE =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAQBJREFUeNrs1rEKwjAUhlETUkj3vP9rdmr1Ysammk2w5wdxuLgcMHyptfawuZX4pJSWZTnfnu/lnIe/jNNxHHGNn//HNbbv+4dr6V+11uF527arU7+u63qfa/bnmh8sWLBgwYJlqRf8MEptXPBXJXa37BSl3ixYsGDBMliwFLyCV/DeLIMFCxYsWLBMwSt4Be/NggXLYMGCBUvBK3iNruC9WbBgwYJlsGApeAWv4L1ZBgsWLFiwYJmCV/AK3psFC5bBggULloJX8BpdwXuzYMGCBctgwVLwCl7Be7MMFixYsGDBsu8FH1FaSmExVfAxBa/gvVmwYMGCZbBg/W4vAQYA5tRF9QYlv/QAAAAASUVORK5CYII='
|
||||
@@ -628,7 +626,7 @@ export class LGraphCanvas
|
||||
dirty_area?: Rect | null
|
||||
/** @deprecated Unused */
|
||||
node_in_panel?: LGraphNode | null
|
||||
last_mouse: ReadOnlyPoint = [0, 0]
|
||||
last_mouse: Point = [0, 0]
|
||||
last_mouseclick: number = 0
|
||||
graph: LGraph | Subgraph | null
|
||||
get _graph(): LGraph | Subgraph {
|
||||
@@ -2633,7 +2631,7 @@ export class LGraphCanvas
|
||||
pointer: CanvasPointer,
|
||||
node?: LGraphNode | undefined
|
||||
): void {
|
||||
const dragRect = new Float32Array(4)
|
||||
const dragRect: [number, number, number, number] = [0, 0, 0, 0]
|
||||
|
||||
dragRect[0] = e.canvasX
|
||||
dragRect[1] = e.canvasY
|
||||
@@ -3166,7 +3164,7 @@ export class LGraphCanvas
|
||||
|
||||
LGraphCanvas.active_canvas = this
|
||||
this.adjustMouseEvent(e)
|
||||
const mouse: ReadOnlyPoint = [e.clientX, e.clientY]
|
||||
const mouse: Point = [e.clientX, e.clientY]
|
||||
this.mouse[0] = mouse[0]
|
||||
this.mouse[1] = mouse[1]
|
||||
const delta = [mouse[0] - this.last_mouse[0], mouse[1] - this.last_mouse[1]]
|
||||
@@ -4055,7 +4053,10 @@ export class LGraphCanvas
|
||||
this.setDirty(true)
|
||||
}
|
||||
|
||||
#handleMultiSelect(e: CanvasPointerEvent, dragRect: Float32Array) {
|
||||
#handleMultiSelect(
|
||||
e: CanvasPointerEvent,
|
||||
dragRect: [number, number, number, number]
|
||||
) {
|
||||
// Process drag
|
||||
// Convert Point pair (pos, offset) to Rect
|
||||
const { graph, selectedItems, subgraph } = this
|
||||
@@ -4826,7 +4827,7 @@ export class LGraphCanvas
|
||||
}
|
||||
|
||||
/** Get the target snap / highlight point in graph space */
|
||||
#getHighlightPosition(): ReadOnlyPoint {
|
||||
#getHighlightPosition(): Point {
|
||||
return LiteGraph.snaps_for_comfy
|
||||
? this.linkConnector.state.snapLinksPos ??
|
||||
this._highlight_pos ??
|
||||
@@ -4841,7 +4842,7 @@ export class LGraphCanvas
|
||||
*/
|
||||
#renderSnapHighlight(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
highlightPos: ReadOnlyPoint
|
||||
highlightPos: Point
|
||||
): void {
|
||||
const linkConnectorSnap = !!this.linkConnector.state.snapLinksPos
|
||||
if (!this._highlight_pos && !linkConnectorSnap) return
|
||||
@@ -5183,7 +5184,8 @@ export class LGraphCanvas
|
||||
// clip if required (mask)
|
||||
const shape = node._shape || RenderShape.BOX
|
||||
const size = LGraphCanvas.#temp_vec2
|
||||
size.set(node.renderingSize)
|
||||
size[0] = node.renderingSize[0]
|
||||
size[1] = node.renderingSize[1]
|
||||
|
||||
if (node.collapsed) {
|
||||
ctx.font = this.inner_text_font
|
||||
@@ -5378,7 +5380,10 @@ export class LGraphCanvas
|
||||
|
||||
// Normalised node dimensions
|
||||
const area = LGraphCanvas.#tmp_area
|
||||
area.set(node.boundingRect)
|
||||
area[0] = node.boundingRect[0]
|
||||
area[1] = node.boundingRect[1]
|
||||
area[2] = node.boundingRect[2]
|
||||
area[3] = node.boundingRect[3]
|
||||
area[0] -= node.pos[0]
|
||||
area[1] -= node.pos[1]
|
||||
|
||||
@@ -5480,7 +5485,10 @@ export class LGraphCanvas
|
||||
shape = RenderShape.ROUND
|
||||
) {
|
||||
const snapGuide = LGraphCanvas.#temp
|
||||
snapGuide.set(item.boundingRect)
|
||||
snapGuide[0] = item.boundingRect[0]
|
||||
snapGuide[1] = item.boundingRect[1]
|
||||
snapGuide[2] = item.boundingRect[2]
|
||||
snapGuide[3] = item.boundingRect[3]
|
||||
|
||||
// Not all items have pos equal to top-left of bounds
|
||||
const { pos } = item
|
||||
@@ -5920,8 +5928,8 @@ export class LGraphCanvas
|
||||
*/
|
||||
renderLink(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
a: ReadOnlyPoint,
|
||||
b: ReadOnlyPoint,
|
||||
a: Point,
|
||||
b: Point,
|
||||
link: LLink | null,
|
||||
skip_border: boolean,
|
||||
flow: number | null,
|
||||
@@ -5938,9 +5946,9 @@ export class LGraphCanvas
|
||||
/** When defined, render data will be saved to this reroute instead of the {@link link}. */
|
||||
reroute?: Reroute
|
||||
/** Offset of the bezier curve control point from {@link a point a} (output side) */
|
||||
startControl?: ReadOnlyPoint
|
||||
startControl?: Point
|
||||
/** Offset of the bezier curve control point from {@link b point b} (input side) */
|
||||
endControl?: ReadOnlyPoint
|
||||
endControl?: Point
|
||||
/** Number of sublines (useful to represent vec3 or rgb) @todo If implemented, refactor calculations out of the loop */
|
||||
num_sublines?: number
|
||||
/** Whether this is a floating link segment */
|
||||
@@ -8411,7 +8419,7 @@ export class LGraphCanvas
|
||||
* Starts an animation to fit the view around the specified selection of nodes.
|
||||
* @param bounds The bounds to animate the view to, defined by a rectangle.
|
||||
*/
|
||||
animateToBounds(bounds: ReadOnlyRect, options: AnimationOptions = {}) {
|
||||
animateToBounds(bounds: Rect | Rectangle, options: AnimationOptions = {}) {
|
||||
const setDirty = () => this.setDirty(true, true)
|
||||
this.ds.animateToBounds(bounds, setDirty, options)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NullGraphError } from '@/lib/litegraph/src/infrastructure/NullGraphError'
|
||||
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
|
||||
|
||||
import type { LGraph } from './LGraph'
|
||||
import { LGraphCanvas } from './LGraphCanvas'
|
||||
@@ -40,15 +41,15 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
|
||||
title: string
|
||||
font?: string
|
||||
font_size: number = LiteGraph.DEFAULT_GROUP_FONT || 24
|
||||
_bounding: Float32Array = new Float32Array([
|
||||
_bounding: [number, number, number, number] = [
|
||||
10,
|
||||
10,
|
||||
LGraphGroup.minWidth,
|
||||
LGraphGroup.minHeight
|
||||
])
|
||||
]
|
||||
|
||||
_pos: Point = this._bounding.subarray(0, 2)
|
||||
_size: Size = this._bounding.subarray(2, 4)
|
||||
_pos: Point = [10, 10]
|
||||
_size: Size = [LGraphGroup.minWidth, LGraphGroup.minHeight]
|
||||
/** @deprecated See {@link _children} */
|
||||
_nodes: LGraphNode[] = []
|
||||
_children: Set<Positionable> = new Set()
|
||||
@@ -107,8 +108,13 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
|
||||
this._size[1] = Math.max(LGraphGroup.minHeight, v[1])
|
||||
}
|
||||
|
||||
get boundingRect() {
|
||||
return this._bounding
|
||||
get boundingRect(): Rectangle {
|
||||
return Rectangle.from([
|
||||
this._pos[0],
|
||||
this._pos[1],
|
||||
this._size[0],
|
||||
this._size[1]
|
||||
])
|
||||
}
|
||||
|
||||
get nodes() {
|
||||
@@ -145,14 +151,17 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
|
||||
configure(o: ISerialisedGroup): void {
|
||||
this.id = o.id
|
||||
this.title = o.title
|
||||
this._bounding.set(o.bounding)
|
||||
this._pos[0] = o.bounding[0]
|
||||
this._pos[1] = o.bounding[1]
|
||||
this._size[0] = o.bounding[2]
|
||||
this._size[1] = o.bounding[3]
|
||||
this.color = o.color
|
||||
this.flags = o.flags || this.flags
|
||||
if (o.font_size) this.font_size = o.font_size
|
||||
}
|
||||
|
||||
serialize(): ISerialisedGroup {
|
||||
const b = this._bounding
|
||||
const b = [this._pos[0], this._pos[1], this._size[0], this._size[1]]
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.title,
|
||||
@@ -210,7 +219,7 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
|
||||
)
|
||||
|
||||
if (LiteGraph.highlight_selected_group && this.selected) {
|
||||
strokeShape(ctx, this._bounding, {
|
||||
strokeShape(ctx, this.boundingRect, {
|
||||
title_height: this.titleHeight,
|
||||
padding
|
||||
})
|
||||
@@ -251,7 +260,7 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
|
||||
|
||||
// Move nodes we overlap the centre point of
|
||||
for (const node of nodes) {
|
||||
if (containsCentre(this._bounding, node.boundingRect)) {
|
||||
if (containsCentre(this.boundingRect, node.boundingRect)) {
|
||||
this._nodes.push(node)
|
||||
children.add(node)
|
||||
}
|
||||
@@ -259,12 +268,13 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
|
||||
|
||||
// Move reroutes we overlap the centre point of
|
||||
for (const reroute of reroutes.values()) {
|
||||
if (isPointInRect(reroute.pos, this._bounding)) children.add(reroute)
|
||||
if (isPointInRect(reroute.pos, this.boundingRect)) children.add(reroute)
|
||||
}
|
||||
|
||||
// Move groups we wholly contain
|
||||
for (const group of groups) {
|
||||
if (containsRect(this._bounding, group._bounding)) children.add(group)
|
||||
if (containsRect(this.boundingRect, group.boundingRect))
|
||||
children.add(group)
|
||||
}
|
||||
|
||||
groups.sort((a, b) => {
|
||||
|
||||
@@ -18,7 +18,6 @@ import type { Reroute, RerouteId } from './Reroute'
|
||||
import { getNodeInputOnPos, getNodeOutputOnPos } from './canvas/measureSlots'
|
||||
import type { IDrawBoundingOptions } from './draw'
|
||||
import { NullGraphError } from './infrastructure/NullGraphError'
|
||||
import type { ReadOnlyRectangle } from './infrastructure/Rectangle'
|
||||
import { Rectangle } from './infrastructure/Rectangle'
|
||||
import type {
|
||||
ColorOption,
|
||||
@@ -37,8 +36,6 @@ import type {
|
||||
ISlotType,
|
||||
Point,
|
||||
Positionable,
|
||||
ReadOnlyPoint,
|
||||
ReadOnlyRect,
|
||||
Rect,
|
||||
Size
|
||||
} from './interfaces'
|
||||
@@ -387,7 +384,7 @@ export class LGraphNode
|
||||
* Called once at the start of every frame. Caller may change the values in {@link out}, which will be reflected in {@link boundingRect}.
|
||||
* WARNING: Making changes to boundingRect via onBounding is poorly supported, and will likely result in strange behaviour.
|
||||
*/
|
||||
onBounding?(this: LGraphNode, out: Rect): void
|
||||
onBounding?(this: LGraphNode, out: Rectangle): void
|
||||
console?: string[]
|
||||
_level?: number
|
||||
_shape?: RenderShape
|
||||
@@ -413,12 +410,12 @@ export class LGraphNode
|
||||
}
|
||||
|
||||
/** @inheritdoc {@link renderArea} */
|
||||
#renderArea: Float32Array = new Float32Array(4)
|
||||
#renderArea: [number, number, number, number] = [0, 0, 0, 0]
|
||||
/**
|
||||
* Rect describing the node area, including shadows and any protrusions.
|
||||
* Determines if the node is visible. Calculated once at the start of every frame.
|
||||
*/
|
||||
get renderArea(): ReadOnlyRect {
|
||||
get renderArea(): Rect {
|
||||
return this.#renderArea
|
||||
}
|
||||
|
||||
@@ -429,12 +426,12 @@ export class LGraphNode
|
||||
*
|
||||
* Determines the node hitbox and other rendering effects. Calculated once at the start of every frame.
|
||||
*/
|
||||
get boundingRect(): ReadOnlyRectangle {
|
||||
get boundingRect(): Rectangle {
|
||||
return this.#boundingRect
|
||||
}
|
||||
|
||||
/** The offset from {@link pos} to the top-left of {@link boundingRect}. */
|
||||
get boundingOffset(): ReadOnlyPoint {
|
||||
get boundingOffset(): Point {
|
||||
const {
|
||||
pos: [posX, posY],
|
||||
boundingRect: [bX, bY]
|
||||
@@ -443,9 +440,9 @@ export class LGraphNode
|
||||
}
|
||||
|
||||
/** {@link pos} and {@link size} values are backed by this {@link Rect}. */
|
||||
_posSize: Float32Array = new Float32Array(4)
|
||||
_pos: Point = this._posSize.subarray(0, 2)
|
||||
_size: Size = this._posSize.subarray(2, 4)
|
||||
_posSize: [number, number, number, number] = [0, 0, 0, 0]
|
||||
_pos: Point = [0, 0]
|
||||
_size: Size = [0, 0]
|
||||
|
||||
public get pos() {
|
||||
return this._pos
|
||||
@@ -1653,7 +1650,7 @@ export class LGraphNode
|
||||
inputs ? inputs.filter((input) => !isWidgetInputSlot(input)).length : 1,
|
||||
outputs ? outputs.length : 1
|
||||
)
|
||||
const size = out || new Float32Array([0, 0])
|
||||
const size = out || [0, 0]
|
||||
rows = Math.max(rows, 1)
|
||||
// although it should be graphcanvas.inner_text_font size
|
||||
const font_size = LiteGraph.NODE_TEXT_SIZE
|
||||
@@ -1978,7 +1975,7 @@ export class LGraphNode
|
||||
* @param out `x, y, width, height` are written to this array.
|
||||
* @param ctx The canvas context to use for measuring text.
|
||||
*/
|
||||
measure(out: Rect, ctx: CanvasRenderingContext2D): void {
|
||||
measure(out: Rectangle, ctx: CanvasRenderingContext2D): void {
|
||||
const titleMode = this.title_mode
|
||||
const renderTitle =
|
||||
titleMode != TitleMode.TRANSPARENT_TITLE &&
|
||||
@@ -2004,13 +2001,13 @@ export class LGraphNode
|
||||
|
||||
/**
|
||||
* returns the bounding of the object, used for rendering purposes
|
||||
* @param out {Float32Array[4]?} [optional] a place to store the output, to free garbage
|
||||
* @param out {Rect?} [optional] a place to store the output, to free garbage
|
||||
* @param includeExternal {boolean?} [optional] set to true to
|
||||
* include the shadow and connection points in the bounding calculation
|
||||
* @returns the bounding box in format of [topleft_cornerx, topleft_cornery, width, height]
|
||||
*/
|
||||
getBounding(out?: Rect, includeExternal?: boolean): Rect {
|
||||
out ||= new Float32Array(4)
|
||||
out ||= [0, 0, 0, 0]
|
||||
|
||||
const rect = includeExternal ? this.renderArea : this.boundingRect
|
||||
out[0] = rect[0]
|
||||
@@ -2031,7 +2028,10 @@ export class LGraphNode
|
||||
this.onBounding?.(bounds)
|
||||
|
||||
const renderArea = this.#renderArea
|
||||
renderArea.set(bounds)
|
||||
renderArea[0] = bounds[0]
|
||||
renderArea[1] = bounds[1]
|
||||
renderArea[2] = bounds[2]
|
||||
renderArea[3] = bounds[3]
|
||||
// 4 offset for collapsed node connection points
|
||||
renderArea[0] -= 4
|
||||
renderArea[1] -= 4
|
||||
@@ -3174,7 +3174,7 @@ export class LGraphNode
|
||||
* @returns the position
|
||||
*/
|
||||
getConnectionPos(is_input: boolean, slot_number: number, out?: Point): Point {
|
||||
out ||= new Float32Array(2)
|
||||
out ||= [0, 0]
|
||||
|
||||
const {
|
||||
pos: [nodeX, nodeY],
|
||||
@@ -3841,7 +3841,7 @@ export class LGraphNode
|
||||
slot.boundingRect[3] = LiteGraph.NODE_SLOT_HEIGHT
|
||||
}
|
||||
|
||||
#measureSlots(): ReadOnlyRect | null {
|
||||
#measureSlots(): Rect | null {
|
||||
const slots: (NodeInputSlot | NodeOutputSlot)[] = []
|
||||
|
||||
for (const [slotIndex, slot] of this.#concreteInputs.entries()) {
|
||||
|
||||
@@ -109,7 +109,7 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
|
||||
data?: number | string | boolean | { toToolTip?(): string }
|
||||
_data?: unknown
|
||||
/** Centre point of the link, calculated during render only - can be inaccurate */
|
||||
_pos: Float32Array
|
||||
_pos: [number, number]
|
||||
/** @todo Clean up - never implemented in comfy. */
|
||||
_last_time?: number
|
||||
/** The last canvas 2D path that was used to render this link */
|
||||
@@ -171,7 +171,7 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
|
||||
|
||||
this._data = null
|
||||
// center
|
||||
this._pos = new Float32Array(2)
|
||||
this._pos = [0, 0]
|
||||
}
|
||||
|
||||
/** @deprecated Use {@link LLink.create} */
|
||||
@@ -205,7 +205,7 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
|
||||
network: Pick<ReadonlyLinkNetwork, 'reroutes'>,
|
||||
linkSegment: LinkSegment
|
||||
): Reroute[] {
|
||||
if (linkSegment.parentId === undefined) return []
|
||||
if (!linkSegment.parentId) return []
|
||||
return network.reroutes.get(linkSegment.parentId)?.getReroutes() ?? []
|
||||
}
|
||||
|
||||
@@ -229,7 +229,7 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
|
||||
linkSegment: LinkSegment,
|
||||
rerouteId: RerouteId
|
||||
): Reroute | null | undefined {
|
||||
if (linkSegment.parentId === undefined) return
|
||||
if (!linkSegment.parentId) return
|
||||
return network.reroutes
|
||||
.get(linkSegment.parentId)
|
||||
?.findNextReroute(rerouteId)
|
||||
@@ -498,7 +498,7 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
|
||||
target_slot: this.target_slot,
|
||||
type: this.type
|
||||
}
|
||||
if (this.parentId !== undefined) copy.parentId = this.parentId
|
||||
if (this.parentId) copy.parentId = this.parentId
|
||||
return copy
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
|
||||
@@ -12,8 +13,8 @@ import type {
|
||||
LinkSegment,
|
||||
Point,
|
||||
Positionable,
|
||||
ReadOnlyRect,
|
||||
ReadonlyLinkNetwork
|
||||
ReadonlyLinkNetwork,
|
||||
Rect
|
||||
} from './interfaces'
|
||||
import { distance, isPointInRect } from './measure'
|
||||
import type { Serialisable, SerialisableReroute } from './types/serialisation'
|
||||
@@ -49,8 +50,6 @@ export class Reroute
|
||||
return Reroute.radius + gap + Reroute.slotRadius
|
||||
}
|
||||
|
||||
#malloc = new Float32Array(8)
|
||||
|
||||
/** The network this reroute belongs to. Contains all valid links and reroutes. */
|
||||
#network: WeakRef<LinkNetwork>
|
||||
|
||||
@@ -73,7 +72,7 @@ export class Reroute
|
||||
/** This property is only defined on the last reroute of a floating reroute chain (closest to input end). */
|
||||
floating?: FloatingRerouteSlot
|
||||
|
||||
#pos = this.#malloc.subarray(0, 2)
|
||||
#pos: [number, number] = [0, 0]
|
||||
/** @inheritdoc */
|
||||
get pos(): Point {
|
||||
return this.#pos
|
||||
@@ -89,17 +88,17 @@ export class Reroute
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
get boundingRect(): ReadOnlyRect {
|
||||
get boundingRect(): Rectangle {
|
||||
const { radius } = Reroute
|
||||
const [x, y] = this.#pos
|
||||
return [x - radius, y - radius, 2 * radius, 2 * radius]
|
||||
return Rectangle.from([x - radius, y - radius, 2 * radius, 2 * radius])
|
||||
}
|
||||
|
||||
/**
|
||||
* Slightly over-sized rectangle, guaranteed to contain the entire surface area for hover detection.
|
||||
* Eliminates most hover positions using an extremely cheap check.
|
||||
*/
|
||||
get #hoverArea(): ReadOnlyRect {
|
||||
get #hoverArea(): Rect {
|
||||
const xOffset = 2 * Reroute.slotOffset
|
||||
const yOffset = 2 * Math.max(Reroute.radius, Reroute.slotRadius)
|
||||
|
||||
@@ -126,14 +125,14 @@ export class Reroute
|
||||
sin: number = 0
|
||||
|
||||
/** Bezier curve control point for the "target" (input) side of the link */
|
||||
controlPoint: Point = this.#malloc.subarray(4, 6)
|
||||
controlPoint: [number, number] = [0, 0]
|
||||
|
||||
/** @inheritdoc */
|
||||
path?: Path2D
|
||||
/** @inheritdoc */
|
||||
_centreAngle?: number
|
||||
/** @inheritdoc */
|
||||
_pos: Float32Array = this.#malloc.subarray(6, 8)
|
||||
_pos: [number, number] = [0, 0]
|
||||
|
||||
/** @inheritdoc */
|
||||
_dragging?: boolean
|
||||
|
||||
@@ -67,7 +67,7 @@ interface IDrawTextInAreaOptions {
|
||||
*/
|
||||
export function strokeShape(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
area: Rect,
|
||||
area: Rect | Rectangle,
|
||||
{
|
||||
shape = RenderShape.BOX,
|
||||
round_radius,
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { clamp } from 'es-toolkit/compat'
|
||||
|
||||
import type {
|
||||
ReadOnlyRect,
|
||||
ReadOnlySize,
|
||||
Size
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import type { Rect, Size } from '@/lib/litegraph/src/interfaces'
|
||||
|
||||
/**
|
||||
* Basic width and height, with min/max constraints.
|
||||
@@ -55,15 +51,15 @@ export class ConstrainedSize {
|
||||
this.desiredHeight = height
|
||||
}
|
||||
|
||||
static fromSize(size: ReadOnlySize): ConstrainedSize {
|
||||
static fromSize(size: Size): ConstrainedSize {
|
||||
return new ConstrainedSize(size[0], size[1])
|
||||
}
|
||||
|
||||
static fromRect(rect: ReadOnlyRect): ConstrainedSize {
|
||||
static fromRect(rect: Rect): ConstrainedSize {
|
||||
return new ConstrainedSize(rect[2], rect[3])
|
||||
}
|
||||
|
||||
setSize(size: ReadOnlySize): void {
|
||||
setSize(size: Size): void {
|
||||
this.desiredWidth = size[0]
|
||||
this.desiredHeight = size[1]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type { LLink, ResolvedConnection } from '@/lib/litegraph/src/LLink'
|
||||
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
|
||||
import type { Rect } from '@/lib/litegraph/src/interfaces'
|
||||
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
|
||||
import type {
|
||||
ExportedSubgraph,
|
||||
@@ -29,7 +29,7 @@ export interface LGraphEventMap {
|
||||
/** The type of subgraph to create. */
|
||||
subgraph: Subgraph
|
||||
/** The boundary around every item that was moved into the subgraph. */
|
||||
bounds: ReadOnlyRect
|
||||
bounds: Rect
|
||||
/** The raw data that was used to create the subgraph. */
|
||||
exportedSubgraph: ExportedSubgraph
|
||||
/** The links that were used to create the subgraph. */
|
||||
|
||||
@@ -1,47 +1,50 @@
|
||||
import type {
|
||||
CompassCorners,
|
||||
Point,
|
||||
ReadOnlyPoint,
|
||||
ReadOnlyRect,
|
||||
ReadOnlySize,
|
||||
ReadOnlyTypedArray,
|
||||
Rect,
|
||||
Size
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import { isInRectangle } from '@/lib/litegraph/src/measure'
|
||||
|
||||
/**
|
||||
* A rectangle, represented as a float64 array of 4 numbers: [x, y, width, height].
|
||||
* A rectangle, represented as an array of 4 numbers: [x, y, width, height].
|
||||
*
|
||||
* This class is a subclass of Float64Array, and so has all the methods of that class. Notably,
|
||||
* {@link Rectangle.from} can be used to convert a {@link ReadOnlyRect}. Typing of this however,
|
||||
* is broken due to the base TS lib returning Float64Array rather than `this`.
|
||||
*
|
||||
* Sub-array properties ({@link Float64Array.subarray}):
|
||||
* - {@link pos}: The position of the top-left corner of the rectangle.
|
||||
* - {@link size}: The size of the rectangle.
|
||||
* This class extends Array and provides both array access (rect[0], rect[1], etc.)
|
||||
* and convenient property access (rect.x, rect.y, rect.width, rect.height).
|
||||
*/
|
||||
export class Rectangle extends Float64Array {
|
||||
#pos: Point | undefined
|
||||
#size: Size | undefined
|
||||
|
||||
export class Rectangle extends Array<number> {
|
||||
constructor(
|
||||
x: number = 0,
|
||||
y: number = 0,
|
||||
width: number = 0,
|
||||
height: number = 0
|
||||
) {
|
||||
super(4)
|
||||
|
||||
super()
|
||||
this[0] = x
|
||||
this[1] = y
|
||||
this[2] = width
|
||||
this[3] = height
|
||||
this.length = 4
|
||||
}
|
||||
|
||||
static override from([x, y, width, height]: ReadOnlyRect): Rectangle {
|
||||
static override from([x, y, width, height]: Rect): Rectangle {
|
||||
return new Rectangle(x, y, width, height)
|
||||
}
|
||||
|
||||
/** Set all values from an array (for TypedArray compatibility) */
|
||||
set(values: ArrayLike<number>): void {
|
||||
this[0] = values[0] ?? 0
|
||||
this[1] = values[1] ?? 0
|
||||
this[2] = values[2] ?? 0
|
||||
this[3] = values[3] ?? 0
|
||||
}
|
||||
|
||||
/** Create a subarray (for TypedArray compatibility) */
|
||||
subarray(begin: number = 0, end?: number): number[] {
|
||||
const endIndex = end ?? this.length
|
||||
return this.slice(begin, endIndex)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new rectangle positioned at the given centre, with the given width/height.
|
||||
* @param centre The centre of the rectangle, as an `[x, y]` point
|
||||
@@ -49,57 +52,38 @@ export class Rectangle extends Float64Array {
|
||||
* @param height The height of the rectangle. Default: {@link width}
|
||||
* @returns A new rectangle whose centre is at {@link x}
|
||||
*/
|
||||
static fromCentre(
|
||||
[x, y]: ReadOnlyPoint,
|
||||
width: number,
|
||||
height = width
|
||||
): Rectangle {
|
||||
static fromCentre([x, y]: Point, width: number, height = width): Rectangle {
|
||||
const left = x - width * 0.5
|
||||
const top = y - height * 0.5
|
||||
return new Rectangle(left, top, width, height)
|
||||
}
|
||||
|
||||
static ensureRect(rect: ReadOnlyRect): Rectangle {
|
||||
static ensureRect(rect: Rect | Rectangle): Rectangle {
|
||||
return rect instanceof Rectangle
|
||||
? rect
|
||||
: new Rectangle(rect[0], rect[1], rect[2], rect[3])
|
||||
}
|
||||
|
||||
override subarray(
|
||||
begin: number = 0,
|
||||
end?: number
|
||||
): Float64Array<ArrayBuffer> {
|
||||
const byteOffset = begin << 3
|
||||
const length = end === undefined ? end : end - begin
|
||||
return new Float64Array(this.buffer, byteOffset, length)
|
||||
}
|
||||
|
||||
/**
|
||||
* A reference to the position of the top-left corner of this rectangle.
|
||||
*
|
||||
* Updating the values of the returned object will update this rectangle.
|
||||
* The position of the top-left corner of this rectangle.
|
||||
*/
|
||||
get pos(): Point {
|
||||
this.#pos ??= this.subarray(0, 2)
|
||||
return this.#pos!
|
||||
return [this[0], this[1]]
|
||||
}
|
||||
|
||||
set pos(value: ReadOnlyPoint) {
|
||||
set pos(value: Point) {
|
||||
this[0] = value[0]
|
||||
this[1] = value[1]
|
||||
}
|
||||
|
||||
/**
|
||||
* A reference to the size of this rectangle.
|
||||
*
|
||||
* Updating the values of the returned object will update this rectangle.
|
||||
* The size of this rectangle.
|
||||
*/
|
||||
get size(): Size {
|
||||
this.#size ??= this.subarray(2, 4)
|
||||
return this.#size!
|
||||
return [this[2], this[3]]
|
||||
}
|
||||
|
||||
set size(value: ReadOnlySize) {
|
||||
set size(value: Size) {
|
||||
this[2] = value[0]
|
||||
this[3] = value[1]
|
||||
}
|
||||
@@ -192,7 +176,7 @@ export class Rectangle extends Float64Array {
|
||||
* Updates the rectangle to the values of {@link rect}.
|
||||
* @param rect The rectangle to update to.
|
||||
*/
|
||||
updateTo(rect: ReadOnlyRect) {
|
||||
updateTo(rect: Rect) {
|
||||
this[0] = rect[0]
|
||||
this[1] = rect[1]
|
||||
this[2] = rect[2]
|
||||
@@ -215,7 +199,7 @@ export class Rectangle extends Float64Array {
|
||||
* @param point The point to check
|
||||
* @returns `true` if {@link point} is inside this rectangle, otherwise `false`.
|
||||
*/
|
||||
containsPoint([x, y]: ReadOnlyPoint): boolean {
|
||||
containsPoint([x, y]: Point): boolean {
|
||||
const [left, top, width, height] = this
|
||||
return x >= left && x < left + width && y >= top && y < top + height
|
||||
}
|
||||
@@ -226,7 +210,7 @@ export class Rectangle extends Float64Array {
|
||||
* @param other The rectangle to check
|
||||
* @returns `true` if {@link other} is inside this rectangle, otherwise `false`.
|
||||
*/
|
||||
containsRect(other: ReadOnlyRect): boolean {
|
||||
containsRect(other: Rect | Rectangle): boolean {
|
||||
const { right, bottom } = this
|
||||
const otherRight = other[0] + other[2]
|
||||
const otherBottom = other[1] + other[3]
|
||||
@@ -251,7 +235,7 @@ export class Rectangle extends Float64Array {
|
||||
* @param rect The rectangle to check
|
||||
* @returns `true` if {@link rect} overlaps with this rectangle, otherwise `false`.
|
||||
*/
|
||||
overlaps(rect: ReadOnlyRect): boolean {
|
||||
overlaps(rect: Rect | Rectangle): boolean {
|
||||
return (
|
||||
this.x < rect[0] + rect[2] &&
|
||||
this.y < rect[1] + rect[3] &&
|
||||
@@ -384,12 +368,12 @@ export class Rectangle extends Float64Array {
|
||||
}
|
||||
|
||||
/** @returns The offset from the top-left of this rectangle to the point [{@link x}, {@link y}], as a new {@link Point}. */
|
||||
getOffsetTo([x, y]: ReadOnlyPoint): Point {
|
||||
getOffsetTo([x, y]: Point): Point {
|
||||
return [x - this[0], y - this[1]]
|
||||
}
|
||||
|
||||
/** @returns The offset from the point [{@link x}, {@link y}] to the top-left of this rectangle, as a new {@link Point}. */
|
||||
getOffsetFrom([x, y]: ReadOnlyPoint): Point {
|
||||
getOffsetFrom([x, y]: Point): Point {
|
||||
return [this[0] - x, this[1] - y]
|
||||
}
|
||||
|
||||
@@ -470,14 +454,4 @@ export class Rectangle extends Float64Array {
|
||||
}
|
||||
}
|
||||
|
||||
export type ReadOnlyRectangle = Omit<
|
||||
ReadOnlyTypedArray<Rectangle>,
|
||||
| 'setHeightBottomAnchored'
|
||||
| 'setWidthRightAnchored'
|
||||
| 'resizeTopLeft'
|
||||
| 'resizeBottomLeft'
|
||||
| 'resizeTopRight'
|
||||
| 'resizeBottomRight'
|
||||
| 'resizeBottomRight'
|
||||
| 'updateTo'
|
||||
>
|
||||
// ReadOnlyRectangle is now just Rectangle since we unified the types
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
|
||||
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
|
||||
import type { ContextMenu } from './ContextMenu'
|
||||
@@ -60,7 +60,7 @@ export interface HasBoundingRect {
|
||||
* @readonly
|
||||
* @see {@link move}
|
||||
*/
|
||||
readonly boundingRect: ReadOnlyRect
|
||||
readonly boundingRect: Rectangle
|
||||
}
|
||||
|
||||
/** An object containing a set of child objects */
|
||||
@@ -193,7 +193,7 @@ export interface LinkSegment {
|
||||
/** The last canvas 2D path that was used to render this segment */
|
||||
path?: Path2D
|
||||
/** Centre point of the {@link path}. Calculated during render only - can be inaccurate */
|
||||
readonly _pos: Float32Array
|
||||
readonly _pos: [number, number]
|
||||
/**
|
||||
* Y-forward along the {@link path} from its centre point, in radians.
|
||||
* `undefined` if using circles for link centres.
|
||||
@@ -225,52 +225,13 @@ export interface IFoundSlot extends IInputOrOutput {
|
||||
}
|
||||
|
||||
/** A point represented as `[x, y]` co-ordinates */
|
||||
export type Point = [x: number, y: number] | Float32Array | Float64Array
|
||||
export type Point = [x: number, y: number]
|
||||
|
||||
/** A size represented as `[width, height]` */
|
||||
export type Size = [width: number, height: number] | Float32Array | Float64Array
|
||||
|
||||
/** A very firm array */
|
||||
type ArRect = [x: number, y: number, width: number, height: number]
|
||||
export type Size = [width: number, height: number]
|
||||
|
||||
/** A rectangle starting at top-left coordinates `[x, y, width, height]` */
|
||||
export type Rect = ArRect | Float32Array | Float64Array
|
||||
|
||||
/** A point represented as `[x, y]` co-ordinates that will not be modified */
|
||||
export type ReadOnlyPoint =
|
||||
| readonly [x: number, y: number]
|
||||
| ReadOnlyTypedArray<Float32Array>
|
||||
| ReadOnlyTypedArray<Float64Array>
|
||||
|
||||
/** A size represented as `[width, height]` that will not be modified */
|
||||
export type ReadOnlySize =
|
||||
| readonly [width: number, height: number]
|
||||
| ReadOnlyTypedArray<Float32Array>
|
||||
| ReadOnlyTypedArray<Float64Array>
|
||||
|
||||
/** A rectangle starting at top-left coordinates `[x, y, width, height]` that will not be modified */
|
||||
export type ReadOnlyRect =
|
||||
| readonly [x: number, y: number, width: number, height: number]
|
||||
| ReadOnlyTypedArray<Float32Array>
|
||||
| ReadOnlyTypedArray<Float64Array>
|
||||
|
||||
type TypedArrays =
|
||||
| Int8Array
|
||||
| Uint8Array
|
||||
| Uint8ClampedArray
|
||||
| Int16Array
|
||||
| Uint16Array
|
||||
| Int32Array
|
||||
| Uint32Array
|
||||
| Float32Array
|
||||
| Float64Array
|
||||
|
||||
type TypedBigIntArrays = BigInt64Array | BigUint64Array
|
||||
export type ReadOnlyTypedArray<T extends TypedArrays | TypedBigIntArrays> =
|
||||
Omit<
|
||||
Readonly<T>,
|
||||
'fill' | 'copyWithin' | 'reverse' | 'set' | 'sort' | 'subarray'
|
||||
>
|
||||
export type Rect = [number, number, number, number]
|
||||
|
||||
/** Union of property names that are of type Match */
|
||||
type KeysOfType<T, Match> = Exclude<
|
||||
@@ -329,7 +290,7 @@ export interface INodeSlot extends HasBoundingRect {
|
||||
nameLocked?: boolean
|
||||
pos?: Point
|
||||
/** @remarks Automatically calculated; not included in serialisation. */
|
||||
boundingRect: Rect
|
||||
boundingRect: Rectangle
|
||||
/**
|
||||
* A list of floating link IDs that are connected to this slot.
|
||||
* This is calculated at runtime; it is **not** serialized.
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface LinkReleaseContextExtended {
|
||||
links: ConnectingLink[]
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
export interface LiteGraphCanvasEvent extends CustomEvent<CanvasEventDetail> {}
|
||||
|
||||
export interface LGraphNodeConstructor<T extends LGraphNode = LGraphNode> {
|
||||
@@ -139,7 +140,7 @@ export { BaseWidget } from './widgets/BaseWidget'
|
||||
|
||||
export { LegacyWidget } from './widgets/LegacyWidget'
|
||||
|
||||
export { isComboWidget, isAssetWidget } from './widgets/widgetMap'
|
||||
export { isComboWidget } from './widgets/widgetMap'
|
||||
// Additional test-specific exports
|
||||
export { LGraphButton } from './LGraphButton'
|
||||
export { MovingOutputLink } from './canvas/MovingOutputLink'
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import type {
|
||||
HasBoundingRect,
|
||||
Point,
|
||||
ReadOnlyPoint,
|
||||
ReadOnlyRect,
|
||||
Rect
|
||||
} from './interfaces'
|
||||
import type { Rectangle } from './infrastructure/Rectangle'
|
||||
import type { HasBoundingRect, Point, Rect } from './interfaces'
|
||||
import { Alignment, LinkDirection, hasFlag } from './types/globalEnums'
|
||||
|
||||
/**
|
||||
@@ -13,7 +8,7 @@ import { Alignment, LinkDirection, hasFlag } from './types/globalEnums'
|
||||
* @param b Point b as `x, y`
|
||||
* @returns Distance between point {@link a} & {@link b}
|
||||
*/
|
||||
export function distance(a: ReadOnlyPoint, b: ReadOnlyPoint): number {
|
||||
export function distance(a: Point, b: Point): number {
|
||||
return Math.sqrt(
|
||||
(b[0] - a[0]) * (b[0] - a[0]) + (b[1] - a[1]) * (b[1] - a[1])
|
||||
)
|
||||
@@ -61,10 +56,7 @@ export function isInRectangle(
|
||||
* @param rect The rectangle, as `x, y, width, height`
|
||||
* @returns `true` if the point is inside the rect, otherwise `false`
|
||||
*/
|
||||
export function isPointInRect(
|
||||
point: ReadOnlyPoint,
|
||||
rect: ReadOnlyRect
|
||||
): boolean {
|
||||
export function isPointInRect(point: Point, rect: Rect | Rectangle): boolean {
|
||||
return (
|
||||
point[0] >= rect[0] &&
|
||||
point[0] < rect[0] + rect[2] &&
|
||||
@@ -80,7 +72,11 @@ export function isPointInRect(
|
||||
* @param rect The rectangle, as `x, y, width, height`
|
||||
* @returns `true` if the point is inside the rect, otherwise `false`
|
||||
*/
|
||||
export function isInRect(x: number, y: number, rect: ReadOnlyRect): boolean {
|
||||
export function isInRect(
|
||||
x: number,
|
||||
y: number,
|
||||
rect: Rect | Rectangle
|
||||
): boolean {
|
||||
return (
|
||||
x >= rect[0] &&
|
||||
x < rect[0] + rect[2] &&
|
||||
@@ -121,7 +117,10 @@ export function isInsideRectangle(
|
||||
* @param b Rectangle B as `x, y, width, height`
|
||||
* @returns `true` if rectangles overlap, otherwise `false`
|
||||
*/
|
||||
export function overlapBounding(a: ReadOnlyRect, b: ReadOnlyRect): boolean {
|
||||
export function overlapBounding(
|
||||
a: Rect | Rectangle,
|
||||
b: Rect | Rectangle
|
||||
): boolean {
|
||||
const aRight = a[0] + a[2]
|
||||
const aBottom = a[1] + a[3]
|
||||
const bRight = b[0] + b[2]
|
||||
@@ -137,7 +136,7 @@ export function overlapBounding(a: ReadOnlyRect, b: ReadOnlyRect): boolean {
|
||||
* @param rect The rectangle, as `x, y, width, height`
|
||||
* @returns The centre of the rectangle, as `x, y`
|
||||
*/
|
||||
export function getCentre(rect: ReadOnlyRect): Point {
|
||||
export function getCentre(rect: Rect | Rectangle): Point {
|
||||
return [rect[0] + rect[2] * 0.5, rect[1] + rect[3] * 0.5]
|
||||
}
|
||||
|
||||
@@ -147,7 +146,10 @@ export function getCentre(rect: ReadOnlyRect): Point {
|
||||
* @param b Sub-rectangle B as `x, y, width, height`
|
||||
* @returns `true` if {@link a} contains most of {@link b}, otherwise `false`
|
||||
*/
|
||||
export function containsCentre(a: ReadOnlyRect, b: ReadOnlyRect): boolean {
|
||||
export function containsCentre(
|
||||
a: Rect | Rectangle,
|
||||
b: Rect | Rectangle
|
||||
): boolean {
|
||||
const centreX = b[0] + b[2] * 0.5
|
||||
const centreY = b[1] + b[3] * 0.5
|
||||
return isInRect(centreX, centreY, a)
|
||||
@@ -159,7 +161,10 @@ export function containsCentre(a: ReadOnlyRect, b: ReadOnlyRect): boolean {
|
||||
* @param b Sub-rectangle B as `x, y, width, height`
|
||||
* @returns `true` if {@link a} wholly contains {@link b}, otherwise `false`
|
||||
*/
|
||||
export function containsRect(a: ReadOnlyRect, b: ReadOnlyRect): boolean {
|
||||
export function containsRect(
|
||||
a: Rect | Rectangle,
|
||||
b: Rect | Rectangle
|
||||
): boolean {
|
||||
const aRight = a[0] + a[2]
|
||||
const aBottom = a[1] + a[3]
|
||||
const bRight = b[0] + b[2]
|
||||
@@ -289,8 +294,8 @@ export function rotateLink(
|
||||
* the right
|
||||
*/
|
||||
export function getOrientation(
|
||||
lineStart: ReadOnlyPoint,
|
||||
lineEnd: ReadOnlyPoint,
|
||||
lineStart: Point,
|
||||
lineEnd: Point,
|
||||
x: number,
|
||||
y: number
|
||||
): number {
|
||||
@@ -310,10 +315,10 @@ export function getOrientation(
|
||||
*/
|
||||
export function findPointOnCurve(
|
||||
out: Point,
|
||||
a: ReadOnlyPoint,
|
||||
b: ReadOnlyPoint,
|
||||
controlA: ReadOnlyPoint,
|
||||
controlB: ReadOnlyPoint,
|
||||
a: Point,
|
||||
b: Point,
|
||||
controlA: Point,
|
||||
controlB: Point,
|
||||
t: number = 0.5
|
||||
): void {
|
||||
const iT = 1 - t
|
||||
@@ -330,8 +335,13 @@ export function findPointOnCurve(
|
||||
export function createBounds(
|
||||
objects: Iterable<HasBoundingRect>,
|
||||
padding: number = 10
|
||||
): ReadOnlyRect | null {
|
||||
const bounds = new Float32Array([Infinity, Infinity, -Infinity, -Infinity])
|
||||
): Rect | null {
|
||||
const bounds: [number, number, number, number] = [
|
||||
Infinity,
|
||||
Infinity,
|
||||
-Infinity,
|
||||
-Infinity
|
||||
]
|
||||
|
||||
for (const obj of objects) {
|
||||
const rect = obj.boundingRect
|
||||
@@ -379,11 +389,11 @@ export function snapPoint(pos: Point | Rect, snapTo: number): boolean {
|
||||
* @returns The original {@link rect}, modified in place.
|
||||
*/
|
||||
export function alignToContainer(
|
||||
rect: Rect,
|
||||
rect: Rect | Rectangle,
|
||||
anchors: Alignment,
|
||||
[containerX, containerY, containerWidth, containerHeight]: ReadOnlyRect,
|
||||
[insetX, insetY]: ReadOnlyPoint = [0, 0]
|
||||
): Rect {
|
||||
[containerX, containerY, containerWidth, containerHeight]: Rect | Rectangle,
|
||||
[insetX, insetY]: Point = [0, 0]
|
||||
): Rect | Rectangle {
|
||||
if (hasFlag(anchors, Alignment.Left)) {
|
||||
// Left
|
||||
rect[0] = containerX + insetX
|
||||
@@ -422,11 +432,11 @@ export function alignToContainer(
|
||||
* @returns The original {@link rect}, modified in place.
|
||||
*/
|
||||
export function alignOutsideContainer(
|
||||
rect: Rect,
|
||||
rect: Rect | Rectangle,
|
||||
anchors: Alignment,
|
||||
[otherX, otherY, otherWidth, otherHeight]: ReadOnlyRect,
|
||||
[outsetX, outsetY]: ReadOnlyPoint = [0, 0]
|
||||
): Rect {
|
||||
[otherX, otherY, otherWidth, otherHeight]: Rect | Rectangle,
|
||||
[outsetX, outsetY]: Point = [0, 0]
|
||||
): Rect | Rectangle {
|
||||
if (hasFlag(anchors, Alignment.Left)) {
|
||||
// Left
|
||||
rect[0] = otherX - outsetX - rect[2]
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot,
|
||||
OptionalProps,
|
||||
ReadOnlyPoint
|
||||
Point
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { type IDrawOptions, NodeSlot } from '@/lib/litegraph/src/node/NodeSlot'
|
||||
@@ -32,7 +32,7 @@ export class NodeInputSlot extends NodeSlot implements INodeInputSlot {
|
||||
this.#widget = widget ? new WeakRef(widget) : undefined
|
||||
}
|
||||
|
||||
get collapsedPos(): ReadOnlyPoint {
|
||||
get collapsedPos(): Point {
|
||||
return [0, LiteGraph.NODE_TITLE_HEIGHT * -0.5]
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot,
|
||||
OptionalProps,
|
||||
ReadOnlyPoint
|
||||
Point
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { type IDrawOptions, NodeSlot } from '@/lib/litegraph/src/node/NodeSlot'
|
||||
@@ -24,7 +24,7 @@ export class NodeOutputSlot extends NodeSlot implements INodeOutputSlot {
|
||||
return false
|
||||
}
|
||||
|
||||
get collapsedPos(): ReadOnlyPoint {
|
||||
get collapsedPos(): Point {
|
||||
return [
|
||||
this.#node._collapsed_width ?? LiteGraph.NODE_COLLAPSED_WIDTH,
|
||||
LiteGraph.NODE_TITLE_HEIGHT * -0.5
|
||||
|
||||
@@ -8,8 +8,7 @@ import type {
|
||||
INodeSlot,
|
||||
ISubgraphInput,
|
||||
OptionalProps,
|
||||
Point,
|
||||
ReadOnlyPoint
|
||||
Point
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import { LiteGraph, Rectangle } from '@/lib/litegraph/src/litegraph'
|
||||
import { getCentre } from '@/lib/litegraph/src/measure'
|
||||
@@ -36,7 +35,7 @@ export abstract class NodeSlot extends SlotBase implements INodeSlot {
|
||||
pos?: Point
|
||||
|
||||
/** The offset from the parent node to the centre point of this slot. */
|
||||
get #centreOffset(): ReadOnlyPoint {
|
||||
get #centreOffset(): Point {
|
||||
const nodePos = this.node.pos
|
||||
const { boundingRect } = this
|
||||
|
||||
@@ -52,7 +51,7 @@ export abstract class NodeSlot extends SlotBase implements INodeSlot {
|
||||
}
|
||||
|
||||
/** The center point of this slot when the node is collapsed. */
|
||||
abstract get collapsedPos(): ReadOnlyPoint
|
||||
abstract get collapsedPos(): Point
|
||||
|
||||
#node: LGraphNode
|
||||
get node(): LGraphNode {
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot,
|
||||
Point,
|
||||
ReadOnlyRect
|
||||
Rect
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
@@ -213,7 +213,7 @@ export class SubgraphInput extends SubgraphSlot {
|
||||
}
|
||||
|
||||
/** For inputs, x is the right edge of the input node. */
|
||||
override arrange(rect: ReadOnlyRect): void {
|
||||
override arrange(rect: Rect): void {
|
||||
const [right, top, width, height] = rect
|
||||
const { boundingRect: b, pos } = this
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot,
|
||||
Point,
|
||||
ReadOnlyRect
|
||||
Rect
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
@@ -119,7 +119,7 @@ export class SubgraphOutput extends SubgraphSlot {
|
||||
return [x + height, y + height * 0.5]
|
||||
}
|
||||
|
||||
override arrange(rect: ReadOnlyRect): void {
|
||||
override arrange(rect: Rect): void {
|
||||
const [left, top, width, height] = rect
|
||||
const { boundingRect: b, pos } = this
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot,
|
||||
Point,
|
||||
ReadOnlyRect,
|
||||
ReadOnlySize
|
||||
Rect,
|
||||
Size
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { SlotBase } from '@/lib/litegraph/src/node/SlotBase'
|
||||
@@ -45,7 +45,7 @@ export abstract class SubgraphSlot
|
||||
return LiteGraph.NODE_SLOT_HEIGHT
|
||||
}
|
||||
|
||||
readonly #pos: Point = new Float32Array(2)
|
||||
readonly #pos: Point = [0, 0]
|
||||
|
||||
readonly measurement: ConstrainedSize = new ConstrainedSize(
|
||||
SubgraphSlot.defaultHeight,
|
||||
@@ -133,7 +133,7 @@ export abstract class SubgraphSlot
|
||||
}
|
||||
}
|
||||
|
||||
measure(): ReadOnlySize {
|
||||
measure(): Size {
|
||||
const width = LGraphCanvas._measureText?.(this.displayName) ?? 0
|
||||
|
||||
const { defaultHeight } = SubgraphSlot
|
||||
@@ -141,7 +141,7 @@ export abstract class SubgraphSlot
|
||||
return this.measurement.toSize()
|
||||
}
|
||||
|
||||
abstract arrange(rect: ReadOnlyRect): void
|
||||
abstract arrange(rect: Rect): void
|
||||
|
||||
abstract connect(
|
||||
slot: INodeInputSlot | INodeOutputSlot,
|
||||
|
||||
@@ -13,22 +13,6 @@ export class AssetWidget
|
||||
this.value = widget.value?.toString() ?? ''
|
||||
}
|
||||
|
||||
override set value(value: IAssetWidget['value']) {
|
||||
const oldValue = this.value
|
||||
super.value = value
|
||||
|
||||
// Force canvas redraw when value changes to show update immediately
|
||||
if (oldValue !== value && this.node.graph?.list_of_graphcanvas) {
|
||||
for (const canvas of this.node.graph.list_of_graphcanvas) {
|
||||
canvas.setDirty(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override get value(): IAssetWidget['value'] {
|
||||
return super.value
|
||||
}
|
||||
|
||||
override get _displayValue(): string {
|
||||
return String(this.value) //FIXME: Resolve asset name
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type {
|
||||
IAssetWidget,
|
||||
IBaseWidget,
|
||||
IComboWidget,
|
||||
IWidget,
|
||||
@@ -133,9 +132,4 @@ export function isComboWidget(widget: IBaseWidget): widget is IComboWidget {
|
||||
return widget.type === 'combo'
|
||||
}
|
||||
|
||||
/** Type guard: Narrow **from {@link IBaseWidget}** to {@link IAssetWidget}. */
|
||||
export function isAssetWidget(widget: IBaseWidget): widget is IAssetWidget {
|
||||
return widget.type === 'asset'
|
||||
}
|
||||
|
||||
// #endregion Type Guards
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, vi } from 'vitest'
|
||||
|
||||
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
|
||||
import type { INodeInputSlot, Point } from '@/lib/litegraph/src/interfaces'
|
||||
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -84,8 +85,8 @@ describe('LGraphNode', () => {
|
||||
}))
|
||||
}
|
||||
node.configure(configureData)
|
||||
expect(node.pos).toEqual(new Float32Array([50, 60]))
|
||||
expect(node.size).toEqual(new Float32Array([70, 80]))
|
||||
expect(node.pos).toEqual([50, 60])
|
||||
expect(node.size).toEqual([70, 80])
|
||||
})
|
||||
|
||||
test('should configure inputs correctly', () => {
|
||||
@@ -571,7 +572,7 @@ describe('LGraphNode', () => {
|
||||
name: 'test_in',
|
||||
type: 'string',
|
||||
link: null,
|
||||
boundingRect: new Float32Array([0, 0, 0, 0])
|
||||
boundingRect: new Rectangle(0, 0, 0, 0)
|
||||
}
|
||||
})
|
||||
test('should return position based on title height when collapsed', () => {
|
||||
@@ -594,7 +595,7 @@ describe('LGraphNode', () => {
|
||||
name: 'test_in_2',
|
||||
type: 'number',
|
||||
link: null,
|
||||
boundingRect: new Float32Array([0, 0, 0, 0])
|
||||
boundingRect: new Rectangle(0, 0, 0, 0)
|
||||
}
|
||||
node.inputs = [inputSlot, inputSlot2]
|
||||
const slotIndex = 0
|
||||
@@ -629,13 +630,13 @@ describe('LGraphNode', () => {
|
||||
name: 'in0',
|
||||
type: 'string',
|
||||
link: null,
|
||||
boundingRect: new Float32Array([0, 0, 0, 0])
|
||||
boundingRect: new Rectangle(0, 0, 0, 0)
|
||||
}
|
||||
const input1: INodeInputSlot = {
|
||||
name: 'in1',
|
||||
type: 'number',
|
||||
link: null,
|
||||
boundingRect: new Float32Array([0, 0, 0, 0]),
|
||||
boundingRect: new Rectangle(0, 0, 0, 0),
|
||||
pos: [5, 45]
|
||||
}
|
||||
node.inputs = [input0, input1]
|
||||
|
||||
@@ -4,19 +4,19 @@ exports[`LGraph configure() > LGraph matches previous snapshot (normal configure
|
||||
LGraph {
|
||||
"_groups": [
|
||||
LGraphGroup {
|
||||
"_bounding": Float32Array [
|
||||
20,
|
||||
20,
|
||||
1,
|
||||
3,
|
||||
"_bounding": [
|
||||
10,
|
||||
10,
|
||||
140,
|
||||
80,
|
||||
],
|
||||
"_children": Set {},
|
||||
"_nodes": [],
|
||||
"_pos": Float32Array [
|
||||
"_pos": [
|
||||
20,
|
||||
20,
|
||||
],
|
||||
"_size": Float32Array [
|
||||
"_size": [
|
||||
1,
|
||||
3,
|
||||
],
|
||||
@@ -39,19 +39,19 @@ LGraph {
|
||||
LGraphNode {
|
||||
"_collapsed_width": undefined,
|
||||
"_level": undefined,
|
||||
"_pos": Float32Array [
|
||||
"_pos": [
|
||||
10,
|
||||
10,
|
||||
],
|
||||
"_posSize": Float32Array [
|
||||
10,
|
||||
10,
|
||||
140,
|
||||
60,
|
||||
"_posSize": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"_relative_id": undefined,
|
||||
"_shape": undefined,
|
||||
"_size": Float32Array [
|
||||
"_size": [
|
||||
140,
|
||||
60,
|
||||
],
|
||||
@@ -98,6 +98,7 @@ LGraph {
|
||||
"selected": [Function],
|
||||
},
|
||||
"title": "LGraphNode",
|
||||
"title_buttons": [],
|
||||
"type": "mustBeSet",
|
||||
"widgets": undefined,
|
||||
"widgets_start_y": undefined,
|
||||
@@ -108,19 +109,19 @@ LGraph {
|
||||
"1": LGraphNode {
|
||||
"_collapsed_width": undefined,
|
||||
"_level": undefined,
|
||||
"_pos": Float32Array [
|
||||
"_pos": [
|
||||
10,
|
||||
10,
|
||||
],
|
||||
"_posSize": Float32Array [
|
||||
10,
|
||||
10,
|
||||
140,
|
||||
60,
|
||||
"_posSize": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"_relative_id": undefined,
|
||||
"_shape": undefined,
|
||||
"_size": Float32Array [
|
||||
"_size": [
|
||||
140,
|
||||
60,
|
||||
],
|
||||
@@ -167,6 +168,7 @@ LGraph {
|
||||
"selected": [Function],
|
||||
},
|
||||
"title": "LGraphNode",
|
||||
"title_buttons": [],
|
||||
"type": "mustBeSet",
|
||||
"widgets": undefined,
|
||||
"widgets_start_y": undefined,
|
||||
@@ -178,19 +180,19 @@ LGraph {
|
||||
LGraphNode {
|
||||
"_collapsed_width": undefined,
|
||||
"_level": undefined,
|
||||
"_pos": Float32Array [
|
||||
"_pos": [
|
||||
10,
|
||||
10,
|
||||
],
|
||||
"_posSize": Float32Array [
|
||||
10,
|
||||
10,
|
||||
140,
|
||||
60,
|
||||
"_posSize": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"_relative_id": undefined,
|
||||
"_shape": undefined,
|
||||
"_size": Float32Array [
|
||||
"_size": [
|
||||
140,
|
||||
60,
|
||||
],
|
||||
@@ -237,6 +239,7 @@ LGraph {
|
||||
"selected": [Function],
|
||||
},
|
||||
"title": "LGraphNode",
|
||||
"title_buttons": [],
|
||||
"type": "mustBeSet",
|
||||
"widgets": undefined,
|
||||
"widgets_start_y": undefined,
|
||||
@@ -249,7 +252,16 @@ LGraph {
|
||||
"config": {},
|
||||
"elapsed_time": 0.01,
|
||||
"errors_in_execution": undefined,
|
||||
"events": CustomEventTarget {},
|
||||
"events": CustomEventTarget {
|
||||
Symbol(listeners): {
|
||||
"bubbling": Map {},
|
||||
"capturing": Map {},
|
||||
},
|
||||
Symbol(listenerOptions): {
|
||||
"bubbling": Map {},
|
||||
"capturing": Map {},
|
||||
},
|
||||
},
|
||||
"execution_time": undefined,
|
||||
"execution_timer_id": undefined,
|
||||
"extra": {},
|
||||
@@ -296,7 +308,16 @@ LGraph {
|
||||
"config": {},
|
||||
"elapsed_time": 0.01,
|
||||
"errors_in_execution": undefined,
|
||||
"events": CustomEventTarget {},
|
||||
"events": CustomEventTarget {
|
||||
Symbol(listeners): {
|
||||
"bubbling": Map {},
|
||||
"capturing": Map {},
|
||||
},
|
||||
Symbol(listenerOptions): {
|
||||
"bubbling": Map {},
|
||||
"capturing": Map {},
|
||||
},
|
||||
},
|
||||
"execution_time": undefined,
|
||||
"execution_timer_id": undefined,
|
||||
"extra": {},
|
||||
|
||||
@@ -4,19 +4,19 @@ exports[`LGraph > supports schema v0.4 graphs > oldSchemaGraph 1`] = `
|
||||
LGraph {
|
||||
"_groups": [
|
||||
LGraphGroup {
|
||||
"_bounding": Float32Array [
|
||||
20,
|
||||
20,
|
||||
1,
|
||||
3,
|
||||
"_bounding": [
|
||||
10,
|
||||
10,
|
||||
140,
|
||||
80,
|
||||
],
|
||||
"_children": Set {},
|
||||
"_nodes": [],
|
||||
"_pos": Float32Array [
|
||||
"_pos": [
|
||||
20,
|
||||
20,
|
||||
],
|
||||
"_size": Float32Array [
|
||||
"_size": [
|
||||
1,
|
||||
3,
|
||||
],
|
||||
@@ -39,19 +39,19 @@ LGraph {
|
||||
LGraphNode {
|
||||
"_collapsed_width": undefined,
|
||||
"_level": undefined,
|
||||
"_pos": Float32Array [
|
||||
"_pos": [
|
||||
10,
|
||||
10,
|
||||
],
|
||||
"_posSize": Float32Array [
|
||||
10,
|
||||
10,
|
||||
140,
|
||||
60,
|
||||
"_posSize": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"_relative_id": undefined,
|
||||
"_shape": undefined,
|
||||
"_size": Float32Array [
|
||||
"_size": [
|
||||
140,
|
||||
60,
|
||||
],
|
||||
@@ -111,19 +111,19 @@ LGraph {
|
||||
"1": LGraphNode {
|
||||
"_collapsed_width": undefined,
|
||||
"_level": undefined,
|
||||
"_pos": Float32Array [
|
||||
"_pos": [
|
||||
10,
|
||||
10,
|
||||
],
|
||||
"_posSize": Float32Array [
|
||||
10,
|
||||
10,
|
||||
140,
|
||||
60,
|
||||
"_posSize": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"_relative_id": undefined,
|
||||
"_shape": undefined,
|
||||
"_size": Float32Array [
|
||||
"_size": [
|
||||
140,
|
||||
60,
|
||||
],
|
||||
@@ -184,19 +184,19 @@ LGraph {
|
||||
LGraphNode {
|
||||
"_collapsed_width": undefined,
|
||||
"_level": undefined,
|
||||
"_pos": Float32Array [
|
||||
"_pos": [
|
||||
10,
|
||||
10,
|
||||
],
|
||||
"_posSize": Float32Array [
|
||||
10,
|
||||
10,
|
||||
140,
|
||||
60,
|
||||
"_posSize": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"_relative_id": undefined,
|
||||
"_shape": undefined,
|
||||
"_size": Float32Array [
|
||||
"_size": [
|
||||
140,
|
||||
60,
|
||||
],
|
||||
@@ -258,7 +258,16 @@ LGraph {
|
||||
"config": {},
|
||||
"elapsed_time": 0.01,
|
||||
"errors_in_execution": undefined,
|
||||
"events": CustomEventTarget {},
|
||||
"events": CustomEventTarget {
|
||||
Symbol(listeners): {
|
||||
"bubbling": Map {},
|
||||
"capturing": Map {},
|
||||
},
|
||||
Symbol(listenerOptions): {
|
||||
"bubbling": Map {},
|
||||
"capturing": Map {},
|
||||
},
|
||||
},
|
||||
"execution_time": undefined,
|
||||
"execution_timer_id": undefined,
|
||||
"extra": {},
|
||||
|
||||
@@ -4,19 +4,19 @@ exports[`LGraph (constructor only) > Matches previous snapshot > basicLGraph 1`]
|
||||
LGraph {
|
||||
"_groups": [
|
||||
LGraphGroup {
|
||||
"_bounding": Float32Array [
|
||||
20,
|
||||
20,
|
||||
1,
|
||||
3,
|
||||
"_bounding": [
|
||||
10,
|
||||
10,
|
||||
140,
|
||||
80,
|
||||
],
|
||||
"_children": Set {},
|
||||
"_nodes": [],
|
||||
"_pos": Float32Array [
|
||||
"_pos": [
|
||||
20,
|
||||
20,
|
||||
],
|
||||
"_size": Float32Array [
|
||||
"_size": [
|
||||
1,
|
||||
3,
|
||||
],
|
||||
@@ -39,19 +39,19 @@ LGraph {
|
||||
LGraphNode {
|
||||
"_collapsed_width": undefined,
|
||||
"_level": undefined,
|
||||
"_pos": Float32Array [
|
||||
"_pos": [
|
||||
10,
|
||||
10,
|
||||
],
|
||||
"_posSize": Float32Array [
|
||||
10,
|
||||
10,
|
||||
140,
|
||||
60,
|
||||
"_posSize": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"_relative_id": undefined,
|
||||
"_shape": undefined,
|
||||
"_size": Float32Array [
|
||||
"_size": [
|
||||
140,
|
||||
60,
|
||||
],
|
||||
@@ -109,19 +109,19 @@ LGraph {
|
||||
"1": LGraphNode {
|
||||
"_collapsed_width": undefined,
|
||||
"_level": undefined,
|
||||
"_pos": Float32Array [
|
||||
"_pos": [
|
||||
10,
|
||||
10,
|
||||
],
|
||||
"_posSize": Float32Array [
|
||||
10,
|
||||
10,
|
||||
140,
|
||||
60,
|
||||
"_posSize": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"_relative_id": undefined,
|
||||
"_shape": undefined,
|
||||
"_size": Float32Array [
|
||||
"_size": [
|
||||
140,
|
||||
60,
|
||||
],
|
||||
@@ -180,19 +180,19 @@ LGraph {
|
||||
LGraphNode {
|
||||
"_collapsed_width": undefined,
|
||||
"_level": undefined,
|
||||
"_pos": Float32Array [
|
||||
"_pos": [
|
||||
10,
|
||||
10,
|
||||
],
|
||||
"_posSize": Float32Array [
|
||||
10,
|
||||
10,
|
||||
140,
|
||||
60,
|
||||
"_posSize": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"_relative_id": undefined,
|
||||
"_shape": undefined,
|
||||
"_size": Float32Array [
|
||||
"_size": [
|
||||
140,
|
||||
60,
|
||||
],
|
||||
@@ -252,7 +252,16 @@ LGraph {
|
||||
"config": {},
|
||||
"elapsed_time": 0.01,
|
||||
"errors_in_execution": undefined,
|
||||
"events": CustomEventTarget {},
|
||||
"events": CustomEventTarget {
|
||||
Symbol(listeners): {
|
||||
"bubbling": Map {},
|
||||
"capturing": Map {},
|
||||
},
|
||||
Symbol(listenerOptions): {
|
||||
"bubbling": Map {},
|
||||
"capturing": Map {},
|
||||
},
|
||||
},
|
||||
"execution_time": undefined,
|
||||
"execution_timer_id": undefined,
|
||||
"extra": {},
|
||||
@@ -299,7 +308,16 @@ LGraph {
|
||||
"config": {},
|
||||
"elapsed_time": 0.01,
|
||||
"errors_in_execution": undefined,
|
||||
"events": CustomEventTarget {},
|
||||
"events": CustomEventTarget {
|
||||
Symbol(listeners): {
|
||||
"bubbling": Map {},
|
||||
"capturing": Map {},
|
||||
},
|
||||
Symbol(listenerOptions): {
|
||||
"bubbling": Map {},
|
||||
"capturing": Map {},
|
||||
},
|
||||
},
|
||||
"execution_time": undefined,
|
||||
"execution_timer_id": undefined,
|
||||
"extra": {},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test as baseTest } from 'vitest'
|
||||
|
||||
import { Rectangle } from '../src/infrastructure/Rectangle'
|
||||
import type { Point, Rect } from '../src/interfaces'
|
||||
import {
|
||||
addDirectionalOffset,
|
||||
@@ -131,8 +132,8 @@ test('snapPoint correctly snaps points to grid', ({ expect }) => {
|
||||
|
||||
test('createBounds correctly creates bounding box', ({ expect }) => {
|
||||
const objects = [
|
||||
{ boundingRect: [0, 0, 10, 10] as Rect },
|
||||
{ boundingRect: [5, 5, 10, 10] as Rect }
|
||||
{ boundingRect: new Rectangle(0, 0, 10, 10) },
|
||||
{ boundingRect: new Rectangle(5, 5, 10, 10) }
|
||||
]
|
||||
|
||||
const defaultBounds = createBounds(objects)
|
||||
|
||||
@@ -1873,13 +1873,6 @@
|
||||
"noModelsInFolder": "No {type} available in this folder",
|
||||
"searchAssetsPlaceholder": "Search assets...",
|
||||
"allModels": "All Models",
|
||||
"unknown": "Unknown",
|
||||
"fileFormats": "File formats",
|
||||
"baseModels": "Base models",
|
||||
"sortBy": "Sort by",
|
||||
"sortAZ": "A-Z",
|
||||
"sortZA": "Z-A",
|
||||
"sortRecent": "Recent",
|
||||
"sortPopular": "Popular"
|
||||
"unknown": "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
|
||||
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import {
|
||||
createMockAssets,
|
||||
mockAssets
|
||||
@@ -57,7 +56,7 @@ export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: { AssetBrowserModal },
|
||||
setup() {
|
||||
const onAssetSelect = (asset: AssetDisplayItem) => {
|
||||
const onAssetSelect = (asset: any) => {
|
||||
console.log('Selected asset:', asset)
|
||||
}
|
||||
const onClose = () => {
|
||||
@@ -97,7 +96,7 @@ export const SingleAssetType: Story = {
|
||||
render: (args) => ({
|
||||
components: { AssetBrowserModal },
|
||||
setup() {
|
||||
const onAssetSelect = (asset: AssetDisplayItem) => {
|
||||
const onAssetSelect = (asset: any) => {
|
||||
console.log('Selected asset:', asset)
|
||||
}
|
||||
const onClose = () => {
|
||||
@@ -146,7 +145,7 @@ export const NoLeftPanel: Story = {
|
||||
render: (args) => ({
|
||||
components: { AssetBrowserModal },
|
||||
setup() {
|
||||
const onAssetSelect = (asset: AssetDisplayItem) => {
|
||||
const onAssetSelect = (asset: any) => {
|
||||
console.log('Selected asset:', asset)
|
||||
}
|
||||
const onClose = () => {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
:nav-items="availableCategories"
|
||||
>
|
||||
<template #header-icon>
|
||||
<div class="icon-[lucide--folder] size-4" />
|
||||
<i-lucide:folder class="size-4" />
|
||||
</template>
|
||||
<template #header-title>{{ $t('assetBrowser.browseAssets') }}</template>
|
||||
</LeftSidePanel>
|
||||
@@ -37,7 +37,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, provide } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import SearchBox from '@/components/input/SearchBox.vue'
|
||||
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
||||
@@ -46,7 +46,6 @@ import AssetGrid from '@/platform/assets/components/AssetGrid.vue'
|
||||
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
|
||||
const props = defineProps<{
|
||||
nodeType?: string
|
||||
@@ -62,28 +61,35 @@ const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
provide(OnCloseKey, props.onClose ?? (() => {}))
|
||||
|
||||
// Use AssetBrowser composable for all business logic
|
||||
const {
|
||||
searchQuery,
|
||||
selectedCategory,
|
||||
availableCategories,
|
||||
contentTitle,
|
||||
filteredAssets,
|
||||
selectAssetWithCallback
|
||||
selectAsset
|
||||
} = useAssetBrowser(props.assets)
|
||||
|
||||
// Dialog controls panel visibility via prop
|
||||
const shouldShowLeftPanel = computed(() => {
|
||||
return props.showLeftPanel ?? true
|
||||
})
|
||||
|
||||
// Handle close button - call both the prop callback and emit the event
|
||||
const handleClose = () => {
|
||||
props.onClose?.()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleAssetSelectAndEmit = async (asset: AssetDisplayItem) => {
|
||||
emit('asset-select', asset)
|
||||
await selectAssetWithCallback(asset.id, props.onSelect)
|
||||
// Handle asset selection and emit to parent
|
||||
const handleAssetSelectAndEmit = (asset: AssetDisplayItem) => {
|
||||
selectAsset(asset) // This logs the selection for dev mode
|
||||
emit('asset-select', asset) // Emit the full asset object
|
||||
|
||||
// Call prop callback if provided
|
||||
if (props.onSelect) {
|
||||
props.onSelect(asset.name) // Use asset name as the asset path
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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:transform focus:-translate-y-0.5 focus:shadow-lg focus:shadow-black/10 dark-theme:focus:shadow-black/30'
|
||||
'focus:outline-none focus:ring-2 focus:ring-blue-500 dark-theme:focus:ring-blue-400'
|
||||
],
|
||||
// Div-specific styles
|
||||
!interactive && [
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div :class="leftSideClasses" data-component-id="asset-filter-bar-left">
|
||||
<MultiSelect
|
||||
v-model="fileFormats"
|
||||
:label="$t('assetBrowser.fileFormats')"
|
||||
label="File formats"
|
||||
:options="fileFormatOptions"
|
||||
:class="selectClasses"
|
||||
data-component-id="asset-filter-file-formats"
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
<MultiSelect
|
||||
v-model="baseModels"
|
||||
:label="$t('assetBrowser.baseModels')"
|
||||
label="Base models"
|
||||
:options="baseModelOptions"
|
||||
:class="selectClasses"
|
||||
data-component-id="asset-filter-base-models"
|
||||
@@ -23,7 +23,7 @@
|
||||
<div :class="rightSideClasses" data-component-id="asset-filter-bar-right">
|
||||
<SingleSelect
|
||||
v-model="sortBy"
|
||||
:label="$t('assetBrowser.sortBy')"
|
||||
label="Sort by"
|
||||
:options="sortOptions"
|
||||
:class="selectClasses"
|
||||
data-component-id="asset-filter-sort"
|
||||
@@ -43,7 +43,6 @@ import { ref } from 'vue'
|
||||
import MultiSelect from '@/components/input/MultiSelect.vue'
|
||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||
import type { SelectOption } from '@/components/input/types'
|
||||
import { t } from '@/i18n'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
export interface FilterState {
|
||||
@@ -75,10 +74,10 @@ const baseModelOptions = [
|
||||
// TODO: Make sortOptions configurable via props
|
||||
// Different asset types might need different sorting options
|
||||
const sortOptions = [
|
||||
{ name: t('assetBrowser.sortAZ'), value: 'name-asc' },
|
||||
{ name: t('assetBrowser.sortZA'), value: 'name-desc' },
|
||||
{ name: t('assetBrowser.sortRecent'), value: 'recent' },
|
||||
{ name: t('assetBrowser.sortPopular'), value: 'popular' }
|
||||
{ name: 'A-Z', value: 'name-asc' },
|
||||
{ name: 'Z-A', value: 'name-desc' },
|
||||
{ name: 'Recent', value: 'recent' },
|
||||
{ name: 'Popular', value: 'popular' }
|
||||
]
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { d, t } from '@/i18n'
|
||||
import { t } from '@/i18n'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { assetFilenameSchema } from '@/platform/assets/schemas/assetSchema'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import {
|
||||
getAssetBaseModel,
|
||||
getAssetDescription
|
||||
@@ -70,7 +69,7 @@ export function useAssetBrowser(assets: AssetItem[] = []) {
|
||||
|
||||
// Create display stats from API data
|
||||
const stats = {
|
||||
formattedDate: d(new Date(asset.created_at), { dateStyle: 'short' }),
|
||||
formattedDate: new Date(asset.created_at).toLocaleDateString(),
|
||||
downloadCount: undefined, // Not available in API
|
||||
stars: undefined // Not available in API
|
||||
}
|
||||
@@ -163,41 +162,12 @@ export function useAssetBrowser(assets: AssetItem[] = []) {
|
||||
return filtered.map(transformAssetForDisplay)
|
||||
})
|
||||
|
||||
/**
|
||||
* Asset selection that fetches full details and executes callback with filename
|
||||
* @param assetId - The asset ID to select and fetch details for
|
||||
* @param onSelect - Optional callback to execute with the asset filename
|
||||
*/
|
||||
async function selectAssetWithCallback(
|
||||
assetId: string,
|
||||
onSelect?: (filename: string) => void
|
||||
): Promise<void> {
|
||||
// Actions
|
||||
function selectAsset(asset: AssetDisplayItem): UUID {
|
||||
if (import.meta.env.DEV) {
|
||||
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)
|
||||
console.log('Asset selected:', asset.id, asset.name)
|
||||
}
|
||||
return asset.id
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -212,6 +182,7 @@ export function useAssetBrowser(assets: AssetItem[] = []) {
|
||||
filteredAssets,
|
||||
|
||||
// Actions
|
||||
selectAssetWithCallback
|
||||
selectAsset,
|
||||
transformAssetForDisplay
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { type DialogComponentProps, useDialogStore } from '@/stores/dialogStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
interface AssetBrowserDialogProps {
|
||||
/** ComfyUI node type for context (e.g., 'CheckpointLoaderSimple') */
|
||||
@@ -10,29 +8,36 @@ interface AssetBrowserDialogProps {
|
||||
inputName: string
|
||||
/** Current selected asset value */
|
||||
currentValue?: string
|
||||
/**
|
||||
* Callback for when an asset is selected
|
||||
* @param {string} filename - The validated filename from user_metadata.filename
|
||||
*/
|
||||
onAssetSelected?: (filename: string) => void
|
||||
/** Callback for when an asset is selected */
|
||||
onAssetSelected?: (assetPath: string) => void
|
||||
}
|
||||
|
||||
export const useAssetBrowserDialog = () => {
|
||||
const dialogStore = useDialogStore()
|
||||
const dialogKey = 'global-asset-browser'
|
||||
|
||||
async function show(props: AssetBrowserDialogProps) {
|
||||
const handleAssetSelected = (filename: string) => {
|
||||
props.onAssetSelected?.(filename)
|
||||
dialogStore.closeDialog({ key: dialogKey })
|
||||
function hide() {
|
||||
dialogStore.closeDialog({ key: dialogKey })
|
||||
}
|
||||
|
||||
function show(props: AssetBrowserDialogProps) {
|
||||
const handleAssetSelected = (assetPath: string) => {
|
||||
props.onAssetSelected?.(assetPath)
|
||||
hide() // Auto-close on selection
|
||||
}
|
||||
const dialogComponentProps: DialogComponentProps = {
|
||||
|
||||
const handleClose = () => {
|
||||
hide()
|
||||
}
|
||||
|
||||
// Default dialog configuration for AssetBrowserModal
|
||||
const dialogComponentProps = {
|
||||
headless: true,
|
||||
modal: true,
|
||||
closable: true,
|
||||
closable: false,
|
||||
pt: {
|
||||
root: {
|
||||
class: 'rounded-2xl overflow-hidden asset-browser-dialog'
|
||||
class: 'rounded-2xl overflow-hidden'
|
||||
},
|
||||
header: {
|
||||
class: 'p-0 hidden'
|
||||
@@ -43,17 +48,6 @@ 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,
|
||||
@@ -61,13 +55,12 @@ export const useAssetBrowserDialog = () => {
|
||||
nodeType: props.nodeType,
|
||||
inputName: props.inputName,
|
||||
currentValue: props.currentValue,
|
||||
assets,
|
||||
onSelect: handleAssetSelected,
|
||||
onClose: () => dialogStore.closeDialog({ key: dialogKey })
|
||||
onClose: handleClose
|
||||
},
|
||||
dialogComponentProps
|
||||
})
|
||||
}
|
||||
|
||||
return { show }
|
||||
return { show, hide }
|
||||
}
|
||||
|
||||
@@ -4,13 +4,13 @@ import { z } from 'zod'
|
||||
const zAsset = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
asset_hash: z.string().nullable(),
|
||||
asset_hash: z.string(),
|
||||
size: z.number(),
|
||||
mime_type: z.string().nullable(),
|
||||
mime_type: z.string(),
|
||||
tags: z.array(z.string()),
|
||||
preview_url: z.string().optional(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string().optional(),
|
||||
updated_at: z.string(),
|
||||
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,14 +33,6 @@ 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
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { fromZodError } from 'zod-validation-error'
|
||||
|
||||
import {
|
||||
type AssetItem,
|
||||
type AssetResponse,
|
||||
type ModelFile,
|
||||
type ModelFolder,
|
||||
@@ -128,75 +127,10 @@ 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<AssetItem[]> - Full asset objects with preserved metadata
|
||||
*/
|
||||
async function getAssetsForNodeType(nodeType: string): Promise<AssetItem[]> {
|
||||
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<AssetItem> - Complete asset object with user_metadata
|
||||
*/
|
||||
async function getAssetDetails(id: string): Promise<AssetItem> {
|
||||
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,
|
||||
getAssetsForNodeType,
|
||||
getAssetDetails
|
||||
isAssetBrowserEligible
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -595,7 +595,7 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
migrateDeprecatedValue: (value: any[]) => {
|
||||
return value.map((keybinding) => {
|
||||
if (keybinding['targetSelector'] === '#graph-canvas') {
|
||||
keybinding['targetElementId'] = 'graph-canvas-container'
|
||||
keybinding['targetElementId'] = 'graph-canvas'
|
||||
}
|
||||
return keybinding
|
||||
})
|
||||
|
||||
@@ -99,16 +99,6 @@ export const useCanvasStore = defineStore('canvas', () => {
|
||||
const currentGraph = shallowRef<LGraph | null>(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) => {
|
||||
@@ -132,7 +122,6 @@ export const useCanvasStore = defineStore('canvas', () => {
|
||||
return {
|
||||
canvas,
|
||||
selectedItems,
|
||||
selectedNodeIds,
|
||||
nodeSelected,
|
||||
groupSelected,
|
||||
rerouteSelected,
|
||||
|
||||
25
src/renderer/core/canvas/injectionKeys.ts
Normal file
25
src/renderer/core/canvas/injectionKeys.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
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<Ref<Set<string>>> =
|
||||
Symbol('selectedNodeIds')
|
||||
|
||||
/**
|
||||
* Injection key for providing executing node IDs to Vue node components.
|
||||
* Contains a reactive Set of currently executing node IDs (as strings).
|
||||
*/
|
||||
export const ExecutingNodeIdsKey: InjectionKey<Ref<Set<string>>> =
|
||||
Symbol('executingNodeIds')
|
||||
|
||||
/**
|
||||
* Injection key for providing node progress states to Vue node components.
|
||||
* Contains a reactive Record of node IDs to their current progress state.
|
||||
*/
|
||||
export const NodeProgressStatesKey: InjectionKey<
|
||||
Ref<Record<string, NodeProgressState>>
|
||||
> = Symbol('nodeProgressStates')
|
||||
@@ -7,11 +7,14 @@
|
||||
* Maintains backward compatibility with existing litegraph integration.
|
||||
*/
|
||||
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type { LLink } from '@/lib/litegraph/src/LLink'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LLink } from '@/lib/litegraph/src/LLink'
|
||||
import type { Reroute } from '@/lib/litegraph/src/Reroute'
|
||||
import type {
|
||||
CanvasColour,
|
||||
ReadOnlyPoint
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot,
|
||||
Point as LitegraphPoint
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
@@ -24,6 +27,7 @@ import {
|
||||
type ArrowShape,
|
||||
CanvasPathRenderer,
|
||||
type Direction,
|
||||
type DragLinkData,
|
||||
type LinkRenderData,
|
||||
type RenderContext as PathRenderContext,
|
||||
type Point,
|
||||
@@ -205,6 +209,7 @@ export class LitegraphLinkAdapter {
|
||||
case LinkDirection.DOWN:
|
||||
return 'down'
|
||||
case LinkDirection.CENTER:
|
||||
case LinkDirection.NONE:
|
||||
return 'none'
|
||||
default:
|
||||
return 'right'
|
||||
@@ -306,22 +311,22 @@ export class LitegraphLinkAdapter {
|
||||
* Critically: does nothing for CENTER/NONE directions (no case for them)
|
||||
*/
|
||||
private applySplineOffset(
|
||||
point: Point,
|
||||
point: LitegraphPoint,
|
||||
direction: LinkDirection,
|
||||
distance: number
|
||||
): void {
|
||||
switch (direction) {
|
||||
case LinkDirection.LEFT:
|
||||
point.x -= distance
|
||||
point[0] -= distance
|
||||
break
|
||||
case LinkDirection.RIGHT:
|
||||
point.x += distance
|
||||
point[0] += distance
|
||||
break
|
||||
case LinkDirection.UP:
|
||||
point.y -= distance
|
||||
point[1] -= distance
|
||||
break
|
||||
case LinkDirection.DOWN:
|
||||
point.y += distance
|
||||
point[1] += distance
|
||||
break
|
||||
// CENTER and NONE: no offset applied (original behavior)
|
||||
}
|
||||
@@ -333,8 +338,8 @@ export class LitegraphLinkAdapter {
|
||||
*/
|
||||
renderLinkDirect(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
a: ReadOnlyPoint,
|
||||
b: ReadOnlyPoint,
|
||||
a: LitegraphPoint,
|
||||
b: LitegraphPoint,
|
||||
link: LLink | null,
|
||||
skip_border: boolean,
|
||||
flow: number | boolean | null,
|
||||
@@ -344,8 +349,8 @@ export class LitegraphLinkAdapter {
|
||||
context: LinkRenderContext,
|
||||
extras: {
|
||||
reroute?: Reroute
|
||||
startControl?: ReadOnlyPoint
|
||||
endControl?: ReadOnlyPoint
|
||||
startControl?: LitegraphPoint
|
||||
endControl?: LitegraphPoint
|
||||
num_sublines?: number
|
||||
disabled?: boolean
|
||||
} = {}
|
||||
@@ -406,13 +411,19 @@ export class LitegraphLinkAdapter {
|
||||
y: a[1] + (extras.startControl![1] || 0)
|
||||
}
|
||||
const end = { x: b[0], y: b[1] }
|
||||
this.applySplineOffset(end, endDir, dist * factor)
|
||||
const endArray: LitegraphPoint = [end.x, end.y]
|
||||
this.applySplineOffset(endArray, endDir, dist * factor)
|
||||
end.x = endArray[0]
|
||||
end.y = endArray[1]
|
||||
cps.push(start, end)
|
||||
linkData.controlPoints = cps
|
||||
} else if (!hasStartCtrl && hasEndCtrl) {
|
||||
// End provided, derive start via direction offset (CENTER => no offset)
|
||||
const start = { x: a[0], y: a[1] }
|
||||
this.applySplineOffset(start, startDir, dist * factor)
|
||||
const startArray: LitegraphPoint = [start.x, start.y]
|
||||
this.applySplineOffset(startArray, startDir, dist * factor)
|
||||
start.x = startArray[0]
|
||||
start.y = startArray[1]
|
||||
const end = {
|
||||
x: b[0] + (extras.endControl![0] || 0),
|
||||
y: b[1] + (extras.endControl![1] || 0)
|
||||
@@ -423,8 +434,14 @@ export class LitegraphLinkAdapter {
|
||||
// Neither provided: derive both from directions (CENTER => no offset)
|
||||
const start = { x: a[0], y: a[1] }
|
||||
const end = { x: b[0], y: b[1] }
|
||||
this.applySplineOffset(start, startDir, dist * factor)
|
||||
this.applySplineOffset(end, endDir, dist * factor)
|
||||
const startArray: LitegraphPoint = [start.x, start.y]
|
||||
const endArray: LitegraphPoint = [end.x, end.y]
|
||||
this.applySplineOffset(startArray, startDir, dist * factor)
|
||||
this.applySplineOffset(endArray, endDir, dist * factor)
|
||||
start.x = startArray[0]
|
||||
start.y = startArray[1]
|
||||
end.x = endArray[0]
|
||||
end.y = endArray[1]
|
||||
cps.push(start, end)
|
||||
linkData.controlPoints = cps
|
||||
}
|
||||
@@ -449,7 +466,7 @@ export class LitegraphLinkAdapter {
|
||||
// Copy calculated center position back to litegraph object
|
||||
// This is needed for hit detection and menu interaction
|
||||
if (linkData.centerPos) {
|
||||
linkSegment._pos = linkSegment._pos || new Float32Array(2)
|
||||
linkSegment._pos = linkSegment._pos || [0, 0]
|
||||
linkSegment._pos[0] = linkData.centerPos.x
|
||||
linkSegment._pos[1] = linkData.centerPos.y
|
||||
|
||||
@@ -463,8 +480,8 @@ export class LitegraphLinkAdapter {
|
||||
if (this.enableLayoutStoreWrites && link && link.id !== -1) {
|
||||
// Calculate bounds and center only when writing
|
||||
const bounds = this.calculateLinkBounds(
|
||||
[linkData.startPoint.x, linkData.startPoint.y] as ReadOnlyPoint,
|
||||
[linkData.endPoint.x, linkData.endPoint.y] as ReadOnlyPoint,
|
||||
[linkData.startPoint.x, linkData.startPoint.y] as LitegraphPoint,
|
||||
[linkData.endPoint.x, linkData.endPoint.y] as LitegraphPoint,
|
||||
linkData
|
||||
)
|
||||
const centerPos = linkData.centerPos || {
|
||||
@@ -497,33 +514,57 @@ export class LitegraphLinkAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a link being dragged from a slot to mouse position
|
||||
* Used during link creation/reconnection
|
||||
*/
|
||||
renderDraggingLink(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
from: ReadOnlyPoint,
|
||||
to: ReadOnlyPoint,
|
||||
colour: CanvasColour,
|
||||
startDir: LinkDirection,
|
||||
endDir: LinkDirection,
|
||||
context: LinkRenderContext
|
||||
fromNode: LGraphNode | null,
|
||||
fromSlot: INodeOutputSlot | INodeInputSlot,
|
||||
fromSlotIndex: number,
|
||||
toPosition: LitegraphPoint,
|
||||
context: LinkRenderContext,
|
||||
options: {
|
||||
fromInput?: boolean
|
||||
color?: CanvasColour
|
||||
disabled?: boolean
|
||||
} = {}
|
||||
): void {
|
||||
this.renderLinkDirect(
|
||||
ctx,
|
||||
from,
|
||||
to,
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
colour,
|
||||
startDir,
|
||||
endDir,
|
||||
{
|
||||
...context,
|
||||
linkMarkerShape: LinkMarkerShape.None
|
||||
},
|
||||
{
|
||||
disabled: false
|
||||
}
|
||||
if (!fromNode) return
|
||||
|
||||
// Get slot position using layout tree if available
|
||||
const slotPos = getSlotPosition(
|
||||
fromNode,
|
||||
fromSlotIndex,
|
||||
options.fromInput || false
|
||||
)
|
||||
if (!slotPos) return
|
||||
|
||||
// Get slot direction
|
||||
const slotDir =
|
||||
fromSlot.dir ||
|
||||
(options.fromInput ? LinkDirection.LEFT : LinkDirection.RIGHT)
|
||||
|
||||
// Create drag data
|
||||
const dragData: DragLinkData = {
|
||||
fixedPoint: { x: slotPos[0], y: slotPos[1] },
|
||||
fixedDirection: this.convertDirection(slotDir),
|
||||
dragPoint: { x: toPosition[0], y: toPosition[1] },
|
||||
color: options.color ? String(options.color) : undefined,
|
||||
type: fromSlot.type !== undefined ? String(fromSlot.type) : undefined,
|
||||
disabled: options.disabled || false,
|
||||
fromInput: options.fromInput || false
|
||||
}
|
||||
|
||||
// Convert context
|
||||
const pathContext = this.convertToPathRenderContext(context)
|
||||
|
||||
// Hide center marker when dragging links
|
||||
pathContext.style.showCenterMarker = false
|
||||
|
||||
// Render using pure renderer
|
||||
this.pathRenderer.drawDraggingLink(ctx, dragData, pathContext)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -531,8 +572,8 @@ export class LitegraphLinkAdapter {
|
||||
* Includes padding for line width and control points
|
||||
*/
|
||||
private calculateLinkBounds(
|
||||
startPos: ReadOnlyPoint,
|
||||
endPos: ReadOnlyPoint,
|
||||
startPos: LitegraphPoint,
|
||||
endPos: LitegraphPoint,
|
||||
linkData: LinkRenderData
|
||||
): Bounds {
|
||||
let minX = Math.min(startPos[0], endPos[0])
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { InjectionKey } from 'vue'
|
||||
|
||||
import type { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
|
||||
import type { Point } from '@/renderer/core/layout/types'
|
||||
|
||||
/**
|
||||
* Lightweight, injectable transform state used by layout-aware components.
|
||||
@@ -21,11 +21,29 @@ import type { useTransformState } from '@/renderer/core/layout/transform/useTran
|
||||
* const state = inject(TransformStateKey)!
|
||||
* const screen = state.canvasToScreen({ x: 100, y: 50 })
|
||||
*/
|
||||
interface TransformState
|
||||
extends Pick<
|
||||
ReturnType<typeof useTransformState>,
|
||||
'screenToCanvas' | 'canvasToScreen' | 'camera' | 'isNodeInViewport'
|
||||
> {}
|
||||
interface TransformState {
|
||||
/** Convert a screen-space point (CSS pixels) to canvas space. */
|
||||
screenToCanvas: (p: Point) => Point
|
||||
/** Convert a canvas-space point to screen space (CSS pixels). */
|
||||
canvasToScreen: (p: Point) => Point
|
||||
/** Current pan/zoom; `x`/`y` are offsets, `z` is scale. */
|
||||
camera?: { x: number; y: number; z: number }
|
||||
/**
|
||||
* Test whether a node's rectangle intersects the (expanded) viewport.
|
||||
* Handy for viewport culling and lazy work.
|
||||
*
|
||||
* @param nodePos Top-left in canvas space `[x, y]`
|
||||
* @param nodeSize Size in canvas units `[width, height]`
|
||||
* @param viewport Screen-space viewport `{ width, height }`
|
||||
* @param margin Optional fractional margin (e.g. `0.2` = 20%)
|
||||
*/
|
||||
isNodeInViewport?: (
|
||||
nodePos: ArrayLike<number>,
|
||||
nodeSize: ArrayLike<number>,
|
||||
viewport: { width: number; height: number },
|
||||
margin?: number
|
||||
) => boolean
|
||||
}
|
||||
|
||||
export const TransformStateKey: InjectionKey<TransformState> =
|
||||
Symbol('transformState')
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import { LLink } from '@/lib/litegraph/src/LLink'
|
||||
import { Reroute } from '@/lib/litegraph/src/Reroute'
|
||||
import type { ReadOnlyPoint } from '@/lib/litegraph/src/interfaces'
|
||||
import type { Point as LitegraphPoint } from '@/lib/litegraph/src/interfaces'
|
||||
import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { LitegraphLinkAdapter } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
|
||||
import type { LinkRenderContext } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
|
||||
@@ -125,7 +125,7 @@ export function useLinkLayoutSync() {
|
||||
|
||||
// Special handling for floating input chain
|
||||
const isFloatingInputChain = !sourceNode && targetNode
|
||||
const startControl: ReadOnlyPoint = isFloatingInputChain
|
||||
const startControl: LitegraphPoint = isFloatingInputChain
|
||||
? [0, 0]
|
||||
: [dist * reroute.cos, dist * reroute.sin]
|
||||
|
||||
@@ -161,7 +161,7 @@ export function useLinkLayoutSync() {
|
||||
(endPos[1] - lastReroute.pos[1]) ** 2
|
||||
)
|
||||
const finalDist = Math.min(Reroute.maxSplineOffset, finalDistance * 0.25)
|
||||
const finalStartControl: ReadOnlyPoint = [
|
||||
const finalStartControl: LitegraphPoint = [
|
||||
finalDist * lastReroute.cos,
|
||||
finalDist * lastReroute.sin
|
||||
]
|
||||
|
||||
@@ -134,11 +134,7 @@ export function useSlotLayoutSync() {
|
||||
restoreHandlers = () => {
|
||||
graph.onNodeAdded = origNodeAdded || undefined
|
||||
graph.onNodeRemoved = origNodeRemoved || undefined
|
||||
// Only restore onTrigger if Vue nodes are not active
|
||||
// Vue node manager sets its own onTrigger handler
|
||||
if (!LiteGraph.vueNodesMode) {
|
||||
graph.onTrigger = origTrigger || undefined
|
||||
}
|
||||
graph.onTrigger = origTrigger || undefined
|
||||
graph.onAfterChange = origAfterChange || undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
:lod-level="lodLevel"
|
||||
:collapsed="isCollapsed"
|
||||
@collapse="handleCollapse"
|
||||
@update:title="handleHeaderTitleUpdate"
|
||||
@update:title="handleTitleUpdate"
|
||||
@enter-subgraph="handleEnterSubgraph"
|
||||
/>
|
||||
</div>
|
||||
@@ -101,6 +101,7 @@
|
||||
:node-data="nodeData"
|
||||
:readonly="readonly"
|
||||
:lod-level="lodLevel"
|
||||
@slot-click="handleSlotClick"
|
||||
/>
|
||||
|
||||
<!-- Widgets rendered at reduced+ detail -->
|
||||
@@ -138,22 +139,29 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, inject, onErrorCaptured, onMounted, provide, ref } from 'vue'
|
||||
import {
|
||||
computed,
|
||||
inject,
|
||||
onErrorCaptured,
|
||||
onMounted,
|
||||
provide,
|
||||
ref,
|
||||
toRef,
|
||||
watch
|
||||
} from 'vue'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
|
||||
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
|
||||
import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/composables/useNodePointerInteractions'
|
||||
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState'
|
||||
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
|
||||
import { LODLevel, useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
|
||||
import { useNodePreviewState } from '@/renderer/extensions/vueNodes/preview/useNodePreviewState'
|
||||
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
@@ -163,6 +171,7 @@ import {
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { useVueElementTracking } from '../composables/useVueNodeResizeTracking'
|
||||
import NodeContent from './NodeContent.vue'
|
||||
import NodeHeader from './NodeHeader.vue'
|
||||
import NodeSlots from './NodeSlots.vue'
|
||||
@@ -181,19 +190,39 @@ interface LGraphNodeProps {
|
||||
|
||||
const {
|
||||
nodeData,
|
||||
position = { x: 0, y: 0 },
|
||||
size = { width: 100, height: 50 },
|
||||
position,
|
||||
size,
|
||||
error = null,
|
||||
readonly = false,
|
||||
zoomLevel = 1
|
||||
} = defineProps<LGraphNodeProps>()
|
||||
|
||||
const { handleNodeCollapse, handleNodeTitleUpdate, handleNodeSelect } =
|
||||
useNodeEventHandlers()
|
||||
const emit = defineEmits<{
|
||||
'node-click': [
|
||||
event: PointerEvent,
|
||||
nodeData: VueNodeData,
|
||||
wasDragging: boolean
|
||||
]
|
||||
'slot-click': [
|
||||
event: PointerEvent,
|
||||
nodeData: VueNodeData,
|
||||
slotIndex: number,
|
||||
isInput: boolean
|
||||
]
|
||||
dragStart: [event: DragEvent, nodeData: VueNodeData]
|
||||
'update:collapsed': [nodeId: string, collapsed: boolean]
|
||||
'update:title': [nodeId: string, newTitle: string]
|
||||
}>()
|
||||
|
||||
useVueElementTracking(() => nodeData.id, 'node')
|
||||
useVueElementTracking(nodeData.id, 'node')
|
||||
|
||||
const { selectedNodeIds } = storeToRefs(useCanvasStore())
|
||||
// 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'
|
||||
)
|
||||
}
|
||||
|
||||
// Inject transform state for coordinate conversion
|
||||
const transformState = inject(TransformStateKey)
|
||||
@@ -204,7 +233,7 @@ const isSelected = computed(() => {
|
||||
})
|
||||
|
||||
// Use execution state composable
|
||||
const { executing, progress } = useNodeExecutionState(() => nodeData.id)
|
||||
const { executing, progress } = useNodeExecutionState(nodeData.id)
|
||||
|
||||
// Direct access to execution store for error state
|
||||
const executionStore = useExecutionStore()
|
||||
@@ -220,16 +249,22 @@ const hasAnyError = computed(
|
||||
const bypassed = computed((): boolean => nodeData.mode === 4)
|
||||
|
||||
// Use canvas interactions for proper wheel event handling and pointer event capture control
|
||||
const { handleWheel, shouldHandleNodePointerEvents } = useCanvasInteractions()
|
||||
const {
|
||||
handleWheel,
|
||||
handlePointer,
|
||||
forwardEventToCanvas,
|
||||
shouldHandleNodePointerEvents
|
||||
} = useCanvasInteractions()
|
||||
|
||||
// LOD (Level of Detail) system based on zoom level
|
||||
const zoomRef = toRef(() => zoomLevel)
|
||||
const {
|
||||
lodLevel,
|
||||
shouldRenderWidgets,
|
||||
shouldRenderSlots,
|
||||
shouldRenderContent,
|
||||
lodCssClass
|
||||
} = useLOD(() => zoomLevel)
|
||||
} = useLOD(zoomRef)
|
||||
|
||||
// Computed properties for template usage
|
||||
const isMinimalLOD = computed(() => lodLevel.value === LODLevel.MINIMAL)
|
||||
@@ -248,15 +283,11 @@ onErrorCaptured((error) => {
|
||||
const {
|
||||
position: layoutPosition,
|
||||
zIndex,
|
||||
startDrag,
|
||||
handleDrag: handleLayoutDrag,
|
||||
endDrag,
|
||||
resize
|
||||
} = useNodeLayout(() => nodeData.id)
|
||||
const {
|
||||
handlePointerDown,
|
||||
handlePointerUp,
|
||||
handlePointerMove,
|
||||
isDragging,
|
||||
dragStyle
|
||||
} = useNodePointerInteractions(() => nodeData, handleNodeSelect)
|
||||
} = useNodeLayout(nodeData.id)
|
||||
|
||||
onMounted(() => {
|
||||
if (size && transformState?.camera) {
|
||||
@@ -269,8 +300,28 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// Drag state for styling
|
||||
const isDragging = ref(false)
|
||||
const dragStyle = computed(() => ({
|
||||
cursor: isDragging.value ? 'grabbing' : 'grab'
|
||||
}))
|
||||
const lastY = ref(0)
|
||||
const lastX = ref(0)
|
||||
// Treat tiny pointer jitter as a click, not a drag
|
||||
const DRAG_THRESHOLD_PX = 4
|
||||
|
||||
// Track collapsed state
|
||||
const isCollapsed = computed(() => nodeData.flags?.collapsed ?? false)
|
||||
const isCollapsed = ref(nodeData.flags?.collapsed ?? false)
|
||||
|
||||
// Watch for external changes to the collapsed state
|
||||
watch(
|
||||
() => nodeData.flags?.collapsed,
|
||||
(newCollapsed: boolean | undefined) => {
|
||||
if (newCollapsed !== undefined && newCollapsed !== isCollapsed.value) {
|
||||
isCollapsed.value = newCollapsed
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Check if node has custom content (like image outputs)
|
||||
const hasCustomContent = computed(() => {
|
||||
@@ -279,30 +330,21 @@ const hasCustomContent = computed(() => {
|
||||
})
|
||||
|
||||
// Computed classes and conditions for better reusability
|
||||
const separatorClasses = cn(
|
||||
const separatorClasses =
|
||||
'bg-sand-100 dark-theme:bg-charcoal-600 h-px mx-0 w-full'
|
||||
)
|
||||
const progressClasses = cn('h-2 bg-primary-500 transition-all duration-300')
|
||||
const progressClasses = 'h-2 bg-primary-500 transition-all duration-300'
|
||||
|
||||
const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState(
|
||||
() => nodeData.id,
|
||||
nodeData.id,
|
||||
{
|
||||
isMinimalLOD,
|
||||
isCollapsed
|
||||
}
|
||||
)
|
||||
|
||||
// Check if any widget bypasses LOD restrictions
|
||||
const hasLODBypassWidgets = computed(() => {
|
||||
if (!nodeData.widgets?.length) return false
|
||||
return nodeData.widgets.some((w: any) => w.options?.bypassLOD === true)
|
||||
})
|
||||
|
||||
// Common condition computations to avoid repetition
|
||||
const shouldShowWidgets = computed(
|
||||
() =>
|
||||
(shouldRenderWidgets.value || hasLODBypassWidgets.value) &&
|
||||
nodeData.widgets?.length
|
||||
() => shouldRenderWidgets.value && nodeData.widgets?.length
|
||||
)
|
||||
|
||||
const shouldShowContent = computed(
|
||||
@@ -333,12 +375,86 @@ const outlineClass = computed(() => {
|
||||
})
|
||||
|
||||
// Event handlers
|
||||
const handleCollapse = () => {
|
||||
handleNodeCollapse(nodeData.id, !isCollapsed.value)
|
||||
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 handleHeaderTitleUpdate = (newTitle: string) => {
|
||||
handleNodeTitleUpdate(nodeData.id, newTitle)
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
// Check if this should be forwarded to canvas (e.g., space panning, middle mouse)
|
||||
handlePointer(event)
|
||||
|
||||
if (isDragging.value) {
|
||||
void handleLayoutDrag(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
|
||||
emit('node-click', event, nodeData, wasDragging)
|
||||
}
|
||||
|
||||
const handleCollapse = () => {
|
||||
isCollapsed.value = !isCollapsed.value
|
||||
// Emit event so parent can sync with LiteGraph if needed
|
||||
emit('update:collapsed', nodeData.id, isCollapsed.value)
|
||||
}
|
||||
|
||||
const handleSlotClick = (
|
||||
event: PointerEvent,
|
||||
slotIndex: number,
|
||||
isInput: boolean
|
||||
) => {
|
||||
if (!nodeData) {
|
||||
console.warn('LGraphNode: nodeData is null/undefined in handleSlotClick')
|
||||
return
|
||||
}
|
||||
|
||||
// Don't handle slot clicks when canvas is in panning mode
|
||||
if (!shouldHandleNodePointerEvents.value) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('slot-click', event, nodeData, slotIndex, isInput)
|
||||
}
|
||||
|
||||
const handleTitleUpdate = (newTitle: string) => {
|
||||
emit('update:title', nodeData.id, newTitle)
|
||||
}
|
||||
|
||||
const handleEnterSubgraph = () => {
|
||||
@@ -368,17 +484,15 @@ const handleEnterSubgraph = () => {
|
||||
|
||||
const nodeOutputs = useNodeOutputStore()
|
||||
|
||||
const nodeOutputLocatorId = computed(() =>
|
||||
nodeData.subgraphId ? `${nodeData.subgraphId}:${nodeData.id}` : nodeData.id
|
||||
)
|
||||
const nodeImageUrls = computed(() => {
|
||||
const newOutputs = nodeOutputs.nodeOutputs[nodeOutputLocatorId.value]
|
||||
const nodeImageUrls = ref<string[]>([])
|
||||
const onNodeOutputsUpdate = (newOutputs: ExecutedWsMessage['output']) => {
|
||||
const locatorId = getLocatorIdFromNodeData(nodeData)
|
||||
|
||||
// Use root graph for getNodeByLocatorId since it needs to traverse from root
|
||||
const rootGraph = app.graph?.rootGraph || app.graph
|
||||
if (!rootGraph) {
|
||||
return []
|
||||
nodeImageUrls.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const node = getNodeByLocatorId(rootGraph, locatorId)
|
||||
@@ -386,13 +500,30 @@ const nodeImageUrls = computed(() => {
|
||||
if (node && newOutputs?.images?.length) {
|
||||
const urls = nodeOutputs.getNodeImageUrls(node)
|
||||
if (urls) {
|
||||
return urls
|
||||
nodeImageUrls.value = urls
|
||||
}
|
||||
} else {
|
||||
// Clear URLs if no outputs or no images
|
||||
nodeImageUrls.value = []
|
||||
}
|
||||
// Clear URLs if no outputs or no images
|
||||
return []
|
||||
})
|
||||
}
|
||||
|
||||
const nodeContainerRef = ref()
|
||||
const nodeOutputLocatorId = computed(() =>
|
||||
nodeData.subgraphId ? `${nodeData.subgraphId}:${nodeData.id}` : nodeData.id
|
||||
)
|
||||
|
||||
watch(
|
||||
() => nodeOutputs.nodeOutputs[nodeOutputLocatorId.value],
|
||||
(newOutputs) => {
|
||||
onNodeOutputsUpdate(newOutputs)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// Template ref for tooltip positioning
|
||||
const nodeContainerRef = ref<HTMLElement>()
|
||||
|
||||
// Provide nodeImageUrls and tooltip container to child components
|
||||
provide('nodeImageUrls', nodeImageUrls)
|
||||
provide('tooltipContainer', nodeContainerRef)
|
||||
</script>
|
||||
|
||||
@@ -63,6 +63,7 @@ import EditableText from '@/components/common/EditableText.vue'
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
|
||||
import type { LODLevel } from '@/renderer/extensions/vueNodes/lod/useLOD'
|
||||
import { app } from '@/scripts/app'
|
||||
import {
|
||||
getLocatorIdFromNodeData,
|
||||
@@ -72,6 +73,7 @@ import {
|
||||
interface NodeHeaderProps {
|
||||
nodeData?: VueNodeData
|
||||
readonly?: boolean
|
||||
lodLevel?: LODLevel
|
||||
collapsed?: boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ import { computed, onErrorCaptured, ref } from 'vue'
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import type { INodeSlot } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LODLevel } from '@/renderer/extensions/vueNodes/lod/useLOD'
|
||||
import { isSlotObject } from '@/utils/typeGuardUtil'
|
||||
|
||||
import InputSlot from './InputSlot.vue'
|
||||
@@ -43,6 +44,7 @@ import OutputSlot from './OutputSlot.vue'
|
||||
interface NodeSlotsProps {
|
||||
nodeData?: VueNodeData
|
||||
readonly?: boolean
|
||||
lodLevel?: LODLevel
|
||||
}
|
||||
|
||||
const { nodeData = null, readonly } = defineProps<NodeSlotsProps>()
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
: 'pointer-events-none'
|
||||
)
|
||||
"
|
||||
@pointerdown.stop="handleWidgetPointerEvent"
|
||||
@pointermove.stop="handleWidgetPointerEvent"
|
||||
@pointerup.stop="handleWidgetPointerEvent"
|
||||
@pointerdown="handleWidgetPointerEvent"
|
||||
@pointermove="handleWidgetPointerEvent"
|
||||
@pointerup="handleWidgetPointerEvent"
|
||||
>
|
||||
<div
|
||||
v-for="(widget, index) in processedWidgets"
|
||||
@@ -125,22 +125,17 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
const widgets = nodeData.widgets as SafeWidgetData[]
|
||||
const result: ProcessedWidget[] = []
|
||||
|
||||
if (lodLevel === LODLevel.MINIMAL) {
|
||||
return []
|
||||
}
|
||||
|
||||
for (const widget of widgets) {
|
||||
if (widget.options?.hidden) continue
|
||||
if (widget.options?.canvasOnly) continue
|
||||
if (!widget.type) continue
|
||||
if (!shouldRenderAsVue(widget)) continue
|
||||
|
||||
const bypassLOD = widget.options?.bypassLOD === true
|
||||
|
||||
if (!bypassLOD) {
|
||||
if (
|
||||
lodLevel === LODLevel.MINIMAL ||
|
||||
(lodLevel === LODLevel.REDUCED && !isEssential(widget.type))
|
||||
) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (lodLevel === LODLevel.REDUCED && !isEssential(widget.type)) continue
|
||||
|
||||
const vueComponent = getComponent(widget.type) || WidgetInputText
|
||||
|
||||
|
||||
@@ -8,17 +8,19 @@
|
||||
* - Layout mutations for visual feedback
|
||||
* - Integration with LiteGraph canvas selection system
|
||||
*/
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
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'
|
||||
|
||||
function useNodeEventHandlersIndividual() {
|
||||
interface NodeManager {
|
||||
getNode: (id: string) => any
|
||||
}
|
||||
|
||||
export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
|
||||
const canvasStore = useCanvasStore()
|
||||
const { nodeManager } = useVueNodeLifecycle()
|
||||
const { bringNodeToFront } = useNodeZIndex()
|
||||
const { shouldHandleNodePointerEvents } = useCanvasInteractions()
|
||||
|
||||
@@ -38,7 +40,7 @@ function useNodeEventHandlersIndividual() {
|
||||
const node = nodeManager.value.getNode(nodeData.id)
|
||||
if (!node) return
|
||||
|
||||
const isMultiSelect = event.ctrlKey || event.metaKey || event.shiftKey
|
||||
const isMultiSelect = event.ctrlKey || event.metaKey
|
||||
|
||||
if (isMultiSelect) {
|
||||
// Ctrl/Cmd+click -> toggle selection
|
||||
@@ -82,7 +84,6 @@ function useNodeEventHandlersIndividual() {
|
||||
const currentCollapsed = node.flags?.collapsed ?? false
|
||||
if (currentCollapsed !== collapsed) {
|
||||
node.collapse()
|
||||
nodeManager.value.scheduleUpdate(nodeId, 'critical')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,7 +237,3 @@ function useNodeEventHandlersIndividual() {
|
||||
deselectNodes
|
||||
}
|
||||
}
|
||||
|
||||
export const useNodeEventHandlers = createSharedComposable(
|
||||
useNodeEventHandlersIndividual
|
||||
)
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
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<VueNodeData>,
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -93,10 +93,10 @@ export function useNodeTooltips(
|
||||
pt: {
|
||||
text: {
|
||||
class:
|
||||
'bg-charcoal-800 border border-slate-300 rounded-md px-4 py-2 text-white text-sm font-normal leading-tight max-w-75 shadow-none'
|
||||
'bg-charcoal-100 border border-slate-300 rounded-md px-4 py-2 text-white text-sm font-normal leading-tight max-w-75 shadow-none'
|
||||
},
|
||||
arrow: {
|
||||
class: 'before:border-slate-300'
|
||||
class: 'before:border-charcoal-100'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,7 @@
|
||||
* Supports different element types (nodes, slots, widgets, etc.) with
|
||||
* customizable data attributes and update handlers.
|
||||
*/
|
||||
import {
|
||||
type MaybeRefOrGetter,
|
||||
getCurrentInstance,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
toValue
|
||||
} from 'vue'
|
||||
import { getCurrentInstance, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -160,10 +154,9 @@ const resizeObserver = new ResizeObserver((entries) => {
|
||||
* ```
|
||||
*/
|
||||
export function useVueElementTracking(
|
||||
appIdentifierMaybe: MaybeRefOrGetter<string>,
|
||||
appIdentifier: string,
|
||||
trackingType: string
|
||||
) {
|
||||
const appIdentifier = toValue(appIdentifierMaybe)
|
||||
onMounted(() => {
|
||||
const element = getCurrentInstance()?.proxy?.$el
|
||||
if (!(element instanceof HTMLElement) || !appIdentifier) return
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, provide } from 'vue'
|
||||
|
||||
import {
|
||||
ExecutingNodeIdsKey,
|
||||
NodeProgressStatesKey
|
||||
} from '@/renderer/core/canvas/injectionKeys'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
|
||||
/**
|
||||
* Composable for providing execution state to Vue node children
|
||||
*
|
||||
* This composable sets up the execution state providers that can be injected
|
||||
* by child Vue nodes using useNodeExecutionState.
|
||||
*
|
||||
* Should be used in the parent component that manages Vue nodes (e.g., GraphCanvas).
|
||||
*/
|
||||
export const useExecutionStateProvider = () => {
|
||||
const executionStore = useExecutionStore()
|
||||
const { executingNodeIds: storeExecutingNodeIds, nodeProgressStates } =
|
||||
storeToRefs(executionStore)
|
||||
|
||||
// Convert execution store data to the format expected by Vue nodes
|
||||
const executingNodeIds = computed(
|
||||
() => new Set(storeExecutingNodeIds.value.map(String))
|
||||
)
|
||||
|
||||
// Provide the execution state to all child Vue nodes
|
||||
provide(ExecutingNodeIdsKey, executingNodeIds)
|
||||
provide(NodeProgressStatesKey, nodeProgressStates)
|
||||
|
||||
return {
|
||||
executingNodeIds,
|
||||
nodeProgressStates
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { type MaybeRefOrGetter, computed, toValue } from 'vue'
|
||||
import { computed, inject, ref } from 'vue'
|
||||
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import {
|
||||
ExecutingNodeIdsKey,
|
||||
NodeProgressStatesKey
|
||||
} from '@/renderer/core/canvas/injectionKeys'
|
||||
import type { NodeProgressState } from '@/schemas/apiSchema'
|
||||
|
||||
/**
|
||||
* Composable for managing execution state of Vue-based nodes
|
||||
@@ -9,18 +12,18 @@ import { useExecutionStore } from '@/stores/executionStore'
|
||||
* Provides reactive access to execution state and progress for a specific node
|
||||
* by injecting execution data from the parent GraphCanvas provider.
|
||||
*
|
||||
* @param nodeIdMaybe - The ID of the node to track execution state for
|
||||
* @param nodeId - The ID of the node to track execution state for
|
||||
* @returns Object containing reactive execution state and progress
|
||||
*/
|
||||
export const useNodeExecutionState = (
|
||||
nodeIdMaybe: MaybeRefOrGetter<string>
|
||||
) => {
|
||||
const nodeId = toValue(nodeIdMaybe)
|
||||
const { uniqueExecutingNodeIdStrings, nodeProgressStates } =
|
||||
storeToRefs(useExecutionStore())
|
||||
export const useNodeExecutionState = (nodeId: string) => {
|
||||
const executingNodeIds = inject(ExecutingNodeIdsKey, ref(new Set<string>()))
|
||||
const nodeProgressStates = inject(
|
||||
NodeProgressStatesKey,
|
||||
ref<Record<string, NodeProgressState>>({})
|
||||
)
|
||||
|
||||
const executing = computed(() => {
|
||||
return uniqueExecutingNodeIdStrings.value.has(nodeId)
|
||||
return executingNodeIds.value.has(nodeId)
|
||||
})
|
||||
|
||||
const progress = computed(() => {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { type MaybeRefOrGetter, computed, inject, toValue } from 'vue'
|
||||
/**
|
||||
* Composable for individual Vue node components
|
||||
*
|
||||
* Uses customRef for shared write access with Canvas renderer.
|
||||
* Provides dragging functionality and reactive layout state.
|
||||
*/
|
||||
import { computed, inject } from 'vue'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
|
||||
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
@@ -11,16 +16,15 @@ import { LayoutSource, type Point } from '@/renderer/core/layout/types'
|
||||
* Composable for individual Vue node components
|
||||
* Uses customRef for shared write access with Canvas renderer
|
||||
*/
|
||||
export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
|
||||
const nodeId = toValue(nodeIdMaybe)
|
||||
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 = layoutStore.getNodeLayoutRef(nodeId)
|
||||
const layoutRef = store.getNodeLayoutRef(nodeId)
|
||||
|
||||
// Computed properties for easy access
|
||||
const position = computed(() => {
|
||||
@@ -49,6 +53,8 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
|
||||
let dragStartMouse: Point | null = null
|
||||
let otherSelectedNodesStartPositions: Map<string, Point> | null = null
|
||||
|
||||
const selectedNodeIds = inject(SelectedNodeIdsKey, null)
|
||||
|
||||
/**
|
||||
* Start dragging the node
|
||||
*/
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
* <NodeSlots v-if="shouldRenderSlots" />
|
||||
* ```
|
||||
*/
|
||||
import { type MaybeRefOrGetter, computed, readonly, toRef } from 'vue'
|
||||
import { type Ref, computed, readonly } from 'vue'
|
||||
|
||||
export enum LODLevel {
|
||||
MINIMAL = 'minimal', // zoom <= 0.4
|
||||
@@ -78,8 +78,7 @@ const LOD_CONFIGS: Record<LODLevel, LODConfig> = {
|
||||
* @param zoomRef - Reactive reference to current zoom level (camera.z)
|
||||
* @returns LOD state and configuration
|
||||
*/
|
||||
export function useLOD(zoomRefMaybe: MaybeRefOrGetter<number>) {
|
||||
const zoomRef = toRef(zoomRefMaybe)
|
||||
export function useLOD(zoomRef: Ref<number>) {
|
||||
// Continuous LOD score (0-1) for smooth transitions
|
||||
const lodScore = computed(() => {
|
||||
const zoom = zoomRef.value
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { type MaybeRefOrGetter, type Ref, computed, toValue } from 'vue'
|
||||
import { type Ref, computed } from 'vue'
|
||||
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
|
||||
export const useNodePreviewState = (
|
||||
nodeIdMaybe: MaybeRefOrGetter<string>,
|
||||
nodeId: string,
|
||||
options?: {
|
||||
isMinimalLOD?: Ref<boolean>
|
||||
isCollapsed?: Ref<boolean>
|
||||
}
|
||||
) => {
|
||||
const nodeId = toValue(nodeIdMaybe)
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { nodePreviewImages } = storeToRefs(useNodeOutputStore())
|
||||
|
||||
|
||||
@@ -3,9 +3,10 @@ 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 { isAssetWidget, isComboWidget } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
IComboWidget
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
|
||||
@@ -72,29 +73,11 @@ const addComboWidget = (
|
||||
const currentValue = getDefaultValue(inputSpec)
|
||||
const displayLabel = currentValue ?? t('widgets.selectModel')
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
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}`
|
||||
)
|
||||
})
|
||||
|
||||
return widget
|
||||
}
|
||||
@@ -113,14 +96,11 @@ 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: widget as IComboWidget
|
||||
})
|
||||
if (inputSpec.remote.refresh_button) remoteWidget.addRefreshButton()
|
||||
|
||||
@@ -136,19 +116,16 @@ 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,
|
||||
widget as IComboWidget,
|
||||
undefined,
|
||||
undefined,
|
||||
transformInputSpecV2ToV1(inputSpec)
|
||||
)
|
||||
}
|
||||
|
||||
return widget
|
||||
return widget as IBaseWidget
|
||||
}
|
||||
|
||||
export const useComboWidget = () => {
|
||||
|
||||
@@ -596,10 +596,7 @@ export class ComfyApp {
|
||||
const keybindingStore = useKeybindingStore()
|
||||
const keybinding = keybindingStore.getKeybinding(keyCombo)
|
||||
|
||||
if (
|
||||
keybinding &&
|
||||
keybinding.targetElementId === 'graph-canvas-container'
|
||||
) {
|
||||
if (keybinding && keybinding.targetElementId === 'graph-canvas') {
|
||||
useCommandStore().execute(keybinding.commandId)
|
||||
|
||||
this.graph.change()
|
||||
|
||||
@@ -43,57 +43,6 @@ interface QueuedPrompt {
|
||||
workflow?: ComfyWorkflow
|
||||
}
|
||||
|
||||
const subgraphNodeIdToSubgraph = (id: string, graph: LGraph | Subgraph) => {
|
||||
const node = graph.getNodeById(id)
|
||||
if (node?.isSubgraphNode()) return node.subgraph
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively get the subgraph objects for the given subgraph instance IDs
|
||||
* @param currentGraph The current graph
|
||||
* @param subgraphNodeIds The instance IDs
|
||||
* @param subgraphs The subgraphs
|
||||
* @returns The subgraphs that correspond to each of the instance IDs.
|
||||
*/
|
||||
function getSubgraphsFromInstanceIds(
|
||||
currentGraph: LGraph | Subgraph,
|
||||
subgraphNodeIds: string[],
|
||||
subgraphs: Subgraph[] = []
|
||||
): Subgraph[] {
|
||||
// Last segment is the node portion; nothing to do.
|
||||
if (subgraphNodeIds.length === 1) return subgraphs
|
||||
|
||||
const currentPart = subgraphNodeIds.shift()
|
||||
if (currentPart === undefined) return subgraphs
|
||||
|
||||
const subgraph = subgraphNodeIdToSubgraph(currentPart, currentGraph)
|
||||
if (!subgraph) throw new Error(`Subgraph not found: ${currentPart}`)
|
||||
|
||||
subgraphs.push(subgraph)
|
||||
return getSubgraphsFromInstanceIds(subgraph, subgraphNodeIds, subgraphs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert execution context node IDs to NodeLocatorIds
|
||||
* @param nodeId The node ID from execution context (could be execution ID)
|
||||
* @returns The NodeLocatorId
|
||||
*/
|
||||
function executionIdToNodeLocatorId(nodeId: string | number): NodeLocatorId {
|
||||
const nodeIdStr = String(nodeId)
|
||||
|
||||
if (!nodeIdStr.includes(':')) {
|
||||
// It's a top-level node ID
|
||||
return nodeIdStr
|
||||
}
|
||||
|
||||
// It's an execution node ID
|
||||
const parts = nodeIdStr.split(':')
|
||||
const localNodeId = parts[parts.length - 1]
|
||||
const subgraphs = getSubgraphsFromInstanceIds(app.graph, parts)
|
||||
const nodeLocatorId = createNodeLocatorId(subgraphs.at(-1)!.id, localNodeId)
|
||||
return nodeLocatorId
|
||||
}
|
||||
|
||||
export const useExecutionStore = defineStore('execution', () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
@@ -106,6 +55,29 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
// This is the progress of all nodes in the currently executing workflow
|
||||
const nodeProgressStates = ref<Record<string, NodeProgressState>>({})
|
||||
|
||||
/**
|
||||
* Convert execution context node IDs to NodeLocatorIds
|
||||
* @param nodeId The node ID from execution context (could be execution ID)
|
||||
* @returns The NodeLocatorId
|
||||
*/
|
||||
const executionIdToNodeLocatorId = (
|
||||
nodeId: string | number
|
||||
): NodeLocatorId => {
|
||||
const nodeIdStr = String(nodeId)
|
||||
|
||||
if (!nodeIdStr.includes(':')) {
|
||||
// It's a top-level node ID
|
||||
return nodeIdStr
|
||||
}
|
||||
|
||||
// It's an execution node ID
|
||||
const parts = nodeIdStr.split(':')
|
||||
const localNodeId = parts[parts.length - 1]
|
||||
const subgraphs = getSubgraphsFromInstanceIds(app.graph, parts)
|
||||
const nodeLocatorId = createNodeLocatorId(subgraphs.at(-1)!.id, localNodeId)
|
||||
return nodeLocatorId
|
||||
}
|
||||
|
||||
const mergeExecutionProgressStates = (
|
||||
currentState: NodeProgressState | undefined,
|
||||
newState: NodeProgressState
|
||||
@@ -167,13 +139,9 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
|
||||
// @deprecated For backward compatibility - stores the primary executing node ID
|
||||
const executingNodeId = computed<NodeId | null>(() => {
|
||||
return executingNodeIds.value[0] ?? null
|
||||
return executingNodeIds.value.length > 0 ? executingNodeIds.value[0] : null
|
||||
})
|
||||
|
||||
const uniqueExecutingNodeIdStrings = computed(
|
||||
() => new Set(executingNodeIds.value.map(String))
|
||||
)
|
||||
|
||||
// For backward compatibility - returns the primary executing node
|
||||
const executingNode = computed<ComfyNode | null>(() => {
|
||||
if (!executingNodeId.value) return null
|
||||
@@ -191,6 +159,36 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
)
|
||||
})
|
||||
|
||||
const subgraphNodeIdToSubgraph = (id: string, graph: LGraph | Subgraph) => {
|
||||
const node = graph.getNodeById(id)
|
||||
if (node?.isSubgraphNode()) return node.subgraph
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively get the subgraph objects for the given subgraph instance IDs
|
||||
* @param currentGraph The current graph
|
||||
* @param subgraphNodeIds The instance IDs
|
||||
* @param subgraphs The subgraphs
|
||||
* @returns The subgraphs that correspond to each of the instance IDs.
|
||||
*/
|
||||
const getSubgraphsFromInstanceIds = (
|
||||
currentGraph: LGraph | Subgraph,
|
||||
subgraphNodeIds: string[],
|
||||
subgraphs: Subgraph[] = []
|
||||
): Subgraph[] => {
|
||||
// Last segment is the node portion; nothing to do.
|
||||
if (subgraphNodeIds.length === 1) return subgraphs
|
||||
|
||||
const currentPart = subgraphNodeIds.shift()
|
||||
if (currentPart === undefined) return subgraphs
|
||||
|
||||
const subgraph = subgraphNodeIdToSubgraph(currentPart, currentGraph)
|
||||
if (!subgraph) throw new Error(`Subgraph not found: ${currentPart}`)
|
||||
|
||||
subgraphs.push(subgraph)
|
||||
return getSubgraphsFromInstanceIds(subgraph, subgraphNodeIds, subgraphs)
|
||||
}
|
||||
|
||||
// This is the progress of the currently executing node (for backward compatibility)
|
||||
const _executingNodeProgress = ref<ProgressWsMessage | null>(null)
|
||||
const executingNodeProgress = computed(() =>
|
||||
@@ -425,25 +423,66 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
return {
|
||||
isIdle,
|
||||
clientId,
|
||||
/**
|
||||
* The id of the prompt that is currently being executed
|
||||
*/
|
||||
activePromptId,
|
||||
/**
|
||||
* The queued prompts
|
||||
*/
|
||||
queuedPrompts,
|
||||
/**
|
||||
* The node errors from the previous execution.
|
||||
*/
|
||||
lastNodeErrors,
|
||||
/**
|
||||
* The error from the previous execution.
|
||||
*/
|
||||
lastExecutionError,
|
||||
/**
|
||||
* Local node ID for the most recent execution error.
|
||||
*/
|
||||
lastExecutionErrorNodeId,
|
||||
/**
|
||||
* The id of the node that is currently being executed (backward compatibility)
|
||||
*/
|
||||
executingNodeId,
|
||||
/**
|
||||
* The list of all nodes that are currently executing
|
||||
*/
|
||||
executingNodeIds,
|
||||
/**
|
||||
* The prompt that is currently being executed
|
||||
*/
|
||||
activePrompt,
|
||||
/**
|
||||
* The total number of nodes to execute
|
||||
*/
|
||||
totalNodesToExecute,
|
||||
/**
|
||||
* The number of nodes that have been executed
|
||||
*/
|
||||
nodesExecuted,
|
||||
/**
|
||||
* The progress of the execution
|
||||
*/
|
||||
executionProgress,
|
||||
/**
|
||||
* The node that is currently being executed (backward compatibility)
|
||||
*/
|
||||
executingNode,
|
||||
/**
|
||||
* The progress of the executing node (backward compatibility)
|
||||
*/
|
||||
executingNodeProgress,
|
||||
/**
|
||||
* All node progress states from progress_state events
|
||||
*/
|
||||
nodeProgressStates,
|
||||
nodeLocationProgressStates,
|
||||
bindExecutionEvents,
|
||||
unbindExecutionEvents,
|
||||
storePrompt,
|
||||
uniqueExecutingNodeIdStrings,
|
||||
// Raw executing progress data for backward compatibility in ComfyApp.
|
||||
_executingNodeProgress,
|
||||
// NodeLocatorId conversion helpers
|
||||
|
||||
@@ -33,43 +33,12 @@ export const useModelToNodeStore = defineStore('modelToNode', () => {
|
||||
)
|
||||
})
|
||||
|
||||
/** Internal computed for efficient reverse lookup: nodeType -> category */
|
||||
const nodeTypeToCategory = computed(() => {
|
||||
const lookup: Record<string, string> = {}
|
||||
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<string> {
|
||||
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.
|
||||
@@ -140,7 +109,6 @@ export const useModelToNodeStore = defineStore('modelToNode', () => {
|
||||
return {
|
||||
modelToNodeMap,
|
||||
getRegisteredNodeTypes,
|
||||
getCategoryForNodeType,
|
||||
getNodeProvider,
|
||||
getAllNodeProviders,
|
||||
registerNodeProvider,
|
||||
|
||||
2
src/types/litegraph-augmentation.d.ts
vendored
2
src/types/litegraph-augmentation.d.ts
vendored
@@ -82,7 +82,7 @@ declare module '@/lib/litegraph/src/litegraph' {
|
||||
}
|
||||
|
||||
// Add interface augmentations into the class itself
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
interface BaseWidget extends IBaseWidget {}
|
||||
|
||||
interface LGraphNode {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
|
||||
import type { Rect } from '@/lib/litegraph/src/interfaces'
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
|
||||
/**
|
||||
@@ -33,9 +33,7 @@ export const lcm = (a: number, b: number): number => {
|
||||
* @param rectangles - Array of rectangle tuples in [x, y, width, height] format
|
||||
* @returns Bounds object with union rectangle, or null if no rectangles provided
|
||||
*/
|
||||
export function computeUnionBounds(
|
||||
rectangles: readonly ReadOnlyRect[]
|
||||
): Bounds | null {
|
||||
export function computeUnionBounds(rectangles: readonly Rect[]): Bounds | null {
|
||||
const n = rectangles.length
|
||||
if (n === 0) {
|
||||
return null
|
||||
|
||||
@@ -33,7 +33,6 @@ 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'
|
||||
@@ -254,30 +253,33 @@ void nextTick(() => {
|
||||
})
|
||||
|
||||
const onGraphReady = () => {
|
||||
runWhenGlobalIdle(() => {
|
||||
// Setting values now available after comfyApp.setup.
|
||||
// Load keybindings.
|
||||
wrapWithErrorHandling(useKeybindingService().registerUserKeybindings)()
|
||||
requestIdleCallback(
|
||||
() => {
|
||||
// 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('')
|
||||
}, 1000)
|
||||
// Node defs now available after comfyApp.setup.
|
||||
// Explicitly initialize nodeSearchService to avoid indexing delay when
|
||||
// node search is triggered
|
||||
useNodeDefStore().nodeSearchService.searchNode('')
|
||||
},
|
||||
{ timeout: 1000 }
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,33 +1,10 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
|
||||
vi.mock('@/platform/assets/services/assetService', () => ({
|
||||
assetService: {
|
||||
getAssetDetails: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'assetBrowser.allModels': 'All Models',
|
||||
'assetBrowser.assets': 'Assets',
|
||||
'assetBrowser.unknown': 'unknown'
|
||||
}
|
||||
return translations[key] || key
|
||||
},
|
||||
d: (date: Date) => date.toLocaleDateString()
|
||||
}))
|
||||
|
||||
describe('useAssetBrowser', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
// Test fixtures - minimal data focused on functionality being tested
|
||||
const createApiAsset = (overrides: Partial<AssetItem> = {}): AssetItem => ({
|
||||
id: 'test-id',
|
||||
@@ -49,8 +26,8 @@ describe('useAssetBrowser', () => {
|
||||
user_metadata: { description: 'Test model' }
|
||||
})
|
||||
|
||||
const { filteredAssets } = useAssetBrowser([apiAsset])
|
||||
const result = filteredAssets.value[0] // Get the transformed asset from filteredAssets
|
||||
const { transformAssetForDisplay } = useAssetBrowser([apiAsset])
|
||||
const result = transformAssetForDisplay(apiAsset)
|
||||
|
||||
// Preserves API properties
|
||||
expect(result.id).toBe(apiAsset.id)
|
||||
@@ -72,13 +49,15 @@ describe('useAssetBrowser', () => {
|
||||
user_metadata: undefined
|
||||
})
|
||||
|
||||
const { filteredAssets } = useAssetBrowser([apiAsset])
|
||||
const result = filteredAssets.value[0]
|
||||
const { transformAssetForDisplay } = useAssetBrowser([apiAsset])
|
||||
const result = transformAssetForDisplay(apiAsset)
|
||||
|
||||
expect(result.description).toBe('loras model')
|
||||
})
|
||||
|
||||
it('formats various file sizes correctly', () => {
|
||||
const { transformAssetForDisplay } = useAssetBrowser([])
|
||||
|
||||
const testCases = [
|
||||
{ size: 512, expected: '512 B' },
|
||||
{ size: 1536, expected: '1.5 KB' },
|
||||
@@ -88,8 +67,7 @@ describe('useAssetBrowser', () => {
|
||||
|
||||
testCases.forEach(({ size, expected }) => {
|
||||
const asset = createApiAsset({ size })
|
||||
const { filteredAssets } = useAssetBrowser([asset])
|
||||
const result = filteredAssets.value[0]
|
||||
const result = transformAssetForDisplay(asset)
|
||||
expect(result.formattedSize).toBe(expected)
|
||||
})
|
||||
})
|
||||
@@ -258,182 +236,18 @@ describe('useAssetBrowser', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Async Asset Selection with Detail Fetching', () => {
|
||||
it('should fetch asset details and call onSelect with filename when provided', async () => {
|
||||
const onSelectSpy = vi.fn()
|
||||
describe('Asset Selection', () => {
|
||||
it('returns selected asset UUID for efficient handling', () => {
|
||||
const asset = createApiAsset({
|
||||
id: 'asset-123',
|
||||
name: 'test-model.safetensors'
|
||||
id: 'test-uuid-123',
|
||||
name: 'selected_model.safetensors'
|
||||
})
|
||||
const { selectAsset, transformAssetForDisplay } = useAssetBrowser([asset])
|
||||
|
||||
const detailAsset = createApiAsset({
|
||||
id: 'asset-123',
|
||||
name: 'test-model.safetensors',
|
||||
user_metadata: { filename: 'checkpoints/test-model.safetensors' }
|
||||
})
|
||||
vi.mocked(assetService.getAssetDetails).mockResolvedValue(detailAsset)
|
||||
const displayAsset = transformAssetForDisplay(asset)
|
||||
const result = selectAsset(displayAsset)
|
||||
|
||||
const { selectAssetWithCallback } = useAssetBrowser([asset])
|
||||
|
||||
await selectAssetWithCallback(asset.id, onSelectSpy)
|
||||
|
||||
expect(assetService.getAssetDetails).toHaveBeenCalledWith('asset-123')
|
||||
expect(onSelectSpy).toHaveBeenCalledWith(
|
||||
'checkpoints/test-model.safetensors'
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle missing user_metadata.filename as error', async () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
const onSelectSpy = vi.fn()
|
||||
const asset = createApiAsset({ id: 'asset-456' })
|
||||
|
||||
const detailAsset = createApiAsset({
|
||||
id: 'asset-456',
|
||||
user_metadata: { filename: '' } // Invalid empty filename
|
||||
})
|
||||
vi.mocked(assetService.getAssetDetails).mockResolvedValue(detailAsset)
|
||||
|
||||
const { selectAssetWithCallback } = useAssetBrowser([asset])
|
||||
|
||||
await selectAssetWithCallback(asset.id, onSelectSpy)
|
||||
|
||||
expect(assetService.getAssetDetails).toHaveBeenCalledWith('asset-456')
|
||||
expect(onSelectSpy).not.toHaveBeenCalled()
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Invalid asset filename:',
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
message: 'Filename cannot be empty'
|
||||
})
|
||||
]),
|
||||
'for asset:',
|
||||
'asset-456'
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle API errors gracefully', async () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
const onSelectSpy = vi.fn()
|
||||
const asset = createApiAsset({ id: 'asset-789' })
|
||||
|
||||
const apiError = new Error('API Error')
|
||||
vi.mocked(assetService.getAssetDetails).mockRejectedValue(apiError)
|
||||
|
||||
const { selectAssetWithCallback } = useAssetBrowser([asset])
|
||||
|
||||
await selectAssetWithCallback(asset.id, onSelectSpy)
|
||||
|
||||
expect(assetService.getAssetDetails).toHaveBeenCalledWith('asset-789')
|
||||
expect(onSelectSpy).not.toHaveBeenCalled()
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to fetch asset details for asset-789'),
|
||||
apiError
|
||||
)
|
||||
})
|
||||
|
||||
it('should not fetch details when no callback provided', async () => {
|
||||
const asset = createApiAsset({ id: 'asset-no-callback' })
|
||||
|
||||
const { selectAssetWithCallback } = useAssetBrowser([asset])
|
||||
|
||||
await selectAssetWithCallback(asset.id)
|
||||
|
||||
expect(assetService.getAssetDetails).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Filename Validation Security', () => {
|
||||
const createValidationTest = (filename: string) => {
|
||||
const testAsset = createApiAsset({ id: 'validation-test' })
|
||||
const detailAsset = createApiAsset({
|
||||
id: 'validation-test',
|
||||
user_metadata: { filename }
|
||||
})
|
||||
return { testAsset, detailAsset }
|
||||
}
|
||||
|
||||
it('accepts valid file paths with forward slashes', async () => {
|
||||
const onSelectSpy = vi.fn()
|
||||
const { testAsset, detailAsset } = createValidationTest(
|
||||
'models/checkpoints/v1/test-model.safetensors'
|
||||
)
|
||||
vi.mocked(assetService.getAssetDetails).mockResolvedValue(detailAsset)
|
||||
|
||||
const { selectAssetWithCallback } = useAssetBrowser([testAsset])
|
||||
await selectAssetWithCallback(testAsset.id, onSelectSpy)
|
||||
|
||||
expect(onSelectSpy).toHaveBeenCalledWith(
|
||||
'models/checkpoints/v1/test-model.safetensors'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects directory traversal attacks', async () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
const onSelectSpy = vi.fn()
|
||||
|
||||
const maliciousPaths = [
|
||||
'../malicious-model.safetensors',
|
||||
'models/../../../etc/passwd',
|
||||
'/etc/passwd'
|
||||
]
|
||||
|
||||
for (const path of maliciousPaths) {
|
||||
const { testAsset, detailAsset } = createValidationTest(path)
|
||||
vi.mocked(assetService.getAssetDetails).mockResolvedValue(detailAsset)
|
||||
|
||||
const { selectAssetWithCallback } = useAssetBrowser([testAsset])
|
||||
await selectAssetWithCallback(testAsset.id, onSelectSpy)
|
||||
|
||||
expect(onSelectSpy).not.toHaveBeenCalled()
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Invalid asset filename:',
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
message: 'Path must not start with / or contain ..'
|
||||
})
|
||||
]),
|
||||
'for asset:',
|
||||
'validation-test'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects invalid filename characters', async () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
const onSelectSpy = vi.fn()
|
||||
|
||||
const invalidChars = ['\\', ':', '*', '?', '"', '<', '>', '|']
|
||||
|
||||
for (const char of invalidChars) {
|
||||
const { testAsset, detailAsset } = createValidationTest(
|
||||
`bad${char}filename.safetensors`
|
||||
)
|
||||
vi.mocked(assetService.getAssetDetails).mockResolvedValue(detailAsset)
|
||||
|
||||
const { selectAssetWithCallback } = useAssetBrowser([testAsset])
|
||||
await selectAssetWithCallback(testAsset.id, onSelectSpy)
|
||||
|
||||
expect(onSelectSpy).not.toHaveBeenCalled()
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Invalid asset filename:',
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
message: 'Invalid filename characters'
|
||||
})
|
||||
]),
|
||||
'for asset:',
|
||||
'validation-test'
|
||||
)
|
||||
}
|
||||
expect(result).toBe('test-uuid-123')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -6,18 +6,11 @@ import { useDialogStore } from '@/stores/dialogStore'
|
||||
// Mock the dialog store
|
||||
vi.mock('@/stores/dialogStore')
|
||||
|
||||
// Mock the asset service
|
||||
vi.mock('@/platform/assets/services/assetService', () => ({
|
||||
assetService: {
|
||||
getAssetsForNodeType: vi.fn().mockResolvedValue([])
|
||||
}
|
||||
}))
|
||||
|
||||
// Test factory functions
|
||||
interface AssetBrowserProps {
|
||||
nodeType: string
|
||||
inputName: string
|
||||
onAssetSelected?: (filename: string) => void
|
||||
onAssetSelected?: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function createAssetBrowserProps(
|
||||
@@ -32,7 +25,7 @@ function createAssetBrowserProps(
|
||||
|
||||
describe('useAssetBrowserDialog', () => {
|
||||
describe('Asset Selection Flow', () => {
|
||||
it('auto-closes dialog when asset is selected', async () => {
|
||||
it('auto-closes dialog when asset is selected', () => {
|
||||
// Create fresh mocks for this test
|
||||
const mockShowDialog = vi.fn()
|
||||
const mockCloseDialog = vi.fn()
|
||||
@@ -48,7 +41,7 @@ describe('useAssetBrowserDialog', () => {
|
||||
const onAssetSelected = vi.fn()
|
||||
const props = createAssetBrowserProps({ onAssetSelected })
|
||||
|
||||
await assetBrowserDialog.show(props)
|
||||
assetBrowserDialog.show(props)
|
||||
|
||||
// Get the onSelect handler that was passed to the dialog
|
||||
const dialogCall = mockShowDialog.mock.calls[0][0]
|
||||
@@ -57,14 +50,14 @@ describe('useAssetBrowserDialog', () => {
|
||||
// Simulate asset selection
|
||||
onSelectHandler('selected-asset-path')
|
||||
|
||||
// Should call the original callback and trigger hide animation
|
||||
// Should call the original callback and close dialog
|
||||
expect(onAssetSelected).toHaveBeenCalledWith('selected-asset-path')
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({
|
||||
key: 'global-asset-browser'
|
||||
})
|
||||
})
|
||||
|
||||
it('closes dialog when close handler is called', async () => {
|
||||
it('closes dialog when close handler is called', () => {
|
||||
// Create fresh mocks for this test
|
||||
const mockShowDialog = vi.fn()
|
||||
const mockCloseDialog = vi.fn()
|
||||
@@ -79,7 +72,7 @@ describe('useAssetBrowserDialog', () => {
|
||||
const assetBrowserDialog = useAssetBrowserDialog()
|
||||
const props = createAssetBrowserProps()
|
||||
|
||||
await assetBrowserDialog.show(props)
|
||||
assetBrowserDialog.show(props)
|
||||
|
||||
// Get the onClose handler that was passed to the dialog
|
||||
const dialogCall = mockShowDialog.mock.calls[0][0]
|
||||
|
||||
@@ -1894,159 +1894,4 @@ describe('useNodePricing', () => {
|
||||
expect(getNodeDisplayPrice(missingDuration)).toBe('Token-based')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dynamic pricing - WanTextToVideoApi', () => {
|
||||
it('should return $1.50 for 10s at 1080p', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('WanTextToVideoApi', [
|
||||
{ name: 'duration', value: '10' },
|
||||
{ name: 'size', value: '1080p: 4:3 (1632x1248)' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$1.50/Run') // 0.15 * 10
|
||||
})
|
||||
|
||||
it('should return $0.50 for 5s at 720p', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('WanTextToVideoApi', [
|
||||
{ name: 'duration', value: 5 },
|
||||
{ name: 'size', value: '720p: 16:9 (1280x720)' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.50/Run') // 0.10 * 5
|
||||
})
|
||||
|
||||
it('should return $0.15 for 3s at 480p', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('WanTextToVideoApi', [
|
||||
{ name: 'duration', value: '3' },
|
||||
{ name: 'size', value: '480p: 1:1 (624x624)' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.15/Run') // 0.05 * 3
|
||||
})
|
||||
|
||||
it('should fall back when widgets are missing', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const missingBoth = createMockNode('WanTextToVideoApi', [])
|
||||
const missingSize = createMockNode('WanTextToVideoApi', [
|
||||
{ name: 'duration', value: '5' }
|
||||
])
|
||||
const missingDuration = createMockNode('WanTextToVideoApi', [
|
||||
{ name: 'size', value: '1080p' }
|
||||
])
|
||||
|
||||
expect(getNodeDisplayPrice(missingBoth)).toBe('$0.05-0.15/second')
|
||||
expect(getNodeDisplayPrice(missingSize)).toBe('$0.05-0.15/second')
|
||||
expect(getNodeDisplayPrice(missingDuration)).toBe('$0.05-0.15/second')
|
||||
})
|
||||
|
||||
it('should fall back on invalid duration', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('WanTextToVideoApi', [
|
||||
{ name: 'duration', value: 'invalid' },
|
||||
{ name: 'size', value: '1080p' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.05-0.15/second')
|
||||
})
|
||||
|
||||
it('should fall back on unknown resolution', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('WanTextToVideoApi', [
|
||||
{ name: 'duration', value: '10' },
|
||||
{ name: 'size', value: '2K' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.05-0.15/second')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dynamic pricing - WanImageToVideoApi', () => {
|
||||
it('should return $0.80 for 8s at 720p', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('WanImageToVideoApi', [
|
||||
{ name: 'duration', value: 8 },
|
||||
{ name: 'resolution', value: '720p' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.80/Run') // 0.10 * 8
|
||||
})
|
||||
|
||||
it('should return $0.60 for 12s at 480P', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('WanImageToVideoApi', [
|
||||
{ name: 'duration', value: '12' },
|
||||
{ name: 'resolution', value: '480P' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.60/Run') // 0.05 * 12
|
||||
})
|
||||
|
||||
it('should return $1.50 for 10s at 1080p', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('WanImageToVideoApi', [
|
||||
{ name: 'duration', value: '10' },
|
||||
{ name: 'resolution', value: '1080p' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$1.50/Run') // 0.15 * 10
|
||||
})
|
||||
|
||||
it('should handle "5s" string duration at 1080P', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('WanImageToVideoApi', [
|
||||
{ name: 'duration', value: '5s' },
|
||||
{ name: 'resolution', value: '1080P' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.75/Run') // 0.15 * 5
|
||||
})
|
||||
|
||||
it('should fall back when widgets are missing', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const missingBoth = createMockNode('WanImageToVideoApi', [])
|
||||
const missingRes = createMockNode('WanImageToVideoApi', [
|
||||
{ name: 'duration', value: '5' }
|
||||
])
|
||||
const missingDuration = createMockNode('WanImageToVideoApi', [
|
||||
{ name: 'resolution', value: '1080p' }
|
||||
])
|
||||
|
||||
expect(getNodeDisplayPrice(missingBoth)).toBe('$0.05-0.15/second')
|
||||
expect(getNodeDisplayPrice(missingRes)).toBe('$0.05-0.15/second')
|
||||
expect(getNodeDisplayPrice(missingDuration)).toBe('$0.05-0.15/second')
|
||||
})
|
||||
|
||||
it('should fall back on invalid duration', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('WanImageToVideoApi', [
|
||||
{ name: 'duration', value: 'invalid' },
|
||||
{ name: 'resolution', value: '720p' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.05-0.15/second')
|
||||
})
|
||||
|
||||
it('should fall back on unknown resolution', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('WanImageToVideoApi', [
|
||||
{ name: 'duration', value: '10' },
|
||||
{ name: 'resolution', value: 'weird-res' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.05-0.15/second')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, vi } from 'vitest'
|
||||
|
||||
import type { INodeInputSlot, Point } from '@/lib/litegraph/src/litegraph'
|
||||
import { Rectangle } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { NodeInputSlot } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -84,8 +85,8 @@ describe('LGraphNode', () => {
|
||||
}))
|
||||
}
|
||||
node.configure(configureData)
|
||||
expect(node.pos).toEqual(new Float32Array([50, 60]))
|
||||
expect(node.size).toEqual(new Float32Array([70, 80]))
|
||||
expect(node.pos).toEqual([50, 60])
|
||||
expect(node.size).toEqual([70, 80])
|
||||
})
|
||||
|
||||
test('should configure inputs correctly', () => {
|
||||
@@ -571,7 +572,7 @@ describe('LGraphNode', () => {
|
||||
name: 'test_in',
|
||||
type: 'string',
|
||||
link: null,
|
||||
boundingRect: new Float32Array([0, 0, 0, 0])
|
||||
boundingRect: new Rectangle(0, 0, 0, 0)
|
||||
}
|
||||
})
|
||||
test('should return position based on title height when collapsed', () => {
|
||||
@@ -594,7 +595,7 @@ describe('LGraphNode', () => {
|
||||
name: 'test_in_2',
|
||||
type: 'number',
|
||||
link: null,
|
||||
boundingRect: new Float32Array([0, 0, 0, 0])
|
||||
boundingRect: new Rectangle(0, 0, 0, 0)
|
||||
}
|
||||
node.inputs = [inputSlot, inputSlot2]
|
||||
const slotIndex = 0
|
||||
|
||||
@@ -4,19 +4,19 @@ exports[`LGraph > supports schema v0.4 graphs > oldSchemaGraph 1`] = `
|
||||
LGraph {
|
||||
"_groups": [
|
||||
LGraphGroup {
|
||||
"_bounding": Float32Array [
|
||||
20,
|
||||
20,
|
||||
1,
|
||||
3,
|
||||
"_bounding": [
|
||||
10,
|
||||
10,
|
||||
140,
|
||||
80,
|
||||
],
|
||||
"_children": Set {},
|
||||
"_nodes": [],
|
||||
"_pos": Float32Array [
|
||||
"_pos": [
|
||||
20,
|
||||
20,
|
||||
],
|
||||
"_size": Float32Array [
|
||||
"_size": [
|
||||
1,
|
||||
3,
|
||||
],
|
||||
@@ -39,19 +39,19 @@ LGraph {
|
||||
LGraphNode {
|
||||
"_collapsed_width": undefined,
|
||||
"_level": undefined,
|
||||
"_pos": Float32Array [
|
||||
"_pos": [
|
||||
10,
|
||||
10,
|
||||
],
|
||||
"_posSize": Float32Array [
|
||||
10,
|
||||
10,
|
||||
140,
|
||||
60,
|
||||
"_posSize": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"_relative_id": undefined,
|
||||
"_shape": undefined,
|
||||
"_size": Float32Array [
|
||||
"_size": [
|
||||
140,
|
||||
60,
|
||||
],
|
||||
@@ -111,19 +111,19 @@ LGraph {
|
||||
"1": LGraphNode {
|
||||
"_collapsed_width": undefined,
|
||||
"_level": undefined,
|
||||
"_pos": Float32Array [
|
||||
"_pos": [
|
||||
10,
|
||||
10,
|
||||
],
|
||||
"_posSize": Float32Array [
|
||||
10,
|
||||
10,
|
||||
140,
|
||||
60,
|
||||
"_posSize": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"_relative_id": undefined,
|
||||
"_shape": undefined,
|
||||
"_size": Float32Array [
|
||||
"_size": [
|
||||
140,
|
||||
60,
|
||||
],
|
||||
@@ -184,19 +184,19 @@ LGraph {
|
||||
LGraphNode {
|
||||
"_collapsed_width": undefined,
|
||||
"_level": undefined,
|
||||
"_pos": Float32Array [
|
||||
"_pos": [
|
||||
10,
|
||||
10,
|
||||
],
|
||||
"_posSize": Float32Array [
|
||||
10,
|
||||
10,
|
||||
140,
|
||||
60,
|
||||
"_posSize": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
],
|
||||
"_relative_id": undefined,
|
||||
"_shape": undefined,
|
||||
"_size": Float32Array [
|
||||
"_size": [
|
||||
140,
|
||||
60,
|
||||
],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// TODO: Fix these tests after migration
|
||||
import { test as baseTest } from 'vitest'
|
||||
|
||||
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
|
||||
import type { Point, Rect } from '@/lib/litegraph/src/interfaces'
|
||||
import {
|
||||
addDirectionalOffset,
|
||||
@@ -132,8 +133,8 @@ test('snapPoint correctly snaps points to grid', ({ expect }) => {
|
||||
|
||||
test('createBounds correctly creates bounding box', ({ expect }) => {
|
||||
const objects = [
|
||||
{ boundingRect: [0, 0, 10, 10] as Rect },
|
||||
{ boundingRect: [5, 5, 10, 10] as Rect }
|
||||
{ boundingRect: new Rectangle(0, 0, 10, 10) },
|
||||
{ boundingRect: new Rectangle(5, 5, 10, 10) }
|
||||
]
|
||||
|
||||
const defaultBounds = createBounds(objects)
|
||||
|
||||
@@ -14,7 +14,7 @@ const createMockCanvasContext = () => ({
|
||||
const isCI = Boolean(process.env.CI)
|
||||
const describeIfNotCI = isCI ? describe.skip : describe
|
||||
|
||||
describeIfNotCI.skip('Transform Performance', () => {
|
||||
describeIfNotCI('Transform Performance', () => {
|
||||
let transformState: ReturnType<typeof useTransformState>
|
||||
let mockCanvas: any
|
||||
|
||||
|
||||
@@ -1,38 +1,14 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, toValue } from 'vue'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import { computed, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
|
||||
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
|
||||
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
|
||||
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
|
||||
|
||||
const mockData = vi.hoisted(() => ({
|
||||
mockNodeIds: new Set<string>(),
|
||||
mockExecuting: false
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => {
|
||||
const getCanvas = vi.fn()
|
||||
const useCanvasStore = () => ({
|
||||
getCanvas,
|
||||
selectedNodeIds: computed(() => mockData.mockNodeIds)
|
||||
})
|
||||
return {
|
||||
useCanvasStore
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/composables/useNodeEventHandlers',
|
||||
() => {
|
||||
const handleNodeSelect = vi.fn()
|
||||
return { useNodeEventHandlers: () => ({ handleNodeSelect }) }
|
||||
}
|
||||
)
|
||||
import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState'
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking',
|
||||
@@ -71,7 +47,7 @@ vi.mock(
|
||||
'@/renderer/extensions/vueNodes/execution/useNodeExecutionState',
|
||||
() => ({
|
||||
useNodeExecutionState: vi.fn(() => ({
|
||||
executing: computed(() => mockData.mockExecuting),
|
||||
executing: computed(() => false),
|
||||
progress: computed(() => undefined),
|
||||
progressPercentage: computed(() => undefined),
|
||||
progressState: computed(() => undefined as any),
|
||||
@@ -96,56 +72,61 @@ const i18n = createI18n({
|
||||
}
|
||||
}
|
||||
})
|
||||
function mountLGraphNode(props: ComponentProps<typeof LGraphNode>) {
|
||||
return mount(LGraphNode, {
|
||||
props,
|
||||
global: {
|
||||
plugins: [
|
||||
createTestingPinia({
|
||||
createSpy: vi.fn
|
||||
}),
|
||||
i18n
|
||||
],
|
||||
stubs: {
|
||||
NodeHeader: true,
|
||||
NodeSlots: true,
|
||||
NodeWidgets: true,
|
||||
NodeContent: true,
|
||||
SlotConnectionDot: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
const mockNodeData: VueNodeData = {
|
||||
id: 'test-node-123',
|
||||
title: 'Test Node',
|
||||
type: 'TestNode',
|
||||
mode: 0,
|
||||
flags: {},
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
widgets: [],
|
||||
selected: false,
|
||||
executing: false
|
||||
}
|
||||
|
||||
describe('LGraphNode', () => {
|
||||
const mockNodeData: VueNodeData = {
|
||||
id: 'test-node-123',
|
||||
title: 'Test Node',
|
||||
type: 'TestNode',
|
||||
mode: 0,
|
||||
flags: {},
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
widgets: [],
|
||||
selected: false,
|
||||
executing: false
|
||||
}
|
||||
|
||||
const mountLGraphNode = (props: any, selectedNodeIds = new Set()) => {
|
||||
return mount(LGraphNode, {
|
||||
props,
|
||||
global: {
|
||||
plugins: [
|
||||
createTestingPinia({
|
||||
createSpy: vi.fn
|
||||
}),
|
||||
i18n
|
||||
],
|
||||
provide: {
|
||||
[SelectedNodeIdsKey as symbol]: ref(selectedNodeIds)
|
||||
},
|
||||
stubs: {
|
||||
NodeHeader: true,
|
||||
NodeSlots: true,
|
||||
NodeWidgets: true,
|
||||
NodeContent: true,
|
||||
SlotConnectionDot: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
mockData.mockNodeIds = new Set()
|
||||
mockData.mockExecuting = false
|
||||
vi.clearAllMocks()
|
||||
// Reset to default mock
|
||||
vi.mocked(useNodeExecutionState).mockReturnValue({
|
||||
executing: computed(() => false),
|
||||
progress: computed(() => undefined),
|
||||
progressPercentage: computed(() => undefined),
|
||||
progressState: computed(() => undefined as any),
|
||||
executionState: computed(() => 'idle' as const)
|
||||
})
|
||||
})
|
||||
|
||||
it('should call resize tracking composable with node ID', () => {
|
||||
mountLGraphNode({ nodeData: mockNodeData })
|
||||
|
||||
expect(useVueElementTracking).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
'node'
|
||||
)
|
||||
const idArg = vi.mocked(useVueElementTracking).mock.calls[0]?.[0]
|
||||
const id = toValue(idArg)
|
||||
expect(id).toEqual('test-node-123')
|
||||
expect(useVueElementTracking).toHaveBeenCalledWith('test-node-123', 'node')
|
||||
})
|
||||
|
||||
it('should render with data-node-id attribute', () => {
|
||||
@@ -165,6 +146,9 @@ describe('LGraphNode', () => {
|
||||
}),
|
||||
i18n
|
||||
],
|
||||
provide: {
|
||||
[SelectedNodeIdsKey as symbol]: ref(new Set())
|
||||
},
|
||||
stubs: {
|
||||
NodeSlots: true,
|
||||
NodeWidgets: true,
|
||||
@@ -178,15 +162,24 @@ describe('LGraphNode', () => {
|
||||
})
|
||||
|
||||
it('should apply selected styling when selected prop is true', () => {
|
||||
mockData.mockNodeIds = new Set(['test-node-123'])
|
||||
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
|
||||
const wrapper = mountLGraphNode(
|
||||
{ nodeData: mockNodeData, selected: true },
|
||||
new Set(['test-node-123'])
|
||||
)
|
||||
expect(wrapper.classes()).toContain('outline-2')
|
||||
expect(wrapper.classes()).toContain('outline-black')
|
||||
expect(wrapper.classes()).toContain('dark-theme:outline-white')
|
||||
})
|
||||
|
||||
it('should apply executing animation when executing prop is true', () => {
|
||||
mockData.mockExecuting = true
|
||||
// Mock the execution state to return executing: true
|
||||
vi.mocked(useNodeExecutionState).mockReturnValue({
|
||||
executing: computed(() => true),
|
||||
progress: computed(() => undefined),
|
||||
progressPercentage: computed(() => undefined),
|
||||
progressState: computed(() => undefined as any),
|
||||
executionState: computed(() => 'running' as const)
|
||||
})
|
||||
|
||||
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
|
||||
|
||||
@@ -194,16 +187,12 @@ describe('LGraphNode', () => {
|
||||
})
|
||||
|
||||
it('should emit node-click event on pointer up', async () => {
|
||||
const { handleNodeSelect } = useNodeEventHandlers()
|
||||
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
|
||||
|
||||
await wrapper.trigger('pointerup')
|
||||
|
||||
expect(handleNodeSelect).toHaveBeenCalledOnce()
|
||||
expect(handleNodeSelect).toHaveBeenCalledWith(
|
||||
expect.any(PointerEvent),
|
||||
mockNodeData,
|
||||
expect.any(Boolean)
|
||||
)
|
||||
expect(wrapper.emitted('node-click')).toHaveLength(1)
|
||||
expect(wrapper.emitted('node-click')?.[0]).toHaveLength(3)
|
||||
expect(wrapper.emitted('node-click')?.[0][1]).toEqual(mockNodeData)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,82 +1,98 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, shallowRef } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import {
|
||||
type GraphNodeManager,
|
||||
type VueNodeData,
|
||||
useGraphNodeManager
|
||||
} from '@/composables/graph/useGraphNodeManager'
|
||||
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
|
||||
import type {
|
||||
LGraph,
|
||||
LGraphCanvas,
|
||||
LGraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import type { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
||||
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => {
|
||||
const canvas: Partial<LGraphCanvas> = {
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({
|
||||
useCanvasInteractions: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/layout/operations/layoutMutations', () => ({
|
||||
useLayoutMutations: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useGraphNodeManager', () => ({
|
||||
useGraphNodeManager: vi.fn()
|
||||
}))
|
||||
|
||||
function createMockCanvas(): Pick<
|
||||
LGraphCanvas,
|
||||
'select' | 'deselect' | 'deselectAll'
|
||||
> {
|
||||
return {
|
||||
select: vi.fn(),
|
||||
deselect: vi.fn(),
|
||||
deselectAll: vi.fn()
|
||||
}
|
||||
const updateSelectedItems = vi.fn()
|
||||
}
|
||||
|
||||
function createMockNode(): Pick<LGraphNode, 'id' | 'selected' | 'flags'> {
|
||||
return {
|
||||
useCanvasStore: vi.fn(() => ({
|
||||
canvas: canvas as LGraphCanvas,
|
||||
updateSelectedItems,
|
||||
selectedItems: []
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({
|
||||
useCanvasInteractions: vi.fn(() => ({
|
||||
shouldHandleNodePointerEvents: computed(() => true) // Default to allowing pointer events
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/layout/operations/layoutMutations', () => {
|
||||
const setSource = vi.fn()
|
||||
const bringNodeToFront = vi.fn()
|
||||
return {
|
||||
useLayoutMutations: vi.fn(() => ({
|
||||
setSource,
|
||||
bringNodeToFront
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/graph/useGraphNodeManager', () => {
|
||||
const mockNode = {
|
||||
id: 'node-1',
|
||||
selected: false,
|
||||
flags: { pinned: false }
|
||||
}
|
||||
const nodeManager = shallowRef({
|
||||
getNode: vi.fn(() => mockNode as Partial<LGraphNode> as LGraphNode)
|
||||
} as Partial<GraphNodeManager> as GraphNodeManager)
|
||||
return {
|
||||
useGraphNodeManager: vi.fn(() => nodeManager)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
vi.mock('@/composables/graph/useVueNodeLifecycle', () => {
|
||||
const nodeManager = useGraphNodeManager(undefined as unknown as LGraph)
|
||||
function createMockNodeManager(
|
||||
node: Pick<LGraphNode, 'id' | 'selected' | 'flags'>
|
||||
) {
|
||||
return {
|
||||
useVueNodeLifecycle: vi.fn(() => ({
|
||||
nodeManager
|
||||
}))
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
function createMockCanvasInteractions(): Pick<
|
||||
ReturnType<typeof useCanvasInteractions>,
|
||||
'shouldHandleNodePointerEvents'
|
||||
> {
|
||||
return {
|
||||
shouldHandleNodePointerEvents: computed(() => true) // Default to allowing pointer events
|
||||
}
|
||||
}
|
||||
|
||||
describe('useNodeEventHandlers', () => {
|
||||
const { nodeManager: mockNodeManager } = useVueNodeLifecycle()
|
||||
|
||||
const mockNode = mockNodeManager.value!.getNode('fake_id')
|
||||
const mockLayoutMutations = useLayoutMutations()
|
||||
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>
|
||||
let mockCanvasInteractions: ReturnType<typeof createMockCanvasInteractions>
|
||||
|
||||
const testNodeData: VueNodeData = {
|
||||
id: 'node-1',
|
||||
@@ -88,13 +104,28 @@ describe('useNodeEventHandlers', () => {
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
mockNode = createMockNode()
|
||||
mockCanvas = createMockCanvas()
|
||||
mockNodeManager = createMockNodeManager(mockNode)
|
||||
mockCanvasStore = createMockCanvasStore(mockCanvas)
|
||||
mockLayoutMutations = createMockLayoutMutations()
|
||||
mockCanvasInteractions = createMockCanvasInteractions()
|
||||
|
||||
vi.mocked(useCanvasStore).mockReturnValue(
|
||||
mockCanvasStore as ReturnType<typeof useCanvasStore>
|
||||
)
|
||||
vi.mocked(useLayoutMutations).mockReturnValue(
|
||||
mockLayoutMutations as ReturnType<typeof useLayoutMutations>
|
||||
)
|
||||
vi.mocked(useCanvasInteractions).mockReturnValue(
|
||||
mockCanvasInteractions as ReturnType<typeof useCanvasInteractions>
|
||||
)
|
||||
})
|
||||
|
||||
describe('handleNodeSelect', () => {
|
||||
it('should select single node on regular click', () => {
|
||||
const { handleNodeSelect } = useNodeEventHandlers()
|
||||
const { canvas, updateSelectedItems } = useCanvasStore()
|
||||
const nodeManager = ref(mockNodeManager)
|
||||
const { handleNodeSelect } = useNodeEventHandlers(nodeManager)
|
||||
|
||||
const event = new PointerEvent('pointerdown', {
|
||||
bubbles: true,
|
||||
@@ -104,17 +135,17 @@ describe('useNodeEventHandlers', () => {
|
||||
|
||||
handleNodeSelect(event, testNodeData, false)
|
||||
|
||||
expect(canvas?.deselectAll).toHaveBeenCalledOnce()
|
||||
expect(canvas?.select).toHaveBeenCalledWith(mockNode)
|
||||
expect(updateSelectedItems).toHaveBeenCalledOnce()
|
||||
expect(mockCanvas.deselectAll).toHaveBeenCalledOnce()
|
||||
expect(mockCanvas.select).toHaveBeenCalledWith(mockNode)
|
||||
expect(mockCanvasStore.updateSelectedItems).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('should toggle selection on ctrl+click', () => {
|
||||
const { handleNodeSelect } = useNodeEventHandlers()
|
||||
const { canvas } = useCanvasStore()
|
||||
const nodeManager = ref(mockNodeManager)
|
||||
const { handleNodeSelect } = useNodeEventHandlers(nodeManager)
|
||||
|
||||
// Test selecting unselected node with ctrl
|
||||
mockNode!.selected = false
|
||||
mockNode.selected = false
|
||||
|
||||
const ctrlClickEvent = new PointerEvent('pointerdown', {
|
||||
bubbles: true,
|
||||
@@ -124,16 +155,16 @@ describe('useNodeEventHandlers', () => {
|
||||
|
||||
handleNodeSelect(ctrlClickEvent, testNodeData, false)
|
||||
|
||||
expect(canvas?.deselectAll).not.toHaveBeenCalled()
|
||||
expect(canvas?.select).toHaveBeenCalledWith(mockNode)
|
||||
expect(mockCanvas.deselectAll).not.toHaveBeenCalled()
|
||||
expect(mockCanvas.select).toHaveBeenCalledWith(mockNode)
|
||||
})
|
||||
|
||||
it('should deselect on ctrl+click of selected node', () => {
|
||||
const { handleNodeSelect } = useNodeEventHandlers()
|
||||
const { canvas } = useCanvasStore()
|
||||
const nodeManager = ref(mockNodeManager)
|
||||
const { handleNodeSelect } = useNodeEventHandlers(nodeManager)
|
||||
|
||||
// Test deselecting selected node with ctrl
|
||||
mockNode!.selected = true
|
||||
mockNode.selected = true
|
||||
|
||||
const ctrlClickEvent = new PointerEvent('pointerdown', {
|
||||
bubbles: true,
|
||||
@@ -143,15 +174,15 @@ describe('useNodeEventHandlers', () => {
|
||||
|
||||
handleNodeSelect(ctrlClickEvent, testNodeData, false)
|
||||
|
||||
expect(canvas?.deselect).toHaveBeenCalledWith(mockNode)
|
||||
expect(canvas?.select).not.toHaveBeenCalled()
|
||||
expect(mockCanvas.deselect).toHaveBeenCalledWith(mockNode)
|
||||
expect(mockCanvas.select).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle meta key (Cmd) on Mac', () => {
|
||||
const { handleNodeSelect } = useNodeEventHandlers()
|
||||
const { canvas } = useCanvasStore()
|
||||
const nodeManager = ref(mockNodeManager)
|
||||
const { handleNodeSelect } = useNodeEventHandlers(nodeManager)
|
||||
|
||||
mockNode!.selected = false
|
||||
mockNode.selected = false
|
||||
|
||||
const metaClickEvent = new PointerEvent('pointerdown', {
|
||||
bubbles: true,
|
||||
@@ -161,14 +192,15 @@ describe('useNodeEventHandlers', () => {
|
||||
|
||||
handleNodeSelect(metaClickEvent, testNodeData, false)
|
||||
|
||||
expect(canvas?.select).toHaveBeenCalledWith(mockNode)
|
||||
expect(canvas?.deselectAll).not.toHaveBeenCalled()
|
||||
expect(mockCanvas.select).toHaveBeenCalledWith(mockNode)
|
||||
expect(mockCanvas.deselectAll).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should bring node to front when not pinned', () => {
|
||||
const { handleNodeSelect } = useNodeEventHandlers()
|
||||
const nodeManager = ref(mockNodeManager)
|
||||
const { handleNodeSelect } = useNodeEventHandlers(nodeManager)
|
||||
|
||||
mockNode!.flags.pinned = false
|
||||
mockNode.flags.pinned = false
|
||||
|
||||
const event = new PointerEvent('pointerdown')
|
||||
handleNodeSelect(event, testNodeData, false)
|
||||
@@ -179,14 +211,49 @@ describe('useNodeEventHandlers', () => {
|
||||
})
|
||||
|
||||
it('should not bring pinned node to front', () => {
|
||||
const { handleNodeSelect } = useNodeEventHandlers()
|
||||
const nodeManager = ref(mockNodeManager)
|
||||
const { handleNodeSelect } = useNodeEventHandlers(nodeManager)
|
||||
|
||||
mockNode!.flags.pinned = true
|
||||
mockNode.flags.pinned = true
|
||||
|
||||
const event = new PointerEvent('pointerdown')
|
||||
handleNodeSelect(event, testNodeData, false)
|
||||
|
||||
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, false)
|
||||
}).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, false)
|
||||
}).not.toThrow()
|
||||
|
||||
expect(mockCanvas.select).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } 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 { useComboWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useComboWidget'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
@@ -30,25 +29,13 @@ vi.mock('@/platform/assets/services/assetService', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/composables/useAssetBrowserDialog', () => {
|
||||
const mockAssetBrowserDialogShow = vi.fn()
|
||||
return {
|
||||
useAssetBrowserDialog: vi.fn(() => ({
|
||||
show: mockAssetBrowserDialogShow
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
// Test factory functions
|
||||
function createMockWidget(overrides: Partial<IBaseWidget> = {}): IBaseWidget {
|
||||
const mockCallback = vi.fn()
|
||||
return {
|
||||
type: 'combo',
|
||||
options: {},
|
||||
name: 'testWidget',
|
||||
value: undefined,
|
||||
callback: mockCallback,
|
||||
y: 0,
|
||||
...overrides
|
||||
} as IBaseWidget
|
||||
}
|
||||
@@ -58,16 +45,7 @@ function createMockNode(comfyClass = 'TestNode'): LGraphNode {
|
||||
node.comfyClass = comfyClass
|
||||
|
||||
// Spy on the addWidget method
|
||||
vi.spyOn(node, 'addWidget').mockImplementation(
|
||||
(type, name, value, callback) => {
|
||||
const widget = createMockWidget({ type, name, value })
|
||||
// Store the callback function on the widget for testing
|
||||
if (typeof callback === 'function') {
|
||||
widget.callback = callback
|
||||
}
|
||||
return widget
|
||||
}
|
||||
)
|
||||
vi.spyOn(node, 'addWidget').mockReturnValue(createMockWidget())
|
||||
|
||||
return node
|
||||
}
|
||||
@@ -83,9 +61,9 @@ function createMockInputSpec(overrides: Partial<InputSpec> = {}): InputSpec {
|
||||
describe('useComboWidget', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Reset to defaults
|
||||
mockSettingStoreGet.mockReturnValue(false)
|
||||
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(false)
|
||||
vi.mocked(useAssetBrowserDialog).mockClear()
|
||||
})
|
||||
|
||||
it('should handle undefined spec', () => {
|
||||
|
||||
@@ -4,8 +4,6 @@ import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
const mockGetCategoryForNodeType = vi.fn()
|
||||
|
||||
vi.mock('@/stores/modelToNodeStore', () => ({
|
||||
useModelToNodeStore: vi.fn(() => ({
|
||||
getRegisteredNodeTypes: vi.fn(
|
||||
@@ -16,13 +14,7 @@ vi.mock('@/stores/modelToNodeStore', () => ({
|
||||
'VAELoader',
|
||||
'TestNode'
|
||||
])
|
||||
),
|
||||
getCategoryForNodeType: mockGetCategoryForNodeType,
|
||||
modelToNodeMap: {
|
||||
checkpoints: [{ nodeDef: { name: 'CheckpointLoaderSimple' } }],
|
||||
loras: [{ nodeDef: { name: 'LoraLoader' } }],
|
||||
vae: [{ nodeDef: { name: 'VAELoader' } }]
|
||||
}
|
||||
)
|
||||
}))
|
||||
}))
|
||||
|
||||
@@ -218,87 +210,4 @@ describe('assetService', () => {
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAssetsForNodeType', () => {
|
||||
beforeEach(() => {
|
||||
mockGetCategoryForNodeType.mockClear()
|
||||
})
|
||||
|
||||
it('should return empty array for unregistered node types', async () => {
|
||||
mockGetCategoryForNodeType.mockReturnValue(undefined)
|
||||
|
||||
const result = await assetService.getAssetsForNodeType('UnknownNode')
|
||||
|
||||
expect(mockGetCategoryForNodeType).toHaveBeenCalledWith('UnknownNode')
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should use getCategoryForNodeType for efficient category lookup', async () => {
|
||||
mockGetCategoryForNodeType.mockReturnValue('checkpoints')
|
||||
const testAssets = [MOCK_ASSETS.checkpoints]
|
||||
mockApiResponse(testAssets)
|
||||
|
||||
const result = await assetService.getAssetsForNodeType(
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
|
||||
expect(mockGetCategoryForNodeType).toHaveBeenCalledWith(
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
expect(result).toEqual(testAssets)
|
||||
|
||||
// Verify API call includes correct category
|
||||
expect(api.fetchApi).toHaveBeenCalledWith(
|
||||
'/assets?include_tags=models,checkpoints'
|
||||
)
|
||||
})
|
||||
|
||||
it('should return empty array when no category found', async () => {
|
||||
mockGetCategoryForNodeType.mockReturnValue(undefined)
|
||||
|
||||
const result = await assetService.getAssetsForNodeType('TestNode')
|
||||
|
||||
expect(result).toEqual([])
|
||||
expect(api.fetchApi).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle API errors gracefully', async () => {
|
||||
mockGetCategoryForNodeType.mockReturnValue('loras')
|
||||
mockApiError(500, 'Internal Server Error')
|
||||
|
||||
await expect(
|
||||
assetService.getAssetsForNodeType('LoraLoader')
|
||||
).rejects.toThrow(
|
||||
'Unable to load assets for LoraLoader: Server returned 500. Please try again.'
|
||||
)
|
||||
})
|
||||
|
||||
it('should return all assets without filtering for different categories', async () => {
|
||||
// Test checkpoints
|
||||
mockGetCategoryForNodeType.mockReturnValue('checkpoints')
|
||||
const checkpointAssets = [MOCK_ASSETS.checkpoints]
|
||||
mockApiResponse(checkpointAssets)
|
||||
|
||||
let result = await assetService.getAssetsForNodeType(
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
expect(result).toEqual(checkpointAssets)
|
||||
|
||||
// Test loras
|
||||
mockGetCategoryForNodeType.mockReturnValue('loras')
|
||||
const loraAssets = [MOCK_ASSETS.loras]
|
||||
mockApiResponse(loraAssets)
|
||||
|
||||
result = await assetService.getAssetsForNodeType('LoraLoader')
|
||||
expect(result).toEqual(loraAssets)
|
||||
|
||||
// Test vae
|
||||
mockGetCategoryForNodeType.mockReturnValue('vae')
|
||||
const vaeAssets = [MOCK_ASSETS.vae]
|
||||
mockApiResponse(vaeAssets)
|
||||
|
||||
result = await assetService.getAssetsForNodeType('VAELoader')
|
||||
expect(result).toEqual(vaeAssets)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,7 +19,7 @@ const EXPECTED_DEFAULT_TYPES = [
|
||||
'gligen'
|
||||
] as const
|
||||
|
||||
type NodeDefStoreType = ReturnType<typeof useNodeDefStore>
|
||||
type NodeDefStoreType = typeof import('@/stores/nodeDefStore')
|
||||
|
||||
// Create minimal but valid ComfyNodeDefImpl for testing
|
||||
function createMockNodeDef(name: string): ComfyNodeDefImpl {
|
||||
@@ -343,107 +343,6 @@ describe('useModelToNodeStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCategoryForNodeType', () => {
|
||||
it('should return category for known node type', () => {
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
modelToNodeStore.registerDefaults()
|
||||
|
||||
expect(
|
||||
modelToNodeStore.getCategoryForNodeType('CheckpointLoaderSimple')
|
||||
).toBe('checkpoints')
|
||||
expect(modelToNodeStore.getCategoryForNodeType('LoraLoader')).toBe(
|
||||
'loras'
|
||||
)
|
||||
expect(modelToNodeStore.getCategoryForNodeType('VAELoader')).toBe('vae')
|
||||
})
|
||||
|
||||
it('should return undefined for unknown node type', () => {
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
modelToNodeStore.registerDefaults()
|
||||
|
||||
expect(
|
||||
modelToNodeStore.getCategoryForNodeType('NonExistentNode')
|
||||
).toBeUndefined()
|
||||
expect(modelToNodeStore.getCategoryForNodeType('')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return first category when node type exists in multiple categories', () => {
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
|
||||
// Test with a node that exists in the defaults but add our own first
|
||||
// Since defaults register 'StyleModelLoader' in 'style_models',
|
||||
// we verify our custom registrations come after defaults in Object.entries iteration
|
||||
const result = modelToNodeStore.getCategoryForNodeType('StyleModelLoader')
|
||||
expect(result).toBe('style_models') // This proves the method works correctly
|
||||
|
||||
// Now test that custom registrations after defaults also work
|
||||
modelToNodeStore.quickRegister(
|
||||
'unicorn_styles',
|
||||
'StyleModelLoader',
|
||||
'param1'
|
||||
)
|
||||
const result2 =
|
||||
modelToNodeStore.getCategoryForNodeType('StyleModelLoader')
|
||||
// Should still be style_models since it was registered first by defaults
|
||||
expect(result2).toBe('style_models')
|
||||
})
|
||||
|
||||
it('should trigger lazy registration when called before registerDefaults', () => {
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
|
||||
const result = modelToNodeStore.getCategoryForNodeType(
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
expect(result).toBe('checkpoints')
|
||||
})
|
||||
|
||||
it('should be performant for repeated lookups', () => {
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
modelToNodeStore.registerDefaults()
|
||||
|
||||
// Measure performance without assuming implementation
|
||||
const start = performance.now()
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
modelToNodeStore.getCategoryForNodeType('CheckpointLoaderSimple')
|
||||
}
|
||||
const end = performance.now()
|
||||
|
||||
// Should be fast enough for UI responsiveness
|
||||
expect(end - start).toBeLessThan(10)
|
||||
})
|
||||
|
||||
it('should handle invalid input types gracefully', () => {
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
modelToNodeStore.registerDefaults()
|
||||
|
||||
// These should not throw but return undefined
|
||||
expect(
|
||||
modelToNodeStore.getCategoryForNodeType(null as any)
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
modelToNodeStore.getCategoryForNodeType(undefined as any)
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
modelToNodeStore.getCategoryForNodeType(123 as any)
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should be case-sensitive for node type matching', () => {
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
modelToNodeStore.registerDefaults()
|
||||
|
||||
expect(
|
||||
modelToNodeStore.getCategoryForNodeType('checkpointloadersimple')
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
modelToNodeStore.getCategoryForNodeType('CHECKPOINTLOADERSIMPLE')
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
modelToNodeStore.getCategoryForNodeType('CheckpointLoaderSimple')
|
||||
).toBe('checkpoints')
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty string model type', () => {
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
|
||||
import type { Rect } from '@/lib/litegraph/src/interfaces'
|
||||
import { computeUnionBounds, gcd, lcm } from '@/utils/mathUtil'
|
||||
|
||||
describe('mathUtil', () => {
|
||||
@@ -27,9 +27,9 @@ describe('mathUtil', () => {
|
||||
expect(computeUnionBounds([])).toBe(null)
|
||||
})
|
||||
|
||||
// Tests for tuple format (ReadOnlyRect)
|
||||
it('should work with ReadOnlyRect tuple format', () => {
|
||||
const tuples: ReadOnlyRect[] = [
|
||||
// Tests for tuple format (Rect)
|
||||
it('should work with Rect tuple format', () => {
|
||||
const tuples: Rect[] = [
|
||||
[10, 20, 30, 40] as const, // bounds: 10,20 to 40,60
|
||||
[50, 10, 20, 30] as const // bounds: 50,10 to 70,40
|
||||
]
|
||||
@@ -44,8 +44,8 @@ describe('mathUtil', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle single ReadOnlyRect tuple', () => {
|
||||
const tuple: ReadOnlyRect = [10, 20, 30, 40] as const
|
||||
it('should handle single Rect tuple', () => {
|
||||
const tuple: Rect = [10, 20, 30, 40] as const
|
||||
const result = computeUnionBounds([tuple])
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -57,7 +57,7 @@ describe('mathUtil', () => {
|
||||
})
|
||||
|
||||
it('should handle tuple format with negative dimensions', () => {
|
||||
const tuples: ReadOnlyRect[] = [
|
||||
const tuples: Rect[] = [
|
||||
[100, 50, -20, -10] as const, // x+width=80, y+height=40
|
||||
[90, 45, 15, 20] as const // x+width=105, y+height=65
|
||||
]
|
||||
@@ -74,7 +74,7 @@ describe('mathUtil', () => {
|
||||
|
||||
it('should maintain optimal performance with SoA tuples', () => {
|
||||
// Test that array access is as expected for typical selection sizes
|
||||
const tuples: ReadOnlyRect[] = Array.from(
|
||||
const tuples: Rect[] = Array.from(
|
||||
{ length: 10 },
|
||||
(_, i) =>
|
||||
[
|
||||
|
||||
@@ -29,15 +29,14 @@
|
||||
"rootDir": "./"
|
||||
},
|
||||
"include": [
|
||||
".storybook/**/*",
|
||||
"eslint.config.ts",
|
||||
"global.d.ts",
|
||||
"knip.config.ts",
|
||||
"src/**/*.vue",
|
||||
"src/**/*",
|
||||
"src/**/*.vue",
|
||||
"src/types/**/*.d.ts",
|
||||
"tests-ui/**/*",
|
||||
"global.d.ts",
|
||||
"eslint.config.ts",
|
||||
"vite.config.mts",
|
||||
"vitest.config.ts",
|
||||
"knip.config.ts",
|
||||
".storybook/**/*"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -31,8 +31,7 @@ export default defineConfig({
|
||||
ignored: [
|
||||
'**/coverage/**',
|
||||
'**/playwright-report/**',
|
||||
'**/*.{test,spec}.ts',
|
||||
'*.config.{ts,mts}'
|
||||
'**/*.{test,spec}.ts'
|
||||
]
|
||||
},
|
||||
proxy: {
|
||||
|
||||
@@ -32,8 +32,7 @@ export default defineConfig({
|
||||
'**/.{idea,git,cache,output,temp}/**',
|
||||
'**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*',
|
||||
'src/lib/litegraph/test/**'
|
||||
],
|
||||
silent: 'passed-only'
|
||||
]
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
Reference in New Issue
Block a user