mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-25 17:24:07 +00:00
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:
@@ -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
22
src/base/pointerUtils.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"> </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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
17
src/components/graph/selectionToolbox/ConfigureSubgraph.vue
Normal file
17
src/components/graph/selectionToolbox/ConfigureSubgraph.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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" />'
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -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]) }
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
315
src/core/graph/subgraph/SubgraphNode.vue
Normal file
315
src/core/graph/subgraph/SubgraphNode.vue
Normal 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>
|
||||
48
src/core/graph/subgraph/SubgraphNodeWidget.vue
Normal file
48
src/core/graph/subgraph/SubgraphNodeWidget.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
138
src/core/graph/subgraph/proxyWidgetUtils.ts
Normal file
138
src/core/graph/subgraph/proxyWidgetUtils.ts
Normal 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
|
||||
}
|
||||
26
src/core/graph/subgraph/useSubgraphNodeDialog.ts
Normal file
26
src/core/graph/subgraph/useSubgraphNodeDialog.ts
Normal 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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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('')
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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} */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}].`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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')
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
Reference in New Issue
Block a user