Merge main (as of 10-06-2025) into rh-test (#5965)

## Summary

Merges latest changes from `main` as of 10-06-2025.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5965-Merge-main-as-of-10-06-2025-into-rh-test-2856d73d3650812cb95fd8917278a770)
by [Unito](https://www.unito.io)

---------

Signed-off-by: Marcel Petrick <mail@marcelpetrick.it>
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Benjamin Lu <benceruleanlu@proton.me>
Co-authored-by: Terry Jia <terryjia88@gmail.com>
Co-authored-by: snomiao <snomiao@gmail.com>
Co-authored-by: Simula_r <18093452+simula-r@users.noreply.github.com>
Co-authored-by: Jake Schroeder <jake.schroeder@isophex.com>
Co-authored-by: Comfy Org PR Bot <snomiao+comfy-pr@gmail.com>
Co-authored-by: AustinMroz <4284322+AustinMroz@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
Co-authored-by: Marcel Petrick <mail@marcelpetrick.it>
Co-authored-by: Alexander Brown <DrJKL0424@gmail.com>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
Co-authored-by: Alexander Piskun <13381981+bigcat88@users.noreply.github.com>
Co-authored-by: Rizumu Ayaka <rizumu@ayaka.moe>
Co-authored-by: JakeSchroeder <jake@axiom.co>
Co-authored-by: AustinMroz <austin@comfy.org>
Co-authored-by: DrJKL <DrJKL@users.noreply.github.com>
Co-authored-by: ComfyUI Wiki <contact@comfyui-wiki.com>
This commit is contained in:
Arjan Singh
2025-10-08 19:06:40 -07:00
committed by GitHub
parent 529a4de583
commit 5869b04e57
454 changed files with 32333 additions and 37002 deletions

View File

@@ -44,7 +44,6 @@ const showContextMenu = (event: MouseEvent) => {
onMounted(() => {
// @ts-expect-error fixme ts strict error
window['__COMFYUI_FRONTEND_VERSION__'] = config.app_version
console.log('ComfyUI Front-end version:', config.app_version)
if (isElectron()) {
document.addEventListener('contextmenu', showContextMenu)

22
src/base/pointerUtils.ts Normal file
View File

@@ -0,0 +1,22 @@
/**
* Utilities for pointer event handling
*/
/**
* Checks if a pointer or mouse event is a middle button input
* @param event - The pointer or mouse event to check
* @returns true if the event is from the middle button/wheel
*/
export function isMiddlePointerInput(
event: PointerEvent | MouseEvent
): boolean {
if ('button' in event && event.button === 1) {
return true
}
if ('buttons' in event && typeof event.buttons === 'number') {
return event.buttons === 4
}
return false
}

View File

@@ -69,7 +69,7 @@ const terminalCreated = (
await loadLogEntries()
} catch (err) {
console.error('Error loading logs', err)
// On older backends the endpoints wont exist
// On older backends the endpoints won't exist
errorMessage.value =
'Unable to load logs, please ensure you have updated your ComfyUI backend.'
return

View File

@@ -1,52 +0,0 @@
<!--
A refresh button that disables and shows a progress spinner whilst active.
Usage:
```vue
<RefreshButton
v-model="isRefreshing"
:outlined="false"
@refresh="refresh"
/>
```
-->
<template>
<Button
class="relative p-button-icon-only"
:outlined="outlined"
:severity="severity"
:disabled="active || disabled"
@click="(event) => $emit('refresh', event)"
>
<span
class="p-button-icon pi pi-refresh transition-all"
:class="{ 'opacity-0': active }"
data-pc-section="icon"
/>
<span class="p-button-label" data-pc-section="label">&nbsp;</span>
<ProgressSpinner v-show="active" class="absolute w-1/2 h-1/2" />
</Button>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import ProgressSpinner from 'primevue/progressspinner'
import type { PrimeVueSeverity } from '@/types/primeVueTypes'
const {
disabled,
outlined = true,
severity = 'secondary'
} = defineProps<{
disabled?: boolean
outlined?: boolean
severity?: PrimeVueSeverity
}>()
// Model
const active = defineModel<boolean>({ required: true })
// Emits
defineEmits(['refresh'])
</script>

View File

@@ -1,71 +0,0 @@
<template>
<div :class="wrapperClass">
<div class="grid grid-rows-2 gap-8">
<!-- Top container: Logo -->
<div class="flex items-end justify-center">
<img
src="/assets/images/comfy-brand-mark.svg"
:alt="t('g.logoAlt')"
class="w-60"
/>
</div>
<!-- Bottom container: Progress and text -->
<div class="flex flex-col items-center justify-center gap-4">
<ProgressBar
v-if="!hideProgress"
:mode="progressMode"
:value="progressPercentage ?? 0"
:show-value="false"
class="w-90 h-2 mt-8"
:pt="{ value: { class: 'bg-brand-yellow' } }"
/>
<h1 v-if="title" class="font-inter font-bold text-3xl text-neutral-300">
{{ title }}
</h1>
<p v-if="statusText" class="text-lg text-neutral-400">
{{ statusText }}
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import ProgressBar from 'primevue/progressbar'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
/** Props for the StartupDisplay component */
interface StartupDisplayProps {
/** Progress: 0-100 for determinate, undefined for indeterminate */
progressPercentage?: number
/** Main title text */
title?: string
/** Status text shown below the title */
statusText?: string
/** Hide the progress bar */
hideProgress?: boolean
/** Use full screen wrapper (default: true) */
fullScreen?: boolean
}
const {
progressPercentage,
title,
statusText,
hideProgress = false,
fullScreen = true
} = defineProps<StartupDisplayProps>()
const progressMode = computed(() =>
progressPercentage === undefined ? 'indeterminate' : 'determinate'
)
const wrapperClass = computed(() =>
fullScreen
? 'flex items-center justify-center min-h-screen'
: 'flex items-center justify-center'
)
</script>

View File

@@ -128,7 +128,7 @@
<!-- Title -->
<span
v-if="isLoading"
class="inline-block h-8 w-48 bg-neutral-200 dark-theme:bg-neutral-700 rounded animate-pulse"
class="inline-block h-8 w-48 bg-dialog-surface rounded animate-pulse"
></span>
<!-- Template Cards Grid -->
@@ -148,7 +148,7 @@
<CardTop ratio="landscape">
<template #default>
<div
class="w-full h-full bg-neutral-200 dark-theme:bg-neutral-700 animate-pulse"
class="w-full h-full bg-dialog-surface animate-pulse"
></div>
</template>
</CardTop>
@@ -157,10 +157,10 @@
<CardBottom>
<div class="px-4 py-3">
<div
class="h-6 bg-neutral-200 dark-theme:bg-neutral-700 rounded animate-pulse mb-2"
class="h-6 bg-dialog-surface rounded animate-pulse mb-2"
></div>
<div
class="h-4 bg-neutral-200 dark-theme:bg-neutral-700 rounded animate-pulse"
class="h-4 bg-dialog-surface rounded animate-pulse"
></div>
</div>
</CardBottom>
@@ -172,7 +172,6 @@
v-for="template in isLoading ? [] : displayTemplates"
:key="template.name"
ref="cardRefs"
v-memo="[template.name, hoveredTemplate === template.name]"
ratio="smallSquare"
type="workflow-template-card"
:data-testid="`template-workflow-${template.name}`"
@@ -324,7 +323,7 @@
<CardTop ratio="square">
<template #default>
<div
class="w-full h-full bg-neutral-200 dark-theme:bg-neutral-700 animate-pulse"
class="w-full h-full bg-dialog-surface animate-pulse"
></div>
</template>
</CardTop>
@@ -333,10 +332,10 @@
<CardBottom>
<div class="px-4 py-3">
<div
class="h-6 bg-neutral-200 dark-theme:bg-neutral-700 rounded animate-pulse mb-2"
class="h-6 bg-dialog-surface rounded animate-pulse mb-2"
></div>
<div
class="h-4 bg-neutral-200 dark-theme:bg-neutral-700 rounded animate-pulse"
class="h-4 bg-dialog-surface rounded animate-pulse"
></div>
</div>
</CardBottom>

View File

@@ -49,37 +49,39 @@
<span class="text-muted">{{ t('auth.login.orContinueWith') }}</span>
</Divider>
<!-- Social Login Buttons -->
<!-- Social Login Buttons (hidden if host not whitelisted) -->
<div class="flex flex-col gap-6">
<Button
type="button"
class="h-10"
severity="secondary"
outlined
@click="signInWithGoogle"
>
<i class="pi pi-google mr-2"></i>
{{
isSignIn
? t('auth.login.loginWithGoogle')
: t('auth.signup.signUpWithGoogle')
}}
</Button>
<template v-if="ssoAllowed">
<Button
type="button"
class="h-10"
severity="secondary"
outlined
@click="signInWithGoogle"
>
<i class="pi pi-google mr-2"></i>
{{
isSignIn
? t('auth.login.loginWithGoogle')
: t('auth.signup.signUpWithGoogle')
}}
</Button>
<Button
type="button"
class="h-10"
severity="secondary"
outlined
@click="signInWithGithub"
>
<i class="pi pi-github mr-2"></i>
{{
isSignIn
? t('auth.login.loginWithGithub')
: t('auth.signup.signUpWithGithub')
}}
</Button>
<Button
type="button"
class="h-10"
severity="secondary"
outlined
@click="signInWithGithub"
>
<i class="pi pi-github mr-2"></i>
{{
isSignIn
? t('auth.login.loginWithGithub')
: t('auth.signup.signUpWithGithub')
}}
</Button>
</template>
<Button
type="button"
@@ -154,6 +156,7 @@ import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthAction
import { COMFY_PLATFORM_BASE_URL } from '@/config/comfyApi'
import type { SignInData, SignUpData } from '@/schemas/signInSchema'
import { translateAuthError } from '@/utils/authErrorTranslation'
import { isHostWhitelisted, normalizeHost } from '@/utils/hostWhitelist'
import { isInChina } from '@/utils/networkUtil'
import ApiKeyForm from './signin/ApiKeyForm.vue'
@@ -170,6 +173,7 @@ const isSecureContext = window.isSecureContext
const isSignIn = ref(true)
const showApiKeyForm = ref(false)
const authError = ref('')
const ssoAllowed = isHostWhitelisted(normalizeHost(window.location.hostname))
const toggleState = () => {
isSignIn.value = !isSignIn.value

View File

@@ -28,7 +28,7 @@
id="graph-canvas"
ref="canvasRef"
tabindex="1"
class="align-top w-full h-full touch-none"
class="absolute inset-0 size-full touch-none"
/>
<!-- TransformPane for Vue node rendering -->
@@ -39,11 +39,10 @@
@wheel.capture="canvasInteractions.forwardEventToCanvas"
>
<!-- Vue nodes rendered based on graph nodes -->
<VueGraphNode
<LGraphNode
v-for="nodeData in allNodes"
:key="nodeData.id"
:node-data="nodeData"
:readonly="false"
:error="
executionStore.lastExecutionError?.node_id === nodeData.id
? 'Execution error'
@@ -104,6 +103,7 @@ import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
import { usePaste } from '@/composables/usePaste'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import { i18n, t } from '@/i18n'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useLitegraphSettings } from '@/platform/settings/composables/useLitegraphSettings'
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -117,7 +117,7 @@ import { attachSlotLinkPreviewRenderer } from '@/renderer/core/canvas/links/slot
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 LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
import { UnauthorizedError, api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
@@ -144,6 +144,8 @@ const workspaceStore = useWorkspaceStore()
const canvasStore = useCanvasStore()
const executionStore = useExecutionStore()
const toastStore = useToastStore()
const colorPaletteStore = useColorPaletteStore()
const colorPaletteService = useColorPaletteService()
const canvasInteractions = useCanvasInteractions()
const betaMenuEnabled = computed(
@@ -193,6 +195,15 @@ const allNodes = computed((): VueNodeData[] =>
Array.from(vueNodeLifecycle.nodeManager.value?.vueNodeData?.values() ?? [])
)
watchEffect(() => {
LiteGraph.nodeOpacity = settingStore.get('Comfy.Node.Opacity')
})
watchEffect(() => {
LiteGraph.nodeLightness = colorPaletteStore.completedActivePalette.light_theme
? 0.5
: undefined
})
watchEffect(() => {
nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated')
})
@@ -239,8 +250,6 @@ watch(
}
)
const colorPaletteService = useColorPaletteService()
const colorPaletteStore = useColorPaletteStore()
watch(
[() => canvasStore.canvas, () => settingStore.get('Comfy.ColorPalette')],
async ([canvas, currentPaletteId]) => {

View File

@@ -22,7 +22,8 @@
<ColorPickerButton v-if="showColorPicker" />
<FrameNodes v-if="showFrameNodes" />
<ConvertToSubgraphButton v-if="showConvertToSubgraph" />
<PublishSubgraphButton v-if="showPublishSubgraph" />
<ConfigureSubgraph v-if="showSubgraphButtons" />
<PublishSubgraphButton v-if="showSubgraphButtons" />
<MaskEditorButton v-if="showMaskEditor" />
<VerticalDivider
v-if="showAnyPrimaryActions && showAnyControlActions"
@@ -50,6 +51,7 @@ import { computed, ref } from 'vue'
import BypassButton from '@/components/graph/selectionToolbox/BypassButton.vue'
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
import ConfigureSubgraph from '@/components/graph/selectionToolbox/ConfigureSubgraph.vue'
import ConvertToSubgraphButton from '@/components/graph/selectionToolbox/ConvertToSubgraphButton.vue'
import DeleteButton from '@/components/graph/selectionToolbox/DeleteButton.vue'
import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue'
@@ -112,7 +114,7 @@ const showInfoButton = computed(() => !!nodeDef.value)
const showColorPicker = computed(() => hasAnySelection.value)
const showConvertToSubgraph = computed(() => hasAnySelection.value)
const showFrameNodes = computed(() => hasMultipleSelection.value)
const showPublishSubgraph = computed(() => isSingleSubgraph.value)
const showSubgraphButtons = computed(() => isSingleSubgraph.value)
const showBypass = computed(
() =>
@@ -130,7 +132,7 @@ const showAnyPrimaryActions = computed(
showColorPicker.value ||
showConvertToSubgraph.value ||
showFrameNodes.value ||
showPublishSubgraph.value
showSubgraphButtons.value
)
const showAnyControlActions = computed(() => showBypass.value)

View File

@@ -0,0 +1,17 @@
<template>
<Button
v-tooltip.top="{
value: $t('Edit Subgraph Widgets'),
showDelay: 1000
}"
severity="secondary"
text
icon="icon-[lucide--settings-2]"
@click="showSubgraphNodeDialog"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { showSubgraphNodeDialog } from '@/core/graph/subgraph/useSubgraphNodeDialog'
</script>

View File

@@ -68,7 +68,7 @@ const updateDomClipping = () => {
return
}
const isSelected = selectedNode === widget.node
const isSelected = selectedNode === widgetState.widget.node
const renderArea = selectedNode?.renderArea
const offset = lgCanvas.ds.offset
const scale = lgCanvas.ds.scale
@@ -125,50 +125,43 @@ watch(
}
}
)
// Set up event listeners only after the widget is mounted and visible
const setupDOMEventListeners = () => {
if (!isDOMWidget(widget) || !widgetState.visible) return
if (widget.element.blur) {
useEventListener(document, 'mousedown', (event) => {
if (!widget.element.contains(event.target as HTMLElement)) {
widget.element.blur()
}
})
useEventListener(document, 'mousedown', (event) => {
if (!isDOMWidget(widget) || !widgetState.visible || !widget.element.blur) {
return
}
if (!widget.element.contains(event.target as HTMLElement)) {
widget.element.blur()
}
})
for (const evt of widget.options.selectOn ?? ['focus', 'click']) {
useEventListener(widget.element, evt, () => {
onMounted(() => {
if (!isDOMWidget(widget)) {
return
}
useEventListener(
widget.element,
widget.options.selectOn ?? ['focus', 'click'],
() => {
const lgCanvas = canvasStore.canvas
lgCanvas?.selectNode(widget.node)
lgCanvas?.bringToFront(widget.node)
})
}
}
// Set up event listeners when widget becomes visible
watch(
() => widgetState.visible,
(visible) => {
if (visible) {
setupDOMEventListeners()
}
},
{ immediate: true }
)
)
})
const inputSpec = widget.node.constructor.nodeData
const tooltip = inputSpec?.inputs?.[widget.name]?.tooltip
// Mount DOM element when widget is or becomes visible
const mountElementIfVisible = () => {
if (widgetState.visible && isDOMWidget(widget) && widgetElement.value) {
// Only append if not already a child
if (!widgetElement.value.contains(widget.element)) {
widgetElement.value.appendChild(widget.element)
}
if (!(widgetState.visible && isDOMWidget(widget) && widgetElement.value)) {
return
}
// Only append if not already a child
if (widgetElement.value.contains(widget.element)) {
return
}
widgetElement.value.appendChild(widget.element)
}
// Check on mount - but only after next tick to ensure visibility is calculated

View File

@@ -8,7 +8,7 @@ const meta: Meta<typeof SearchBox> = {
component: SearchBox,
tags: ['autodocs'],
argTypes: {
placeHolder: {
placeholder: {
control: 'text'
},
showBorder: {
@@ -22,7 +22,7 @@ const meta: Meta<typeof SearchBox> = {
}
},
args: {
placeHolder: 'Search...',
placeholder: 'Search...',
showBorder: false,
size: 'md'
}

View File

@@ -5,10 +5,10 @@
ref="input"
v-model="searchQuery"
:aria-label="
placeHolder || t('templateWidgets.sort.searchPlaceholder', 'Search...')
placeholder || t('templateWidgets.sort.searchPlaceholder', 'Search...')
"
:placeholder="
placeHolder || t('templateWidgets.sort.searchPlaceholder', 'Search...')
placeholder || t('templateWidgets.sort.searchPlaceholder', 'Search...')
"
type="text"
unstyled
@@ -19,17 +19,19 @@
<script setup lang="ts">
import InputText from 'primevue/inputtext'
import { computed, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { t } from '@/i18n'
import { cn } from '@/utils/tailwindUtil'
const {
placeHolder,
autofocus = false,
placeholder,
showBorder = false,
size = 'md'
} = defineProps<{
placeHolder?: string
autofocus?: boolean
placeholder?: string
showBorder?: boolean
size?: 'md' | 'lg'
}>()
@@ -43,6 +45,8 @@ const focusInput = () => {
}
}
onMounted(() => autofocus && focusInput())
const wrapperStyle = computed(() => {
const baseClasses = [
'relative flex w-full items-center gap-2',

View File

@@ -1,133 +0,0 @@
<template>
<div class="flex flex-col gap-6 w-[600px]">
<div class="flex flex-col gap-4">
<h2 class="text-2xl font-semibold text-neutral-100">
{{ $t('install.desktopAppSettings') }}
</h2>
<p class="text-neutral-400 my-0">
{{ $t('install.desktopAppSettingsDescription') }}
</p>
</div>
<div class="flex flex-col bg-neutral-800 p-4 rounded-lg text-sm">
<!-- Auto Update Setting -->
<div class="flex items-center gap-4">
<div class="flex-1">
<h3 class="text-lg font-medium text-neutral-100">
{{ $t('install.settings.autoUpdate') }}
</h3>
<p class="text-neutral-400 mt-1">
{{ $t('install.settings.autoUpdateDescription') }}
</p>
</div>
<ToggleSwitch v-model="autoUpdate" />
</div>
<Divider />
<!-- Metrics Collection Setting -->
<div class="flex items-center gap-4">
<div class="flex-1">
<h3 class="text-lg font-medium text-neutral-100">
{{ $t('install.settings.allowMetrics') }}
</h3>
<p class="text-neutral-400">
{{ $t('install.settings.allowMetricsDescription') }}
</p>
<a href="#" @click.prevent="showMetricsInfo">
{{ $t('install.settings.learnMoreAboutData') }}
</a>
</div>
<ToggleSwitch v-model="allowMetrics" />
</div>
</div>
<!-- Info Dialog -->
<Dialog
v-model:visible="showDialog"
modal
dismissable-mask
:header="$t('install.settings.dataCollectionDialog.title')"
class="select-none"
>
<div class="text-neutral-300">
<h4 class="font-medium mb-2">
{{ $t('install.settings.dataCollectionDialog.whatWeCollect') }}
</h4>
<ul class="list-disc pl-6 space-y-1">
<li>
{{
$t('install.settings.dataCollectionDialog.collect.errorReports')
}}
</li>
<li>
{{ $t('install.settings.dataCollectionDialog.collect.systemInfo') }}
</li>
<li>
{{
$t(
'install.settings.dataCollectionDialog.collect.userJourneyEvents'
)
}}
</li>
</ul>
<h4 class="font-medium mt-4 mb-2">
{{ $t('install.settings.dataCollectionDialog.whatWeDoNotCollect') }}
</h4>
<ul class="list-disc pl-6 space-y-1">
<li>
{{
$t(
'install.settings.dataCollectionDialog.doNotCollect.personalInformation'
)
}}
</li>
<li>
{{
$t(
'install.settings.dataCollectionDialog.doNotCollect.workflowContents'
)
}}
</li>
<li>
{{
$t(
'install.settings.dataCollectionDialog.doNotCollect.fileSystemInformation'
)
}}
</li>
<li>
{{
$t(
'install.settings.dataCollectionDialog.doNotCollect.customNodeConfigurations'
)
}}
</li>
</ul>
<div class="mt-4">
<a href="https://comfy.org/privacy" target="_blank">
{{ $t('install.settings.dataCollectionDialog.viewFullPolicy') }}
</a>
</div>
</div>
</Dialog>
</div>
</template>
<script setup lang="ts">
import Dialog from 'primevue/dialog'
import Divider from 'primevue/divider'
import ToggleSwitch from 'primevue/toggleswitch'
import { ref } from 'vue'
const showDialog = ref(false)
const autoUpdate = defineModel<boolean>('autoUpdate', { required: true })
const allowMetrics = defineModel<boolean>('allowMetrics', { required: true })
const showMetricsInfo = () => {
showDialog.value = true
}
</script>

View File

@@ -1,103 +0,0 @@
<template>
<div
class="grid grid-rows-[1fr_auto_auto_1fr] w-full max-w-3xl mx-auto h-[40rem] select-none"
>
<h2 class="font-inter font-bold text-3xl text-neutral-100 text-center">
{{ $t('install.gpuPicker.title') }}
</h2>
<!-- GPU Selection buttons - takes up remaining space and centers content -->
<div class="flex-1 flex gap-8 justify-center items-center">
<!-- Apple Metal / NVIDIA -->
<HardwareOption
v-if="platform === 'darwin'"
:image-path="'/assets/images/apple-mps-logo.png'"
placeholder-text="Apple Metal"
subtitle="Apple Metal"
:value="'mps'"
:selected="selected === 'mps'"
:recommended="true"
@click="pickGpu('mps')"
/>
<HardwareOption
v-else
:image-path="'/assets/images/nvidia-logo-square.jpg'"
placeholder-text="NVIDIA"
:subtitle="$t('install.gpuPicker.nvidiaSubtitle')"
:value="'nvidia'"
:selected="selected === 'nvidia'"
:recommended="true"
@click="pickGpu('nvidia')"
/>
<!-- CPU -->
<HardwareOption
placeholder-text="CPU"
:subtitle="$t('install.gpuPicker.cpuSubtitle')"
:value="'cpu'"
:selected="selected === 'cpu'"
@click="pickGpu('cpu')"
/>
<!-- Manual Install -->
<HardwareOption
placeholder-text="Manual Install"
:subtitle="$t('install.gpuPicker.manualSubtitle')"
:value="'unsupported'"
:selected="selected === 'unsupported'"
@click="pickGpu('unsupported')"
/>
</div>
<div class="pt-12 px-24 h-16">
<div v-show="showRecommendedBadge" class="flex items-center gap-2">
<Tag
:value="$t('install.gpuPicker.recommended')"
class="bg-neutral-300 text-neutral-900 rounded-full text-sm font-bold px-2 py-[1px]"
/>
<i-lucide:badge-check class="text-neutral-300 text-lg" />
</div>
</div>
<div class="text-neutral-300 px-24">
<p v-show="descriptionText" class="leading-relaxed">
{{ descriptionText }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
import Tag from 'primevue/tag'
import { computed } from 'vue'
import HardwareOption from '@/components/install/HardwareOption.vue'
import { st } from '@/i18n'
import { electronAPI } from '@/utils/envUtil'
const selected = defineModel<TorchDeviceType | null>('device', {
required: true
})
const electron = electronAPI()
const platform = electron.getPlatform()
const showRecommendedBadge = computed(
() => selected.value === 'mps' || selected.value === 'nvidia'
)
const descriptionKeys = {
mps: 'appleMetal',
nvidia: 'nvidia',
cpu: 'cpu',
unsupported: 'manual'
} as const
const descriptionText = computed(() => {
const key = selected.value ? descriptionKeys[selected.value] : undefined
return st(`install.gpuPicker.${key}Description`, '')
})
const pickGpu = (value: TorchDeviceType) => {
selected.value = value
}
</script>

View File

@@ -1,73 +0,0 @@
// eslint-disable-next-line storybook/no-renderer-packages
import type { Meta, StoryObj } from '@storybook/vue3'
import HardwareOption from './HardwareOption.vue'
const meta: Meta<typeof HardwareOption> = {
title: 'Desktop/Components/HardwareOption',
component: HardwareOption,
parameters: {
layout: 'centered',
backgrounds: {
default: 'dark',
values: [{ name: 'dark', value: '#1a1a1a' }]
}
},
argTypes: {
selected: { control: 'boolean' },
imagePath: { control: 'text' },
placeholderText: { control: 'text' },
subtitle: { control: 'text' }
}
}
export default meta
type Story = StoryObj<typeof meta>
export const AppleMetalSelected: Story = {
args: {
imagePath: '/assets/images/apple-mps-logo.png',
placeholderText: 'Apple Metal',
subtitle: 'Apple Metal',
value: 'mps',
selected: true
}
}
export const AppleMetalUnselected: Story = {
args: {
imagePath: '/assets/images/apple-mps-logo.png',
placeholderText: 'Apple Metal',
subtitle: 'Apple Metal',
value: 'mps',
selected: false
}
}
export const CPUOption: Story = {
args: {
placeholderText: 'CPU',
subtitle: 'Subtitle',
value: 'cpu',
selected: false
}
}
export const ManualInstall: Story = {
args: {
placeholderText: 'Manual Install',
subtitle: 'Subtitle',
value: 'unsupported',
selected: false
}
}
export const NvidiaSelected: Story = {
args: {
imagePath: '/assets/images/nvidia-logo-square.jpg',
placeholderText: 'NVIDIA',
subtitle: 'NVIDIA',
value: 'nvidia',
selected: true
}
}

View File

@@ -1,55 +0,0 @@
<template>
<div class="relative">
<!-- Recommended Badge -->
<button
:class="
cn(
'hardware-option w-[170px] h-[190px] p-5 flex flex-col items-center rounded-3xl transition-all duration-200 bg-neutral-900/70 border-4',
selected ? 'border-solid border-brand-yellow' : 'border-transparent'
)
"
@click="$emit('click')"
>
<!-- Icon/Logo Area - Rounded square container -->
<div
class="icon-container w-[110px] h-[110px] shrink-0 rounded-2xl bg-neutral-800 flex items-center justify-center overflow-hidden"
>
<img
v-if="imagePath"
:src="imagePath"
:alt="placeholderText"
class="w-full h-full object-cover"
style="object-position: 57% center"
draggable="false"
/>
<span v-else class="text-xl font-medium text-neutral-400">
{{ placeholderText }}
</span>
</div>
<!-- Text Content -->
<div v-if="subtitle" class="text-center mt-4">
<div class="text-sm text-neutral-500">{{ subtitle }}</div>
</div>
</button>
</div>
</template>
<script setup lang="ts">
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
import { cn } from '@/utils/tailwindUtil'
interface Props {
imagePath?: string
placeholderText: string
subtitle?: string
value: TorchDeviceType
selected?: boolean
recommended?: boolean
}
defineProps<Props>()
defineEmits<{ click: [] }>()
</script>

View File

@@ -1,79 +0,0 @@
<template>
<div class="grid grid-cols-[1fr_auto_1fr] items-center gap-4">
<!-- Back button -->
<Button
v-if="currentStep !== '1'"
:label="$t('g.back')"
severity="secondary"
icon="pi pi-arrow-left"
class="font-inter rounded-lg border-0 px-6 py-2 justify-self-start"
@click="$emit('previous')"
/>
<div v-else></div>
<!-- Step indicators in center -->
<StepList class="flex justify-center items-center gap-3 select-none">
<Step value="1" :pt="stepPassthrough">
{{ $t('install.gpu') }}
</Step>
<Step value="2" :disabled="disableLocationStep" :pt="stepPassthrough">
{{ $t('install.installLocation') }}
</Step>
<Step value="3" :disabled="disableSettingsStep" :pt="stepPassthrough">
{{ $t('install.desktopSettings') }}
</Step>
</StepList>
<!-- Next/Install button -->
<Button
:label="currentStep !== '3' ? $t('g.next') : $t('g.install')"
class="px-8 py-2 bg-brand-yellow hover:bg-brand-yellow/90 font-inter rounded-lg border-0 transition-colors justify-self-end"
:pt="{
label: { class: 'text-neutral-900 font-inter font-black' }
}"
:disabled="!canProceed"
@click="currentStep !== '3' ? $emit('next') : $emit('install')"
/>
</div>
</template>
<script setup lang="ts">
import type { PassThrough } from '@primevue/core'
import Button from 'primevue/button'
import Step, { type StepPassThroughOptions } from 'primevue/step'
import StepList from 'primevue/steplist'
defineProps<{
/** Current step index as string ('1', '2', '3', '4') */
currentStep: string
/** Whether the user can proceed to the next step */
canProceed: boolean
/** Whether the location step should be disabled */
disableLocationStep: boolean
/** Whether the migration step should be disabled */
disableMigrationStep: boolean
/** Whether the settings step should be disabled */
disableSettingsStep: boolean
}>()
defineEmits<{
previous: []
next: []
install: []
}>()
const stepPassthrough: PassThrough<StepPassThroughOptions> = {
root: { class: 'flex-none p-0 m-0' },
header: ({ context }) => ({
class: [
'h-2.5 p-0 m-0 border-0 rounded-full transition-all duration-300',
context.active
? 'bg-brand-yellow w-8 rounded-sm'
: 'bg-neutral-700 w-2.5',
context.disabled ? 'opacity-60 cursor-not-allowed' : ''
].join(' ')
}),
number: { class: 'hidden' },
title: { class: 'hidden' }
}
</script>

View File

@@ -1,148 +0,0 @@
// eslint-disable-next-line storybook/no-renderer-packages
import type { Meta, StoryObj } from '@storybook/vue3'
import { ref } from 'vue'
import InstallLocationPicker from './InstallLocationPicker.vue'
const meta: Meta<typeof InstallLocationPicker> = {
title: 'Desktop/Components/InstallLocationPicker',
component: InstallLocationPicker,
parameters: {
layout: 'padded',
backgrounds: {
default: 'dark',
values: [
{ name: 'dark', value: '#0a0a0a' },
{ name: 'neutral-900', value: '#171717' },
{ name: 'neutral-950', value: '#0a0a0a' }
]
}
},
decorators: [
() => {
// Mock electron API
;(window as any).electronAPI = {
getSystemPaths: () =>
Promise.resolve({
defaultInstallPath: '/Users/username/ComfyUI'
}),
validateInstallPath: () =>
Promise.resolve({
isValid: true,
exists: false,
canWrite: true,
freeSpace: 100000000000,
requiredSpace: 10000000000,
isNonDefaultDrive: false
}),
validateComfyUISource: () =>
Promise.resolve({
isValid: true
}),
showDirectoryPicker: () => Promise.resolve('/Users/username/ComfyUI')
}
return { template: '<story />' }
}
]
}
export default meta
type Story = StoryObj<typeof meta>
// Default story with accordion expanded
export const Default: Story = {
render: (args) => ({
components: { InstallLocationPicker },
setup() {
const installPath = ref('/Users/username/ComfyUI')
const pathError = ref('')
const migrationSourcePath = ref('/Users/username/ComfyUI-old')
const migrationItemIds = ref<string[]>(['models', 'custom_nodes'])
return {
args,
installPath,
pathError,
migrationSourcePath,
migrationItemIds
}
},
template: `
<div class="min-h-screen bg-neutral-950 p-8">
<InstallLocationPicker
v-model:installPath="installPath"
v-model:pathError="pathError"
v-model:migrationSourcePath="migrationSourcePath"
v-model:migrationItemIds="migrationItemIds"
/>
</div>
`
})
}
// Story with different background to test transparency
export const OnNeutral900: Story = {
render: (args) => ({
components: { InstallLocationPicker },
setup() {
const installPath = ref('/Users/username/ComfyUI')
const pathError = ref('')
const migrationSourcePath = ref('/Users/username/ComfyUI-old')
const migrationItemIds = ref<string[]>(['models', 'custom_nodes'])
return {
args,
installPath,
pathError,
migrationSourcePath,
migrationItemIds
}
},
template: `
<div class="min-h-screen bg-neutral-900 p-8">
<InstallLocationPicker
v-model:installPath="installPath"
v-model:pathError="pathError"
v-model:migrationSourcePath="migrationSourcePath"
v-model:migrationItemIds="migrationItemIds"
/>
</div>
`
})
}
// Story with debug overlay showing background colors
export const DebugBackgrounds: Story = {
render: (args) => ({
components: { InstallLocationPicker },
setup() {
const installPath = ref('/Users/username/ComfyUI')
const pathError = ref('')
const migrationSourcePath = ref('/Users/username/ComfyUI-old')
const migrationItemIds = ref<string[]>(['models', 'custom_nodes'])
return {
args,
installPath,
pathError,
migrationSourcePath,
migrationItemIds
}
},
template: `
<div class="min-h-screen bg-neutral-950 p-8 relative">
<div class="absolute top-4 right-4 text-white text-xs space-y-2 z-50">
<div>Parent bg: neutral-950 (#0a0a0a)</div>
<div>Accordion content: bg-transparent</div>
<div>Migration options: bg-transparent + p-4 rounded-lg</div>
</div>
<InstallLocationPicker
v-model:installPath="installPath"
v-model:pathError="pathError"
v-model:migrationSourcePath="migrationSourcePath"
v-model:migrationItemIds="migrationItemIds"
/>
</div>
`
})
}

View File

@@ -1,314 +0,0 @@
<template>
<div class="flex flex-col gap-8 w-full max-w-3xl mx-auto select-none">
<!-- Installation Path Section -->
<div class="grow flex flex-col gap-6 text-neutral-300">
<h2 class="font-inter font-bold text-3xl text-neutral-100 text-center">
{{ $t('install.locationPicker.title') }}
</h2>
<p class="text-center text-neutral-400 px-12">
{{ $t('install.locationPicker.subtitle') }}
</p>
<!-- Path Input -->
<div class="flex gap-2 px-12">
<InputText
v-model="installPath"
:placeholder="$t('install.locationPicker.pathPlaceholder')"
class="flex-1 bg-neutral-800/50 border-neutral-700 text-neutral-200 placeholder:text-neutral-500"
:class="{ 'p-invalid': pathError }"
@update:model-value="validatePath"
@focus="onFocus"
/>
<Button
icon="pi pi-folder-open"
severity="secondary"
class="bg-neutral-700 hover:bg-neutral-600 border-0"
@click="browsePath"
/>
</div>
<!-- Error Messages -->
<div v-if="pathError || pathExists || nonDefaultDrive" class="px-12">
<Message
v-if="pathError"
severity="error"
class="whitespace-pre-line w-full"
>
{{ pathError }}
</Message>
<Message v-if="pathExists" severity="warn" class="w-full">
{{ $t('install.pathExists') }}
</Message>
<Message v-if="nonDefaultDrive" severity="warn" class="w-full">
{{ $t('install.nonDefaultDrive') }}
</Message>
</div>
<!-- Collapsible Sections using PrimeVue Accordion -->
<Accordion
v-model:value="activeAccordionIndex"
:multiple="true"
class="location-picker-accordion"
:pt="{
root: 'bg-transparent border-0',
panel: {
root: 'border-0 mb-0'
},
header: {
root: 'border-0',
content:
'text-neutral-400 hover:text-neutral-300 px-4 py-2 flex items-center gap-3',
toggleicon: 'text-xs order-first mr-0'
},
content: {
root: 'bg-transparent border-0',
content: 'text-neutral-500 text-sm pl-11 pb-3 pt-0'
}
}"
>
<AccordionPanel value="0">
<AccordionHeader>
{{ $t('install.locationPicker.migrateFromExisting') }}
</AccordionHeader>
<AccordionContent>
<MigrationPicker
v-model:source-path="migrationSourcePath"
v-model:migration-item-ids="migrationItemIds"
/>
</AccordionContent>
</AccordionPanel>
<AccordionPanel value="1">
<AccordionHeader>
{{ $t('install.locationPicker.chooseDownloadServers') }}
</AccordionHeader>
<AccordionContent>
<template
v-for="([item, modelValue], index) in mirrors"
:key="item.settingId + item.mirror"
>
<Divider v-if="index > 0" class="my-8" />
<MirrorItem
v-model="modelValue.value"
:item="item"
@state-change="validationStates[index] = $event"
/>
</template>
</AccordionContent>
</AccordionPanel>
</Accordion>
</div>
</div>
</template>
<script setup lang="ts">
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
import { TorchMirrorUrl } from '@comfyorg/comfyui-electron-types'
import Accordion from 'primevue/accordion'
import AccordionContent from 'primevue/accordioncontent'
import AccordionHeader from 'primevue/accordionheader'
import AccordionPanel from 'primevue/accordionpanel'
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import InputText from 'primevue/inputtext'
import Message from 'primevue/message'
import { type ModelRef, computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import MigrationPicker from '@/components/install/MigrationPicker.vue'
import MirrorItem from '@/components/install/mirror/MirrorItem.vue'
import {
PYPI_MIRROR,
PYTHON_MIRROR,
type UVMirror
} from '@/constants/uvMirrors'
import { electronAPI } from '@/utils/envUtil'
import { isInChina } from '@/utils/networkUtil'
import { ValidationState } from '@/utils/validationUtil'
const { t } = useI18n()
const installPath = defineModel<string>('installPath', { required: true })
const pathError = defineModel<string>('pathError', { required: true })
const migrationSourcePath = defineModel<string>('migrationSourcePath')
const migrationItemIds = defineModel<string[]>('migrationItemIds')
const pythonMirror = defineModel<string>('pythonMirror', {
default: ''
})
const pypiMirror = defineModel<string>('pypiMirror', {
default: ''
})
const torchMirror = defineModel<string>('torchMirror', {
default: ''
})
const { device } = defineProps<{ device: TorchDeviceType | null }>()
const pathExists = ref(false)
const nonDefaultDrive = ref(false)
const inputTouched = ref(false)
// Accordion state - array of active panel values
const activeAccordionIndex = ref<string[] | undefined>(undefined)
const electron = electronAPI()
// Mirror configuration logic
const getTorchMirrorItem = (device: TorchDeviceType): UVMirror => {
const settingId = 'Comfy-Desktop.UV.TorchInstallMirror'
switch (device) {
case 'mps':
return {
settingId,
mirror: TorchMirrorUrl.NightlyCpu,
fallbackMirror: TorchMirrorUrl.NightlyCpu
}
case 'nvidia':
return {
settingId,
mirror: TorchMirrorUrl.Cuda,
fallbackMirror: TorchMirrorUrl.Cuda
}
case 'cpu':
default:
return {
settingId,
mirror: PYPI_MIRROR.mirror,
fallbackMirror: PYPI_MIRROR.fallbackMirror
}
}
}
const userIsInChina = ref(false)
const useFallbackMirror = (mirror: UVMirror) => ({
...mirror,
mirror: mirror.fallbackMirror
})
const mirrors = computed<[UVMirror, ModelRef<string>][]>(() =>
(
[
[PYTHON_MIRROR, pythonMirror],
[PYPI_MIRROR, pypiMirror],
[getTorchMirrorItem(device ?? 'cpu'), torchMirror]
] as [UVMirror, ModelRef<string>][]
).map(([item, modelValue]) => [
userIsInChina.value ? useFallbackMirror(item) : item,
modelValue
])
)
const validationStates = ref<ValidationState[]>(
mirrors.value.map(() => ValidationState.IDLE)
)
// Get default install path on component mount
onMounted(async () => {
const paths = await electron.getSystemPaths()
installPath.value = paths.defaultInstallPath
await validatePath(paths.defaultInstallPath)
userIsInChina.value = await isInChina()
})
const validatePath = async (path: string | undefined) => {
try {
pathError.value = ''
pathExists.value = false
nonDefaultDrive.value = false
const validation = await electron.validateInstallPath(path ?? '')
// Create a pre-formatted list of errors
if (!validation.isValid) {
const errors: string[] = []
if (validation.cannotWrite) errors.push(t('install.cannotWrite'))
if (validation.freeSpace < validation.requiredSpace) {
const requiredGB = validation.requiredSpace / 1024 / 1024 / 1024
errors.push(`${t('install.insufficientFreeSpace')}: ${requiredGB} GB`)
}
if (validation.parentMissing) errors.push(t('install.parentMissing'))
if (validation.isOneDrive) errors.push(t('install.isOneDrive'))
if (validation.error)
errors.push(`${t('install.unhandledError')}: ${validation.error}`)
pathError.value = errors.join('\n')
}
if (validation.isNonDefaultDrive) nonDefaultDrive.value = true
if (validation.exists) pathExists.value = true
} catch (error) {
pathError.value = t('install.pathValidationFailed')
}
}
const browsePath = async () => {
try {
const result = await electron.showDirectoryPicker()
if (result) {
installPath.value = result
await validatePath(result)
}
} catch (error) {
pathError.value = t('install.failedToSelectDirectory')
}
}
const onFocus = async () => {
if (!inputTouched.value) {
inputTouched.value = true
return
}
// Refresh validation on re-focus
await validatePath(installPath.value)
}
</script>
<style scoped>
@reference '../../assets/css/style.css';
:deep(.location-picker-accordion) {
@apply px-12;
.p-accordionpanel {
@apply border-0 bg-transparent;
}
.p-accordionheader {
@apply bg-neutral-800/50 border-0 rounded-xl mt-2 hover:bg-neutral-700/50;
transition:
background-color 0.2s ease,
border-radius 0.5s ease;
}
/* When panel is expanded, adjust header border radius */
.p-accordionpanel-active {
.p-accordionheader {
@apply rounded-t-xl rounded-b-none;
}
}
.p-accordioncontent {
@apply bg-neutral-800/50 border-0 rounded-b-xl rounded-t-none;
}
.p-accordioncontent-content {
@apply bg-transparent pt-3 pr-5 pb-5 pl-5;
}
/* Override default chevron icons to use up/down */
.p-accordionheader-toggle-icon {
&::before {
content: '\e933';
}
}
.p-accordionpanel-active {
.p-accordionheader-toggle-icon {
&::before {
content: '\e902';
}
}
}
}
</style>

View File

@@ -1,45 +0,0 @@
// eslint-disable-next-line storybook/no-renderer-packages
import type { Meta, StoryObj } from '@storybook/vue3'
import { ref } from 'vue'
import MigrationPicker from './MigrationPicker.vue'
const meta: Meta<typeof MigrationPicker> = {
title: 'Desktop/Components/MigrationPicker',
component: MigrationPicker,
parameters: {
backgrounds: {
default: 'dark',
values: [
{ name: 'dark', value: '#0a0a0a' },
{ name: 'neutral-900', value: '#171717' }
]
}
},
decorators: [
() => {
;(window as any).electronAPI = {
validateComfyUISource: () => Promise.resolve({ isValid: true }),
showDirectoryPicker: () => Promise.resolve('/Users/username/ComfyUI')
}
return { template: '<story />' }
}
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => ({
components: { MigrationPicker },
setup() {
const sourcePath = ref('')
const migrationItemIds = ref<string[]>([])
return { sourcePath, migrationItemIds }
},
template:
'<MigrationPicker v-model:sourcePath="sourcePath" v-model:migrationItemIds="migrationItemIds" />'
})
}

View File

@@ -1,130 +0,0 @@
<template>
<div class="flex flex-col gap-6 w-[600px]">
<!-- Source Location Section -->
<div class="flex flex-col gap-4">
<p class="text-neutral-400 my-0">
{{ $t('install.migrationSourcePathDescription') }}
</p>
<div class="flex gap-2">
<InputText
v-model="sourcePath"
:placeholder="$t('install.locationPicker.migrationPathPlaceholder')"
class="flex-1"
:class="{ 'p-invalid': pathError }"
@update:model-value="validateSource"
/>
<Button icon="pi pi-folder" class="w-12" @click="browsePath" />
</div>
<Message v-if="pathError" severity="error">
{{ pathError }}
</Message>
</div>
<!-- Migration Options -->
<div v-if="isValidSource" class="flex flex-col gap-4 p-4 rounded-lg">
<h3 class="text-lg mt-0 font-medium text-neutral-100">
{{ $t('install.selectItemsToMigrate') }}
</h3>
<div class="flex flex-col gap-3">
<div
v-for="item in migrationItems"
:key="item.id"
class="flex items-center gap-3 p-2 hover:bg-neutral-700 rounded"
@click="item.selected = !item.selected"
>
<Checkbox
v-model="item.selected"
:input-id="item.id"
:binary="true"
@click.stop
/>
<div>
<label :for="item.id" class="text-neutral-200 font-medium">
{{ item.label }}
</label>
<p class="text-sm text-neutral-400 my-1">
{{ item.description }}
</p>
</div>
</div>
</div>
</div>
<!-- Skip Migration -->
<div v-else class="text-neutral-400 italic">
{{ $t('install.migrationOptional') }}
</div>
</div>
</template>
<script setup lang="ts">
import { MigrationItems } from '@comfyorg/comfyui-electron-types'
import Button from 'primevue/button'
import Checkbox from 'primevue/checkbox'
import InputText from 'primevue/inputtext'
import Message from 'primevue/message'
import { computed, ref, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import { electronAPI } from '@/utils/envUtil'
const { t } = useI18n()
const electron = electronAPI()
const sourcePath = defineModel<string>('sourcePath', { required: false })
const migrationItemIds = defineModel<string[]>('migrationItemIds', {
required: false
})
const migrationItems = ref(
MigrationItems.map((item) => ({
...item,
selected: true
}))
)
const pathError = ref('')
const isValidSource = computed(
() => sourcePath.value !== '' && pathError.value === ''
)
const validateSource = async (sourcePath: string | undefined) => {
if (!sourcePath) {
pathError.value = ''
return
}
try {
pathError.value = ''
const validation = await electron.validateComfyUISource(sourcePath)
if (!validation.isValid) pathError.value = validation.error ?? 'ERROR'
} catch (error) {
console.error(error)
pathError.value = t('install.pathValidationFailed')
}
}
const browsePath = async () => {
try {
const result = await electron.showDirectoryPicker()
if (result) {
sourcePath.value = result
await validateSource(result)
}
} catch (error) {
console.error(error)
pathError.value = t('install.failedToSelectDirectory')
}
}
watchEffect(() => {
migrationItemIds.value = migrationItems.value
.filter((item) => item.selected)
.map((item) => item.id)
})
</script>

View File

@@ -1,109 +0,0 @@
<template>
<div class="flex flex-col gap-4 text-neutral-400 text-sm">
<div>
<h3 class="text-lg font-medium text-neutral-100 mb-3 mt-0">
{{ $t(`settings.${normalizedSettingId}.name`) }}
</h3>
<p class="my-1">
{{ $t(`settings.${normalizedSettingId}.tooltip`) }}
</p>
</div>
<UrlInput
v-model="modelValue"
:validate-url-fn="
(mirror: string) =>
checkMirrorReachable(mirror + (item.validationPathSuffix ?? ''))
"
@state-change="validationState = $event"
/>
<div v-if="secondParagraph" class="mt-2">
<a href="#" @click.prevent="showDialog = true">
{{ $t('g.learnMore') }}
</a>
<Dialog
v-model:visible="showDialog"
modal
dismissable-mask
:header="$t(`settings.${normalizedSettingId}.urlFormatTitle`)"
class="select-none max-w-3xl"
>
<div class="text-neutral-300">
<p class="mt-1 whitespace-pre-wrap">{{ secondParagraph }}</p>
<div class="mt-2 break-all">
<span class="text-neutral-300 font-semibold">
{{ EXAMPLE_URL_FIRST_PART }}
</span>
<span>{{ EXAMPLE_URL_SECOND_PART }}</span>
</div>
<Divider />
<p>
{{ $t(`settings.${normalizedSettingId}.fileUrlDescription`) }}
</p>
<span class="text-neutral-300 font-semibold">
{{ FILE_URL_SCHEME }}
</span>
<span>
{{ EXAMPLE_FILE_URL }}
</span>
</div>
</Dialog>
</div>
</div>
</template>
<script setup lang="ts">
import Dialog from 'primevue/dialog'
import Divider from 'primevue/divider'
import { computed, onMounted, ref, watch } from 'vue'
import UrlInput from '@/components/common/UrlInput.vue'
import type { UVMirror } from '@/constants/uvMirrors'
import { st } from '@/i18n'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { checkMirrorReachable } from '@/utils/networkUtil'
import { ValidationState } from '@/utils/validationUtil'
const FILE_URL_SCHEME = 'file://'
const EXAMPLE_FILE_URL = '/C:/MyPythonInstallers/'
const EXAMPLE_URL_FIRST_PART =
'https://github.com/astral-sh/python-build-standalone/releases/download'
const EXAMPLE_URL_SECOND_PART =
'/20250902/cpython-3.12.11+20250902-x86_64-pc-windows-msvc-install_only.tar.gz'
const { item } = defineProps<{
item: UVMirror
}>()
const emit = defineEmits<{
'state-change': [state: ValidationState]
}>()
const modelValue = defineModel<string>('modelValue', { required: true })
const validationState = ref<ValidationState>(ValidationState.IDLE)
const showDialog = ref(false)
const normalizedSettingId = computed(() => {
return normalizeI18nKey(item.settingId)
})
const secondParagraph = computed(() =>
st(`settings.${normalizedSettingId.value}.urlDescription`, '')
)
onMounted(() => {
modelValue.value = item.mirror
})
watch(validationState, (newState) => {
emit('state-change', newState)
// Set fallback mirror if default mirror is invalid
if (
newState === ValidationState.INVALID &&
modelValue.value === item.mirror
) {
modelValue.value = item.fallbackMirror
}
})
</script>

View File

@@ -1,36 +0,0 @@
<template>
<Tag :icon :severity :value />
</template>
<script setup lang="ts">
import { PrimeIcons } from '@primevue/core/api'
import Tag from 'primevue/tag'
import { computed } from 'vue'
import { t } from '@/i18n'
// Properties
const props = defineProps<{
error: boolean
refreshing?: boolean
}>()
// Bindings
const icon = computed(() => {
if (props.refreshing) return PrimeIcons.QUESTION
if (props.error) return PrimeIcons.TIMES
return PrimeIcons.CHECK
})
const severity = computed(() => {
if (props.refreshing) return 'info'
if (props.error) return 'danger'
return 'success'
})
const value = computed(() => {
if (props.refreshing) return t('maintenance.refreshing')
if (props.error) return t('g.error')
return t('maintenance.OK')
})
</script>

View File

@@ -1,133 +0,0 @@
<template>
<div
class="task-div max-w-48 min-h-52 grid relative"
:class="{ 'opacity-75': isLoading }"
>
<Card
class="max-w-48 relative h-full overflow-hidden"
:class="{ 'opacity-65': runner.state !== 'error' }"
v-bind="(({ onClick, ...rest }) => rest)($attrs)"
>
<template #header>
<i
v-if="runner.state === 'error'"
class="pi pi-exclamation-triangle text-red-500 absolute m-2 top-0 -right-14 opacity-15"
style="font-size: 10rem"
/>
<img
v-if="task.headerImg"
:src="task.headerImg"
class="object-contain w-full h-full opacity-25 pt-4 px-4"
/>
</template>
<template #title>
{{ task.name }}
</template>
<template #content>
{{ description }}
</template>
<template #footer>
<div class="flex gap-4 mt-1">
<Button
:icon="task.button?.icon"
:label="task.button?.text"
class="w-full"
raised
icon-pos="right"
:loading="isExecuting"
@click="(event) => $emit('execute', event)"
/>
</div>
</template>
</Card>
<i
v-if="!isLoading && runner.state === 'OK'"
class="task-card-ok pi pi-check"
/>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Card from 'primevue/card'
import { computed } from 'vue'
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
import { useMinLoadingDurationRef } from '@/utils/refUtil'
const taskStore = useMaintenanceTaskStore()
const runner = computed(() => taskStore.getRunner(props.task))
// Properties
const props = defineProps<{
task: MaintenanceTask
}>()
// Events
defineEmits<{
execute: [event: MouseEvent]
}>()
// Bindings
const description = computed(() =>
runner.value.state === 'error'
? props.task.errorDescription ?? props.task.shortDescription
: props.task.shortDescription
)
// Use a minimum run time to ensure tasks "feel" like they have run
const reactiveLoading = computed(() => !!runner.value.refreshing)
const reactiveExecuting = computed(() => !!runner.value.executing)
const isLoading = useMinLoadingDurationRef(reactiveLoading, 250)
const isExecuting = useMinLoadingDurationRef(reactiveExecuting, 250)
</script>
<style scoped>
@reference '../../assets/css/style.css';
.task-card-ok {
@apply text-green-500 absolute -right-4 -bottom-4 opacity-100 row-span-full col-span-full transition-opacity;
font-size: 4rem;
text-shadow: 0.25rem 0 0.5rem black;
z-index: 10;
}
.p-card {
@apply transition-opacity;
--p-card-background: var(--p-button-secondary-background);
opacity: 0.9;
&.opacity-65 {
opacity: 0.4;
}
&:hover {
opacity: 1;
}
}
:deep(.p-card-header) {
z-index: 0;
}
:deep(.p-card-body) {
z-index: 1;
flex-grow: 1;
justify-content: space-between;
}
.task-div {
> i {
pointer-events: none;
}
&:hover > i {
opacity: 0.2;
}
}
</style>

View File

@@ -1,88 +0,0 @@
<template>
<tr
class="border-neutral-700 border-solid border-y"
:class="{
'opacity-50': runner.resolved,
'opacity-75': isLoading && runner.resolved
}"
>
<td class="text-center w-16">
<TaskListStatusIcon :state="runner.state" :loading="isLoading" />
</td>
<td>
<p class="inline-block">
{{ task.name }}
</p>
<Button
class="inline-block mx-2"
type="button"
:icon="PrimeIcons.INFO_CIRCLE"
severity="secondary"
:text="true"
@click="toggle"
/>
<Popover ref="infoPopover" class="block m-1 max-w-64 min-w-32">
<span class="whitespace-pre-line">{{ task.description }}</span>
</Popover>
</td>
<td class="text-right px-4">
<Button
:icon="task.button?.icon"
:label="task.button?.text"
:severity
icon-pos="right"
:loading="isExecuting"
@click="(event) => $emit('execute', event)"
/>
</td>
</tr>
</template>
<script setup lang="ts">
import { PrimeIcons } from '@primevue/core/api'
import Button from 'primevue/button'
import Popover from 'primevue/popover'
import { computed, ref } from 'vue'
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
import type { PrimeVueSeverity } from '@/types/primeVueTypes'
import { useMinLoadingDurationRef } from '@/utils/refUtil'
import TaskListStatusIcon from './TaskListStatusIcon.vue'
const taskStore = useMaintenanceTaskStore()
const runner = computed(() => taskStore.getRunner(props.task))
// Properties
const props = defineProps<{
task: MaintenanceTask
}>()
// Events
defineEmits<{
execute: [event: MouseEvent]
}>()
// Binding
const severity = computed<PrimeVueSeverity>(() =>
runner.value.state === 'error' || runner.value.state === 'warning'
? 'primary'
: 'secondary'
)
// Use a minimum run time to ensure tasks "feel" like they have run
const reactiveLoading = computed(() => !!runner.value.refreshing)
const reactiveExecuting = computed(() => !!runner.value.executing)
const isLoading = useMinLoadingDurationRef(reactiveLoading, 250)
const isExecuting = useMinLoadingDurationRef(reactiveExecuting, 250)
// Popover
const infoPopover = ref<InstanceType<typeof Popover> | null>(null)
const toggle = (event: Event) => {
infoPopover.value?.toggle(event)
}
</script>

View File

@@ -1,115 +0,0 @@
<template>
<!-- Tasks -->
<section class="my-4">
<template v-if="filter.tasks.length === 0">
<!-- Empty filter -->
<Divider />
<p class="text-neutral-400 w-full text-center">
{{ $t('maintenance.allOk') }}
</p>
</template>
<template v-else>
<!-- Display: List -->
<table
v-if="displayAsList === PrimeIcons.LIST"
class="w-full border-collapse border-hidden"
>
<TaskListItem
v-for="task in filter.tasks"
:key="task.id"
:task
@execute="(event) => confirmButton(event, task)"
/>
</table>
<!-- Display: Cards -->
<template v-else>
<div class="flex flex-wrap justify-evenly gap-8 pad-y my-4">
<TaskCard
v-for="task in filter.tasks"
:key="task.id"
:task
@execute="(event) => confirmButton(event, task)"
/>
</div>
</template>
</template>
<ConfirmPopup />
</section>
</template>
<script setup lang="ts">
import { PrimeIcons } from '@primevue/core/api'
import { useConfirm, useToast } from 'primevue'
import ConfirmPopup from 'primevue/confirmpopup'
import Divider from 'primevue/divider'
import { t } from '@/i18n'
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
import type {
MaintenanceFilter,
MaintenanceTask
} from '@/types/desktop/maintenanceTypes'
import TaskCard from './TaskCard.vue'
import TaskListItem from './TaskListItem.vue'
const toast = useToast()
const confirm = useConfirm()
const taskStore = useMaintenanceTaskStore()
// Properties
defineProps<{
displayAsList: string
filter: MaintenanceFilter
isRefreshing: boolean
}>()
const executeTask = async (task: MaintenanceTask) => {
let message: string | undefined
try {
// Success
if ((await taskStore.execute(task)) === true) return
message = t('maintenance.error.taskFailed')
} catch (error) {
message = (error as Error)?.message
}
toast.add({
severity: 'error',
summary: t('maintenance.error.toastTitle'),
detail: message ?? t('maintenance.error.defaultDescription'),
life: 10_000
})
}
// Commands
const confirmButton = async (event: MouseEvent, task: MaintenanceTask) => {
if (!task.requireConfirm) {
await executeTask(task)
return
}
confirm.require({
target: event.currentTarget as HTMLElement,
message: task.confirmText ?? t('maintenance.confirmTitle'),
icon: 'pi pi-exclamation-circle',
rejectProps: {
label: t('g.cancel'),
severity: 'secondary',
outlined: true
},
acceptProps: {
label: task.button?.text ?? t('g.save'),
severity: task.severity ?? 'primary'
},
// TODO: Not awaited.
accept: async () => {
await executeTask(task)
}
})
}
</script>

View File

@@ -1,45 +0,0 @@
<template>
<ProgressSpinner v-if="!state || loading" class="h-8 w-8" />
<template v-else>
<i v-tooltip.top="{ value: tooltip, showDelay: 250 }" :class="cssClasses" />
</template>
</template>
<script setup lang="ts">
import { PrimeIcons } from '@primevue/core/api'
import ProgressSpinner from 'primevue/progressspinner'
import type { MaybeRef } from 'vue'
import { computed } from 'vue'
import { t } from '@/i18n'
// Properties
const tooltip = computed(() => {
if (props.state === 'error') {
return t('g.error')
} else if (props.state === 'OK') {
return t('maintenance.OK')
} else {
return t('maintenance.Skipped')
}
})
const cssClasses = computed(() => {
let classes: string
if (props.state === 'error') {
classes = `${PrimeIcons.EXCLAMATION_TRIANGLE} text-red-500`
} else if (props.state === 'OK') {
classes = `${PrimeIcons.CHECK} text-green-500`
} else {
classes = PrimeIcons.MINUS
}
return `text-3xl pi ${classes}`
})
// Model
const props = defineProps<{
state: 'warning' | 'error' | 'resolved' | 'OK' | 'skipped' | undefined
loading?: MaybeRef<boolean>
}>()
</script>

View File

@@ -1,62 +0,0 @@
<template>
<Drawer
v-model:visible="terminalVisible"
:header
position="bottom"
style="height: max(50vh, 34rem)"
>
<BaseTerminal @created="terminalCreated" @unmounted="terminalUnmounted" />
</Drawer>
</template>
<script setup lang="ts">
import type { Terminal } from '@xterm/xterm'
import Drawer from 'primevue/drawer'
import type { Ref } from 'vue'
import { onMounted } from 'vue'
import BaseTerminal from '@/components/bottomPanel/tabs/terminal/BaseTerminal.vue'
import type { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
import { useTerminalBuffer } from '@/composables/bottomPanelTabs/useTerminalBuffer'
import { electronAPI } from '@/utils/envUtil'
// Model
const terminalVisible = defineModel<boolean>({ required: true })
const props = defineProps<{
header: string
defaultMessage: string
}>()
const electron = electronAPI()
/** The actual output of all terminal commands - not rendered */
const buffer = useTerminalBuffer()
let xterm: Terminal | null = null
// Created and destroyed with the Drawer - contents copied from hidden buffer
const terminalCreated = (
{ terminal, useAutoSize }: ReturnType<typeof useTerminal>,
root: Ref<HTMLElement | undefined>
) => {
xterm = terminal
useAutoSize({ root, autoRows: true, autoCols: true })
terminal.write(props.defaultMessage)
buffer.copyTo(terminal)
terminal.options.cursorBlink = false
terminal.options.cursorStyle = 'bar'
terminal.options.cursorInactiveStyle = 'bar'
terminal.options.disableStdin = true
}
const terminalUnmounted = () => {
xterm = null
}
onMounted(async () => {
electron.onLogMessage((message: string) => {
buffer.write(message)
xterm?.write(message)
})
})
</script>

View File

@@ -38,6 +38,7 @@ import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue'
import SidebarShortcutsToggleButton from '@/components/sidebar/SidebarShortcutsToggleButton.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCommandStore } from '@/stores/commandStore'
import { useKeybindingStore } from '@/stores/keybindingStore'
import { useUserStore } from '@/stores/userStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
@@ -51,6 +52,7 @@ import SidebarTemplatesButton from './SidebarTemplatesButton.vue'
const workspaceStore = useWorkspaceStore()
const settingStore = useSettingStore()
const userStore = useUserStore()
const commandStore = useCommandStore()
const teleportTarget = computed(() =>
settingStore.get('Comfy.Sidebar.Location') === 'left'
@@ -64,9 +66,12 @@ const isSmall = computed(
const tabs = computed(() => workspaceStore.getSidebarTabs())
const selectedTab = computed(() => workspaceStore.sidebarTab.activeSidebarTab)
const onTabClick = (item: SidebarTabExtension) => {
workspaceStore.sidebarTab.toggleSidebarTab(item.id)
}
const onTabClick = async (item: SidebarTabExtension) =>
await commandStore.commands
.find((cmd) => cmd.id === `Workspace.ToggleSidebarTab.${item.id}`)
?.function?.()
const keybindingStore = useKeybindingStore()
const getTabTooltipSuffix = (tab: SidebarTabExtension) => {
const keybinding = keybindingStore.getKeybindingByCommandId(

View File

@@ -13,7 +13,7 @@
<div class="relative">
<span
v-if="shouldShowStatusIndicator"
class="group-hover:hidden absolute font-bold text-2xl top-1/2 left-1/2 -translate-1/2 z-10 bg-(--comfy-menu-secondary-bg) w-4"
class="group-hover:hidden absolute font-bold text-2xl top-1/2 left-1/2 -translate-1/2 z-10 bg-(--comfy-menu-bg) w-4"
></span
>
<Button

View File

@@ -127,7 +127,7 @@
</template>
<script setup lang="ts">
import { computed, provide, ref, watch } from 'vue'
import { computed, provide, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
@@ -202,12 +202,4 @@ const selectedSort = ref<string>('popular')
const selectedNavItem = ref<string | null>('installed')
const gridStyle = computed(() => createGridStyle())
watch(searchText, (newQuery) => {
console.log('searchText:', searchText.value, newQuery)
})
watch(searchQuery, (newQuery) => {
console.log('searchQuery:', searchQuery.value, newQuery)
})
</script>

View File

@@ -86,7 +86,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
const t = (k: string) => k
const onClose = () => {
console.log('OnClose invoked')
// OnClose handler for story
}
provide(OnCloseKey, onClose)

View File

@@ -48,7 +48,10 @@
<main class="flex flex-col flex-1 min-h-0">
<!-- Fallback title bar when no leftPanel is provided -->
<slot name="contentFilter"></slot>
<h2 v-if="!$slots.leftPanel" class="text-xxl px-6 pt-2 pb-6 m-0">
<h2
v-if="!$slots.leftPanel"
class="text-xxl px-6 pt-2 pb-6 m-0 capitalize"
>
{{ contentTitle }}
</h2>
<div :class="contentContainerClasses">

View File

@@ -1,30 +0,0 @@
import { SerializeAddon } from '@xterm/addon-serialize'
import { Terminal } from '@xterm/xterm'
import { markRaw, onMounted, onUnmounted } from 'vue'
export function useTerminalBuffer() {
const serializeAddon = new SerializeAddon()
const terminal = markRaw(new Terminal({ convertEol: true }))
const copyTo = (destinationTerminal: Terminal) => {
destinationTerminal.write(serializeAddon.serialize())
}
const write = (message: string) => terminal.write(message)
const serialize = () => serializeAddon.serialize()
onMounted(() => {
terminal.loadAddon(serializeAddon)
})
onUnmounted(() => {
terminal.dispose()
})
return {
copyTo,
serialize,
write
}
}

View File

@@ -5,7 +5,7 @@ import type { Ref } from 'vue'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
@@ -89,7 +89,7 @@ export function useSelectionToolboxPosition(
}
} else {
// Fallback to LiteGraph bounds for regular nodes or non-string IDs
if (item instanceof LGraphNode) {
if (item instanceof LGraphNode || item instanceof LGraphGroup) {
const bounds = item.getBounding()
allBounds.push([bounds[0], bounds[1], bounds[2], bounds[3]] as const)
}

View File

@@ -241,7 +241,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
}
/**
* Sets up widget callbacks for a node - now with reduced nesting
* Sets up widget callbacks for a node
*/
const setupNodeWidgetCallbacks = (node: LGraphNode) => {
if (!node.widgets) return

View File

@@ -98,7 +98,7 @@ const useNodePreview = <T extends MediaElement>(
/**
* Attaches a preview image to a node.
*/
export const useNodeImage = (node: LGraphNode) => {
export const useNodeImage = (node: LGraphNode, callback?: () => void) => {
node.previewMediaType = 'image'
const loadElement = (url: string): Promise<HTMLImageElement | null> =>
@@ -112,6 +112,7 @@ export const useNodeImage = (node: LGraphNode) => {
const onLoaded = (elements: HTMLImageElement[]) => {
node.imageIndex = null
node.imgs = elements
callback?.()
}
return useNodePreview(node, {
@@ -126,7 +127,7 @@ export const useNodeImage = (node: LGraphNode) => {
/**
* Attaches a preview video to a node.
*/
export const useNodeVideo = (node: LGraphNode) => {
export const useNodeVideo = (node: LGraphNode, callback?: () => void) => {
node.previewMediaType = 'video'
let minHeight = DEFAULT_VIDEO_SIZE
let minWidth = DEFAULT_VIDEO_SIZE
@@ -187,6 +188,7 @@ export const useNodeVideo = (node: LGraphNode) => {
}
node.videoContainer.replaceChildren(videoElement)
callback?.()
}
return useNodePreview(node, {

View File

@@ -300,9 +300,6 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
const modeValue = String(modeWidget.value)
const durationValue = String(durationWidget.value)
const modelValue = String(modelWidget.value)
console.log('modelValue', modelValue)
console.log('modeValue', modeValue)
console.log('durationValue', durationValue)
// Same pricing matrix as KlingTextToVideoNode
if (modelValue.includes('v1-6') || modelValue.includes('v1-5')) {
@@ -356,12 +353,14 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
const modeValue = String(modeWidget.value)
const durationValue = String(durationWidget.value)
const modelValue = String(modelWidget.value)
console.log('modelValue', modelValue)
console.log('modeValue', modeValue)
console.log('durationValue', durationValue)
// Same pricing matrix as KlingTextToVideoNode
if (
if (modelValue.includes('v2-5-turbo')) {
if (durationValue.includes('10')) {
return '$0.70/Run'
}
return '$0.35/Run' // 5s default
} else if (
modelValue.includes('v2-1-master') ||
modelValue.includes('v2-master')
) {
@@ -511,7 +510,12 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
const modeValue = String(modeWidget.value)
// Pricing matrix from CSV data based on mode string content
if (modeValue.includes('v2-1-master')) {
if (modeValue.includes('v2-5-turbo')) {
if (modeValue.includes('10')) {
return '$0.70/Run'
}
return '$0.35/Run' // 5s default
} else if (modeValue.includes('v2-1-master')) {
if (modeValue.includes('10s')) {
return '$2.80/Run' // price is the same as for v2-master model
}
@@ -564,9 +568,6 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
const model = String(modelWidget.value)
const resolution = String(resolutionWidget.value).toLowerCase()
const duration = String(durationWidget.value)
console.log('model', model)
console.log('resolution', resolution)
console.log('duration', duration)
if (model.includes('ray-flash-2')) {
if (duration.includes('5s')) {

View File

@@ -5,6 +5,7 @@ import {
DEFAULT_DARK_COLOR_PALETTE,
DEFAULT_LIGHT_COLOR_PALETTE
} from '@/constants/coreColorPalettes'
import { promoteRecommendedWidgets } from '@/core/graph/subgraph/proxyWidgetUtils'
import { t } from '@/i18n'
import {
LGraphEventMode,
@@ -14,6 +15,8 @@ import {
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import type { Point } from '@/lib/litegraph/src/litegraph'
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
import { createModelNodeFromAsset } from '@/platform/assets/utils/createModelNodeFromAsset'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
@@ -909,6 +912,7 @@ export function useCoreCommands(): ComfyCommand[] {
const { node } = res
canvas.select(node)
promoteRecommendedWidgets(node)
canvasStore.updateSelectedItems()
}
},
@@ -1060,6 +1064,60 @@ export function useCoreCommands(): ComfyCommand[] {
}
await api.freeMemory({ freeExecutionCache: true })
}
},
{
id: 'Comfy.BrowseModelAssets',
icon: 'pi pi-folder-open',
label: 'Experimental: Browse Model Assets',
versionAdded: '1.28.3',
function: async () => {
if (!useSettingStore().get('Comfy.Assets.UseAssetAPI')) {
const confirmed = await dialogService.confirm({
title: 'Enable Asset API',
message:
'The Asset API is currently disabled. Would you like to enable it?',
type: 'default'
})
if (!confirmed) return
const settingStore = useSettingStore()
await settingStore.set('Comfy.Assets.UseAssetAPI', true)
await workflowService.reloadCurrentWorkflow()
}
const assetBrowserDialog = useAssetBrowserDialog()
await assetBrowserDialog.browse({
assetType: 'models',
title: t('sideToolbar.modelLibrary'),
onAssetSelected: (asset) => {
const result = createModelNodeFromAsset(asset)
if (!result.success) {
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: t('assetBrowser.failedToCreateNode')
})
console.error('Node creation failed:', result.error)
}
}
})
}
},
{
id: 'Comfy.ToggleAssetAPI',
icon: 'pi pi-database',
label: () =>
`Experimental: ${
useSettingStore().get('Comfy.Assets.UseAssetAPI')
? 'Disable'
: 'Enable'
} AssetAPI`,
function: async () => {
const settingStore = useSettingStore()
const current = settingStore.get('Comfy.Assets.UseAssetAPI') ?? false
await settingStore.set('Comfy.Assets.UseAssetAPI', !current)
await useWorkflowService().reloadCurrentWorkflow() // ensure changes take effect immediately
}
}
]

View File

@@ -1,75 +0,0 @@
export interface DialogAction {
readonly label: string
readonly action: 'openUrl' | 'close' | 'cancel'
readonly url?: string
readonly severity?: 'danger' | 'primary' | 'secondary' | 'warn'
readonly returnValue: string
}
interface DesktopDialog {
readonly title: string
readonly message: string
readonly buttons: DialogAction[]
}
export const DESKTOP_DIALOGS = {
/** Shown when a corrupt venv is detected. */
reinstallVenv: {
title: 'Reinstall ComfyUI (Fresh Start)?',
message: `Sorry, we can't launch ComfyUI because some installed packages aren't compatible.
Click Reinstall to restore ComfyUI and get back up and running.
Please note: if you've added custom nodes, you'll need to reinstall them after this process.`,
buttons: [
{
label: 'Learn More',
action: 'openUrl',
url: 'https://docs.comfy.org',
returnValue: 'openDocs'
},
{
label: 'Reinstall',
action: 'close',
severity: 'danger',
returnValue: 'resetVenv'
}
]
},
/** A dialog that is shown when an invalid dialog ID is provided. */
invalidDialog: {
title: 'Invalid Dialog',
message: `Invalid dialog ID was provided.`,
buttons: [
{
label: 'Close',
action: 'cancel',
returnValue: 'cancel'
}
]
}
} as const satisfies { [K: string]: DesktopDialog }
/** The ID of a desktop dialog. */
type DesktopDialogId = keyof typeof DESKTOP_DIALOGS
/**
* Checks if {@link id} is a valid dialog ID.
* @param id The string to check
* @returns `true` if the ID is a valid dialog ID, otherwise `false`
*/
function isDialogId(id: unknown): id is DesktopDialogId {
return typeof id === 'string' && id in DESKTOP_DIALOGS
}
/**
* Gets the dialog with the given ID.
* @param dialogId The ID of the dialog to get
* @returns The dialog with the given ID
*/
export function getDialog(
dialogId: string | string[]
): DesktopDialog & { id: DesktopDialogId } {
const id = isDialogId(dialogId) ? dialogId : 'invalidDialog'
return { id, ...structuredClone(DESKTOP_DIALOGS[id]) }
}

View File

@@ -1,147 +0,0 @@
import { PrimeIcons } from '@primevue/core'
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
import { electronAPI } from '@/utils/envUtil'
const electron = electronAPI()
const openUrl = (url: string) => {
window.open(url, '_blank')
return true
}
export const DESKTOP_MAINTENANCE_TASKS: Readonly<MaintenanceTask>[] = [
{
id: 'basePath',
execute: async () => await electron.setBasePath(),
name: 'Base path',
shortDescription: 'Change the application base path.',
errorDescription: 'Unable to open the base path. Please select a new one.',
description:
'The base path is the default location where ComfyUI stores data. It is the location for the python environment, and may also contain models, custom nodes, and other extensions.',
isInstallationFix: true,
button: {
icon: PrimeIcons.QUESTION,
text: 'Select'
}
},
{
id: 'git',
headerImg: 'assets/images/Git-Logo-White.svg',
execute: () => openUrl('https://git-scm.com/downloads/'),
name: 'Download git',
shortDescription: 'Open the git download page.',
errorDescription:
'Git is missing. Please download and install git, then restart ComfyUI Desktop.',
description:
'Git is required to download and manage custom nodes and other extensions. This task opens the download page in your default browser, where you can download the latest version of git. Once you have installed git, please restart ComfyUI Desktop.',
button: {
icon: PrimeIcons.EXTERNAL_LINK,
text: 'Download'
}
},
{
id: 'vcRedist',
execute: () => openUrl('https://aka.ms/vs/17/release/vc_redist.x64.exe'),
name: 'Download VC++ Redist',
shortDescription: 'Download the latest VC++ Redistributable runtime.',
description:
'The Visual C++ runtime libraries are required to run ComfyUI. You will need to download and install this file.',
button: {
icon: PrimeIcons.EXTERNAL_LINK,
text: 'Download'
}
},
{
id: 'reinstall',
severity: 'danger',
requireConfirm: true,
execute: async () => {
await electron.reinstall()
return true
},
name: 'Reinstall ComfyUI',
shortDescription:
'Deletes the desktop app config and load the welcome screen.',
description:
'Delete the desktop app config, restart the app, and load the installation screen.',
confirmText: 'Delete all saved config and reinstall?',
button: {
icon: PrimeIcons.EXCLAMATION_TRIANGLE,
text: 'Reinstall'
}
},
{
id: 'pythonPackages',
requireConfirm: true,
execute: async () => {
try {
await electron.uv.installRequirements()
return true
} catch (error) {
return false
}
},
name: 'Install python packages',
shortDescription:
'Installs the base python packages required to run ComfyUI.',
errorDescription:
'Python packages that are required to run ComfyUI are not installed.',
description:
'This will install the python packages required to run ComfyUI. This includes torch, torchvision, and other dependencies.',
usesTerminal: true,
isInstallationFix: true,
button: {
icon: PrimeIcons.DOWNLOAD,
text: 'Install'
}
},
{
id: 'uv',
execute: () =>
openUrl('https://docs.astral.sh/uv/getting-started/installation/'),
name: 'uv executable',
shortDescription: 'uv installs and maintains the python environment.',
description:
"This will open the download page for Astral's uv tool. uv is used to install python and manage python packages.",
button: {
icon: 'pi pi-asterisk',
text: 'Download'
}
},
{
id: 'uvCache',
severity: 'danger',
requireConfirm: true,
execute: async () => await electron.uv.clearCache(),
name: 'uv cache',
shortDescription: 'Remove the Astral uv cache of python packages.',
description:
'This will remove the uv cache directory and its contents. All downloaded python packages will need to be downloaded again.',
confirmText: 'Delete uv cache of python packages?',
usesTerminal: true,
isInstallationFix: true,
button: {
icon: PrimeIcons.TRASH,
text: 'Clear cache'
}
},
{
id: 'venvDirectory',
severity: 'danger',
requireConfirm: true,
execute: async () => await electron.uv.resetVenv(),
name: 'Reset virtual environment',
shortDescription:
'Remove and recreate the .venv directory. This removes all python packages.',
description:
'The python environment is where ComfyUI installs python and python packages. It is used to run the ComfyUI server.',
confirmText: 'Delete the .venv directory?',
usesTerminal: true,
isInstallationFix: true,
button: {
icon: PrimeIcons.FOLDER,
text: 'Recreate'
}
}
] as const

View File

@@ -344,7 +344,7 @@ export const SERVER_CONFIG_ITEMS: ServerConfig<any>[] = [
type: 'number',
defaultValue: null,
tooltip:
'Set the amount of vram in GB you want to reserve for use by your OS/other software. By default some amount is reverved depending on your OS.'
'Set the amount of vram in GB you want to reserve for use by your OS/other software. By default some amount is reserved depending on your OS.'
},
// Misc settings

View File

@@ -1,4 +1,4 @@
export interface UVMirror {
interface UVMirror {
/**
* The setting id defined for the mirror.
*/
@@ -26,9 +26,3 @@ export const PYTHON_MIRROR: UVMirror = {
validationPathSuffix:
'/20250115/cpython-3.10.16+20250115-aarch64-apple-darwin-debug-full.tar.zst.sha256'
}
export const PYPI_MIRROR: UVMirror = {
settingId: 'Comfy-Desktop.UV.PypiInstallMirror',
mirror: 'https://pypi.org/simple/',
fallbackMirror: 'https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple'
}

View File

@@ -0,0 +1,315 @@
<script setup lang="ts">
import { refDebounced, watchDebounced } from '@vueuse/core'
import {
computed,
customRef,
onBeforeUnmount,
onMounted,
ref,
triggerRef
} from 'vue'
import SearchBox from '@/components/common/SearchBox.vue'
import SubgraphNodeWidget from '@/core/graph/subgraph/SubgraphNodeWidget.vue'
import {
type WidgetItem,
demoteWidget,
isRecommendedWidget,
matchesPropertyItem,
matchesWidgetItem,
promoteWidget,
widgetItemToProperty
} from '@/core/graph/subgraph/proxyWidgetUtils'
import {
type ProxyWidgetsProperty,
parseProxyWidgets
} from '@/core/schemas/proxyWidget'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { DraggableList } from '@/scripts/ui/draggableList'
import { useLitegraphService } from '@/services/litegraphService'
import { useDialogStore } from '@/stores/dialogStore'
const canvasStore = useCanvasStore()
const draggableList = ref<DraggableList | undefined>(undefined)
const draggableItems = ref()
const searchQuery = ref<string>('')
const debouncedQuery = refDebounced(searchQuery, 200)
const proxyWidgets = customRef<ProxyWidgetsProperty>((track, trigger) => ({
get() {
track()
const node = activeNode.value
if (!node) return []
return parseProxyWidgets(node.properties.proxyWidgets)
},
set(value?: ProxyWidgetsProperty) {
trigger()
const node = activeNode.value
if (!value) return
if (!node) {
console.error('Attempted to toggle widgets with no node selected')
return
}
node.properties.proxyWidgets = value
}
}))
const activeNode = computed(() => {
const node = canvasStore.selectedItems[0]
if (node instanceof SubgraphNode) return node
useDialogStore().closeDialog()
return undefined
})
const activeWidgets = computed<WidgetItem[]>({
get() {
const node = activeNode.value
if (!node) return []
return proxyWidgets.value.flatMap(([id, name]: [string, string]) => {
const wNode = node.subgraph._nodes_by_id[id]
if (!wNode?.widgets) return []
const w = wNode.widgets.find((w) => w.name === name)
if (!w) return []
return [[wNode, w]]
})
},
set(value: WidgetItem[]) {
const node = activeNode.value
if (!node) {
console.error('Attempted to toggle widgets with no node selected')
return
}
//map back to id/name
const widgets: ProxyWidgetsProperty = value.map(widgetItemToProperty)
proxyWidgets.value = widgets
}
})
const interiorWidgets = computed<WidgetItem[]>(() => {
const node = activeNode.value
if (!node) return []
const { updatePreviews } = useLitegraphService()
const interiorNodes = node.subgraph.nodes
for (const node of interiorNodes) {
node.updateComputedDisabled()
updatePreviews(node)
}
return interiorNodes
.flatMap(nodeWidgets)
.filter(([_, w]: WidgetItem) => !w.computedDisabled)
})
const candidateWidgets = computed<WidgetItem[]>(() => {
const node = activeNode.value
if (!node) return []
const widgets = proxyWidgets.value
return interiorWidgets.value.filter(
(widgetItem: WidgetItem) => !widgets.some(matchesPropertyItem(widgetItem))
)
})
const filteredCandidates = computed<WidgetItem[]>(() => {
const query = debouncedQuery.value.toLowerCase()
if (!query) return candidateWidgets.value
return candidateWidgets.value.filter(
([n, w]: WidgetItem) =>
n.title.toLowerCase().includes(query) ||
w.name.toLowerCase().includes(query)
)
})
const recommendedWidgets = computed(() => {
const node = activeNode.value
if (!node) return [] //Not reachable
return filteredCandidates.value.filter(isRecommendedWidget)
})
const filteredActive = computed<WidgetItem[]>(() => {
const query = debouncedQuery.value.toLowerCase()
if (!query) return activeWidgets.value
return activeWidgets.value.filter(
([n, w]: WidgetItem) =>
n.title.toLowerCase().includes(query) ||
w.name.toLowerCase().includes(query)
)
})
function toKey(item: WidgetItem) {
return `${item[0].id}: ${item[1].name}`
}
function nodeWidgets(n: LGraphNode): WidgetItem[] {
if (!n.widgets) return []
return n.widgets.map((w: IBaseWidget) => [n, w])
}
function demote([node, widget]: WidgetItem) {
const subgraphNode = activeNode.value
if (!subgraphNode) return []
demoteWidget(node, widget, [subgraphNode])
triggerRef(proxyWidgets)
}
function promote([node, widget]: WidgetItem) {
const subgraphNode = activeNode.value
if (!subgraphNode) return []
promoteWidget(node, widget, [subgraphNode])
triggerRef(proxyWidgets)
}
function showAll() {
const node = activeNode.value
if (!node) return //Not reachable
const widgets = proxyWidgets.value
const toAdd: ProxyWidgetsProperty =
filteredCandidates.value.map(widgetItemToProperty)
widgets.push(...toAdd)
proxyWidgets.value = widgets
}
function hideAll() {
const node = activeNode.value
if (!node) return //Not reachable
//Not great from a nesting perspective, but path is cold
//and it cleans up potential error states
proxyWidgets.value = proxyWidgets.value.filter(
(widgetItem) => !filteredActive.value.some(matchesWidgetItem(widgetItem))
)
}
function showRecommended() {
const node = activeNode.value
if (!node) return //Not reachable
const widgets = proxyWidgets.value
const toAdd: ProxyWidgetsProperty =
recommendedWidgets.value.map(widgetItemToProperty)
//TODO: Add sort step here
//Input should always be before output by default
widgets.push(...toAdd)
proxyWidgets.value = widgets
}
function setDraggableState() {
draggableList.value?.dispose()
if (debouncedQuery.value || !draggableItems.value?.children?.length) return
draggableList.value = new DraggableList(
draggableItems.value,
'.draggable-item'
)
//Original implementation plays really poorly with vue,
//It has been modified to not add/remove elements
draggableList.value.applyNewItemsOrder = function () {
const reorderedItems = []
let oldPosition = -1
this.getAllItems().forEach((item, index) => {
if (item === this.draggableItem) {
oldPosition = index
return
}
if (!this.isItemToggled(item)) {
reorderedItems[index] = item
return
}
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1
reorderedItems[newIndex] = item
})
for (let index = 0; index < this.getAllItems().length; index++) {
const item = reorderedItems[index]
if (typeof item === 'undefined') {
reorderedItems[index] = this.draggableItem
}
}
const newPosition = reorderedItems.indexOf(this.draggableItem)
const aw = activeWidgets.value
const [w] = aw.splice(oldPosition, 1)
aw.splice(newPosition, 0, w)
activeWidgets.value = aw
}
}
watchDebounced(
filteredActive,
() => {
setDraggableState()
},
{ debounce: 100 }
)
onMounted(() => {
setDraggableState()
})
onBeforeUnmount(() => {
draggableList.value?.dispose()
})
</script>
<template>
<SearchBox
v-model:model-value="searchQuery"
class="p-2"
:placeholder="$t('g.search') + '...'"
/>
<div
v-if="filteredActive.length"
class="pt-1 pb-4 border-b-1 border-node-component-border"
>
<div class="flex py-0 px-4 justify-between">
<div class="text-slate-100 text-[9px] font-semibold uppercase">
{{ $t('subgraphStore.shown') }}
</div>
<a
class="cursor-pointer text-right text-blue-100 text-[11px] font-normal"
@click.stop="hideAll"
>
{{ $t('subgraphStore.hideAll') }}</a
>
</div>
<div ref="draggableItems">
<div
v-for="[node, widget] in filteredActive"
:key="toKey([node, widget])"
class="w-full draggable-item"
style=""
>
<SubgraphNodeWidget
:node-title="node.title"
:widget-name="widget.name"
:is-shown="true"
:is-draggable="!debouncedQuery"
@toggle-visibility="demote([node, widget])"
/>
</div>
</div>
</div>
<div v-if="filteredCandidates.length" class="pt-1 pb-4">
<div class="flex py-0 px-4 justify-between">
<div class="text-slate-100 text-[9px] font-semibold uppercase">
{{ $t('subgraphStore.hidden') }}
</div>
<a
class="cursor-pointer text-right text-blue-100 text-[11px] font-normal"
@click.stop="showAll"
>
{{ $t('subgraphStore.showAll') }}</a
>
</div>
<div
v-for="[node, widget] in filteredCandidates"
:key="toKey([node, widget])"
class="w-full"
>
<SubgraphNodeWidget
:node-title="node.title"
:widget-name="widget.name"
@toggle-visibility="promote([node, widget])"
/>
</div>
</div>
<div
v-if="recommendedWidgets.length"
class="justify-center flex py-4 border-t-1 border-node-component-border"
>
<Button
size="small"
class="rounded border-none px-3 py-0.5"
@click.stop="showRecommended"
>
{{ $t('subgraphStore.showRecommended') }}
</Button>
</div>
</template>

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
import Button from 'primevue/button'
import { cn } from '@/utils/tailwindUtil'
const props = defineProps<{
nodeTitle: string
widgetName: string
isShown?: boolean
isDraggable?: boolean
}>()
defineEmits<{
(e: 'toggleVisibility'): void
}>()
function classes() {
return cn(
'flex py-1 pr-4 pl-0 break-all rounded items-center gap-1',
'bg-node-component-surface',
props.isDraggable
? 'drag-handle cursor-grab [.is-draggable]:cursor-grabbing'
: ''
)
}
</script>
<template>
<div :class="classes()">
<div
:class="
cn(
'size-4 pointer-events-none',
isDraggable ? 'icon-[lucide--grip-vertical]' : ''
)
"
/>
<div class="flex-1 pointer-events-none">
<div class="text-slate-100 text-[10px]">{{ nodeTitle }}</div>
<div class="text-xs">{{ widgetName }}</div>
</div>
<Button
size="small"
text
:icon="isDraggable ? 'icon-[lucide--eye]' : 'icon-[lucide--eye-off]'"
severity="secondary"
@click.stop="$emit('toggleVisibility')"
/>
</div>
</template>

View File

@@ -1,13 +1,18 @@
import { useNodeImage } from '@/composables/node/useNodeImage'
import { demoteWidget } from '@/core/graph/subgraph/proxyWidgetUtils'
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type {
LGraph,
LGraphCanvas,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { ISerialisedNode } from '@/lib/litegraph/src/types/serialisation'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets.ts'
import { disconnectedWidget } from '@/lib/litegraph/src/widgets/DisconnectedWidget'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { DOMWidgetImpl } from '@/scripts/domWidget'
import { useLitegraphService } from '@/services/litegraphService'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
/**
@@ -43,14 +48,33 @@ function isProxyWidget(w: IBaseWidget): w is ProxyWidget {
return (w as { _overlay?: Overlay })?._overlay?.isProxyWidget ?? false
}
export function registerProxyWidgets(canvas: LGraphCanvas) {
//NOTE: canvasStore hasn't been initialized yet
canvas.canvas.addEventListener<'subgraph-opened'>('subgraph-opened', (e) => {
const { subgraph, fromNode } = e.detail
const proxyWidgets = parseProxyWidgets(fromNode.properties.proxyWidgets)
for (const node of subgraph.nodes) {
for (const widget of node.widgets ?? []) {
widget.promoted = proxyWidgets.some(
([n, w]) => node.id == n && widget.name == w
)
}
}
})
SubgraphNode.prototype.onConfigure = onConfigure
}
const originalOnConfigure = SubgraphNode.prototype.onConfigure
SubgraphNode.prototype.onConfigure = function (serialisedNode) {
const onConfigure = function (
this: LGraphNode,
serialisedNode: ISerialisedNode
) {
if (!this.isSubgraphNode())
throw new Error("Can't add proxyWidgets to non-subgraphNode")
const canvasStore = useCanvasStore()
//Must give value to proxyWidgets prior to defining or it won't serialize
this.properties.proxyWidgets ??= '[]'
this.properties.proxyWidgets ??= []
let proxyWidgets = this.properties.proxyWidgets
originalOnConfigure?.call(this, serialisedNode)
@@ -62,13 +86,16 @@ SubgraphNode.prototype.onConfigure = function (serialisedNode) {
set: (property: string) => {
const parsed = parseProxyWidgets(property)
const { deactivateWidget, setWidget } = useDomWidgetStore()
for (const w of this.widgets.filter((w) => isProxyWidget(w))) {
if (w instanceof DOMWidgetImpl) deactivateWidget(w.id)
const isActiveGraph = useCanvasStore().canvas?.graph === this.graph
if (isActiveGraph) {
for (const w of this.widgets.filter((w) => isProxyWidget(w))) {
if (w instanceof DOMWidgetImpl) deactivateWidget(w.id)
}
}
this.widgets = this.widgets.filter((w) => !isProxyWidget(w))
for (const [nodeId, widgetName] of parsed) {
const w = addProxyWidget(this, `${nodeId}`, widgetName)
if (w instanceof DOMWidgetImpl) setWidget(w)
if (isActiveGraph && w instanceof DOMWidgetImpl) setWidget(w)
}
proxyWidgets = property
canvasStore.canvas?.setDirty(true, true)
@@ -86,19 +113,22 @@ function addProxyWidget(
) {
const name = `${nodeId}: ${widgetName}`
const overlay = {
//items specific for proxy management
nodeId,
widgetName,
graph: subgraphNode.subgraph,
name,
label: name,
isProxyWidget: true,
y: 0,
last_y: undefined,
width: undefined,
computedHeight: undefined,
widgetName,
//Items which normally exist on widgets
afterQueued: undefined,
computedHeight: undefined,
isProxyWidget: true,
last_y: undefined,
name,
node: subgraphNode,
onRemove: undefined,
node: subgraphNode
promoted: undefined,
serialize: false,
width: undefined,
y: 0
}
return addProxyFromOverlay(subgraphNode, overlay)
}
@@ -110,23 +140,20 @@ function resolveLinkedWidget(
if (!n) return [undefined, undefined]
return [n, n.widgets?.find((w: IBaseWidget) => w.name === widgetName)]
}
function addProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {
const { updatePreviews } = useLitegraphService()
let [linkedNode, linkedWidget] = resolveLinkedWidget(overlay)
let backingWidget = linkedWidget ?? disconnectedWidget
if (overlay.widgetName == '$$canvas-image-preview')
if (overlay.widgetName.startsWith('$$')) {
overlay.node = new Proxy(subgraphNode, {
get(_t, p) {
if (p !== 'imgs') return Reflect.get(subgraphNode, p)
if (!linkedNode) return []
const images =
useNodeOutputStore().getNodeOutputs(linkedNode)?.images ?? []
if (images !== linkedNode.images) {
linkedNode.images = images
useNodeImage(linkedNode).showPreview()
}
return linkedNode.imgs
}
})
}
/**
* A set of handlers which define widget interaction
* Many arguments are shared between function calls
@@ -135,7 +162,7 @@ function addProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {
* @param {string} property - The name of the accessed value.
* Checked for conditional logic, but never changed
* @param {object} receiver - The object the result is set to
* and the vlaue used as 'this' if property is a get/set method
* and the value used as 'this' if property is a get/set method
* @param {unknown} value - only used on set calls. The thing being assigned
*/
const handler = {
@@ -155,6 +182,12 @@ function addProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {
let redirectedReceiver = receiver
if (property == 'value') redirectedReceiver = backingWidget
else if (property == 'computedHeight') {
if (overlay.widgetName.startsWith('$$') && linkedNode) {
updatePreviews(linkedNode)
}
if (linkedNode && linkedWidget?.computedDisabled) {
demoteWidget(linkedNode, linkedWidget, [subgraphNode])
}
//update linkage regularly, but no more than once per frame
;[linkedNode, linkedWidget] = resolveLinkedWidget(overlay)
backingWidget = linkedWidget ?? disconnectedWidget

View File

@@ -0,0 +1,138 @@
import {
type ProxyWidgetsProperty,
parseProxyWidgets
} from '@/core/schemas/proxyWidget'
import type {
IContextMenuValue,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets.ts'
import { useLitegraphService } from '@/services/litegraphService'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
export type WidgetItem = [LGraphNode, IBaseWidget]
function getProxyWidgets(node: SubgraphNode) {
return parseProxyWidgets(node.properties.proxyWidgets)
}
export function promoteWidget(
node: LGraphNode,
widget: IBaseWidget,
parents: SubgraphNode[]
) {
for (const parent of parents) {
const proxyWidgets = [
...getProxyWidgets(parent),
widgetItemToProperty([node, widget])
]
parent.properties.proxyWidgets = proxyWidgets
}
widget.promoted = true
}
export function demoteWidget(
node: LGraphNode,
widget: IBaseWidget,
parents: SubgraphNode[]
) {
for (const parent of parents) {
const proxyWidgets = getProxyWidgets(parent).filter(
(widgetItem) => !matchesPropertyItem([node, widget])(widgetItem)
)
parent.properties.proxyWidgets = proxyWidgets
}
widget.promoted = false
}
export function matchesWidgetItem([nodeId, widgetName]: [string, string]) {
return ([n, w]: WidgetItem) => n.id == nodeId && w.name === widgetName
}
export function matchesPropertyItem([n, w]: WidgetItem) {
return ([nodeId, widgetName]: [string, string]) =>
n.id == nodeId && w.name === widgetName
}
export function widgetItemToProperty([n, w]: WidgetItem): [string, string] {
return [`${n.id}`, w.name]
}
function getParentNodes(): SubgraphNode[] {
//NOTE: support for determining parents of a subgraph is limited
//This function will require rework to properly support linked subgraphs
//Either by including actual parents in the navigation stack,
//or by adding a new event for parent listeners to collect from
const { navigationStack } = useSubgraphNavigationStore()
const subgraph = navigationStack.at(-1)
if (!subgraph) throw new Error("Can't promote widget when not in subgraph")
const parentGraph = navigationStack.at(-2) ?? subgraph.rootGraph
return parentGraph.nodes.filter(
(node): node is SubgraphNode =>
node.type === subgraph.id && node.isSubgraphNode()
)
}
export function addWidgetPromotionOptions(
options: (IContextMenuValue<unknown> | null)[],
widget: IBaseWidget,
node: LGraphNode
) {
const parents = getParentNodes()
const promotableParents = parents.filter(
(s) => !getProxyWidgets(s).some(matchesPropertyItem([node, widget]))
)
if (promotableParents.length > 0)
options.unshift({
content: `Promote Widget: ${widget.label ?? widget.name}`,
callback: () => {
promoteWidget(node, widget, promotableParents)
}
})
else {
options.unshift({
content: `Un-Promote Widget: ${widget.label ?? widget.name}`,
callback: () => {
demoteWidget(node, widget, parents)
}
})
}
}
const recommendedNodes = [
'CLIPTextEncode',
'LoadImage',
'SaveImage',
'PreviewImage'
]
const recommendedWidgetNames = ['seed']
export function isRecommendedWidget([node, widget]: WidgetItem) {
return (
!widget.computedDisabled &&
(recommendedNodes.includes(node.type) ||
recommendedWidgetNames.includes(widget.name))
)
}
function nodeWidgets(n: LGraphNode): WidgetItem[] {
return n.widgets?.map((w: IBaseWidget) => [n, w]) ?? []
}
export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
const { updatePreviews } = useLitegraphService()
const interiorNodes = subgraphNode.subgraph.nodes
for (const node of interiorNodes) {
node.updateComputedDisabled()
function checkWidgets() {
updatePreviews(node)
const widget = node.widgets?.find((w) => w.name.startsWith('$$'))
if (!widget) return
const pw = getProxyWidgets(subgraphNode)
if (pw.some(matchesPropertyItem([node, widget]))) return
promoteWidget(node, widget, [subgraphNode])
}
requestAnimationFrame(() => updatePreviews(node, checkWidgets))
}
const filteredWidgets: WidgetItem[] = interiorNodes
.flatMap(nodeWidgets)
.filter(isRecommendedWidget)
const proxyWidgets: ProxyWidgetsProperty =
filteredWidgets.map(widgetItemToProperty)
subgraphNode.properties.proxyWidgets = proxyWidgets
}

View File

@@ -0,0 +1,26 @@
import SubgraphNode from '@/core/graph/subgraph/SubgraphNode.vue'
import { type DialogComponentProps, useDialogStore } from '@/stores/dialogStore'
const key = 'global-subgraph-node-config'
export function showSubgraphNodeDialog() {
const dialogStore = useDialogStore()
const dialogComponentProps: DialogComponentProps = {
modal: false,
position: 'topright',
pt: {
root: {
class: 'bg-node-component-surface mt-22'
},
header: {
class: 'h-8 text-xs ml-3'
}
}
}
dialogStore.showDialog({
title: 'Parameters',
key,
component: SubgraphNode,
dialogComponentProps
})
}

View File

@@ -4,18 +4,15 @@ import { fromZodError } from 'zod-validation-error'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
const proxyWidgetsPropertySchema = z.array(z.tuple([z.string(), z.string()]))
type ProxyWidgetsProperty = z.infer<typeof proxyWidgetsPropertySchema>
export type ProxyWidgetsProperty = z.infer<typeof proxyWidgetsPropertySchema>
export function parseProxyWidgets(
property: NodeProperty | undefined
): ProxyWidgetsProperty {
if (typeof property !== 'string') {
throw new Error(
'Invalid assignment for properties.proxyWidgets:\nValue must be a string'
)
}
const parsed = JSON.parse(property)
const result = proxyWidgetsPropertySchema.safeParse(parsed)
if (typeof property === 'string') property = JSON.parse(property)
const result = proxyWidgetsPropertySchema.safeParse(
typeof property === 'string' ? JSON.parse(property) : property
)
if (result.success) return result.data
const error = fromZodError(result.error)

View File

@@ -6,8 +6,8 @@ import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { checkMirrorReachable } from '@/utils/electronMirrorCheck'
import { electronAPI as getElectronAPI, isElectron } from '@/utils/envUtil'
import { checkMirrorReachable } from '@/utils/networkUtil'
// Desktop documentation URLs
const DESKTOP_DOCS = {

View File

@@ -251,7 +251,7 @@ app.registerExtension({
inputName,
'',
openFileSelection,
{ serialize: false }
{ serialize: false, canvasOnly: true }
)
uploadWidget.label = t('g.choose_file_to_upload')
@@ -408,7 +408,7 @@ app.registerExtension({
mediaRecorder.stop()
}
},
{ serialize: false }
{ serialize: false, canvasOnly: true }
)
recordWidget.label = t('g.startRecording')

View File

@@ -106,7 +106,8 @@ app.registerExtension({
'button',
'waiting for camera...',
'capture',
capture
capture,
{ canvasOnly: true }
)
btn.disabled = true
btn.serializeValue = () => undefined
@@ -146,7 +147,7 @@ app.registerExtension({
// @ts-expect-error fixme ts strict error
node[WEBCAM_READY].then((v) => {
video = v
// If width isnt specified then use video output resolution
// If width isn't specified then use video output resolution
// @ts-expect-error fixme ts strict error
if (!w.value) {
// @ts-expect-error fixme ts strict error

View File

@@ -149,7 +149,7 @@ export class PrimitiveNode extends LGraphNode {
target_slot: number
) {
// Fires before the link is made allowing us to reject it if it isn't valid
// No widget, we cant connect
// No widget, we can't connect
if (!input.widget && !(input.type in ComfyWidgets)) {
return false
}
@@ -388,7 +388,7 @@ export class PrimitiveNode extends LGraphNode {
}
onLastDisconnect() {
// We cant remove + re-add the output here as if you drag a link over the same link
// We can't remove + re-add the output here as if you drag a link over the same link
// it removes, then re-adds, causing it to break
this.outputs[0].type = '*'
this.outputs[0].name = 'connect to widget input'
@@ -595,7 +595,7 @@ app.registerExtension({
this.graph?.add(node)
// Calculate a position that wont directly overlap another node
// Calculate a position that won't directly overlap another node
const pos: [number, number] = [
this.pos[0] - node.size[0] - 30,
this.pos[1]

View File

@@ -146,8 +146,8 @@ Litegraph has no runtime dependencies. The build tooling has been tested on Node
Use GitHub actions to release normal versions.
1. Run the `Release a New Version` action, selecting the version incrment type
1. Merge the resultion PR
1. Run the `Release a New Version` action, selecting the version increment type
1. Merge the resolution PR
1. A GitHub release is automatically published on merge
### Pre-release

View File

@@ -274,8 +274,6 @@ export class LGraph
* @param o data from previous serialization [optional]
*/
constructor(o?: ISerialisedGraph | SerialisableGraph) {
if (LiteGraph.debug) console.log('Graph created')
/** @see MapProxyHandler */
const links = this._links
MapProxyHandler.bindAllMethods(links)
@@ -532,7 +530,7 @@ export class LGraph
this.errors_in_execution = true
if (LiteGraph.throw_errors) throw error
if (LiteGraph.debug) console.log('Error during execution:', error)
if (LiteGraph.debug) console.error('Error during execution:', error)
this.stop()
}
}
@@ -1128,7 +1126,7 @@ export class LGraph
/**
* Snaps the provided items to a grid.
*
* Item positions are reounded to the nearest multiple of {@link LiteGraph.CANVAS_GRID_SIZE}.
* Item positions are rounded to the nearest multiple of {@link LiteGraph.CANVAS_GRID_SIZE}.
*
* When {@link LiteGraph.alwaysSnapToGrid} is enabled
* and the grid size is falsy, a default of 1 is used.
@@ -1167,7 +1165,7 @@ export class LGraph
const ctor = LiteGraph.registered_node_types[node.type]
if (node.constructor == ctor) continue
console.log('node being replaced by newer version:', node.type)
console.warn('node being replaced by newer version:', node.type)
const newnode = LiteGraph.createNode(node.type)
if (!newnode) continue
_nodes[i] = newnode
@@ -1229,9 +1227,6 @@ export class LGraph
/* Called when something visually changed (not the graph!) */
change(): void {
if (LiteGraph.debug) {
console.log('Graph changed')
}
this.canvasAction((c) => c.setDirty(true, true))
this.on_change?.(this)
}
@@ -1626,12 +1621,6 @@ export class LGraph
} else {
throw new TypeError('Subgraph input node is not a SubgraphInput')
}
console.debug(
'Reconnect input links in parent graph',
{ ...link },
this.links.get(link.id),
this.links.get(link.id) === link
)
for (const resolved of others) {
resolved.link.disconnect(this)
@@ -2233,7 +2222,7 @@ export class LGraph
let node = LiteGraph.createNode(String(n_info.type), n_info.title)
if (!node) {
if (LiteGraph.debug)
console.log('Node not found or has errors:', n_info.type)
console.warn('Node not found or has errors:', n_info.type)
// in case of error we create a replacement node to avoid losing info
node = new LGraphNode('')

View File

@@ -1,13 +1,14 @@
import { toString } from 'es-toolkit/compat'
import { PREFIX, SEPARATOR } from '@/constants/groupNodeConstants'
import { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
import {
type LinkRenderContext,
LitegraphLinkAdapter
} from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculations'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import { CanvasPointer } from './CanvasPointer'
import type { ContextMenu } from './ContextMenu'
@@ -17,6 +18,7 @@ import { LGraphGroup } from './LGraphGroup'
import { LGraphNode, type NodeId, type NodeProperty } from './LGraphNode'
import { LLink, type LinkId } from './LLink'
import { Reroute, type RerouteId } from './Reroute'
import { LinkConnector } from './canvas/LinkConnector'
import { isOverNodeInput, isOverNodeOutput } from './canvas/measureSlots'
import { strokeShape } from './draw'
import type {
@@ -25,6 +27,7 @@ import type {
} from './infrastructure/CustomEventTarget'
import type { LGraphCanvasEventMap } from './infrastructure/LGraphCanvasEventMap'
import { NullGraphError } from './infrastructure/NullGraphError'
import { Rectangle } from './infrastructure/Rectangle'
import type {
CanvasColour,
ColorOption,
@@ -47,12 +50,11 @@ import type {
NullableProperties,
Point,
Positionable,
ReadOnlyPoint,
ReadOnlyRect,
Rect,
Size
} from './interfaces'
import { LiteGraph, Rectangle, SubgraphNode, createUuidv4 } from './litegraph'
import { LiteGraph } from './litegraph'
import {
containsRect,
createBounds,
@@ -67,6 +69,7 @@ import { NodeInputSlot } from './node/NodeInputSlot'
import type { Subgraph } from './subgraph/Subgraph'
import { SubgraphIONodeBase } from './subgraph/SubgraphIONodeBase'
import type { SubgraphInputNode } from './subgraph/SubgraphInputNode'
import { SubgraphNode } from './subgraph/SubgraphNode'
import type { SubgraphOutputNode } from './subgraph/SubgraphOutputNode'
import type {
CanvasPointerEvent,
@@ -88,6 +91,7 @@ import type { IBaseWidget } from './types/widgets'
import { alignNodes, distributeNodes, getBoundaryNodes } from './utils/arrange'
import { findFirstNode, getAllNestedItems } from './utils/collections'
import { resolveConnectingLinkColor } from './utils/linkColors'
import { createUuidv4 } from './utils/uuid'
import type { UUID } from './utils/uuid'
import { BaseWidget } from './widgets/BaseWidget'
import { toConcreteWidget } from './widgets/widgetMap'
@@ -228,6 +232,12 @@ const cursors = {
NW: 'nwse-resize'
} as const
// Optimised buffers used during rendering
const temp = new Rectangle()
const temp_vec2: Point = [0, 0]
const tmp_area = new Rectangle()
const margin_area = new Rectangle()
const link_bounding = new Rectangle()
/**
* This class is in charge of rendering one graph inside a canvas. And provides all the interaction required.
* Valid callbacks are: onNodeSelected, onNodeDeselected, onShowNodePanel, onNodeDblClicked
@@ -235,13 +245,6 @@ const cursors = {
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 DEFAULT_BACKGROUND_IMAGE =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAQBJREFUeNrs1rEKwjAUhlETUkj3vP9rdmr1Ysammk2w5wdxuLgcMHyptfawuZX4pJSWZTnfnu/lnIe/jNNxHHGNn//HNbbv+4dr6V+11uF527arU7+u63qfa/bnmh8sWLBgwYJlqRf8MEptXPBXJXa37BSl3ixYsGDBMliwFLyCV/DeLIMFCxYsWLBMwSt4Be/NggXLYMGCBUvBK3iNruC9WbBgwYJlsGApeAWv4L1ZBgsWLFiwYJmCV/AK3psFC5bBggULloJX8BpdwXuzYMGCBctgwVLwCl7Be7MMFixYsGDBsu8FH1FaSmExVfAxBa/gvVmwYMGCZbBg/W4vAQYA5tRF9QYlv/QAAAAASUVORK5CYII='
@@ -461,7 +464,7 @@ export class LGraphCanvas
}
const baseFontSize = LiteGraph.NODE_TEXT_SIZE // 14px
const dprAdjustment = Math.sqrt(window.devicePixelRatio || 1) //Using sqrt here because higher DPR monitors do not linearily scale the readability of the font, instead they increase the font by some heurisitc, and to approximate we use sqrt to say bascially a DPR of 2 increases the readibility by 40%, 3 by 70%
const dprAdjustment = Math.sqrt(window.devicePixelRatio || 1) //Using sqrt here because higher DPR monitors do not linearily scale the readability of the font, instead they increase the font by some heurisitc, and to approximate we use sqrt to say basically a DPR of 2 increases the readability by 40%, 3 by 70%
// Calculate the zoom level where text becomes unreadable
this._lowQualityZoomThreshold =
@@ -547,7 +550,7 @@ export class LGraphCanvas
linkMarkerShape: LinkMarkerShape = LinkMarkerShape.Circle
links_render_mode: number
/** Minimum font size in pixels before switching to low quality rendering.
* This intializes first and if we cant get the value from the settings we default to 8px
* This initializes first and if we can't get the value from the settings we default to 8px
*/
private _min_font_size_for_lod: number = 8
@@ -628,7 +631,7 @@ export class LGraphCanvas
dirty_area?: Rect | null
/** @deprecated Unused */
node_in_panel?: LGraphNode | null
last_mouse: ReadOnlyPoint = [0, 0]
last_mouse: Readonly<Point> = [0, 0]
last_mouseclick: number = 0
graph: LGraph | Subgraph | null
get _graph(): LGraph | Subgraph {
@@ -1228,7 +1231,7 @@ export class LGraphCanvas
className: 'event'
})
}
// add callback for modifing the menu elements onMenuNodeOutputs
// add callback for modifying the menu elements onMenuNodeOutputs
const retEntries = node.onMenuNodeOutputs?.(entries)
if (retEntries) entries = retEntries
@@ -1862,13 +1865,13 @@ export class LGraphCanvas
this.#dirty()
}
openSubgraph(subgraph: Subgraph): void {
openSubgraph(subgraph: Subgraph, fromNode: SubgraphNode): void {
const { graph } = this
if (!graph) throw new NullGraphError()
const options = {
bubbles: true,
detail: { subgraph, closingGraph: graph },
detail: { subgraph, closingGraph: graph, fromNode },
cancelable: true
}
const mayContinue = this.canvas.dispatchEvent(
@@ -2634,7 +2637,7 @@ export class LGraphCanvas
pointer: CanvasPointer,
node?: LGraphNode | undefined
): void {
const dragRect = new Float32Array(4)
const dragRect: Rect = [0, 0, 0, 0]
dragRect[0] = e.canvasX
dragRect[1] = e.canvasY
@@ -2794,7 +2797,7 @@ export class LGraphCanvas
if (pos[1] < 0 && !inCollapse) {
node.onNodeTitleDblClick?.(e, pos, this)
} else if (node instanceof SubgraphNode) {
this.openSubgraph(node.subgraph)
this.openSubgraph(node.subgraph, node)
}
node.onDblClick?.(e, pos, this)
@@ -3174,7 +3177,7 @@ export class LGraphCanvas
LGraphCanvas.active_canvas = this
this.adjustMouseEvent(e)
const mouse: ReadOnlyPoint = [e.clientX, e.clientY]
const mouse: Readonly<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]]
@@ -3430,8 +3433,13 @@ export class LGraphCanvas
const deltaX = delta[0] / this.ds.scale
const deltaY = delta[1] / this.ds.scale
for (const item of allItems) {
item.move(deltaX, deltaY, true)
if (LiteGraph.vueNodesMode) {
this.moveChildNodesInGroupVueMode(allItems, deltaX, deltaY)
} else {
for (const item of allItems) {
item.move(deltaX, deltaY, true)
}
}
this.#dirty()
@@ -3902,7 +3910,7 @@ export class LGraphCanvas
for (const item of [...parsed.nodes, ...parsed.reroutes]) {
if (item.pos == null)
throw new TypeError(
'Invalid node encounterd on paste. `pos` was null.'
'Invalid node encountered on paste. `pos` was null.'
)
if (item.pos[0] < offsetX) offsetX = item.pos[0]
@@ -4077,7 +4085,7 @@ export class LGraphCanvas
this.setDirty(true)
}
#handleMultiSelect(e: CanvasPointerEvent, dragRect: Float32Array) {
#handleMultiSelect(e: CanvasPointerEvent, dragRect: Rect) {
// Process drag
// Convert Point pair (pos, offset) to Rect
const { graph, selectedItems, subgraph } = this
@@ -4848,7 +4856,7 @@ export class LGraphCanvas
}
/** Get the target snap / highlight point in graph space */
#getHighlightPosition(): ReadOnlyPoint {
#getHighlightPosition(): Readonly<Point> {
return LiteGraph.snaps_for_comfy
? this.linkConnector.state.snapLinksPos ??
this._highlight_pos ??
@@ -4863,7 +4871,7 @@ export class LGraphCanvas
*/
#renderSnapHighlight(
ctx: CanvasRenderingContext2D,
highlightPos: ReadOnlyPoint
highlightPos: Readonly<Point>
): void {
const linkConnectorSnap = !!this.linkConnector.state.snapLinksPos
if (!this._highlight_pos && !linkConnectorSnap) return
@@ -5186,10 +5194,9 @@ export class LGraphCanvas
const color = node.renderingColor
const bgcolor = node.renderingBgColor
const { low_quality, editor_alpha } = this
ctx.globalAlpha = editor_alpha
ctx.globalAlpha = this.getNodeModeAlpha(node)
if (this.render_shadows && !low_quality) {
if (this.render_shadows && !this.low_quality) {
ctx.shadowColor = LiteGraph.DEFAULT_SHADOW_COLOR
ctx.shadowOffsetX = 2 * this.ds.scale
ctx.shadowOffsetY = 2 * this.ds.scale
@@ -5204,8 +5211,9 @@ export class LGraphCanvas
// clip if required (mask)
const shape = node._shape || RenderShape.BOX
const size = LGraphCanvas.#temp_vec2
size.set(node.renderingSize)
const size = temp_vec2
size[0] = node.renderingSize[0]
size[1] = node.renderingSize[1]
if (node.collapsed) {
ctx.font = this.inner_text_font
@@ -5250,7 +5258,7 @@ export class LGraphCanvas
}
}
if (!low_quality) {
if (!this.low_quality) {
node.drawBadges(ctx)
}
@@ -5399,7 +5407,7 @@ export class LGraphCanvas
: true
// Normalised node dimensions
const area = LGraphCanvas.#tmp_area
const area = tmp_area
area.set(node.boundingRect)
area[0] -= node.pos[0]
area[1] -= node.pos[1]
@@ -5501,7 +5509,7 @@ export class LGraphCanvas
item: Positionable,
shape = RenderShape.ROUND
) {
const snapGuide = LGraphCanvas.#temp
const snapGuide = temp
snapGuide.set(item.boundingRect)
// Not all items have pos equal to top-left of bounds
@@ -5548,10 +5556,10 @@ export class LGraphCanvas
const now = LiteGraph.getTime()
const { visible_area } = this
LGraphCanvas.#margin_area[0] = visible_area[0] - 20
LGraphCanvas.#margin_area[1] = visible_area[1] - 20
LGraphCanvas.#margin_area[2] = visible_area[2] + 40
LGraphCanvas.#margin_area[3] = visible_area[3] + 40
margin_area[0] = visible_area[0] - 20
margin_area[1] = visible_area[1] - 20
margin_area[2] = visible_area[2] + 40
margin_area[3] = visible_area[3] + 40
// draw connections
ctx.lineWidth = this.connections_width
@@ -5617,7 +5625,9 @@ export class LGraphCanvas
const { link, inputNode, input } = resolved
if (!inputNode || !input) continue
const endPos = inputNode.getInputPos(link.target_slot)
const endPos = LiteGraph.vueNodesMode
? getSlotPosition(inputNode, link.target_slot, true)
: inputNode.getInputPos(link.target_slot)
this.#renderAllLinkSegments(
ctx,
@@ -5642,7 +5652,9 @@ export class LGraphCanvas
const { link, outputNode, output } = resolved
if (!outputNode || !output) continue
const startPos = outputNode.getOutputPos(link.origin_slot)
const startPos = LiteGraph.vueNodesMode
? getSlotPosition(outputNode, link.origin_slot, false)
: outputNode.getOutputPos(link.origin_slot)
this.#renderAllLinkSegments(
ctx,
@@ -5684,6 +5696,14 @@ export class LGraphCanvas
ctx.globalAlpha = 1
}
private getNodeModeAlpha(node: LGraphNode) {
return node.mode === LGraphEventMode.BYPASS
? 0.2
: node.mode === LGraphEventMode.NEVER
? 0.4
: this.editor_alpha
}
#renderFloatingLinks(
ctx: CanvasRenderingContext2D,
graph: LGraph,
@@ -5772,18 +5792,13 @@ export class LGraphCanvas
// Bounding box of all points (bezier overshoot on long links will be cut)
const pointsX = points.map((x) => x[0])
const pointsY = points.map((x) => x[1])
LGraphCanvas.#link_bounding[0] = Math.min(...pointsX)
LGraphCanvas.#link_bounding[1] = Math.min(...pointsY)
LGraphCanvas.#link_bounding[2] =
Math.max(...pointsX) - LGraphCanvas.#link_bounding[0]
LGraphCanvas.#link_bounding[3] =
Math.max(...pointsY) - LGraphCanvas.#link_bounding[1]
link_bounding[0] = Math.min(...pointsX)
link_bounding[1] = Math.min(...pointsY)
link_bounding[2] = Math.max(...pointsX) - link_bounding[0]
link_bounding[3] = Math.max(...pointsY) - link_bounding[1]
// skip links outside of the visible area of the canvas
if (
!overlapBounding(LGraphCanvas.#link_bounding, LGraphCanvas.#margin_area)
)
return
if (!overlapBounding(link_bounding, margin_area)) return
const start_dir = startDirection || LinkDirection.RIGHT
const end_dir = endDirection || LinkDirection.LEFT
@@ -5942,8 +5957,8 @@ export class LGraphCanvas
*/
renderLink(
ctx: CanvasRenderingContext2D,
a: ReadOnlyPoint,
b: ReadOnlyPoint,
a: Readonly<Point>,
b: Readonly<Point>,
link: LLink | null,
skip_border: boolean,
flow: number | null,
@@ -5960,9 +5975,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?: Readonly<Point>
/** Offset of the bezier curve control point from {@link b point b} (input side) */
endControl?: ReadOnlyPoint
endControl?: Readonly<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 */
@@ -6040,7 +6055,7 @@ export class LGraphCanvas
): void {
node.drawWidgets(ctx, {
lowQuality: this.low_quality,
editorAlpha: this.editor_alpha
editorAlpha: this.getNodeModeAlpha(node)
})
}
@@ -6406,7 +6421,7 @@ export class LGraphCanvas
return true
}
console.log(`failed creating ${nodeNewType}`)
console.error(`failed creating ${nodeNewType}`)
}
}
return false
@@ -6818,7 +6833,7 @@ export class LGraphCanvas
canvas.focus()
root_document.body.style.overflow = ''
// important, if canvas loses focus keys wont be captured
// important, if canvas loses focus keys won't be captured
setTimeout(() => canvas.focus(), 20)
dialog.remove()
}
@@ -7095,7 +7110,7 @@ export class LGraphCanvas
)
}
} else {
// console.warn("cant find slot " + options.slot_from);
// console.warn("can't find slot " + options.slot_from);
}
}
if (options.node_to) {
@@ -7140,7 +7155,7 @@ export class LGraphCanvas
)
}
} else {
// console.warn("cant find slot_nodeTO " + options.slot_from);
// console.warn("can't find slot_nodeTO " + options.slot_from);
}
}
@@ -7478,7 +7493,7 @@ export class LGraphCanvas
return dialog
}
// TODO refactor, theer are different dialog, some uses createDialog, some dont
// TODO refactor, there are different dialog, some uses createDialog, some dont
createDialog(html: string, options: IDialogOptions): IDialog {
const def_options = {
checkForInput: false,
@@ -8007,7 +8022,7 @@ export class LGraphCanvas
if (Object.keys(this.selected_nodes).length > 1) {
options.push(
{
content: 'Convert to Subgraph 🆕',
content: 'Convert to Subgraph',
callback: () => {
if (!this.selectedItems.size)
throw new Error('Convert to Subgraph: Nothing selected.')
@@ -8042,7 +8057,7 @@ export class LGraphCanvas
} else {
options = [
{
content: 'Convert to Subgraph 🆕',
content: 'Convert to Subgraph',
callback: () => {
// find groupnodes, degroup and select children
if (this.selectedItems.size) {
@@ -8455,4 +8470,120 @@ export class LGraphCanvas
const setDirty = () => this.setDirty(true, true)
this.ds.animateToBounds(bounds, setDirty, options)
}
/**
* Calculate new position with delta
*/
private calculateNewPosition(
node: LGraphNode,
deltaX: number,
deltaY: number
): { x: number; y: number } {
return {
x: node.pos[0] + deltaX,
y: node.pos[1] + deltaY
}
}
/**
* Apply batched node position updates
*/
private applyNodePositionUpdates(
nodesToMove: Array<{ node: LGraphNode; newPos: { x: number; y: number } }>,
mutations: ReturnType<typeof useLayoutMutations>
): void {
for (const { node, newPos } of nodesToMove) {
// Update LiteGraph position first so next drag uses correct base position
node.pos[0] = newPos.x
node.pos[1] = newPos.y
// Then update layout store which will update Vue nodes
mutations.moveNode(node.id, newPos)
}
}
/**
* Initialize layout mutations with Canvas source
*/
private initLayoutMutations(): ReturnType<typeof useLayoutMutations> {
const mutations = useLayoutMutations()
mutations.setSource(LayoutSource.Canvas)
return mutations
}
/**
* Collect all nodes that are children of groups in the selection
*/
private collectNodesInGroups(items: Set<Positionable>): Set<LGraphNode> {
const nodesInGroups = new Set<LGraphNode>()
for (const item of items) {
if (item instanceof LGraphGroup) {
for (const child of item._children) {
if (child instanceof LGraphNode) {
nodesInGroups.add(child)
}
}
}
}
return nodesInGroups
}
/**
* Move group children (both nodes and non-nodes)
*/
private moveGroupChildren(
group: LGraphGroup,
deltaX: number,
deltaY: number,
nodesToMove: Array<{ node: LGraphNode; newPos: { x: number; y: number } }>
): void {
for (const child of group._children) {
if (child instanceof LGraphNode) {
const node = child as LGraphNode
nodesToMove.push({
node,
newPos: this.calculateNewPosition(node, deltaX, deltaY)
})
} else {
// Non-node children (nested groups, reroutes)
child.move(deltaX, deltaY)
}
}
}
moveChildNodesInGroupVueMode(
allItems: Set<Positionable>,
deltaX: number,
deltaY: number
) {
const mutations = this.initLayoutMutations()
const nodesInMovingGroups = this.collectNodesInGroups(allItems)
const nodesToMove: Array<{
node: LGraphNode
newPos: { x: number; y: number }
}> = []
// First, collect all the moves we need to make
for (const item of allItems) {
const isNode = item instanceof LGraphNode
if (isNode) {
const node = item as LGraphNode
if (nodesInMovingGroups.has(node)) {
continue
}
nodesToMove.push({
node,
newPos: this.calculateNewPosition(node, deltaX, deltaY)
})
} else if (item instanceof LGraphGroup) {
item.move(deltaX, deltaY, true)
this.moveGroupChildren(item, deltaX, deltaY, nodesToMove)
} else {
// Other items (reroutes, etc.)
item.move(deltaX, deltaY, true)
}
}
// Now apply all the node moves at once
this.applyNodePositionUpdates(nodesToMove, mutations)
}
}

View File

@@ -13,7 +13,7 @@ import type {
Positionable,
Size
} from './interfaces'
import { LiteGraph } from './litegraph'
import { LiteGraph, Rectangle } from './litegraph'
import {
containsCentre,
containsRect,
@@ -40,15 +40,10 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
title: string
font?: string
font_size: number = LiteGraph.DEFAULT_GROUP_FONT || 24
_bounding: Float32Array = new Float32Array([
10,
10,
LGraphGroup.minWidth,
LGraphGroup.minHeight
])
_bounding = new Rectangle(10, 10, LGraphGroup.minWidth, LGraphGroup.minHeight)
_pos: Point = this._bounding.subarray(0, 2)
_size: Size = this._bounding.subarray(2, 4)
_pos: Point = this._bounding.pos
_size: Size = this._bounding.size
/** @deprecated See {@link _children} */
_nodes: LGraphNode[] = []
_children: Set<Positionable> = new Set()
@@ -111,6 +106,10 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
return this._bounding
}
getBounding() {
return this._bounding
}
get nodes() {
return this._nodes
}

View File

@@ -7,6 +7,7 @@ import {
} from '@/renderer/core/canvas/litegraph/slotCalculations'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import { type ColorAdjustOptions, adjustColor } from '@/utils/colorUtil'
import type { DragAndScale } from './DragAndScale'
import type { LGraph } from './LGraph'
@@ -37,7 +38,6 @@ import type {
ISlotType,
Point,
Positionable,
ReadOnlyPoint,
ReadOnlyRect,
Rect,
Size
@@ -167,8 +167,8 @@ input|output: every connection
general properties:
+ clip_area: if you render outside the node, it will be clipped
+ unsafe_execution: not allowed for safe execution
+ skip_repeated_outputs: when adding new outputs, it wont show if there is one already connected
+ resizable: if set to false it wont be resizable with the mouse
+ skip_repeated_outputs: when adding new outputs, it won't show if there is one already connected
+ resizable: if set to false it won't be resizable with the mouse
+ widgets_start_y: widgets start at y distance from the top of the node
flags object:
@@ -303,13 +303,25 @@ export class LGraphNode
/** The fg color used to render the node. */
get renderingColor(): string {
return this.color || this.constructor.color || LiteGraph.NODE_DEFAULT_COLOR
const baseColor =
this.color || this.constructor.color || LiteGraph.NODE_DEFAULT_COLOR
return adjustColor(baseColor, { lightness: LiteGraph.nodeLightness })
}
/** The bg color used to render the node. */
get renderingBgColor(): string {
return (
const baseBgColor =
this.bgcolor || this.constructor.bgcolor || LiteGraph.NODE_DEFAULT_BGCOLOR
const adjustments: ColorAdjustOptions = {
opacity: LiteGraph.nodeOpacity,
lightness: LiteGraph.nodeLightness
}
return adjustColor(
this.mode === LGraphEventMode.BYPASS
? LiteGraph.NODE_DEFAULT_BYPASS_COLOR
: baseBgColor,
adjustments
)
}
@@ -413,7 +425,7 @@ export class LGraphNode
}
/** @inheritdoc {@link renderArea} */
#renderArea: Float32Array = new Float32Array(4)
#renderArea = new Rectangle()
/**
* Rect describing the node area, including shadows and any protrusions.
* Determines if the node is visible. Calculated once at the start of every frame.
@@ -434,7 +446,7 @@ export class LGraphNode
}
/** The offset from {@link pos} to the top-left of {@link boundingRect}. */
get boundingOffset(): ReadOnlyPoint {
get boundingOffset(): Readonly<Point> {
const {
pos: [posX, posY],
boundingRect: [bX, bY]
@@ -442,10 +454,10 @@ export class LGraphNode
return [posX - bX, posY - bY]
}
/** {@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)
/** {@link pos} and {@link size} values are backed by this {@link Rectangle}. */
_posSize = new Rectangle()
_pos: Point = this._posSize.pos
_size: Size = this._posSize.size
public get pos() {
return this._pos
@@ -902,7 +914,7 @@ export class LGraphNode
if (this.onSerialize?.(o))
console.warn(
'node onSerialize shouldnt return anything, data should be stored in the object pass in the first parameter'
"node onSerialize shouldn't return anything, data should be stored in the object pass in the first parameter"
)
return o
@@ -1653,7 +1665,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
@@ -1950,7 +1962,7 @@ export class LGraphNode
try {
this.removeWidget(widget)
} catch (error) {
console.debug('Failed to remove widget', error)
console.error('Failed to remove widget', error)
}
}
@@ -2004,13 +2016,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]
@@ -2351,7 +2363,7 @@ export class LGraphNode
/**
* returns the output (or input) slot with a given type, -1 if not found
* @param input uise inputs instead of outputs
* @param input use inputs instead of outputs
* @param type the type of the slot to find
* @param returnObj if the obj itself wanted
* @param preferFreeSlot if we want a free slot (if not found, will return the first of the type anyway)
@@ -2583,12 +2595,7 @@ export class LGraphNode
if (slotIndex !== undefined)
return this.connect(slot, target_node, slotIndex, optsIn?.afterRerouteId)
console.debug(
'[connectByType]: no way to connect type:',
target_slotType,
'to node:',
target_node
)
// No compatible slot found - connection not possible
return null
}
@@ -2621,7 +2628,7 @@ export class LGraphNode
if (slotIndex !== undefined)
return source_node.connect(slotIndex, this, slot, optsIn?.afterRerouteId)
console.debug(
console.error(
'[connectByType]: no way to connect type:',
source_slotType,
'to node:',
@@ -2661,7 +2668,7 @@ export class LGraphNode
if (!graph) {
// could be connected before adding it to a graph
// due to link ids being associated with graphs
console.log(
console.error(
"Connect: Error, node doesn't belong to any graph. Nodes must be added first to a graph before connecting them."
)
return null
@@ -2672,11 +2679,12 @@ export class LGraphNode
slot = this.findOutputSlot(slot)
if (slot == -1) {
if (LiteGraph.debug)
console.log(`Connect: Error, no slot of name ${slot}`)
console.error(`Connect: Error, no slot of name ${slot}`)
return null
}
} else if (!outputs || slot >= outputs.length) {
if (LiteGraph.debug) console.log('Connect: Error, slot number not found')
if (LiteGraph.debug)
console.error('Connect: Error, slot number not found')
return null
}
@@ -2696,7 +2704,7 @@ export class LGraphNode
targetIndex = target_node.findInputSlot(target_slot)
if (targetIndex == -1) {
if (LiteGraph.debug)
console.log(`Connect: Error, no slot of name ${targetIndex}`)
console.error(`Connect: Error, no slot of name ${targetIndex}`)
return null
}
} else if (target_slot === LiteGraph.EVENT) {
@@ -2728,7 +2736,8 @@ export class LGraphNode
!target_node.inputs ||
targetIndex >= target_node.inputs.length
) {
if (LiteGraph.debug) console.log('Connect: Error, slot number not found')
if (LiteGraph.debug)
console.error('Connect: Error, slot number not found')
return null
}
@@ -2914,7 +2923,7 @@ export class LGraphNode
const fromLastFloatingReroute =
parentReroute?.floating?.slotType === 'output'
// Adding from an ouput, or a floating reroute that is NOT the tip of an existing floating chain
// Adding from an output, or a floating reroute that is NOT the tip of an existing floating chain
if (afterRerouteId == null || !fromLastFloatingReroute) {
const link = new LLink(
-1,
@@ -2955,11 +2964,12 @@ export class LGraphNode
slot = this.findOutputSlot(slot)
if (slot == -1) {
if (LiteGraph.debug)
console.log(`Connect: Error, no slot of name ${slot}`)
console.error(`Connect: Error, no slot of name ${slot}`)
return false
}
} else if (!this.outputs || slot >= this.outputs.length) {
if (LiteGraph.debug) console.log('Connect: Error, slot number not found')
if (LiteGraph.debug)
console.error('Connect: Error, slot number not found')
return false
}
@@ -3075,19 +3085,19 @@ export class LGraphNode
slot = this.findInputSlot(slot)
if (slot == -1) {
if (LiteGraph.debug)
console.log(`Connect: Error, no slot of name ${slot}`)
console.error(`Connect: Error, no slot of name ${slot}`)
return false
}
} else if (!this.inputs || slot >= this.inputs.length) {
if (LiteGraph.debug) {
console.log('Connect: Error, slot number not found')
console.error('Connect: Error, slot number not found')
}
return false
}
const input = this.inputs[slot]
if (!input) {
console.debug('disconnectInput: input not found', slot, this.inputs)
console.error('disconnectInput: input not found', slot, this.inputs)
return false
}
@@ -3116,19 +3126,16 @@ export class LGraphNode
const target_node = graph.getNodeById(link_info.origin_id)
if (!target_node) {
console.debug(
'disconnectInput: target node not found',
link_info.origin_id
console.error(
'disconnectInput: output not found',
link_info.origin_slot
)
return false
}
const output = target_node.outputs[link_info.origin_slot]
if (!output?.links?.length) {
console.debug(
'disconnectInput: output not found',
link_info.origin_slot
)
// Output not found - may have been removed
return false
}
@@ -3174,7 +3181,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],
@@ -3754,6 +3761,13 @@ export class LGraphNode
return !isHidden
}
updateComputedDisabled() {
if (!this.widgets) return
for (const widget of this.widgets)
widget.computedDisabled =
widget.disabled || this.getSlotFromWidget(widget)?.link != null
}
drawWidgets(
ctx: CanvasRenderingContext2D,
{ lowQuality = false, editorAlpha = 1 }: DrawWidgetsOptions
@@ -3767,6 +3781,7 @@ export class LGraphNode
ctx.save()
ctx.globalAlpha = editorAlpha
this.updateComputedDisabled()
for (const widget of widgets) {
if (!this.isWidgetVisible(widget)) continue
@@ -3776,9 +3791,6 @@ export class LGraphNode
: LiteGraph.WIDGET_OUTLINE_COLOR
widget.last_y = y
// Disable widget if it is disabled or if the value is passed from socket connection.
widget.computedDisabled =
widget.disabled || this.getSlotFromWidget(widget)?.link != null
ctx.strokeStyle = outlineColour
ctx.fillStyle = '#222'

View File

@@ -14,6 +14,7 @@ import type {
ISlotType,
LinkNetwork,
LinkSegment,
Point,
ReadonlyLinkNetwork
} from './interfaces'
import type {
@@ -109,7 +110,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: Point
/** @todo Clean up - never implemented in comfy. */
_last_time?: number
/** The last canvas 2D path that was used to render this link */
@@ -171,7 +172,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} */

View File

@@ -60,6 +60,8 @@ export class LiteGraphGlobal {
NODE_BOX_OUTLINE_COLOR = '#FFF'
NODE_ERROR_COLOUR = '#E00'
NODE_FONT = 'Arial'
NODE_DEFAULT_BYPASS_COLOR = '#FF00FF'
NODE_OPACITY = 0.9
DEFAULT_FONT = 'Arial'
DEFAULT_SHADOW_COLOR = 'rgba(0,0,0,0.5)'
@@ -70,6 +72,7 @@ export class LiteGraphGlobal {
WIDGET_BGCOLOR = '#222'
WIDGET_OUTLINE_COLOR = '#666'
WIDGET_PROMOTED_OUTLINE_COLOR = '#BF00FF'
WIDGET_ADVANCED_OUTLINE_COLOR = 'rgba(56, 139, 253, 0.8)'
WIDGET_TEXT_COLOR = '#DDD'
WIDGET_SECONDARY_TEXT_COLOR = '#999'
@@ -241,10 +244,10 @@ export class LiteGraphGlobal {
*/
do_add_triggers_slots = false
/** [false!] being events, it is strongly reccomended to use them sequentially, one by one */
/** [false!] being events, it is strongly recommended to use them sequentially, one by one */
allow_multi_output_for_events = true
/** [true!] allows to create and connect a ndoe clicking with the third button (wheel) */
/** [true!] allows to create and connect a node clicking with the third button (wheel) */
middle_click_slot_add_default_node = false
/** [true!] dragging a link to empty space will open a menu, add from list, search or defaults */
@@ -347,6 +350,10 @@ export class LiteGraphGlobal {
*/
vueNodesMode: boolean = false
// Special Rendering Values pulled out of app.ts patches
nodeOpacity = 1
nodeLightness: number | undefined = undefined
// TODO: Remove legacy accessors
LGraph = LGraph
LLink = LLink
@@ -398,8 +405,6 @@ export class LiteGraphGlobal {
throw 'Cannot register a simple object, it must be a class with a prototype'
base_class.type = type
if (this.debug) console.log('Node registered:', type)
const classname = base_class.name
const pos = type.lastIndexOf('/')
@@ -415,7 +420,7 @@ export class LiteGraphGlobal {
const prev = this.registered_node_types[type]
if (prev && this.debug) {
console.log('replacing node type:', type)
console.warn('replacing node type:', type)
}
this.registered_node_types[type] = base_class
@@ -430,7 +435,7 @@ export class LiteGraphGlobal {
`LiteGraph node class ${type} has onPropertyChange method, it must be called onPropertyChanged with d at the end`
)
// TODO one would want to know input and ouput :: this would allow through registerNodeAndSlotType to get all the slots types
// TODO one would want to know input and output :: this would allow through registerNodeAndSlotType to get all the slots types
if (this.auto_load_slot_types) new base_class(base_class.title || 'tmpnode')
}
@@ -524,7 +529,7 @@ export class LiteGraphGlobal {
): LGraphNode | null {
const base_class = this.registered_node_types[type]
if (!base_class) {
if (this.debug) console.log(`GraphNode type "${type}" not registered.`)
if (this.debug) console.warn(`GraphNode type "${type}" not registered.`)
return null
}
@@ -637,7 +642,6 @@ export class LiteGraphGlobal {
continue
try {
if (this.debug) console.log('Reloading:', src)
const dynamicScript = document.createElement('script')
dynamicScript.type = 'text/javascript'
dynamicScript.src = src
@@ -645,11 +649,9 @@ export class LiteGraphGlobal {
script_file.remove()
} catch (error) {
if (this.throw_errors) throw error
if (this.debug) console.log('Error while reloading', src)
if (this.debug) console.error('Error while reloading', src)
}
}
if (this.debug) console.log('Nodes reloaded')
}
// separated just to improve if it doesn't work
@@ -749,7 +751,7 @@ export class LiteGraphGlobal {
// convert pointerevents to touch event when not available
if (sMethod == 'pointer' && !window.PointerEvent) {
console.warn("sMethod=='pointer' && !window.PointerEvent")
console.log(
console.warn(
`Converting pointer[${sEvent}] : down move up cancel enter TO touchstart touchmove touchend, etc ..`
)
switch (sEvent) {
@@ -774,7 +776,7 @@ export class LiteGraphGlobal {
break
}
case 'enter': {
console.log('debug: Should I send a move event?') // ???
// TODO: Determine if a move event should be sent
break
}
// case "over": case "out": not used at now

View File

@@ -49,8 +49,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 +71,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: Point = [0, 0]
/** @inheritdoc */
get pos(): Point {
return this.#pos
@@ -126,14 +124,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: Point = [0, 0]
/** @inheritdoc */
path?: Path2D
/** @inheritdoc */
_centreAngle?: number
/** @inheritdoc */
_pos: Float32Array = this.#malloc.subarray(6, 8)
_pos: Point = [0, 0]
/** @inheritdoc */
_dragging?: boolean

View File

@@ -906,7 +906,6 @@ export class LinkConnector {
if (connectingTo === 'output') {
// Dropping new output link
const output = node.findOutputByType(firstLink.fromSlot.type)?.slot
console.debug('out', node, output, firstLink.fromSlot)
if (output === undefined) {
console.warn(
`Could not find slot for link type: [${firstLink.fromSlot.type}].`
@@ -918,7 +917,6 @@ export class LinkConnector {
} else if (connectingTo === 'input') {
// Dropping new input link
const input = node.findInputByType(firstLink.fromSlot.type)?.slot
console.debug('in', node, input, firstLink.fromSlot)
if (input === undefined) {
console.warn(
`Could not find slot for link type: [${firstLink.fromSlot.type}].`

View File

@@ -11,6 +11,7 @@ import type {
} from '@/lib/litegraph/src/interfaces'
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
import type { SubgraphOutput } from '@/lib/litegraph/src/subgraph/SubgraphOutput'
import type { NodeLike } from '@/lib/litegraph/src/types/NodeLike'
import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
import type { RenderLink } from './RenderLink'
@@ -99,6 +100,14 @@ export abstract class MovingLinkBase implements RenderLink {
this.inputPos = inputNode.getInputPos(inputIndex)
}
abstract canConnectToInput(
inputNode: NodeLike,
input: INodeInputSlot
): boolean
abstract canConnectToOutput(
outputNode: NodeLike,
output: INodeOutputSlot
): boolean
abstract connectToInput(
node: LGraphNode,
input: INodeInputSlot,

View File

@@ -11,6 +11,7 @@ import type {
import type { SubgraphIONodeBase } from '@/lib/litegraph/src/subgraph/SubgraphIONodeBase'
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
import type { SubgraphOutput } from '@/lib/litegraph/src/subgraph/SubgraphOutput'
import type { NodeLike } from '@/lib/litegraph/src/types/NodeLike'
import type { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
export interface RenderLink {
@@ -38,6 +39,17 @@ export interface RenderLink {
/** The reroute that the link is being connected from. */
readonly fromReroute?: Reroute
/**
* Capability checks used for hit-testing and validation during drag.
* Implementations should return `false` when a connection is not possible
* rather than throwing.
*/
canConnectToInput(node: NodeLike, input: INodeInputSlot): boolean
canConnectToOutput(node: NodeLike, output: INodeOutputSlot): boolean
/** Optional: only some links support validating subgraph IO or reroutes. */
canConnectToSubgraphInput?(input: SubgraphInput): boolean
canConnectToReroute?(reroute: Reroute): boolean
connectToInput(
node: LGraphNode,
input: INodeInputSlot,

View File

@@ -1,5 +1,5 @@
import type { Rectangle } from './infrastructure/Rectangle'
import type { CanvasColour, Rect } from './interfaces'
import type { CanvasColour } from './interfaces'
import { LiteGraph } from './litegraph'
import { RenderShape, TitleMode } from './types/globalEnums'
@@ -67,7 +67,7 @@ interface IDrawTextInAreaOptions {
*/
export function strokeShape(
ctx: CanvasRenderingContext2D,
area: Rect,
area: Rectangle,
{
shape = RenderShape.BOX,
round_radius,

View File

@@ -1,10 +1,6 @@
import { clamp } from 'es-toolkit/compat'
import type {
ReadOnlyRect,
ReadOnlySize,
Size
} from '@/lib/litegraph/src/interfaces'
import type { ReadOnlyRect, Size } from '@/lib/litegraph/src/interfaces'
/**
* Basic width and height, with min/max constraints.
@@ -55,7 +51,7 @@ export class ConstrainedSize {
this.desiredHeight = height
}
static fromSize(size: ReadOnlySize): ConstrainedSize {
static fromSize(size: Readonly<Size>): ConstrainedSize {
return new ConstrainedSize(size[0], size[1])
}
@@ -63,7 +59,7 @@ export class ConstrainedSize {
return new ConstrainedSize(rect[2], rect[3])
}
setSize(size: ReadOnlySize): void {
setSize(size: Readonly<Size>): void {
this.desiredWidth = size[0]
this.desiredHeight = size[1]
}

View File

@@ -4,6 +4,7 @@ import type { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { ConnectingLink } from '@/lib/litegraph/src/interfaces'
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
export interface LGraphCanvasEventMap {
@@ -14,6 +15,11 @@ export interface LGraphCanvasEventMap {
/** The old active graph, or `null` if there was no active graph. */
oldGraph: LGraph | Subgraph | null | undefined
}
'subgraph-opened': {
subgraph: Subgraph
closingGraph: LGraph
fromNode: SubgraphNode
}
'litegraph:canvas':
| { subType: 'before-change' | 'after-change' }

View File

@@ -1,9 +1,7 @@
import type {
CompassCorners,
Point,
ReadOnlyPoint,
ReadOnlyRect,
ReadOnlySize,
ReadOnlyTypedArray,
Size
} from '@/lib/litegraph/src/interfaces'
@@ -21,8 +19,8 @@ import { isInRectangle } from '@/lib/litegraph/src/measure'
* - {@link size}: The size of the rectangle.
*/
export class Rectangle extends Float64Array {
#pos: Point | undefined
#size: Size | undefined
#pos: Float64Array<ArrayBuffer> | undefined
#size: Float64Array<ArrayBuffer> | undefined
constructor(
x: number = 0,
@@ -50,7 +48,7 @@ export class Rectangle extends Float64Array {
* @returns A new rectangle whose centre is at {@link x}
*/
static fromCentre(
[x, y]: ReadOnlyPoint,
[x, y]: Readonly<Point>,
width: number,
height = width
): Rectangle {
@@ -81,10 +79,10 @@ export class Rectangle extends Float64Array {
*/
get pos(): Point {
this.#pos ??= this.subarray(0, 2)
return this.#pos!
return this.#pos! as unknown as Point
}
set pos(value: ReadOnlyPoint) {
set pos(value: Readonly<Point>) {
this[0] = value[0]
this[1] = value[1]
}
@@ -96,10 +94,10 @@ export class Rectangle extends Float64Array {
*/
get size(): Size {
this.#size ??= this.subarray(2, 4)
return this.#size!
return this.#size! as unknown as Size
}
set size(value: ReadOnlySize) {
set size(value: Readonly<Size>) {
this[2] = value[0]
this[3] = value[1]
}
@@ -215,7 +213,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]: Readonly<Point>): boolean {
const [left, top, width, height] = this
return x >= left && x < left + width && y >= top && y < top + height
}
@@ -384,12 +382,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]: Readonly<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]: Readonly<Point>): Point {
return [this[0] - x, this[1] - y]
}

View File

@@ -194,7 +194,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: Point
/**
* Y-forward along the {@link path} from its centre point, in radians.
* `undefined` if using circles for link centres.
@@ -226,52 +226,25 @@ 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>
export type Rect =
| [x: number, y: number, width: number, height: number]
| 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 ReadOnlyTypedArray<T extends Float64Array> = Omit<
Readonly<T>,
'fill' | 'copyWithin' | 'reverse' | 'set' | 'sort' | 'subarray'
>
/** Union of property names that are of type Match */
type KeysOfType<T, Match> = Exclude<
@@ -330,7 +303,7 @@ export interface INodeSlot extends HasBoundingRect {
nameLocked?: boolean
pos?: Point
/** @remarks Automatically calculated; not included in serialisation. */
boundingRect: Rect
boundingRect: ReadOnlyRect
/**
* A list of floating link IDs that are connected to this slot.
* This is calculated at runtime; it is **not** serialized.

View File

@@ -1,10 +1,4 @@
import type {
HasBoundingRect,
Point,
ReadOnlyPoint,
ReadOnlyRect,
Rect
} from './interfaces'
import type { HasBoundingRect, Point, ReadOnlyRect, Rect } from './interfaces'
import { Alignment, LinkDirection, hasFlag } from './types/globalEnums'
/**
@@ -13,7 +7,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: Readonly<Point>, b: Readonly<Point>): number {
return Math.sqrt(
(b[0] - a[0]) * (b[0] - a[0]) + (b[1] - a[1]) * (b[1] - a[1])
)
@@ -62,7 +56,7 @@ export function isInRectangle(
* @returns `true` if the point is inside the rect, otherwise `false`
*/
export function isPointInRect(
point: ReadOnlyPoint,
point: Readonly<Point>,
rect: ReadOnlyRect
): boolean {
return (
@@ -289,8 +283,8 @@ export function rotateLink(
* the right
*/
export function getOrientation(
lineStart: ReadOnlyPoint,
lineEnd: ReadOnlyPoint,
lineStart: Readonly<Point>,
lineEnd: Readonly<Point>,
x: number,
y: number
): number {
@@ -310,10 +304,10 @@ export function getOrientation(
*/
export function findPointOnCurve(
out: Point,
a: ReadOnlyPoint,
b: ReadOnlyPoint,
controlA: ReadOnlyPoint,
controlB: ReadOnlyPoint,
a: Readonly<Point>,
b: Readonly<Point>,
controlA: Readonly<Point>,
controlB: Readonly<Point>,
t: number = 0.5
): void {
const iT = 1 - t
@@ -331,7 +325,7 @@ export function createBounds(
objects: Iterable<HasBoundingRect>,
padding: number = 10
): ReadOnlyRect | null {
const bounds = new Float32Array([Infinity, Infinity, -Infinity, -Infinity])
const bounds: Rect = [Infinity, Infinity, -Infinity, -Infinity]
for (const obj of objects) {
const rect = obj.boundingRect
@@ -382,7 +376,7 @@ export function alignToContainer(
rect: Rect,
anchors: Alignment,
[containerX, containerY, containerWidth, containerHeight]: ReadOnlyRect,
[insetX, insetY]: ReadOnlyPoint = [0, 0]
[insetX, insetY]: Readonly<Point> = [0, 0]
): Rect {
if (hasFlag(anchors, Alignment.Left)) {
// Left
@@ -425,7 +419,7 @@ export function alignOutsideContainer(
rect: Rect,
anchors: Alignment,
[otherX, otherY, otherWidth, otherHeight]: ReadOnlyRect,
[outsetX, outsetY]: ReadOnlyPoint = [0, 0]
[outsetX, outsetY]: Readonly<Point> = [0, 0]
): Rect {
if (hasFlag(anchors, Alignment.Left)) {
// Left

View File

@@ -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(): Readonly<Point> {
return [0, LiteGraph.NODE_TITLE_HEIGHT * -0.5]
}

View File

@@ -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(): Readonly<Point> {
return [
this.#node._collapsed_width ?? LiteGraph.NODE_COLLAPSED_WIDTH,
LiteGraph.NODE_TITLE_HEIGHT * -0.5

View File

@@ -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(): Readonly<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(): Readonly<Point>
#node: LGraphNode
get node(): LGraphNode {

View File

@@ -57,7 +57,7 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
#id: ExecutionId
/**
* The path to the acutal node through subgraph instances, represented as a list of all subgraph node IDs (instances),
* The path to the actual node through subgraph instances, represented as a list of all subgraph node IDs (instances),
* followed by the actual original node ID within the subgraph. Each segment is separated by `:`.
*
* e.g. `1:2:3`:
@@ -104,7 +104,7 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
readonly subgraphNodePath: readonly NodeId[],
/** A flattened map of all DTOs in this node network. Subgraph instances have been expanded into their inner nodes. */
readonly nodesByExecutionId: Map<ExecutionId, ExecutableLGraphNode>,
/** The actual subgraph instance that contains this node, otherise undefined. */
/** The actual subgraph instance that contains this node, otherwise undefined. */
readonly subgraphNode?: SubgraphNode
) {
if (!node.graph) throw new NullGraphError()
@@ -271,9 +271,9 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
// Bypass nodes by finding first input with matching type
const matchingIndex = this.#getBypassSlotIndex(slot, type)
// No input types match
// No input types match - bypass not possible
if (matchingIndex === -1) {
console.debug(
console.warn(
`[ExecutableNodeDTO.resolveOutput] No input types match type [${type}] for id [${this.id}] slot [${slot}]`,
this
)

View File

@@ -175,8 +175,6 @@ export class SubgraphInput extends SubgraphSlot {
}
widgets.push(widget)
} else {
console.debug('No input found on link id', linkId, link)
}
}
return widgets

View File

@@ -188,7 +188,7 @@ export class SubgraphInputNode
const subgraphInput = this.slots.at(subgraphInputIndex)
if (!subgraphInput) {
console.debug(
console.warn(
'disconnectNodeInput: subgraphInput not found',
this,
subgraphInputIndex
@@ -201,7 +201,7 @@ export class SubgraphInputNode
if (index !== -1) {
subgraphInput.linkIds.splice(index, 1)
} else {
console.debug(
console.warn(
'disconnectNodeInput: link ID not found in subgraphInput linkIds',
link.id
)

View File

@@ -168,7 +168,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
canvas: LGraphCanvas
): void {
if (button.name === 'enter_subgraph') {
canvas.openSubgraph(this.subgraph)
canvas.openSubgraph(this.subgraph, this)
} else {
super.onTitleButtonClick(button, canvas)
}
@@ -430,7 +430,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
const inputSlot = this.subgraph.inputNode.slots[slot]
const innerLinks = inputSlot.getLinks()
if (innerLinks.length === 0) {
console.debug(
console.warn(
`[SubgraphNode.resolveSubgraphInputLinks] No inner links found for input slot [${slot}] ${inputSlot.name}`,
this
)
@@ -447,9 +447,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
resolveSubgraphOutputLink(slot: number): ResolvedConnection | undefined {
const outputSlot = this.subgraph.outputNode.slots[slot]
const innerLink = outputSlot.getLinks().at(0)
if (innerLink) return innerLink.resolve(this.subgraph)
console.debug(
if (innerLink) {
return innerLink.resolve(this.subgraph)
}
console.warn(
`[SubgraphNode.resolveSubgraphOutputLink] No inner link found for output slot [${slot}] ${outputSlot.name}`,
this
)

View File

@@ -12,7 +12,7 @@ import type {
INodeOutputSlot,
Point,
ReadOnlyRect,
ReadOnlySize
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(): Readonly<Size> {
const width = LGraphCanvas._measureText?.(this.displayName) ?? 0
const { defaultHeight } = SubgraphSlot

View File

@@ -116,7 +116,7 @@ export function getBoundaryLinks(
const resolved = LLink.resolve(input.link, graph)
if (!resolved) {
console.debug(`Failed to resolve link ID [${input.link}]`)
console.warn(`Failed to resolve link ID [${input.link}]`)
continue
}

View File

@@ -157,7 +157,7 @@ export interface SubgraphIO extends SubgraphIOShared {
id: UUID
/** The data type this slot uses. Unlike nodes, this does not support legacy numeric types. */
type: string
/** Links connected to this slot, or `undefined` if not connected. An ouptut slot should only ever have one link. */
/** Links connected to this slot, or `undefined` if not connected. An output slot should only ever have one link. */
linkIds?: LinkId[]
}

View File

@@ -25,6 +25,8 @@ export interface IWidgetOptions<TValues = unknown[]> {
property?: string
/** If `true`, an input socket will not be created for this widget. */
socketless?: boolean
/** If `true`, the widget will not be rendered by the Vue renderer. */
canvasOnly?: boolean
values?: TValues
callback?: IWidget['callback']
@@ -306,6 +308,13 @@ export interface IBaseWidget<
hidden?: boolean
advanced?: boolean
/**
* This property is automatically computed on graph change
* and should not be changed.
* Promoted widgets have a colored border
* @see /core/graph/subgraph/proxyWidget.registerProxyWidgets
*/
promoted?: boolean
tooltip?: string

View File

@@ -74,6 +74,7 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
computedDisabled?: boolean
hidden?: boolean
advanced?: boolean
promoted?: boolean
tooltip?: string
element?: HTMLElement
callback?(
@@ -146,6 +147,7 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
}
get outline_color() {
if (this.promoted) return LiteGraph.WIDGET_PROMOTED_OUTLINE_COLOR
return this.advanced
? LiteGraph.WIDGET_ADVANCED_OUTLINE_COLOR
: LiteGraph.WIDGET_OUTLINE_COLOR

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +0,0 @@
import { describe } from 'vitest'
import { LGraph } from '@/lib/litegraph/src/litegraph'
import { dirtyTest } from './testExtensions'
describe('LGraph configure()', () => {
dirtyTest(
'LGraph matches previous snapshot (normal configure() usage)',
({ expect, minimalSerialisableGraph, basicSerialisableGraph }) => {
const configuredMinGraph = new LGraph()
configuredMinGraph.configure(minimalSerialisableGraph)
expect(configuredMinGraph).toMatchSnapshot('configuredMinGraph')
const configuredBasicGraph = new LGraph()
configuredBasicGraph.configure(basicSerialisableGraph)
expect(configuredBasicGraph).toMatchSnapshot('configuredBasicGraph')
}
)
})

View File

@@ -1,144 +0,0 @@
import { describe } from 'vitest'
import { LGraph, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { test } from './testExtensions'
describe('LGraph', () => {
test('can be instantiated', ({ expect }) => {
// @ts-expect-error Intentional - extra holds any / all consumer data that should be serialised
const graph = new LGraph({ extra: 'TestGraph' })
expect(graph).toBeInstanceOf(LGraph)
expect(graph.extra).toBe('TestGraph')
expect(graph.extra).toBe('TestGraph')
})
test('is exactly the same type', async ({ expect }) => {
const directImport = await import('@/lib/litegraph/src/LGraph')
const entryPointImport = await import('@/lib/litegraph/src/litegraph')
expect(LiteGraph.LGraph).toBe(directImport.LGraph)
expect(LiteGraph.LGraph).toBe(entryPointImport.LGraph)
})
test('populates optional values', ({ expect, minimalSerialisableGraph }) => {
const dGraph = new LGraph(minimalSerialisableGraph)
expect(dGraph.links).toBeInstanceOf(Map)
expect(dGraph.nodes).toBeInstanceOf(Array)
expect(dGraph.groups).toBeInstanceOf(Array)
})
test('supports schema v0.4 graphs', ({ expect, oldSchemaGraph }) => {
const fromOldSchema = new LGraph(oldSchemaGraph)
expect(fromOldSchema).toMatchSnapshot('oldSchemaGraph')
})
})
describe('Floating Links / Reroutes', () => {
test('Floating reroute should be removed when node and link are removed', ({
expect,
floatingLinkGraph
}) => {
const graph = new LGraph(floatingLinkGraph)
expect(graph.nodes.length).toBe(1)
graph.remove(graph.nodes[0])
expect(graph.nodes.length).toBe(0)
expect(graph.links.size).toBe(0)
expect(graph.floatingLinks.size).toBe(0)
expect(graph.reroutes.size).toBe(0)
})
test('Can add reroute to existing link', ({ expect, linkedNodesGraph }) => {
const graph = new LGraph(linkedNodesGraph)
expect(graph.nodes.length).toBe(2)
expect(graph.links.size).toBe(1)
expect(graph.reroutes.size).toBe(0)
graph.createReroute([0, 0], graph.links.values().next().value!)
expect(graph.links.size).toBe(1)
expect(graph.reroutes.size).toBe(1)
})
test('Create floating reroute when one side of node is removed', ({
expect,
linkedNodesGraph
}) => {
const graph = new LGraph(linkedNodesGraph)
graph.createReroute([0, 0], graph.links.values().next().value!)
graph.remove(graph.nodes[0])
expect(graph.links.size).toBe(0)
expect(graph.floatingLinks.size).toBe(1)
expect(graph.reroutes.size).toBe(1)
expect(graph.reroutes.values().next().value!.floating).not.toBeUndefined()
})
test('Create floating reroute when one side of link is removed', ({
expect,
linkedNodesGraph
}) => {
const graph = new LGraph(linkedNodesGraph)
graph.createReroute([0, 0], graph.links.values().next().value!)
graph.nodes[0].disconnectOutput(0)
expect(graph.links.size).toBe(0)
expect(graph.floatingLinks.size).toBe(1)
expect(graph.reroutes.size).toBe(1)
expect(graph.reroutes.values().next().value!.floating).not.toBeUndefined()
})
test('Reroutes and branches should be retained when the input node is removed', ({
expect,
floatingBranchGraph: graph
}) => {
expect(graph.nodes.length).toBe(3)
graph.remove(graph.nodes[2])
expect(graph.nodes.length).toBe(2)
expect(graph.links.size).toBe(1)
expect(graph.floatingLinks.size).toBe(1)
expect(graph.reroutes.size).toBe(4)
graph.remove(graph.nodes[1])
expect(graph.nodes.length).toBe(1)
expect(graph.links.size).toBe(0)
expect(graph.floatingLinks.size).toBe(2)
expect(graph.reroutes.size).toBe(4)
})
test('Floating reroutes should be removed when neither input nor output is connected', ({
expect,
floatingBranchGraph: graph
}) => {
// Remove output node
graph.remove(graph.nodes[0])
expect(graph.nodes.length).toBe(2)
expect(graph.links.size).toBe(0)
expect(graph.floatingLinks.size).toBe(2)
// The original floating reroute should be removed
expect(graph.reroutes.size).toBe(3)
graph.remove(graph.nodes[0])
expect(graph.nodes.length).toBe(1)
expect(graph.links.size).toBe(0)
expect(graph.floatingLinks.size).toBe(1)
expect(graph.reroutes.size).toBe(3)
graph.remove(graph.nodes[0])
expect(graph.nodes.length).toBe(0)
expect(graph.links.size).toBe(0)
expect(graph.floatingLinks.size).toBe(0)
expect(graph.reroutes.size).toBe(0)
})
})
describe('Legacy LGraph Compatibility Layer', () => {
test('can be extended via prototype', ({ expect, minimalGraph }) => {
// @ts-expect-error Should always be an error.
LGraph.prototype.newMethod = function () {
return 'New method added via prototype'
}
// @ts-expect-error Should always be an error.
expect(minimalGraph.newMethod()).toBe('New method added via prototype')
})
test('is correctly assigned to LiteGraph', ({ expect }) => {
expect(LiteGraph.LGraph).toBe(LGraph)
})
})

View File

@@ -1,195 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { LGraphButton } from '@/lib/litegraph/src/LGraphButton'
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
describe('LGraphButton', () => {
describe('Constructor', () => {
it('should create a button with default options', () => {
// @ts-expect-error TODO: Fix after merge - LGraphButton constructor type issues
const button = new LGraphButton({})
expect(button).toBeInstanceOf(LGraphButton)
expect(button.name).toBeUndefined()
expect(button._last_area).toBeInstanceOf(Rectangle)
})
it('should create a button with custom name', () => {
// @ts-expect-error TODO: Fix after merge - LGraphButton constructor type issues
const button = new LGraphButton({ name: 'test_button' })
expect(button.name).toBe('test_button')
})
it('should inherit badge properties', () => {
const button = new LGraphButton({
text: 'Test',
fgColor: '#FF0000',
bgColor: '#0000FF',
fontSize: 16
})
expect(button.text).toBe('Test')
expect(button.fgColor).toBe('#FF0000')
expect(button.bgColor).toBe('#0000FF')
expect(button.fontSize).toBe(16)
expect(button.visible).toBe(true) // visible is computed based on text length
})
})
describe('draw', () => {
it('should not draw if not visible', () => {
const button = new LGraphButton({ text: '' }) // Empty text makes it invisible
const ctx = {
measureText: vi.fn().mockReturnValue({ width: 100 })
} as unknown as CanvasRenderingContext2D
const superDrawSpy = vi.spyOn(
Object.getPrototypeOf(Object.getPrototypeOf(button)),
'draw'
)
button.draw(ctx, 50, 100)
expect(superDrawSpy).not.toHaveBeenCalled()
expect(button._last_area.width).toBe(0) // Rectangle default width
})
it('should draw and update last area when visible', () => {
const button = new LGraphButton({
text: 'Click',
xOffset: 5,
yOffset: 10
})
const ctx = {
measureText: vi.fn().mockReturnValue({ width: 60 }),
fillRect: vi.fn(),
fillText: vi.fn(),
beginPath: vi.fn(),
roundRect: vi.fn(),
fill: vi.fn(),
font: '',
fillStyle: '',
globalAlpha: 1
} as unknown as CanvasRenderingContext2D
const mockGetWidth = vi.fn().mockReturnValue(80)
button.getWidth = mockGetWidth
const x = 100
const y = 50
button.draw(ctx, x, y)
// Check that last area was updated correctly
expect(button._last_area[0]).toBe(x + button.xOffset) // 100 + 5 = 105
expect(button._last_area[1]).toBe(y + button.yOffset) // 50 + 10 = 60
expect(button._last_area[2]).toBe(80)
expect(button._last_area[3]).toBe(button.height)
})
it('should calculate last area without offsets', () => {
const button = new LGraphButton({
text: 'Test'
})
const ctx = {
measureText: vi.fn().mockReturnValue({ width: 40 }),
fillRect: vi.fn(),
fillText: vi.fn(),
beginPath: vi.fn(),
roundRect: vi.fn(),
fill: vi.fn(),
font: '',
fillStyle: '',
globalAlpha: 1
} as unknown as CanvasRenderingContext2D
const mockGetWidth = vi.fn().mockReturnValue(50)
button.getWidth = mockGetWidth
button.draw(ctx, 200, 100)
expect(button._last_area[0]).toBe(200)
expect(button._last_area[1]).toBe(100)
expect(button._last_area[2]).toBe(50)
})
})
describe('isPointInside', () => {
it('should return true when point is inside button area', () => {
const button = new LGraphButton({ text: 'Test' })
// Set the last area manually
button._last_area[0] = 100
button._last_area[1] = 50
button._last_area[2] = 80
button._last_area[3] = 20
// Test various points inside
expect(button.isPointInside(100, 50)).toBe(true) // Top-left corner
expect(button.isPointInside(179, 69)).toBe(true) // Bottom-right corner
expect(button.isPointInside(140, 60)).toBe(true) // Center
})
it('should return false when point is outside button area', () => {
const button = new LGraphButton({ text: 'Test' })
// Set the last area manually
button._last_area[0] = 100
button._last_area[1] = 50
button._last_area[2] = 80
button._last_area[3] = 20
// Test various points outside
expect(button.isPointInside(99, 50)).toBe(false) // Just left
expect(button.isPointInside(181, 50)).toBe(false) // Just right
expect(button.isPointInside(100, 49)).toBe(false) // Just above
expect(button.isPointInside(100, 71)).toBe(false) // Just below
expect(button.isPointInside(0, 0)).toBe(false) // Far away
})
it('should work with buttons that have not been drawn yet', () => {
const button = new LGraphButton({ text: 'Test' })
// _last_area has default values (0, 0, 0, 0)
expect(button.isPointInside(10, 10)).toBe(false)
expect(button.isPointInside(0, 0)).toBe(false)
})
})
describe('Integration with LGraphBadge', () => {
it('should properly inherit and use badge functionality', () => {
const button = new LGraphButton({
text: '→',
fontSize: 20,
// @ts-expect-error TODO: Fix after merge - color property not defined in type
color: '#FFFFFF',
backgroundColor: '#333333',
xOffset: -10,
yOffset: 5
})
const ctx = {
measureText: vi.fn().mockReturnValue({ width: 20 }),
fillRect: vi.fn(),
fillText: vi.fn(),
beginPath: vi.fn(),
roundRect: vi.fn(),
fill: vi.fn(),
font: '',
fillStyle: '',
globalAlpha: 1
} as unknown as CanvasRenderingContext2D
// Draw the button
button.draw(ctx, 100, 50)
// Verify button draws text without background
expect(ctx.beginPath).not.toHaveBeenCalled() // No background
expect(ctx.roundRect).not.toHaveBeenCalled() // No background
expect(ctx.fill).not.toHaveBeenCalled() // No background
expect(ctx.fillText).toHaveBeenCalledWith(
'→',
expect.any(Number),
expect.any(Number)
) // Just text
})
})
})

View File

@@ -1,290 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
describe('LGraphCanvas Title Button Rendering', () => {
let canvas: LGraphCanvas
let ctx: CanvasRenderingContext2D
let node: LGraphNode
beforeEach(() => {
// Create a mock canvas element
const canvasElement = document.createElement('canvas')
ctx = {
save: vi.fn(),
restore: vi.fn(),
translate: vi.fn(),
scale: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
fillText: vi.fn(),
measureText: vi.fn().mockReturnValue({ width: 50 }),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(),
fill: vi.fn(),
closePath: vi.fn(),
arc: vi.fn(),
rect: vi.fn(),
clip: vi.fn(),
clearRect: vi.fn(),
setTransform: vi.fn(),
roundRect: vi.fn(),
font: '',
fillStyle: '',
strokeStyle: '',
lineWidth: 1,
globalAlpha: 1,
textAlign: 'left' as CanvasTextAlign,
textBaseline: 'alphabetic' as CanvasTextBaseline
} as unknown as CanvasRenderingContext2D
canvasElement.getContext = vi.fn().mockReturnValue(ctx)
// @ts-expect-error TODO: Fix after merge - LGraphCanvas constructor type issues
canvas = new LGraphCanvas(canvasElement, null, {
skip_render: true,
skip_events: true
})
node = new LGraphNode('Test Node')
node.pos = [100, 200]
node.size = [200, 100]
// Mock required methods
node.drawTitleBarBackground = vi.fn()
// @ts-expect-error Property 'drawTitleBarText' does not exist on type 'LGraphNode'
node.drawTitleBarText = vi.fn()
node.drawBadges = vi.fn()
// @ts-expect-error TODO: Fix after merge - drawToggles not defined in type
node.drawToggles = vi.fn()
// @ts-expect-error TODO: Fix after merge - drawNodeShape not defined in type
node.drawNodeShape = vi.fn()
node.drawSlots = vi.fn()
// @ts-expect-error TODO: Fix after merge - drawContent not defined in type
node.drawContent = vi.fn()
node.drawWidgets = vi.fn()
node.drawCollapsedSlots = vi.fn()
node.drawTitleBox = vi.fn()
node.drawTitleText = vi.fn()
node.drawProgressBar = vi.fn()
node._setConcreteSlots = vi.fn()
node.arrange = vi.fn()
// @ts-expect-error TODO: Fix after merge - isSelectable not defined in type
node.isSelectable = vi.fn().mockReturnValue(true)
})
describe('drawNode title button rendering', () => {
it('should render visible title buttons', () => {
const button1 = node.addTitleButton({
name: 'button1',
text: 'A',
// @ts-expect-error TODO: Fix after merge - visible property not in LGraphButtonOptions
visible: true
})
const button2 = node.addTitleButton({
name: 'button2',
text: 'B',
// @ts-expect-error TODO: Fix after merge - visible property not in LGraphButtonOptions
visible: true
})
// Mock button methods
const getWidth1 = vi.fn().mockReturnValue(20)
const getWidth2 = vi.fn().mockReturnValue(25)
const draw1 = vi.spyOn(button1, 'draw')
const draw2 = vi.spyOn(button2, 'draw')
button1.getWidth = getWidth1
button2.getWidth = getWidth2
// Draw the node (this is a simplified version of what drawNode does)
canvas.drawNode(node, ctx)
// Verify both buttons' getWidth was called
expect(getWidth1).toHaveBeenCalledWith(ctx)
expect(getWidth2).toHaveBeenCalledWith(ctx)
// Verify both buttons were drawn
expect(draw1).toHaveBeenCalled()
expect(draw2).toHaveBeenCalled()
// Check draw positions (right-aligned from node width)
// First button (rightmost): 200 - 5 = 195, then subtract width
// Second button: first button position - 5 - button width
const titleHeight = LiteGraph.NODE_TITLE_HEIGHT
const buttonY = -titleHeight + (titleHeight - 20) / 2 // Centered
expect(draw1).toHaveBeenCalledWith(ctx, 180, buttonY) // 200 - 20
expect(draw2).toHaveBeenCalledWith(ctx, 153, buttonY) // 180 - 2 - 25
})
it('should skip invisible title buttons', () => {
const visibleButton = node.addTitleButton({
name: 'visible',
text: 'V',
// @ts-expect-error TODO: Fix after merge - visible property not in LGraphButtonOptions
visible: true
})
const invisibleButton = node.addTitleButton({
name: 'invisible',
text: '' // Empty text makes it invisible
})
const getWidthVisible = vi.fn().mockReturnValue(30)
const getWidthInvisible = vi.fn().mockReturnValue(30)
const drawVisible = vi.spyOn(visibleButton, 'draw')
const drawInvisible = vi.spyOn(invisibleButton, 'draw')
visibleButton.getWidth = getWidthVisible
invisibleButton.getWidth = getWidthInvisible
canvas.drawNode(node, ctx)
// Only visible button should be measured and drawn
expect(getWidthVisible).toHaveBeenCalledWith(ctx)
expect(getWidthInvisible).not.toHaveBeenCalled()
expect(drawVisible).toHaveBeenCalled()
expect(drawInvisible).not.toHaveBeenCalled()
})
it('should handle nodes without title buttons', () => {
// Node has no title buttons
expect(node.title_buttons).toHaveLength(0)
// Should draw without errors
expect(() => canvas.drawNode(node, ctx)).not.toThrow()
})
it('should position multiple buttons with correct spacing', () => {
const buttons = []
const drawSpies = []
// Add 3 buttons
for (let i = 0; i < 3; i++) {
const button = node.addTitleButton({
name: `button${i}`,
text: String(i),
// @ts-expect-error TODO: Fix after merge - visible property not in LGraphButtonOptions
visible: true
})
button.getWidth = vi.fn().mockReturnValue(15) // All same width for simplicity
const spy = vi.spyOn(button, 'draw')
buttons.push(button)
drawSpies.push(spy)
}
canvas.drawNode(node, ctx)
const titleHeight = LiteGraph.NODE_TITLE_HEIGHT
// Check positions are correctly spaced (right to left)
// Starting position: 200
const buttonY = -titleHeight + (titleHeight - 20) / 2 // Button height is 20 (default)
expect(drawSpies[0]).toHaveBeenCalledWith(ctx, 185, buttonY) // 200 - 15
expect(drawSpies[1]).toHaveBeenCalledWith(ctx, 168, buttonY) // 185 - 2 - 15
expect(drawSpies[2]).toHaveBeenCalledWith(ctx, 151, buttonY) // 168 - 2 - 15
})
it('should render buttons in low quality mode', () => {
const button = node.addTitleButton({
name: 'test',
text: 'T',
// @ts-expect-error TODO: Fix after merge - visible property not in LGraphButtonOptions
visible: true
})
button.getWidth = vi.fn().mockReturnValue(20)
const drawSpy = vi.spyOn(button, 'draw')
// Set low quality rendering
// @ts-expect-error TODO: Fix after merge - lowQualityRenderingRequired not defined in type
canvas.lowQualityRenderingRequired = true
canvas.drawNode(node, ctx)
// Buttons should still be rendered in low quality mode
const buttonY =
-LiteGraph.NODE_TITLE_HEIGHT + (LiteGraph.NODE_TITLE_HEIGHT - 20) / 2
expect(drawSpy).toHaveBeenCalledWith(ctx, 180, buttonY)
})
it('should handle buttons with different widths', () => {
const smallButton = node.addTitleButton({
name: 'small',
text: 'S',
// @ts-expect-error TODO: Fix after merge - visible property not in LGraphButtonOptions
visible: true
})
const largeButton = node.addTitleButton({
name: 'large',
text: 'LARGE',
// @ts-expect-error TODO: Fix after merge - visible property not in LGraphButtonOptions
visible: true
})
smallButton.getWidth = vi.fn().mockReturnValue(15)
largeButton.getWidth = vi.fn().mockReturnValue(50)
const drawSmall = vi.spyOn(smallButton, 'draw')
const drawLarge = vi.spyOn(largeButton, 'draw')
canvas.drawNode(node, ctx)
const titleHeight = LiteGraph.NODE_TITLE_HEIGHT
// Small button (rightmost): 200 - 15 = 185
const buttonY = -titleHeight + (titleHeight - 20) / 2
expect(drawSmall).toHaveBeenCalledWith(ctx, 185, buttonY)
// Large button: 185 - 2 - 50 = 133
expect(drawLarge).toHaveBeenCalledWith(ctx, 133, buttonY)
})
})
describe('Integration with node properties', () => {
it('should respect node size for button positioning', () => {
node.size = [300, 150] // Wider node
const button = node.addTitleButton({
name: 'test',
text: 'X',
// @ts-expect-error TODO: Fix after merge - visible property not in LGraphButtonOptions
visible: true
})
button.getWidth = vi.fn().mockReturnValue(20)
const drawSpy = vi.spyOn(button, 'draw')
canvas.drawNode(node, ctx)
const titleHeight = LiteGraph.NODE_TITLE_HEIGHT
// Should use new width: 300 - 20 = 280
const buttonY = -titleHeight + (titleHeight - 20) / 2
expect(drawSpy).toHaveBeenCalledWith(ctx, 280, buttonY)
})
it('should NOT render buttons on collapsed nodes', () => {
node.flags.collapsed = true
const button = node.addTitleButton({
name: 'test',
text: 'C'
})
button.getWidth = vi.fn().mockReturnValue(20)
const drawSpy = vi.spyOn(button, 'draw')
canvas.drawNode(node, ctx)
// Title buttons should NOT be rendered on collapsed nodes
expect(drawSpy).not.toHaveBeenCalled()
expect(button.getWidth).not.toHaveBeenCalled()
})
})
})

View File

@@ -1,12 +0,0 @@
import { describe, expect } from 'vitest'
import { LGraphGroup } from '@/lib/litegraph/src/litegraph'
import { test } from './testExtensions'
describe('LGraphGroup', () => {
test('serializes to the existing format', () => {
const link = new LGraphGroup('title', 929)
expect(link.serialize()).toMatchSnapshot('Basic')
})
})

View File

@@ -1,131 +0,0 @@
import { beforeEach, describe, expect } from 'vitest'
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { test } from './testExtensions'
describe('LGraphNode resize functionality', () => {
let node: LGraphNode
beforeEach(() => {
// Set up LiteGraph constants needed for measure
LiteGraph.NODE_TITLE_HEIGHT = 20
node = new LGraphNode('Test Node')
node.pos = [100, 100]
node.size = [200, 150]
// Create a mock canvas context for updateArea
const mockCtx = {} as CanvasRenderingContext2D
// Call updateArea to populate boundingRect
node.updateArea(mockCtx)
})
describe('findResizeDirection', () => {
describe('corners', () => {
test('should detect NW (top-left) corner', () => {
// With title bar, top is at y=80 (100 - 20)
// Corner is from (100, 80) to (100 + 15, 80 + 15)
expect(node.findResizeDirection(100, 80)).toBe('NW')
expect(node.findResizeDirection(110, 90)).toBe('NW')
expect(node.findResizeDirection(114, 94)).toBe('NW')
})
test('should detect NE (top-right) corner', () => {
// Corner is from (300 - 15, 80) to (300, 80 + 15)
expect(node.findResizeDirection(285, 80)).toBe('NE')
expect(node.findResizeDirection(290, 90)).toBe('NE')
expect(node.findResizeDirection(299, 94)).toBe('NE')
})
test('should detect SW (bottom-left) corner', () => {
// Bottom is at y=250 (100 + 150)
// Corner is from (100, 250 - 15) to (100 + 15, 250)
expect(node.findResizeDirection(100, 235)).toBe('SW')
expect(node.findResizeDirection(110, 240)).toBe('SW')
expect(node.findResizeDirection(114, 249)).toBe('SW')
})
test('should detect SE (bottom-right) corner', () => {
// Corner is from (300 - 15, 250 - 15) to (300, 250)
expect(node.findResizeDirection(285, 235)).toBe('SE')
expect(node.findResizeDirection(290, 240)).toBe('SE')
expect(node.findResizeDirection(299, 249)).toBe('SE')
})
})
describe('priority', () => {
test('corners should have priority over edges', () => {
// These points are technically on both corner and edge
// Corner should win
expect(node.findResizeDirection(100, 84)).toBe('NW') // Not "W"
expect(node.findResizeDirection(104, 80)).toBe('NW') // Not "N"
})
})
describe('negative cases', () => {
test('should return undefined when outside node bounds', () => {
expect(node.findResizeDirection(50, 50)).toBeUndefined()
expect(node.findResizeDirection(350, 300)).toBeUndefined()
expect(node.findResizeDirection(99, 150)).toBeUndefined()
expect(node.findResizeDirection(301, 150)).toBeUndefined()
})
test('should return undefined when inside node but not on resize areas', () => {
// Center of node (accounting for title bar offset)
expect(node.findResizeDirection(200, 165)).toBeUndefined()
// Just inside the edge threshold
expect(node.findResizeDirection(106, 150)).toBeUndefined()
expect(node.findResizeDirection(294, 150)).toBeUndefined()
expect(node.findResizeDirection(150, 86)).toBeUndefined() // 80 + 6
expect(node.findResizeDirection(150, 244)).toBeUndefined()
})
test('should return undefined when node is not resizable', () => {
node.resizable = false
expect(node.findResizeDirection(100, 100)).toBeUndefined()
expect(node.findResizeDirection(300, 250)).toBeUndefined()
expect(node.findResizeDirection(150, 100)).toBeUndefined()
})
})
describe('edge cases', () => {
test('should handle nodes at origin', () => {
node.pos = [0, 0]
node.size = [100, 100]
// Update boundingRect with new position/size
const mockCtx = {} as CanvasRenderingContext2D
node.updateArea(mockCtx)
expect(node.findResizeDirection(0, -20)).toBe('NW') // Account for title bar
expect(node.findResizeDirection(99, 99)).toBe('SE') // Bottom-right corner (100-1, 100-1)
})
test('should handle very small nodes', () => {
node.size = [20, 20] // Smaller than corner size
// Update boundingRect with new size
const mockCtx = {} as CanvasRenderingContext2D
node.updateArea(mockCtx)
// Corners still work (accounting for title bar offset)
expect(node.findResizeDirection(100, 80)).toBe('NW')
expect(node.findResizeDirection(119, 119)).toBe('SE')
})
})
})
describe('resizeEdgeSize static property', () => {
test('should have default value of 5', () => {
expect(LGraphNode.resizeEdgeSize).toBe(5)
})
})
describe('resizeHandleSize static property', () => {
test('should have default value of 15', () => {
expect(LGraphNode.resizeHandleSize).toBe(15)
})
})
})

View File

@@ -1,774 +0,0 @@
import { afterEach, beforeEach, describe, expect, vi } from 'vitest'
import type { INodeInputSlot, Point } from '@/lib/litegraph/src/interfaces'
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { LGraph } from '@/lib/litegraph/src/litegraph'
import { NodeInputSlot } from '@/lib/litegraph/src/node/NodeInputSlot'
import { NodeOutputSlot } from '@/lib/litegraph/src/node/NodeOutputSlot'
import type { ISerialisedNode } from '@/lib/litegraph/src/types/serialisation'
import { test } from './testExtensions'
function getMockISerialisedNode(
data: Partial<ISerialisedNode>
): ISerialisedNode {
return Object.assign(
{
id: 0,
flags: {},
type: 'TestNode',
pos: [100, 100],
size: [100, 100],
order: 0,
mode: 0
},
data
)
}
describe('LGraphNode', () => {
let node: LGraphNode
let origLiteGraph: typeof LiteGraph
beforeEach(() => {
origLiteGraph = Object.assign({}, LiteGraph)
// @ts-expect-error TODO: Fix after merge - Classes property not in type
delete origLiteGraph.Classes
Object.assign(LiteGraph, {
NODE_TITLE_HEIGHT: 20,
NODE_SLOT_HEIGHT: 15,
NODE_TEXT_SIZE: 14,
DEFAULT_SHADOW_COLOR: 'rgba(0,0,0,0.5)',
DEFAULT_GROUP_FONT_SIZE: 24,
isValidConnection: vi.fn().mockReturnValue(true)
})
node = new LGraphNode('Test Node')
node.pos = [100, 200]
node.size = [150, 100] // Example size
// Reset mocks if needed
vi.clearAllMocks()
})
afterEach(() => {
Object.assign(LiteGraph, origLiteGraph)
})
test('should serialize position/size correctly', () => {
const node = new LGraphNode('TestNode')
node.pos = [10, 20]
node.size = [30, 40]
const json = node.serialize()
expect(json.pos).toEqual([10, 20])
expect(json.size).toEqual([30, 40])
const configureData: ISerialisedNode = {
id: node.id,
type: node.type,
pos: [50, 60],
size: [70, 80],
flags: {},
order: node.order,
mode: node.mode,
inputs: node.inputs?.map((i) => ({
name: i.name,
type: i.type,
link: i.link
})),
outputs: node.outputs?.map((o) => ({
name: o.name,
type: o.type,
links: o.links,
slot_index: o.slot_index
}))
}
node.configure(configureData)
expect(node.pos).toEqual(new Float32Array([50, 60]))
expect(node.size).toEqual(new Float32Array([70, 80]))
})
test('should configure inputs correctly', () => {
const node = new LGraphNode('TestNode')
node.configure(
getMockISerialisedNode({
id: 0,
inputs: [{ name: 'TestInput', type: 'number', link: null }]
})
)
expect(node.inputs.length).toEqual(1)
expect(node.inputs[0].name).toEqual('TestInput')
expect(node.inputs[0].link).toEqual(null)
expect(node.inputs[0]).instanceOf(NodeInputSlot)
// Should not override existing inputs
node.configure(getMockISerialisedNode({ id: 1 }))
expect(node.id).toEqual(1)
expect(node.inputs.length).toEqual(1)
})
test('should configure outputs correctly', () => {
const node = new LGraphNode('TestNode')
node.configure(
getMockISerialisedNode({
id: 0,
outputs: [{ name: 'TestOutput', type: 'number', links: [] }]
})
)
expect(node.outputs.length).toEqual(1)
expect(node.outputs[0].name).toEqual('TestOutput')
expect(node.outputs[0].type).toEqual('number')
expect(node.outputs[0].links).toEqual([])
expect(node.outputs[0]).instanceOf(NodeOutputSlot)
// Should not override existing outputs
node.configure(getMockISerialisedNode({ id: 1 }))
expect(node.id).toEqual(1)
expect(node.outputs.length).toEqual(1)
})
describe('Disconnect I/O Slots', () => {
test('should disconnect input correctly', () => {
const node1 = new LGraphNode('SourceNode')
const node2 = new LGraphNode('TargetNode')
// Configure nodes with input/output slots
node1.configure(
getMockISerialisedNode({
id: 1,
outputs: [{ name: 'Output1', type: 'number', links: [] }]
})
)
node2.configure(
getMockISerialisedNode({
id: 2,
inputs: [{ name: 'Input1', type: 'number', link: null }]
})
)
// Create a graph and add nodes to it
const graph = new LGraph()
graph.add(node1)
graph.add(node2)
// Connect the nodes
const link = node1.connect(0, node2, 0)
expect(link).not.toBeNull()
expect(node2.inputs[0].link).toBe(link?.id)
expect(node1.outputs[0].links).toContain(link?.id)
// Test disconnecting by slot number
const disconnected = node2.disconnectInput(0)
expect(disconnected).toBe(true)
expect(node2.inputs[0].link).toBeNull()
expect(node1.outputs[0].links?.length).toBe(0)
expect(graph._links.has(link?.id ?? -1)).toBe(false)
// Test disconnecting by slot name
node1.connect(0, node2, 0)
const disconnectedByName = node2.disconnectInput('Input1')
expect(disconnectedByName).toBe(true)
expect(node2.inputs[0].link).toBeNull()
// Test disconnecting non-existent slot
const invalidDisconnect = node2.disconnectInput(999)
expect(invalidDisconnect).toBe(false)
// Test disconnecting already disconnected input
const alreadyDisconnected = node2.disconnectInput(0)
expect(alreadyDisconnected).toBe(true)
})
test('should disconnect output correctly', () => {
const sourceNode = new LGraphNode('SourceNode')
const targetNode1 = new LGraphNode('TargetNode1')
const targetNode2 = new LGraphNode('TargetNode2')
// Configure nodes with input/output slots
sourceNode.configure(
getMockISerialisedNode({
id: 1,
outputs: [
{ name: 'Output1', type: 'number', links: [] },
{ name: 'Output2', type: 'number', links: [] }
]
})
)
targetNode1.configure(
getMockISerialisedNode({
id: 2,
inputs: [{ name: 'Input1', type: 'number', link: null }]
})
)
targetNode2.configure(
getMockISerialisedNode({
id: 3,
inputs: [{ name: 'Input1', type: 'number', link: null }]
})
)
// Create a graph and add nodes to it
const graph = new LGraph()
graph.add(sourceNode)
graph.add(targetNode1)
graph.add(targetNode2)
// Connect multiple nodes to the same output
const link1 = sourceNode.connect(0, targetNode1, 0)
const link2 = sourceNode.connect(0, targetNode2, 0)
expect(link1).not.toBeNull()
expect(link2).not.toBeNull()
expect(sourceNode.outputs[0].links?.length).toBe(2)
// Test disconnecting specific target node
const disconnectedSpecific = sourceNode.disconnectOutput(0, targetNode1)
expect(disconnectedSpecific).toBe(true)
expect(targetNode1.inputs[0].link).toBeNull()
expect(sourceNode.outputs[0].links?.length).toBe(1)
expect(graph._links.has(link1?.id ?? -1)).toBe(false)
expect(graph._links.has(link2?.id ?? -1)).toBe(true)
// Test disconnecting by slot name
const link3 = sourceNode.connect(1, targetNode1, 0)
expect(link3).not.toBeNull()
const disconnectedByName = sourceNode.disconnectOutput(
'Output2',
targetNode1
)
expect(disconnectedByName).toBe(true)
expect(targetNode1.inputs[0].link).toBeNull()
expect(sourceNode.outputs[1].links?.length).toBe(0)
// Test disconnecting all connections from an output
const link4 = sourceNode.connect(0, targetNode1, 0)
expect(link4).not.toBeNull()
expect(sourceNode.outputs[0].links?.length).toBe(2)
const disconnectedAll = sourceNode.disconnectOutput(0)
expect(disconnectedAll).toBe(true)
expect(sourceNode.outputs[0].links).toBeNull()
expect(targetNode1.inputs[0].link).toBeNull()
expect(targetNode2.inputs[0].link).toBeNull()
expect(graph._links.has(link2?.id ?? -1)).toBe(false)
expect(graph._links.has(link4?.id ?? -1)).toBe(false)
// Test disconnecting non-existent slot
const invalidDisconnect = sourceNode.disconnectOutput(999)
expect(invalidDisconnect).toBe(false)
// Test disconnecting already disconnected output
const alreadyDisconnected = sourceNode.disconnectOutput(0)
expect(alreadyDisconnected).toBe(false)
})
})
describe('getInputPos and getOutputPos', () => {
test('should handle collapsed nodes correctly', () => {
const node = new LGraphNode('TestNode') as unknown as Omit<
LGraphNode,
'boundingRect'
> & { boundingRect: Float32Array }
node.pos = [100, 100]
node.size = [100, 100]
node.boundingRect[0] = 100
node.boundingRect[1] = 100
node.boundingRect[2] = 100
node.boundingRect[3] = 100
node.configure(
getMockISerialisedNode({
id: 1,
inputs: [{ name: 'Input1', type: 'number', link: null }],
outputs: [{ name: 'Output1', type: 'number', links: [] }]
})
)
// Collapse the node
node.flags.collapsed = true
// Get positions in collapsed state
const inputPos = node.getInputPos(0)
const outputPos = node.getOutputPos(0)
expect(inputPos).toEqual([100, 90])
expect(outputPos).toEqual([180, 90])
})
test('should return correct positions for input and output slots', () => {
const node = new LGraphNode('TestNode')
node.pos = [100, 100]
node.size = [100, 100]
node.configure(
getMockISerialisedNode({
id: 1,
inputs: [{ name: 'Input1', type: 'number', link: null }],
outputs: [{ name: 'Output1', type: 'number', links: [] }]
})
)
const inputPos = node.getInputPos(0)
const outputPos = node.getOutputPos(0)
expect(inputPos).toEqual([107.5, 110.5])
expect(outputPos).toEqual([193.5, 110.5])
})
})
describe('getSlotOnPos', () => {
test('should return undefined when point is outside node bounds', () => {
const node = new LGraphNode('TestNode')
node.pos = [100, 100]
node.size = [100, 100]
node.configure(
getMockISerialisedNode({
id: 1,
inputs: [{ name: 'Input1', type: 'number', link: null }],
outputs: [{ name: 'Output1', type: 'number', links: [] }]
})
)
// Test point far outside node bounds
expect(node.getSlotOnPos([0, 0])).toBeUndefined()
// Test point just outside node bounds
expect(node.getSlotOnPos([99, 99])).toBeUndefined()
})
test('should detect input slots correctly', () => {
const node = new LGraphNode('TestNode') as unknown as Omit<
LGraphNode,
'boundingRect'
> & { boundingRect: Float32Array }
node.pos = [100, 100]
node.size = [100, 100]
node.boundingRect[0] = 100
node.boundingRect[1] = 100
node.boundingRect[2] = 200
node.boundingRect[3] = 200
node.configure(
getMockISerialisedNode({
id: 1,
inputs: [
{ name: 'Input1', type: 'number', link: null },
{ name: 'Input2', type: 'string', link: null }
]
})
)
// Get position of first input slot
const inputPos = node.getInputPos(0)
// Test point directly on input slot
const slot = node.getSlotOnPos(inputPos)
expect(slot).toBeDefined()
expect(slot?.name).toBe('Input1')
// Test point near but not on input slot
expect(node.getSlotOnPos([inputPos[0] - 15, inputPos[1]])).toBeUndefined()
})
test('should detect output slots correctly', () => {
const node = new LGraphNode('TestNode') as unknown as Omit<
LGraphNode,
'boundingRect'
> & { boundingRect: Float32Array }
node.pos = [100, 100]
node.size = [100, 100]
node.boundingRect[0] = 100
node.boundingRect[1] = 100
node.boundingRect[2] = 200
node.boundingRect[3] = 200
node.configure(
getMockISerialisedNode({
id: 1,
outputs: [
{ name: 'Output1', type: 'number', links: [] },
{ name: 'Output2', type: 'string', links: [] }
]
})
)
// Get position of first output slot
const outputPos = node.getOutputPos(0)
// Test point directly on output slot
const slot = node.getSlotOnPos(outputPos)
expect(slot).toBeDefined()
expect(slot?.name).toBe('Output1')
// Test point near but not on output slot
const gotslot = node.getSlotOnPos([outputPos[0] + 30, outputPos[1]])
expect(gotslot).toBeUndefined()
})
test('should prioritize input slots over output slots', () => {
const node = new LGraphNode('TestNode') as unknown as Omit<
LGraphNode,
'boundingRect'
> & { boundingRect: Float32Array }
node.pos = [100, 100]
node.size = [100, 100]
node.boundingRect[0] = 100
node.boundingRect[1] = 100
node.boundingRect[2] = 200
node.boundingRect[3] = 200
node.configure(
getMockISerialisedNode({
id: 1,
inputs: [{ name: 'Input1', type: 'number', link: null }],
outputs: [{ name: 'Output1', type: 'number', links: [] }]
})
)
// Get positions of first input and output slots
const inputPos = node.getInputPos(0)
// Test point that could theoretically hit both slots
// Should return the input slot due to priority
const slot = node.getSlotOnPos(inputPos)
expect(slot).toBeDefined()
expect(slot?.name).toBe('Input1')
})
})
describe('LGraphNode slot positioning', () => {
test('should correctly position slots with absolute coordinates', () => {
// Setup
const node = new LGraphNode('test')
node.pos = [100, 100]
// Add input/output with absolute positions
node.addInput('abs-input', 'number')
node.inputs[0].pos = [10, 20]
node.addOutput('abs-output', 'number')
node.outputs[0].pos = [50, 30]
// Test
const inputPos = node.getInputPos(0)
const outputPos = node.getOutputPos(0)
// Absolute positions should be relative to node position
expect(inputPos).toEqual([110, 120]) // node.pos + slot.pos
expect(outputPos).toEqual([150, 130]) // node.pos + slot.pos
})
test('should correctly position default vertical slots', () => {
// Setup
const node = new LGraphNode('test')
node.pos = [100, 100]
// Add multiple inputs/outputs without absolute positions
node.addInput('input1', 'number')
node.addInput('input2', 'number')
node.addOutput('output1', 'number')
node.addOutput('output2', 'number')
// Calculate expected positions
const slotOffset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
const slotSpacing = LiteGraph.NODE_SLOT_HEIGHT
const nodeWidth = node.size[0]
// Test input positions
expect(node.getInputPos(0)).toEqual([
100 + slotOffset,
100 + (0 + 0.7) * slotSpacing
])
expect(node.getInputPos(1)).toEqual([
100 + slotOffset,
100 + (1 + 0.7) * slotSpacing
])
// Test output positions
expect(node.getOutputPos(0)).toEqual([
100 + nodeWidth + 1 - slotOffset,
100 + (0 + 0.7) * slotSpacing
])
expect(node.getOutputPos(1)).toEqual([
100 + nodeWidth + 1 - slotOffset,
100 + (1 + 0.7) * slotSpacing
])
})
test('should skip absolute positioned slots when calculating vertical positions', () => {
// Setup
const node = new LGraphNode('test')
node.pos = [100, 100]
// Add mix of absolute and default positioned slots
node.addInput('abs-input', 'number')
node.inputs[0].pos = [10, 20]
node.addInput('default-input1', 'number')
node.addInput('default-input2', 'number')
const slotOffset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
const slotSpacing = LiteGraph.NODE_SLOT_HEIGHT
// Test: default positioned slots should be consecutive, ignoring absolute positioned ones
expect(node.getInputPos(1)).toEqual([
100 + slotOffset,
100 + (0 + 0.7) * slotSpacing // First default slot starts at index 0
])
expect(node.getInputPos(2)).toEqual([
100 + slotOffset,
100 + (1 + 0.7) * slotSpacing // Second default slot at index 1
])
})
})
describe('widget serialization', () => {
test('should only serialize widgets with serialize flag not set to false', () => {
const node = new LGraphNode('TestNode')
node.serialize_widgets = true
// Add widgets with different serialization settings
node.addWidget('number', 'serializable1', 1, null)
node.addWidget('number', 'serializable2', 2, null)
node.addWidget('number', 'non-serializable', 3, null)
expect(node.widgets?.length).toBe(3)
// Set serialize flag to false for the last widget
node.widgets![2].serialize = false
// Set some widget values
node.widgets![0].value = 10
node.widgets![1].value = 20
node.widgets![2].value = 30
// Serialize the node
const serialized = node.serialize()
// Check that only serializable widgets' values are included
expect(serialized.widgets_values).toEqual([10, 20])
expect(serialized.widgets_values).toHaveLength(2)
})
test('should only configure widgets with serialize flag not set to false', () => {
const node = new LGraphNode('TestNode')
node.serialize_widgets = true
node.addWidget('number', 'non-serializable', 1, null)
node.addWidget('number', 'serializable1', 2, null)
expect(node.widgets?.length).toBe(2)
node.widgets![0].serialize = false
node.configure(
getMockISerialisedNode({
id: 1,
type: 'TestNode',
pos: [100, 100],
size: [100, 100],
properties: {},
widgets_values: [100]
})
)
expect(node.widgets![0].value).toBe(1)
expect(node.widgets![1].value).toBe(100)
})
})
describe('getInputSlotPos', () => {
let inputSlot: INodeInputSlot
beforeEach(() => {
inputSlot = {
name: 'test_in',
type: 'string',
link: null,
boundingRect: new Float32Array([0, 0, 0, 0])
}
})
test('should return position based on title height when collapsed', () => {
node.flags.collapsed = true
const expectedPos: Point = [100, 200 - LiteGraph.NODE_TITLE_HEIGHT * 0.5]
expect(node.getInputSlotPos(inputSlot)).toEqual(expectedPos)
})
test('should return position based on input.pos when defined and not collapsed', () => {
node.flags.collapsed = false
inputSlot.pos = [10, 50]
node.inputs = [inputSlot]
const expectedPos: Point = [100 + 10, 200 + 50]
expect(node.getInputSlotPos(inputSlot)).toEqual(expectedPos)
})
test('should return default vertical position when input.pos is undefined and not collapsed', () => {
node.flags.collapsed = false
const inputSlot2 = {
name: 'test_in_2',
type: 'number',
link: null,
boundingRect: new Float32Array([0, 0, 0, 0])
}
node.inputs = [inputSlot, inputSlot2]
const slotIndex = 0
const nodeOffsetY = (node.constructor as any).slot_start_y || 0
const expectedY =
200 + (slotIndex + 0.7) * LiteGraph.NODE_SLOT_HEIGHT + nodeOffsetY
const expectedX = 100 + LiteGraph.NODE_SLOT_HEIGHT * 0.5
expect(node.getInputSlotPos(inputSlot)).toEqual([expectedX, expectedY])
const slotIndex2 = 1
const expectedY2 =
200 + (slotIndex2 + 0.7) * LiteGraph.NODE_SLOT_HEIGHT + nodeOffsetY
expect(node.getInputSlotPos(inputSlot2)).toEqual([expectedX, expectedY2])
})
test('should return default vertical position including slot_start_y when defined', () => {
;(node.constructor as any).slot_start_y = 25
node.flags.collapsed = false
node.inputs = [inputSlot]
const slotIndex = 0
const nodeOffsetY = 25
const expectedY =
200 + (slotIndex + 0.7) * LiteGraph.NODE_SLOT_HEIGHT + nodeOffsetY
const expectedX = 100 + LiteGraph.NODE_SLOT_HEIGHT * 0.5
expect(node.getInputSlotPos(inputSlot)).toEqual([expectedX, expectedY])
delete (node.constructor as any).slot_start_y
})
})
describe('getInputPos', () => {
test('should call getInputSlotPos with the correct input slot from inputs array', () => {
const input0: INodeInputSlot = {
name: 'in0',
type: 'string',
link: null,
boundingRect: new Float32Array([0, 0, 0, 0])
}
const input1: INodeInputSlot = {
name: 'in1',
type: 'number',
link: null,
boundingRect: new Float32Array([0, 0, 0, 0]),
pos: [5, 45]
}
node.inputs = [input0, input1]
const spy = vi.spyOn(node, 'getInputSlotPos')
node.getInputPos(1)
expect(spy).toHaveBeenCalledWith(input1)
const expectedPos: Point = [100 + 5, 200 + 45]
expect(node.getInputPos(1)).toEqual(expectedPos)
spy.mockClear()
node.getInputPos(0)
expect(spy).toHaveBeenCalledWith(input0)
const slotIndex = 0
const nodeOffsetY = (node.constructor as any).slot_start_y || 0
const expectedDefaultY =
200 + (slotIndex + 0.7) * LiteGraph.NODE_SLOT_HEIGHT + nodeOffsetY
const expectedDefaultX = 100 + LiteGraph.NODE_SLOT_HEIGHT * 0.5
expect(node.getInputPos(0)).toEqual([expectedDefaultX, expectedDefaultY])
spy.mockRestore()
})
})
describe('removeInput/removeOutput on copied nodes', () => {
beforeEach(() => {
// Register a test node type so clone() can work
LiteGraph.registerNodeType('TestNode', LGraphNode)
})
test('should NOT throw error when calling removeInput on a copied node without graph', () => {
// Create a node with an input
const originalNode = new LGraphNode('Test Node')
originalNode.type = 'TestNode'
originalNode.addInput('input1', 'number')
// Clone the node (which creates a node without graph reference)
const copiedNode = originalNode.clone()
// This should NOT throw anymore - we can remove inputs on nodes without graph
expect(() => copiedNode!.removeInput(0)).not.toThrow()
expect(copiedNode!.inputs).toHaveLength(0)
})
test('should NOT throw error when calling removeOutput on a copied node without graph', () => {
// Create a node with an output
const originalNode = new LGraphNode('Test Node')
originalNode.type = 'TestNode'
originalNode.addOutput('output1', 'number')
// Clone the node (which creates a node without graph reference)
const copiedNode = originalNode.clone()
// This should NOT throw anymore - we can remove outputs on nodes without graph
expect(() => copiedNode!.removeOutput(0)).not.toThrow()
expect(copiedNode!.outputs).toHaveLength(0)
})
test('should skip disconnectInput/disconnectOutput when node has no graph', () => {
// Create nodes with input/output
const nodeWithInput = new LGraphNode('Test Node')
nodeWithInput.type = 'TestNode'
nodeWithInput.addInput('input1', 'number')
const nodeWithOutput = new LGraphNode('Test Node')
nodeWithOutput.type = 'TestNode'
nodeWithOutput.addOutput('output1', 'number')
// Clone nodes (no graph reference)
const clonedInput = nodeWithInput.clone()
const clonedOutput = nodeWithOutput.clone()
// Mock disconnect methods to verify they're not called
clonedInput!.disconnectInput = vi.fn()
clonedOutput!.disconnectOutput = vi.fn()
// Remove input/output - disconnect methods should NOT be called
clonedInput!.removeInput(0)
clonedOutput!.removeOutput(0)
expect(clonedInput!.disconnectInput).not.toHaveBeenCalled()
expect(clonedOutput!.disconnectOutput).not.toHaveBeenCalled()
})
test('should be able to removeInput on a copied node after adding to graph', () => {
// Create a graph and a node with an input
const graph = new LGraph()
const originalNode = new LGraphNode('Test Node')
originalNode.type = 'TestNode'
originalNode.addInput('input1', 'number')
// Clone the node and add to graph
const copiedNode = originalNode.clone()
expect(copiedNode).not.toBeNull()
graph.add(copiedNode!)
// This should work now that the node has a graph reference
expect(() => copiedNode!.removeInput(0)).not.toThrow()
expect(copiedNode!.inputs).toHaveLength(0)
})
test('should be able to removeOutput on a copied node after adding to graph', () => {
// Create a graph and a node with an output
const graph = new LGraph()
const originalNode = new LGraphNode('Test Node')
originalNode.type = 'TestNode'
originalNode.addOutput('output1', 'number')
// Clone the node and add to graph
const copiedNode = originalNode.clone()
expect(copiedNode).not.toBeNull()
graph.add(copiedNode!)
// This should work now that the node has a graph reference
expect(() => copiedNode!.removeOutput(0)).not.toThrow()
expect(copiedNode!.outputs).toHaveLength(0)
})
test('RerouteNode clone scenario - should be able to removeOutput and addOutput on cloned node', () => {
// This simulates the RerouteNode clone method behavior
const originalNode = new LGraphNode('Reroute')
originalNode.type = 'TestNode'
originalNode.addOutput('*', '*')
// Clone the node (simulating RerouteNode.clone)
const clonedNode = originalNode.clone()
expect(clonedNode).not.toBeNull()
// This should not throw - we should be able to modify outputs on a cloned node
expect(() => {
clonedNode!.removeOutput(0)
clonedNode!.addOutput('renamed', '*')
}).not.toThrow()
expect(clonedNode!.outputs).toHaveLength(1)
expect(clonedNode!.outputs[0].name).toBe('renamed')
})
})
})

View File

@@ -1,298 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { LGraphButton } from '@/lib/litegraph/src/LGraphButton'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
describe('LGraphNode Title Buttons', () => {
describe('addTitleButton', () => {
it('should add a title button to the node', () => {
const node = new LGraphNode('Test Node')
const button = node.addTitleButton({
name: 'test_button',
text: 'X',
fgColor: '#FF0000'
})
expect(button).toBeInstanceOf(LGraphButton)
expect(button.name).toBe('test_button')
expect(button.text).toBe('X')
expect(button.fgColor).toBe('#FF0000')
expect(node.title_buttons).toHaveLength(1)
expect(node.title_buttons[0]).toBe(button)
})
it('should add multiple title buttons', () => {
const node = new LGraphNode('Test Node')
const button1 = node.addTitleButton({ name: 'button1', text: 'A' })
const button2 = node.addTitleButton({ name: 'button2', text: 'B' })
const button3 = node.addTitleButton({ name: 'button3', text: 'C' })
expect(node.title_buttons).toHaveLength(3)
expect(node.title_buttons[0]).toBe(button1)
expect(node.title_buttons[1]).toBe(button2)
expect(node.title_buttons[2]).toBe(button3)
})
it('should create buttons with default options', () => {
const node = new LGraphNode('Test Node')
// @ts-expect-error TODO: Fix after merge - addTitleButton type issues
const button = node.addTitleButton({})
expect(button).toBeInstanceOf(LGraphButton)
expect(button.name).toBeUndefined()
expect(node.title_buttons).toHaveLength(1)
})
})
describe('onMouseDown with title buttons', () => {
it('should handle click on title button', () => {
const node = new LGraphNode('Test Node')
node.pos = [100, 200]
node.size = [180, 60]
const button = node.addTitleButton({
name: 'close_button',
text: 'X',
// @ts-expect-error TODO: Fix after merge - visible property not defined in type
visible: true
})
// Mock button dimensions
button.getWidth = vi.fn().mockReturnValue(20)
button.height = 16
// Simulate button being drawn to populate _last_area
// Button is drawn at node-relative coordinates
// Button x: node.size[0] - 5 - button_width = 180 - 5 - 20 = 155
// Button y: -LiteGraph.NODE_TITLE_HEIGHT = -30
button._last_area[0] = 155
button._last_area[1] = -30
button._last_area[2] = 20
button._last_area[3] = 16
const canvas = {
ctx: {} as CanvasRenderingContext2D,
dispatch: vi.fn()
} as unknown as LGraphCanvas
const event = {
canvasX: 265, // node.pos[0] + node.size[0] - 5 - button_width = 100 + 180 - 5 - 20 = 255, click in middle = 265
canvasY: 178 // node.pos[1] - LiteGraph.NODE_TITLE_HEIGHT + 8 = 200 - 30 + 8 = 178
} as any
// Calculate node-relative position for the click
const clickPosRelativeToNode: [number, number] = [
265 - node.pos[0], // 265 - 100 = 165
178 - node.pos[1] // 178 - 200 = -22
]
// Simulate the click - onMouseDown should detect button click
// @ts-expect-error TODO: Fix after merge - onMouseDown method type issues
const handled = node.onMouseDown(event, clickPosRelativeToNode, canvas)
expect(handled).toBe(true)
expect(canvas.dispatch).toHaveBeenCalledWith(
'litegraph:node-title-button-clicked',
{
node: node,
button: button
}
)
})
it('should not handle click outside title buttons', () => {
const node = new LGraphNode('Test Node')
node.pos = [100, 200]
node.size = [180, 60]
const button = node.addTitleButton({
name: 'test_button',
text: 'T',
// @ts-expect-error TODO: Fix after merge - visible property not defined in type
visible: true
})
button.getWidth = vi.fn().mockReturnValue(20)
button.height = 16
// Simulate button being drawn at node-relative coordinates
button._last_area[0] = 155 // 180 - 5 - 20
button._last_area[1] = -30 // -NODE_TITLE_HEIGHT
button._last_area[2] = 20
button._last_area[3] = 16
const canvas = {
ctx: {} as CanvasRenderingContext2D,
dispatch: vi.fn()
} as unknown as LGraphCanvas
const event = {
canvasX: 150, // Click in the middle of the node, not on button
canvasY: 180
} as any
// Calculate node-relative position
const clickPosRelativeToNode: [number, number] = [
150 - node.pos[0], // 150 - 100 = 50
180 - node.pos[1] // 180 - 200 = -20
]
// @ts-expect-error TODO: Fix after merge - onMouseDown method type issues
const handled = node.onMouseDown(event, clickPosRelativeToNode, canvas)
expect(handled).toBe(false)
expect(canvas.dispatch).not.toHaveBeenCalled()
})
it('should handle multiple buttons correctly', () => {
const node = new LGraphNode('Test Node')
node.pos = [100, 200]
node.size = [200, 60]
const button1 = node.addTitleButton({
name: 'button1',
text: 'A',
// @ts-expect-error TODO: Fix after merge - visible property not defined in type
visible: true
})
const button2 = node.addTitleButton({
name: 'button2',
text: 'B',
// @ts-expect-error TODO: Fix after merge - visible property not defined in type
visible: true
})
// Mock button dimensions
button1.getWidth = vi.fn().mockReturnValue(20)
button2.getWidth = vi.fn().mockReturnValue(20)
button1.height = button2.height = 16
// Simulate buttons being drawn at node-relative coordinates
// First button (rightmost): 200 - 5 - 20 = 175
button1._last_area[0] = 175
button1._last_area[1] = -30 // -NODE_TITLE_HEIGHT
button1._last_area[2] = 20
button1._last_area[3] = 16
// Second button: 175 - 5 - 20 = 150
button2._last_area[0] = 150
button2._last_area[1] = -30 // -NODE_TITLE_HEIGHT
button2._last_area[2] = 20
button2._last_area[3] = 16
const canvas = {
ctx: {} as CanvasRenderingContext2D,
dispatch: vi.fn()
} as unknown as LGraphCanvas
// Click on second button (leftmost, since they're right-aligned)
const titleY = 170 + 8 // node.pos[1] - NODE_TITLE_HEIGHT + 8 = 200 - 30 + 8 = 178
const event = {
canvasX: 255, // First button at: 100 + 200 - 5 - 20 = 275, Second button at: 275 - 5 - 20 = 250, click in middle = 255
canvasY: titleY
} as any
// Calculate node-relative position
const clickPosRelativeToNode: [number, number] = [
255 - node.pos[0], // 255 - 100 = 155
titleY - node.pos[1] // 178 - 200 = -22
]
// @ts-expect-error onMouseDown possibly undefined
const handled = node.onMouseDown(event, clickPosRelativeToNode, canvas)
expect(handled).toBe(true)
expect(canvas.dispatch).toHaveBeenCalledWith(
'litegraph:node-title-button-clicked',
{
node: node,
button: button2
}
)
})
it('should skip invisible buttons', () => {
const node = new LGraphNode('Test Node')
node.pos = [100, 200]
node.size = [180, 60]
const button1 = node.addTitleButton({
name: 'invisible_button',
text: '' // Empty text makes it invisible
})
const button2 = node.addTitleButton({
name: 'visible_button',
text: 'V'
})
button1.getWidth = vi.fn().mockReturnValue(20)
button2.getWidth = vi.fn().mockReturnValue(20)
button1.height = button2.height = 16
// Simulate buttons being drawn at node-relative coordinates
// Only visible button gets drawn area
button2._last_area[0] = 155 // 180 - 5 - 20
button2._last_area[1] = -30 // -NODE_TITLE_HEIGHT
button2._last_area[2] = 20
button2._last_area[3] = 16
const canvas = {
ctx: {} as CanvasRenderingContext2D,
dispatch: vi.fn()
} as unknown as LGraphCanvas
// Click where the visible button is (invisible button is skipped)
const titleY = 178 // node.pos[1] - NODE_TITLE_HEIGHT + 8 = 200 - 30 + 8 = 178
const event = {
canvasX: 265, // Visible button at: 100 + 180 - 5 - 20 = 255, click in middle = 265
canvasY: titleY
} as any
// Calculate node-relative position
const clickPosRelativeToNode: [number, number] = [
265 - node.pos[0], // 265 - 100 = 165
titleY - node.pos[1] // 178 - 200 = -22
]
// @ts-expect-error onMouseDown possibly undefined
const handled = node.onMouseDown(event, clickPosRelativeToNode, canvas)
expect(handled).toBe(true)
expect(canvas.dispatch).toHaveBeenCalledWith(
'litegraph:node-title-button-clicked',
{
node: node,
button: button2 // Should click visible button, not invisible
}
)
})
})
describe('onTitleButtonClick', () => {
it('should dispatch litegraph:node-title-button-clicked event', () => {
const node = new LGraphNode('Test Node')
// @ts-expect-error TODO: Fix after merge - LGraphButton constructor type issues
const button = new LGraphButton({ name: 'test_button' })
const canvas = {
dispatch: vi.fn()
} as unknown as LGraphCanvas
node.onTitleButtonClick(button, canvas)
expect(canvas.dispatch).toHaveBeenCalledWith(
'litegraph:node-title-button-clicked',
{
node: node,
button: button
}
)
})
})
})

View File

@@ -1,163 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphNodeProperties } from '../src/LGraphNodeProperties'
describe('LGraphNodeProperties', () => {
let mockNode: any
let mockGraph: any
beforeEach(() => {
mockGraph = {
trigger: vi.fn()
}
mockNode = {
id: 123,
title: 'Test Node',
flags: {},
graph: mockGraph
}
})
describe('constructor', () => {
it('should initialize with default tracked properties', () => {
const propManager = new LGraphNodeProperties(mockNode)
const tracked = propManager.getTrackedProperties()
expect(tracked).toHaveLength(2)
expect(tracked).toContain('title')
expect(tracked).toContain('flags.collapsed')
})
})
describe('property tracking', () => {
it('should track changes to existing properties', () => {
new LGraphNodeProperties(mockNode)
mockNode.title = 'New Title'
expect(mockGraph.trigger).toHaveBeenCalledWith('node:property:changed', {
nodeId: mockNode.id,
property: 'title',
oldValue: 'Test Node',
newValue: 'New Title'
})
})
it('should track changes to nested properties', () => {
new LGraphNodeProperties(mockNode)
mockNode.flags.collapsed = true
expect(mockGraph.trigger).toHaveBeenCalledWith('node:property:changed', {
nodeId: mockNode.id,
property: 'flags.collapsed',
oldValue: undefined,
newValue: true
})
})
it("should not emit events when value doesn't change", () => {
new LGraphNodeProperties(mockNode)
mockNode.title = 'Test Node' // Same value as original
expect(mockGraph.trigger).toHaveBeenCalledTimes(0)
})
it('should not emit events when node has no graph', () => {
mockNode.graph = null
new LGraphNodeProperties(mockNode)
// Should not throw
expect(() => {
mockNode.title = 'New Title'
}).not.toThrow()
})
})
describe('isTracked', () => {
it('should correctly identify tracked properties', () => {
const propManager = new LGraphNodeProperties(mockNode)
expect(propManager.isTracked('title')).toBe(true)
expect(propManager.isTracked('flags.collapsed')).toBe(true)
expect(propManager.isTracked('untracked')).toBe(false)
})
})
describe('serialization behavior', () => {
it('should not make non-existent properties enumerable', () => {
new LGraphNodeProperties(mockNode)
// flags.collapsed doesn't exist initially
const descriptor = Object.getOwnPropertyDescriptor(
mockNode.flags,
'collapsed'
)
expect(descriptor?.enumerable).toBe(false)
})
it('should make properties enumerable when set to non-default values', () => {
new LGraphNodeProperties(mockNode)
mockNode.flags.collapsed = true
const descriptor = Object.getOwnPropertyDescriptor(
mockNode.flags,
'collapsed'
)
expect(descriptor?.enumerable).toBe(true)
})
it('should make properties non-enumerable when set back to undefined', () => {
new LGraphNodeProperties(mockNode)
mockNode.flags.collapsed = true
mockNode.flags.collapsed = undefined
const descriptor = Object.getOwnPropertyDescriptor(
mockNode.flags,
'collapsed'
)
expect(descriptor?.enumerable).toBe(false)
})
it('should keep existing properties enumerable', () => {
// title exists initially
const initialDescriptor = Object.getOwnPropertyDescriptor(
mockNode,
'title'
)
expect(initialDescriptor?.enumerable).toBe(true)
new LGraphNodeProperties(mockNode)
const afterDescriptor = Object.getOwnPropertyDescriptor(mockNode, 'title')
expect(afterDescriptor?.enumerable).toBe(true)
})
it('should only include non-undefined values in JSON.stringify', () => {
new LGraphNodeProperties(mockNode)
// Initially, flags.collapsed shouldn't appear
let json = JSON.parse(JSON.stringify(mockNode))
expect(json.flags.collapsed).toBeUndefined()
// After setting to true, it should appear
mockNode.flags.collapsed = true
json = JSON.parse(JSON.stringify(mockNode))
expect(json.flags.collapsed).toBe(true)
// After setting to false, it should still appear (false is not undefined)
mockNode.flags.collapsed = false
json = JSON.parse(JSON.stringify(mockNode))
expect(json.flags.collapsed).toBe(false)
// After setting back to undefined, it should disappear
mockNode.flags.collapsed = undefined
json = JSON.parse(JSON.stringify(mockNode))
expect(json.flags.collapsed).toBeUndefined()
})
})
})

View File

@@ -1,18 +0,0 @@
import { describe } from 'vitest'
import { LGraph } from '@/lib/litegraph/src/litegraph'
import { dirtyTest } from './testExtensions'
describe('LGraph (constructor only)', () => {
dirtyTest(
'Matches previous snapshot',
({ expect, minimalSerialisableGraph, basicSerialisableGraph }) => {
const minLGraph = new LGraph(minimalSerialisableGraph)
expect(minLGraph).toMatchSnapshot('minLGraph')
const basicLGraph = new LGraph(basicSerialisableGraph)
expect(basicLGraph).toMatchSnapshot('basicLGraph')
}
)
})

View File

@@ -1,97 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { LGraph, LGraphNode, LLink } from '@/lib/litegraph/src/litegraph'
import { test } from './testExtensions'
describe('LLink', () => {
test('matches previous snapshot', () => {
const link = new LLink(1, 'float', 4, 2, 5, 3)
expect(link.serialize()).toMatchSnapshot('Basic')
})
test('serializes to the previous snapshot', () => {
const link = new LLink(1, 'float', 4, 2, 5, 3)
expect(link.serialize()).toMatchSnapshot('Basic')
})
describe('disconnect', () => {
it('should clear the target input link reference when disconnecting', () => {
// Create a graph and nodes
const graph = new LGraph()
const sourceNode = new LGraphNode('Source')
const targetNode = new LGraphNode('Target')
// Add nodes to graph
graph.add(sourceNode)
graph.add(targetNode)
// Add slots
sourceNode.addOutput('out', 'number')
targetNode.addInput('in', 'number')
// Connect the nodes
const link = sourceNode.connect(0, targetNode, 0)
expect(link).toBeDefined()
expect(targetNode.inputs[0].link).toBe(link?.id)
// Mock setDirtyCanvas
const setDirtyCanvasSpy = vi.spyOn(targetNode, 'setDirtyCanvas')
// Disconnect the link
link?.disconnect(graph)
// Verify the target input's link reference is cleared
expect(targetNode.inputs[0].link).toBeNull()
// Verify setDirtyCanvas was called
expect(setDirtyCanvasSpy).toHaveBeenCalledWith(true, false)
})
it('should handle disconnecting when target node is not found', () => {
// Create a link with invalid target
const graph = new LGraph()
const link = new LLink(1, 'number', 1, 0, 999, 0) // Invalid target id
// Should not throw when disconnecting
expect(() => link.disconnect(graph)).not.toThrow()
})
it('should only clear link reference if it matches the current link id', () => {
// Create a graph and nodes
const graph = new LGraph()
const sourceNode1 = new LGraphNode('Source1')
const sourceNode2 = new LGraphNode('Source2')
const targetNode = new LGraphNode('Target')
// Add nodes to graph
graph.add(sourceNode1)
graph.add(sourceNode2)
graph.add(targetNode)
// Add slots
sourceNode1.addOutput('out', 'number')
sourceNode2.addOutput('out', 'number')
targetNode.addInput('in', 'number')
// Create first connection
const link1 = sourceNode1.connect(0, targetNode, 0)
expect(link1).toBeDefined()
// Disconnect first connection
targetNode.disconnectInput(0)
// Create second connection
const link2 = sourceNode2.connect(0, targetNode, 0)
expect(link2).toBeDefined()
expect(targetNode.inputs[0].link).toBe(link2?.id)
// Try to disconnect the first link (which is already disconnected)
// It should not affect the current connection
link1?.disconnect(graph)
// The input should still have the second link
expect(targetNode.inputs[0].link).toBe(link2?.id)
})
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -1,325 +0,0 @@
import { test as baseTest, describe, expect, vi } from 'vitest'
import { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
import type { MovingInputLink } from '@/lib/litegraph/src/canvas/MovingInputLink'
import { ToInputRenderLink } from '@/lib/litegraph/src/canvas/ToInputRenderLink'
import type { LinkNetwork } from '@/lib/litegraph/src/interfaces'
import type { ISlotType } from '@/lib/litegraph/src/interfaces'
import {
LGraph,
LGraphNode,
LLink,
Reroute,
type RerouteId
} from '@/lib/litegraph/src/litegraph'
import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
interface TestContext {
network: LinkNetwork & { add(node: LGraphNode): void }
connector: LinkConnector
setConnectingLinks: ReturnType<typeof vi.fn>
createTestNode: (id: number, slotType?: ISlotType) => LGraphNode
createTestLink: (
id: number,
sourceId: number,
targetId: number,
slotType?: ISlotType
) => LLink
}
const test = baseTest.extend<TestContext>({
// eslint-disable-next-line no-empty-pattern
network: async ({}, use) => {
const graph = new LGraph()
const floatingLinks = new Map<number, LLink>()
const reroutes = new Map<number, Reroute>()
await use({
links: new Map<number, LLink>(),
reroutes,
floatingLinks,
getLink: graph.getLink.bind(graph),
getNodeById: (id: number) => graph.getNodeById(id),
addFloatingLink: (link: LLink) => {
floatingLinks.set(link.id, link)
return link
},
removeFloatingLink: (link: LLink) => floatingLinks.delete(link.id),
getReroute: ((id: RerouteId | null | undefined) =>
id == null ? undefined : reroutes.get(id)) as LinkNetwork['getReroute'],
removeReroute: (id: number) => reroutes.delete(id),
add: (node: LGraphNode) => graph.add(node)
})
},
setConnectingLinks: async (
// eslint-disable-next-line no-empty-pattern
{},
use: (mock: ReturnType<typeof vi.fn>) => Promise<void>
) => {
const mock = vi.fn()
await use(mock)
},
connector: async ({ setConnectingLinks }, use) => {
const connector = new LinkConnector(setConnectingLinks)
await use(connector)
},
createTestNode: async ({ network }, use) => {
await use((id: number): LGraphNode => {
const node = new LGraphNode('test')
node.id = id
network.add(node)
return node
})
},
createTestLink: async ({ network }, use) => {
await use(
(
id: number,
sourceId: number,
targetId: number,
slotType: ISlotType = 'number'
): LLink => {
const link = new LLink(id, slotType, sourceId, 0, targetId, 0)
network.links.set(link.id, link)
return link
}
)
}
})
describe('LinkConnector', () => {
test('should initialize with default state', ({ connector }) => {
expect(connector.state).toEqual({
connectingTo: undefined,
multi: false,
draggingExistingLinks: false
})
expect(connector.renderLinks).toEqual([])
expect(connector.inputLinks).toEqual([])
expect(connector.outputLinks).toEqual([])
expect(connector.hiddenReroutes.size).toBe(0)
})
describe('Moving Input Links', () => {
test('should handle moving input links', ({
network,
connector,
createTestNode
}) => {
const sourceNode = createTestNode(1)
const targetNode = createTestNode(2)
const slotType: ISlotType = 'number'
sourceNode.addOutput('out', slotType)
targetNode.addInput('in', slotType)
const link = new LLink(1, slotType, 1, 0, 2, 0)
network.links.set(link.id, link)
targetNode.inputs[0].link = link.id
connector.moveInputLink(network, targetNode.inputs[0])
expect(connector.state.connectingTo).toBe('input')
expect(connector.state.draggingExistingLinks).toBe(true)
expect(connector.inputLinks).toContain(link)
expect(link._dragging).toBe(true)
})
test('should not move input link if already connecting', ({
connector,
network
}) => {
connector.state.connectingTo = 'input'
expect(() => {
connector.moveInputLink(network, { link: 1 } as any)
}).toThrow('Already dragging links.')
})
})
describe('Moving Output Links', () => {
test('should handle moving output links', ({
network,
connector,
createTestNode
}) => {
const sourceNode = createTestNode(1)
const targetNode = createTestNode(2)
const slotType: ISlotType = 'number'
sourceNode.addOutput('out', slotType)
targetNode.addInput('in', slotType)
const link = new LLink(1, slotType, 1, 0, 2, 0)
network.links.set(link.id, link)
sourceNode.outputs[0].links = [link.id]
connector.moveOutputLink(network, sourceNode.outputs[0])
expect(connector.state.connectingTo).toBe('output')
expect(connector.state.draggingExistingLinks).toBe(true)
expect(connector.state.multi).toBe(true)
expect(connector.outputLinks).toContain(link)
expect(link._dragging).toBe(true)
})
test('should not move output link if already connecting', ({
connector,
network
}) => {
connector.state.connectingTo = 'output'
expect(() => {
connector.moveOutputLink(network, { links: [1] } as any)
}).toThrow('Already dragging links.')
})
})
describe('Dragging New Links', () => {
test('should handle dragging new link from output', ({
network,
connector,
createTestNode
}) => {
const sourceNode = createTestNode(1)
const slotType: ISlotType = 'number'
sourceNode.addOutput('out', slotType)
connector.dragNewFromOutput(network, sourceNode, sourceNode.outputs[0])
expect(connector.state.connectingTo).toBe('input')
expect(connector.renderLinks.length).toBe(1)
expect(connector.state.draggingExistingLinks).toBe(false)
})
test('should handle dragging new link from input', ({
network,
connector,
createTestNode
}) => {
const targetNode = createTestNode(1)
const slotType: ISlotType = 'number'
targetNode.addInput('in', slotType)
connector.dragNewFromInput(network, targetNode, targetNode.inputs[0])
expect(connector.state.connectingTo).toBe('output')
expect(connector.renderLinks.length).toBe(1)
expect(connector.state.draggingExistingLinks).toBe(false)
})
})
describe('Dragging from reroutes', () => {
test('should handle dragging from reroutes', ({
network,
connector,
createTestNode,
createTestLink
}) => {
const originNode = createTestNode(1)
const targetNode = createTestNode(2)
const output = originNode.addOutput('out', 'number')
targetNode.addInput('in', 'number')
const link = createTestLink(1, 1, 2)
const reroute = new Reroute(1, network, [0, 0], undefined, [link.id])
network.reroutes.set(reroute.id, reroute)
link.parentId = reroute.id
connector.dragFromReroute(network, reroute)
expect(connector.state.connectingTo).toBe('input')
expect(connector.state.draggingExistingLinks).toBe(false)
expect(connector.renderLinks.length).toBe(1)
const renderLink = connector.renderLinks[0]
expect(renderLink instanceof ToInputRenderLink).toBe(true)
expect(renderLink.toType).toEqual('input')
expect(renderLink.node).toEqual(originNode)
expect(renderLink.fromSlot).toEqual(output)
expect(renderLink.fromReroute).toEqual(reroute)
expect(renderLink.fromDirection).toEqual(LinkDirection.NONE)
expect(renderLink.network).toEqual(network)
})
})
describe('Reset', () => {
test('should reset state and clear links', ({ network, connector }) => {
connector.state.connectingTo = 'input'
connector.state.multi = true
connector.state.draggingExistingLinks = true
const link = new LLink(1, 'number', 1, 0, 2, 0)
link._dragging = true
connector.inputLinks.push(link)
const reroute = new Reroute(1, network)
reroute.pos = [0, 0]
reroute._dragging = true
connector.hiddenReroutes.add(reroute)
connector.reset()
expect(connector.state).toEqual({
connectingTo: undefined,
multi: false,
draggingExistingLinks: false
})
expect(connector.renderLinks).toEqual([])
expect(connector.inputLinks).toEqual([])
expect(connector.outputLinks).toEqual([])
expect(connector.hiddenReroutes.size).toBe(0)
expect(link._dragging).toBeUndefined()
expect(reroute._dragging).toBeUndefined()
})
})
describe('Event Handling', () => {
test('should handle event listeners until reset', ({
connector,
createTestNode
}) => {
const listener = vi.fn()
connector.listenUntilReset('input-moved', listener)
const sourceNode = createTestNode(1)
const mockRenderLink = {
node: sourceNode,
fromSlot: { name: 'out', type: 'number' },
fromPos: [0, 0],
fromDirection: LinkDirection.RIGHT,
toType: 'input',
link: new LLink(1, 'number', 1, 0, 2, 0)
} as MovingInputLink
connector.events.dispatch('input-moved', mockRenderLink)
expect(listener).toHaveBeenCalled()
connector.reset()
connector.events.dispatch('input-moved', mockRenderLink)
expect(listener).toHaveBeenCalledTimes(1)
})
})
describe('Export', () => {
test('should export current state', ({ network, connector }) => {
connector.state.connectingTo = 'input'
connector.state.multi = true
const link = new LLink(1, 'number', 1, 0, 2, 0)
connector.inputLinks.push(link)
const exported = connector.export(network)
expect(exported.state).toEqual(connector.state)
expect(exported.inputLinks).toEqual(connector.inputLinks)
expect(exported.outputLinks).toEqual(connector.outputLinks)
expect(exported.renderLinks).toEqual(connector.renderLinks)
expect(exported.network).toBe(network)
})
})
})

View File

@@ -1,83 +0,0 @@
import { describe, expect, it } from 'vitest'
import type {
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import {
inputAsSerialisable,
outputAsSerialisable
} from '@/lib/litegraph/src/node/slotUtils'
describe('NodeSlot', () => {
describe('inputAsSerialisable', () => {
it('removes _data from serialized slot', () => {
// @ts-expect-error Missing boundingRect property for test
const slot: INodeOutputSlot = {
_data: 'test data',
name: 'test-id',
type: 'STRING',
links: []
}
// @ts-expect-error Argument type mismatch for test
const serialized = outputAsSerialisable(slot)
expect(serialized).not.toHaveProperty('_data')
})
it('removes pos from widget input slots', () => {
const widgetInputSlot: INodeInputSlot = {
name: 'test-id',
pos: [10, 20],
type: 'STRING',
link: null,
widget: {
name: 'test-widget',
// @ts-expect-error TODO: Fix after merge - type property not in IWidgetLocator
type: 'combo',
value: 'test-value-1',
options: {
values: ['test-value-1', 'test-value-2']
}
}
}
const serialized = inputAsSerialisable(widgetInputSlot)
expect(serialized).not.toHaveProperty('pos')
})
it('preserves pos for non-widget input slots', () => {
// @ts-expect-error TODO: Fix after merge - missing boundingRect property for test
const normalSlot: INodeInputSlot = {
name: 'test-id',
type: 'STRING',
pos: [10, 20],
link: null
}
const serialized = inputAsSerialisable(normalSlot)
expect(serialized).toHaveProperty('pos')
})
it('preserves only widget name during serialization', () => {
const widgetInputSlot: INodeInputSlot = {
name: 'test-id',
type: 'STRING',
link: null,
widget: {
name: 'test-widget',
// @ts-expect-error TODO: Fix after merge - type property not in IWidgetLocator
type: 'combo',
value: 'test-value-1',
options: {
values: ['test-value-1', 'test-value-2']
}
}
}
const serialized = inputAsSerialisable(widgetInputSlot)
expect(serialized.widget).toEqual({ name: 'test-widget' })
expect(serialized.widget).not.toHaveProperty('type')
expect(serialized.widget).not.toHaveProperty('value')
expect(serialized.widget).not.toHaveProperty('options')
})
})
})

View File

@@ -1,96 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { ToOutputRenderLink } from '@/lib/litegraph/src/canvas/ToOutputRenderLink'
import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
describe('ToOutputRenderLink', () => {
describe('connectToOutput', () => {
it('should return early if inputNode is null', () => {
// Setup
const mockNetwork = {}
const mockFromSlot = {}
const mockNode = {
id: 'test-id',
inputs: [mockFromSlot],
getInputPos: vi.fn().mockReturnValue([0, 0])
}
const renderLink = new ToOutputRenderLink(
mockNetwork as any,
mockNode as any,
mockFromSlot as any,
undefined,
LinkDirection.CENTER
)
// Override the node property to simulate null case
Object.defineProperty(renderLink, 'node', {
value: null
})
const mockTargetNode = {
connectSlots: vi.fn()
}
const mockEvents = {
dispatch: vi.fn()
}
// Act
renderLink.connectToOutput(
mockTargetNode as any,
{} as any,
mockEvents as any
)
// Assert
expect(mockTargetNode.connectSlots).not.toHaveBeenCalled()
expect(mockEvents.dispatch).not.toHaveBeenCalled()
})
it('should create connection and dispatch event when inputNode exists', () => {
// Setup
const mockNetwork = {}
const mockFromSlot = {}
const mockNode = {
id: 'test-id',
inputs: [mockFromSlot],
getInputPos: vi.fn().mockReturnValue([0, 0])
}
const renderLink = new ToOutputRenderLink(
mockNetwork as any,
mockNode as any,
mockFromSlot as any,
undefined,
LinkDirection.CENTER
)
const mockNewLink = { id: 'new-link' }
const mockTargetNode = {
connectSlots: vi.fn().mockReturnValue(mockNewLink)
}
const mockEvents = {
dispatch: vi.fn()
}
// Act
renderLink.connectToOutput(
mockTargetNode as any,
{} as any,
mockEvents as any
)
// Assert
expect(mockTargetNode.connectSlots).toHaveBeenCalledWith(
expect.anything(),
mockNode,
mockFromSlot,
undefined
)
expect(mockEvents.dispatch).toHaveBeenCalledWith(
'link-created',
mockNewLink
)
})
})
})

View File

@@ -1,328 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`LGraph configure() > LGraph matches previous snapshot (normal configure() usage) > configuredBasicGraph 1`] = `
LGraph {
"_groups": [
LGraphGroup {
"_bounding": Float32Array [
20,
20,
1,
3,
],
"_children": Set {},
"_nodes": [],
"_pos": Float32Array [
20,
20,
],
"_size": Float32Array [
1,
3,
],
"color": "#6029aa",
"flags": {},
"font": undefined,
"font_size": 14,
"graph": [Circular],
"id": 123,
"isPointInside": [Function],
"selected": undefined,
"setDirtyCanvas": [Function],
"title": "A group to test with",
},
],
"_input_nodes": undefined,
"_last_trigger_time": undefined,
"_links": Map {},
"_nodes": [
LGraphNode {
"_collapsed_width": undefined,
"_level": undefined,
"_pos": Float32Array [
10,
10,
],
"_posSize": Float32Array [
10,
10,
140,
60,
],
"_relative_id": undefined,
"_shape": undefined,
"_size": Float32Array [
140,
60,
],
"action_call": undefined,
"action_triggered": undefined,
"badgePosition": "top-left",
"badges": [],
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
"changeTracker": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,
"console": undefined,
"exec_version": undefined,
"execute_triggered": undefined,
"flags": {},
"freeWidgetSpace": undefined,
"gotFocusAt": undefined,
"graph": [Circular],
"has_errors": undefined,
"id": 1,
"ignore_remove": undefined,
"inputs": [],
"last_serialization": undefined,
"locked": undefined,
"lostFocusAt": undefined,
"mode": 0,
"mouseOver": undefined,
"order": 0,
"outputs": [],
"progress": undefined,
"properties": {},
"properties_info": [],
"redraw_on_mouse": undefined,
"removable": undefined,
"resizable": undefined,
"selected": undefined,
"serialize_widgets": undefined,
"showAdvanced": undefined,
"strokeStyles": {
"error": [Function],
"selected": [Function],
},
"title": "LGraphNode",
"type": "mustBeSet",
"widgets": undefined,
"widgets_start_y": undefined,
"widgets_up": undefined,
},
],
"_nodes_by_id": {
"1": LGraphNode {
"_collapsed_width": undefined,
"_level": undefined,
"_pos": Float32Array [
10,
10,
],
"_posSize": Float32Array [
10,
10,
140,
60,
],
"_relative_id": undefined,
"_shape": undefined,
"_size": Float32Array [
140,
60,
],
"action_call": undefined,
"action_triggered": undefined,
"badgePosition": "top-left",
"badges": [],
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
"changeTracker": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,
"console": undefined,
"exec_version": undefined,
"execute_triggered": undefined,
"flags": {},
"freeWidgetSpace": undefined,
"gotFocusAt": undefined,
"graph": [Circular],
"has_errors": undefined,
"id": 1,
"ignore_remove": undefined,
"inputs": [],
"last_serialization": undefined,
"locked": undefined,
"lostFocusAt": undefined,
"mode": 0,
"mouseOver": undefined,
"order": 0,
"outputs": [],
"progress": undefined,
"properties": {},
"properties_info": [],
"redraw_on_mouse": undefined,
"removable": undefined,
"resizable": undefined,
"selected": undefined,
"serialize_widgets": undefined,
"showAdvanced": undefined,
"strokeStyles": {
"error": [Function],
"selected": [Function],
},
"title": "LGraphNode",
"type": "mustBeSet",
"widgets": undefined,
"widgets_start_y": undefined,
"widgets_up": undefined,
},
},
"_nodes_executable": [],
"_nodes_in_order": [
LGraphNode {
"_collapsed_width": undefined,
"_level": undefined,
"_pos": Float32Array [
10,
10,
],
"_posSize": Float32Array [
10,
10,
140,
60,
],
"_relative_id": undefined,
"_shape": undefined,
"_size": Float32Array [
140,
60,
],
"action_call": undefined,
"action_triggered": undefined,
"badgePosition": "top-left",
"badges": [],
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
"changeTracker": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,
"console": undefined,
"exec_version": undefined,
"execute_triggered": undefined,
"flags": {},
"freeWidgetSpace": undefined,
"gotFocusAt": undefined,
"graph": [Circular],
"has_errors": undefined,
"id": 1,
"ignore_remove": undefined,
"inputs": [],
"last_serialization": undefined,
"locked": undefined,
"lostFocusAt": undefined,
"mode": 0,
"mouseOver": undefined,
"order": 0,
"outputs": [],
"progress": undefined,
"properties": {},
"properties_info": [],
"redraw_on_mouse": undefined,
"removable": undefined,
"resizable": undefined,
"selected": undefined,
"serialize_widgets": undefined,
"showAdvanced": undefined,
"strokeStyles": {
"error": [Function],
"selected": [Function],
},
"title": "LGraphNode",
"type": "mustBeSet",
"widgets": undefined,
"widgets_start_y": undefined,
"widgets_up": undefined,
},
],
"_subgraphs": Map {},
"_version": 3,
"catch_errors": true,
"config": {},
"elapsed_time": 0.01,
"errors_in_execution": undefined,
"events": CustomEventTarget {},
"execution_time": undefined,
"execution_timer_id": undefined,
"extra": {},
"filter": undefined,
"fixedtime": 0,
"fixedtime_lapse": 0.01,
"globaltime": 0,
"id": "ca9da7d8-fddd-4707-ad32-67be9be13140",
"iteration": 0,
"last_update_time": 0,
"links": Map {},
"list_of_graphcanvas": null,
"nodes_actioning": [],
"nodes_executedAction": [],
"nodes_executing": [],
"revision": 0,
"runningtime": 0,
"starttime": 0,
"state": {
"lastGroupId": 123,
"lastLinkId": 0,
"lastNodeId": 1,
"lastRerouteId": 0,
},
"status": 1,
"vars": {},
"version": 1,
}
`;
exports[`LGraph configure() > LGraph matches previous snapshot (normal configure() usage) > configuredMinGraph 1`] = `
LGraph {
"_groups": [],
"_input_nodes": undefined,
"_last_trigger_time": undefined,
"_links": Map {},
"_nodes": [],
"_nodes_by_id": {},
"_nodes_executable": [],
"_nodes_in_order": [],
"_subgraphs": Map {},
"_version": 0,
"catch_errors": true,
"config": {},
"elapsed_time": 0.01,
"errors_in_execution": undefined,
"events": CustomEventTarget {},
"execution_time": undefined,
"execution_timer_id": undefined,
"extra": {},
"filter": undefined,
"fixedtime": 0,
"fixedtime_lapse": 0.01,
"globaltime": 0,
"id": "d175890f-716a-4ece-ba33-1d17a513b7be",
"iteration": 0,
"last_update_time": 0,
"links": Map {},
"list_of_graphcanvas": null,
"nodes_actioning": [],
"nodes_executedAction": [],
"nodes_executing": [],
"revision": 0,
"runningtime": 0,
"starttime": 0,
"state": {
"lastGroupId": 0,
"lastLinkId": 0,
"lastNodeId": 0,
"lastRerouteId": 0,
},
"status": 1,
"vars": {},
"version": 1,
}
`;

Some files were not shown because too many files have changed in this diff Show More