mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-28 10:12:11 +00:00
Compare commits
19 Commits
v1.37.4
...
claude/sla
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d930514bea | ||
|
|
99cb7a2da1 | ||
|
|
b3d87673ec | ||
|
|
6a733918a7 | ||
|
|
a87d2cf1bd | ||
|
|
a1d689d3b3 | ||
|
|
dc64e16f7c | ||
|
|
c19a004f0d | ||
|
|
626d8dac70 | ||
|
|
b6a12ddae1 | ||
|
|
11f8cdb9bd | ||
|
|
dcf0886d89 | ||
|
|
ab6678534f | ||
|
|
ea3b3ceb00 | ||
|
|
2356b0bc9e | ||
|
|
dad1eafecc | ||
|
|
6e5dfc0109 | ||
|
|
43f0ac2e8f | ||
|
|
76a0b0b4b4 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.37.4",
|
||||
"version": "1.37.5",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
|
||||
@config '../../tailwind.config.ts';
|
||||
|
||||
@custom-variant touch (@media (hover: none));
|
||||
|
||||
@theme {
|
||||
--text-xxs: 0.625rem;
|
||||
--text-xxs--line-height: calc(1 / 0.625);
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex justify-center items-center shrink-0 outline-hidden border-none p-0 rounded-lg bg-secondary-background shadow-sm transition-all duration-200 cursor-pointer'
|
||||
'flex justify-center items-center shrink-0 outline-hidden border-none p-0 rounded-lg shadow-sm transition-all duration-200 cursor-pointer',
|
||||
backgroundClass || 'bg-secondary-background'
|
||||
)
|
||||
"
|
||||
>
|
||||
@@ -12,4 +13,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { backgroundClass } = defineProps<{
|
||||
backgroundClass?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="node-actions motion-safe:opacity-0 motion-safe:group-hover/tree-node:opacity-100"
|
||||
class="node-actions touch:opacity-100 motion-safe:opacity-0 motion-safe:group-hover/tree-node:opacity-100"
|
||||
>
|
||||
<slot name="actions" :node="props.node" />
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
<!-- OSS mode: Open Manager + Install All buttons -->
|
||||
<div v-else-if="showManagerButtons" class="flex justify-end gap-1 py-2 px-4">
|
||||
<Button variant="textonly" size="sm" @click="openManager">{{
|
||||
<Button variant="textonly" @click="openManager">{{
|
||||
$t('g.openManager')
|
||||
}}</Button>
|
||||
<PackInstallButton
|
||||
|
||||
@@ -49,25 +49,66 @@
|
||||
@select="selectedCredits = option.credits"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground w-96">
|
||||
{{ $t('credits.topUp.templateNote') }}
|
||||
<div class="flex flex-row items-center gap-2 group pt-2">
|
||||
<i
|
||||
class="pi pi-question-circle text-xs text-muted-foreground group-hover:text-base-foreground"
|
||||
/>
|
||||
<span
|
||||
class="text-sm font-normal text-muted-foreground cursor-pointer group-hover:text-base-foreground"
|
||||
@click="togglePopover"
|
||||
>
|
||||
{{ t('subscription.videoTemplateBasedCredits') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Buy Button -->
|
||||
<Button
|
||||
:disabled="!selectedCredits || loading"
|
||||
:loading="loading"
|
||||
variant="primary"
|
||||
:class="cn('w-full', (!selectedCredits || loading) && 'opacity-30')"
|
||||
@click="handleBuy"
|
||||
<!-- Buy Button -->
|
||||
<Button
|
||||
:disabled="!selectedCredits || loading"
|
||||
:loading="loading"
|
||||
variant="primary"
|
||||
:class="cn('w-full', (!selectedCredits || loading) && 'opacity-30')"
|
||||
@click="handleBuy"
|
||||
>
|
||||
{{ $t('credits.topUp.buy') }}
|
||||
</Button>
|
||||
</div>
|
||||
<Popover
|
||||
ref="popover"
|
||||
append-to="body"
|
||||
:auto-z-index="true"
|
||||
:base-z-index="1000"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
class:
|
||||
'rounded-lg border border-interface-stroke bg-interface-panel-surface shadow-lg p-4 max-w-xs'
|
||||
}
|
||||
}"
|
||||
>
|
||||
{{ $t('credits.topUp.buy') }}
|
||||
</Button>
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-sm text-base-foreground leading-normal">
|
||||
{{ t('subscription.videoEstimateExplanation') }}
|
||||
</p>
|
||||
<a
|
||||
href="https://cloud.comfy.org/?template=video_wan2_2_14B_fun_camera"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm text-azure-600 hover:text-azure-400 no-underline flex gap-1"
|
||||
>
|
||||
<span class="underline">
|
||||
{{ t('subscription.videoEstimateTryTemplate') }}
|
||||
</span>
|
||||
<span class="no-underline" v-html="'→'"></span>
|
||||
</a>
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Popover } from 'primevue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -101,22 +142,28 @@ const toast = useToast()
|
||||
const selectedCredits = ref<number | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const popover = ref()
|
||||
|
||||
const togglePopover = (event: Event) => {
|
||||
popover.value.toggle(event)
|
||||
}
|
||||
|
||||
const creditOptions: CreditOption[] = [
|
||||
{
|
||||
credits: 1055, // $5.00
|
||||
description: t('credits.topUp.videosEstimate', { count: 41 })
|
||||
description: t('credits.topUp.videosEstimate', { count: 30 })
|
||||
},
|
||||
{
|
||||
credits: 2110, // $10.00
|
||||
description: t('credits.topUp.videosEstimate', { count: 82 })
|
||||
description: t('credits.topUp.videosEstimate', { count: 60 })
|
||||
},
|
||||
{
|
||||
credits: 4220, // $20.00
|
||||
description: t('credits.topUp.videosEstimate', { count: 184 })
|
||||
description: t('credits.topUp.videosEstimate', { count: 120 })
|
||||
},
|
||||
{
|
||||
credits: 10550, // $50.00
|
||||
description: t('credits.topUp.videosEstimate', { count: 412 })
|
||||
description: t('credits.topUp.videosEstimate', { count: 301 })
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
v-model:light-config="lightConfig"
|
||||
:is-splat-model="isSplatModel"
|
||||
:is-ply-model="isPlyModel"
|
||||
:has-skeleton="hasSkeleton"
|
||||
@update-background-image="handleBackgroundImageUpdate"
|
||||
@export-model="handleExportModel"
|
||||
/>
|
||||
@@ -116,6 +117,7 @@ const {
|
||||
isPreview,
|
||||
isSplatModel,
|
||||
isPlyModel,
|
||||
hasSkeleton,
|
||||
hasRecording,
|
||||
recordingDuration,
|
||||
animations,
|
||||
|
||||
@@ -58,8 +58,10 @@
|
||||
v-if="showModelControls"
|
||||
v-model:material-mode="modelConfig!.materialMode"
|
||||
v-model:up-direction="modelConfig!.upDirection"
|
||||
v-model:show-skeleton="modelConfig!.showSkeleton"
|
||||
:hide-material-mode="isSplatModel"
|
||||
:is-ply-model="isPlyModel"
|
||||
:has-skeleton="hasSkeleton"
|
||||
/>
|
||||
|
||||
<CameraControls
|
||||
@@ -99,9 +101,14 @@ import type {
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { isSplatModel = false, isPlyModel = false } = defineProps<{
|
||||
const {
|
||||
isSplatModel = false,
|
||||
isPlyModel = false,
|
||||
hasSkeleton = false
|
||||
} = defineProps<{
|
||||
isSplatModel?: boolean
|
||||
isPlyModel?: boolean
|
||||
hasSkeleton?: boolean
|
||||
}>()
|
||||
|
||||
const sceneConfig = defineModel<SceneConfig>('sceneConfig')
|
||||
|
||||
@@ -70,6 +70,22 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasSkeleton">
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: t('load3d.showSkeleton'),
|
||||
showDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
:class="cn('rounded-full', showSkeleton && 'bg-blue-500')"
|
||||
:aria-label="t('load3d.showSkeleton')"
|
||||
@click="showSkeleton = !showSkeleton"
|
||||
>
|
||||
<i class="pi pi-sitemap text-lg text-white" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -84,13 +100,19 @@ import type {
|
||||
import { t } from '@/i18n'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { hideMaterialMode = false, isPlyModel = false } = defineProps<{
|
||||
const {
|
||||
hideMaterialMode = false,
|
||||
isPlyModel = false,
|
||||
hasSkeleton = false
|
||||
} = defineProps<{
|
||||
hideMaterialMode?: boolean
|
||||
isPlyModel?: boolean
|
||||
hasSkeleton?: boolean
|
||||
}>()
|
||||
|
||||
const materialMode = defineModel<MaterialMode>('materialMode')
|
||||
const upDirection = defineModel<UpDirection>('upDirection')
|
||||
const showSkeleton = defineModel<boolean>('showSkeleton')
|
||||
|
||||
const showUpDirection = ref(false)
|
||||
const showMaterialMode = ref(false)
|
||||
|
||||
@@ -52,7 +52,31 @@
|
||||
class="pb-1 px-2 2xl:px-4"
|
||||
:show-generation-time-sort="activeTab === 'output'"
|
||||
/>
|
||||
<Divider type="dashed" class="my-2" />
|
||||
<div
|
||||
v-if="isQueuePanelV2Enabled"
|
||||
class="flex items-center justify-between px-2 py-2 2xl:px-4"
|
||||
>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ activeJobsLabel }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ t('sideToolbar.queueProgressOverlay.clearQueueTooltip') }}
|
||||
</span>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="
|
||||
t('sideToolbar.queueProgressOverlay.clearQueueTooltip')
|
||||
"
|
||||
:disabled="queuedCount === 0"
|
||||
@click="handleClearQueue"
|
||||
>
|
||||
<i class="icon-[lucide--list-x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Divider v-else type="dashed" class="my-2" />
|
||||
</template>
|
||||
<template #body>
|
||||
<div v-if="loading && !displayAssets.length">
|
||||
@@ -165,7 +189,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useDebounceFn, useElementHover, useResizeObserver } from '@vueuse/core'
|
||||
import { Divider } from 'primevue'
|
||||
import Divider from 'primevue/divider'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
@@ -188,18 +212,26 @@ import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAs
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { ResultItemImpl } from '@/stores/queueStore'
|
||||
import { ResultItemImpl, useQueueStore } from '@/stores/queueStore'
|
||||
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { t, n } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const queueStore = useQueueStore()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const activeTab = ref<'input' | 'output'>('output')
|
||||
const folderPromptId = ref<string | null>(null)
|
||||
const folderExecutionTime = ref<number | undefined>(undefined)
|
||||
const isInFolderView = computed(() => folderPromptId.value !== null)
|
||||
const viewMode = ref<'list' | 'grid'>('grid')
|
||||
const isQueuePanelV2Enabled = computed(() =>
|
||||
settingStore.get('Comfy.Queue.QPOV2')
|
||||
)
|
||||
|
||||
// Track which asset's context menu is open (for single-instance context menu management)
|
||||
const openContextMenuId = ref<string | null>(null)
|
||||
@@ -228,6 +260,19 @@ const formattedExecutionTime = computed(() => {
|
||||
return formatDuration(folderExecutionTime.value * 1000)
|
||||
})
|
||||
|
||||
const queuedCount = computed(() => queueStore.pendingTasks.length)
|
||||
const activeJobsCount = computed(
|
||||
() => queueStore.pendingTasks.length + queueStore.runningTasks.length
|
||||
)
|
||||
const activeJobsLabel = computed(() => {
|
||||
const count = activeJobsCount.value
|
||||
return t(
|
||||
'sideToolbar.queueProgressOverlay.activeJobs',
|
||||
{ count: n(count) },
|
||||
count
|
||||
)
|
||||
})
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const inputAssets = useMediaAssets('input')
|
||||
@@ -492,6 +537,10 @@ const handleDeleteSelected = async () => {
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
const handleClearQueue = async () => {
|
||||
await commandStore.execute('Comfy.ClearPendingTasks')
|
||||
}
|
||||
|
||||
const handleApproachEnd = useDebounceFn(async () => {
|
||||
if (
|
||||
activeTab.value === 'output' &&
|
||||
|
||||
@@ -18,7 +18,8 @@ export const buttonVariants = cva({
|
||||
'muted-textonly':
|
||||
'text-muted-foreground bg-transparent hover:bg-secondary-background-hover',
|
||||
'destructive-textonly':
|
||||
'text-destructive-background bg-transparent hover:bg-destructive-background/10'
|
||||
'text-destructive-background bg-transparent hover:bg-destructive-background/10',
|
||||
'overlay-white': 'bg-white text-gray-600 hover:bg-white/90'
|
||||
},
|
||||
size: {
|
||||
sm: 'h-6 rounded-sm px-2 py-1 text-xs',
|
||||
@@ -44,7 +45,8 @@ const variants = [
|
||||
'destructive',
|
||||
'textonly',
|
||||
'muted-textonly',
|
||||
'destructive-textonly'
|
||||
'destructive-textonly',
|
||||
'overlay-white'
|
||||
] as const satisfies Array<ButtonVariants['variant']>
|
||||
const sizes = ['sm', 'md', 'lg', 'icon', 'icon-sm'] as const satisfies Array<
|
||||
ButtonVariants['size']
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<i :class="icon" class="text-neutral text-sm" />
|
||||
<i :class="icon" class="text-neutral text-sm shrink-0" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-2 rounded-md px-4 py-3 text-sm transition-colors text-base-foreground"
|
||||
class="flex cursor-pointer items-start gap-2 rounded-md px-4 py-3 text-sm transition-colors text-base-foreground"
|
||||
:class="
|
||||
active
|
||||
? 'bg-interface-menu-component-surface-selected'
|
||||
@@ -9,9 +9,11 @@
|
||||
role="button"
|
||||
@click="onClick"
|
||||
>
|
||||
<NavIcon v-if="icon" :icon="icon" />
|
||||
<i v-else class="text-neutral icon-[lucide--folder] text-xs" />
|
||||
<span class="flex items-center">
|
||||
<div v-if="icon" class="py-0.5">
|
||||
<NavIcon :icon="icon" />
|
||||
</div>
|
||||
<i v-else class="text-neutral icon-[lucide--folder] text-xs shrink-0" />
|
||||
<span class="flex items-center break-all">
|
||||
<slot></slot>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,8 @@ export enum ServerFeatureFlag {
|
||||
MAX_UPLOAD_SIZE = 'max_upload_size',
|
||||
MANAGER_SUPPORTS_V4 = 'extension.manager.supports_v4',
|
||||
MODEL_UPLOAD_BUTTON_ENABLED = 'model_upload_button_enabled',
|
||||
ASSET_UPDATE_OPTIONS_ENABLED = 'asset_update_options_enabled',
|
||||
ASSET_DELETION_ENABLED = 'asset_deletion_enabled',
|
||||
ASSET_RENAME_ENABLED = 'asset_rename_enabled',
|
||||
PRIVATE_MODELS_ENABLED = 'private_models_enabled',
|
||||
ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled',
|
||||
HUGGINGFACE_MODEL_IMPORT_ENABLED = 'huggingface_model_import_enabled',
|
||||
@@ -42,14 +43,16 @@ export function useFeatureFlags() {
|
||||
)
|
||||
)
|
||||
},
|
||||
get assetUpdateOptionsEnabled() {
|
||||
// Check remote config first (from /api/features), fall back to websocket feature flags
|
||||
get assetDeletionEnabled() {
|
||||
return (
|
||||
remoteConfig.value.asset_update_options_enabled ??
|
||||
api.getServerFeature(
|
||||
ServerFeatureFlag.ASSET_UPDATE_OPTIONS_ENABLED,
|
||||
false
|
||||
)
|
||||
remoteConfig.value.asset_deletion_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.ASSET_DELETION_ENABLED, false)
|
||||
)
|
||||
},
|
||||
get assetRenameEnabled() {
|
||||
return (
|
||||
remoteConfig.value.asset_rename_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.ASSET_RENAME_ENABLED, false)
|
||||
)
|
||||
},
|
||||
get privateModelsEnabled() {
|
||||
|
||||
@@ -54,7 +54,8 @@ describe('useLoad3d', () => {
|
||||
},
|
||||
'Model Config': {
|
||||
upDirection: 'original',
|
||||
materialMode: 'original'
|
||||
materialMode: 'original',
|
||||
showSkeleton: false
|
||||
},
|
||||
'Camera Config': {
|
||||
cameraType: 'perspective',
|
||||
@@ -107,6 +108,8 @@ describe('useLoad3d', () => {
|
||||
exportModel: vi.fn().mockResolvedValue(undefined),
|
||||
isSplatModel: vi.fn().mockReturnValue(false),
|
||||
isPlyModel: vi.fn().mockReturnValue(false),
|
||||
hasSkeleton: vi.fn().mockReturnValue(false),
|
||||
setShowSkeleton: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
@@ -143,7 +146,8 @@ describe('useLoad3d', () => {
|
||||
})
|
||||
expect(composable.modelConfig.value).toEqual({
|
||||
upDirection: 'original',
|
||||
materialMode: 'original'
|
||||
materialMode: 'original',
|
||||
showSkeleton: false
|
||||
})
|
||||
expect(composable.cameraConfig.value).toEqual({
|
||||
cameraType: 'perspective',
|
||||
@@ -410,7 +414,8 @@ describe('useLoad3d', () => {
|
||||
expect(mockLoad3d.setMaterialMode).toHaveBeenCalledWith('wireframe')
|
||||
expect(mockNode.properties['Model Config']).toEqual({
|
||||
upDirection: '+y',
|
||||
materialMode: 'wireframe'
|
||||
materialMode: 'wireframe',
|
||||
showSkeleton: false
|
||||
})
|
||||
})
|
||||
|
||||
@@ -696,10 +701,13 @@ describe('useLoad3d', () => {
|
||||
'backgroundImageLoadingEnd',
|
||||
'modelLoadingStart',
|
||||
'modelLoadingEnd',
|
||||
'skeletonVisibilityChange',
|
||||
'exportLoadingStart',
|
||||
'exportLoadingEnd',
|
||||
'recordingStatusChange',
|
||||
'animationListChange'
|
||||
'animationListChange',
|
||||
'animationProgressChange',
|
||||
'cameraChanged'
|
||||
]
|
||||
|
||||
expectedEvents.forEach((event) => {
|
||||
|
||||
@@ -40,9 +40,12 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
|
||||
const modelConfig = ref<ModelConfig>({
|
||||
upDirection: 'original',
|
||||
materialMode: 'original'
|
||||
materialMode: 'original',
|
||||
showSkeleton: false
|
||||
})
|
||||
|
||||
const hasSkeleton = ref(false)
|
||||
|
||||
const cameraConfig = ref<CameraConfig>({
|
||||
cameraType: 'perspective',
|
||||
fov: 75
|
||||
@@ -273,6 +276,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
nodeRef.value.properties['Model Config'] = newValue
|
||||
load3d.setUpDirection(newValue.upDirection)
|
||||
load3d.setMaterialMode(newValue.materialMode)
|
||||
load3d.setShowSkeleton(newValue.showSkeleton)
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
@@ -503,6 +507,12 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
loading.value = false
|
||||
isSplatModel.value = load3d?.isSplatModel() ?? false
|
||||
isPlyModel.value = load3d?.isPlyModel() ?? false
|
||||
hasSkeleton.value = load3d?.hasSkeleton() ?? false
|
||||
// Reset skeleton visibility when loading new model
|
||||
modelConfig.value.showSkeleton = false
|
||||
},
|
||||
skeletonVisibilityChange: (value: boolean) => {
|
||||
modelConfig.value.showSkeleton = value
|
||||
},
|
||||
exportLoadingStart: (message: string) => {
|
||||
loadingMessage.value = message || t('load3d.exportingModel')
|
||||
@@ -584,6 +594,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
isPreview,
|
||||
isSplatModel,
|
||||
isPlyModel,
|
||||
hasSkeleton,
|
||||
hasRecording,
|
||||
recordingDuration,
|
||||
animations,
|
||||
|
||||
@@ -272,4 +272,108 @@ describe('useTemplateFiltering', () => {
|
||||
'beta-pro'
|
||||
])
|
||||
})
|
||||
|
||||
it('incorporates search relevance into recommended sorting', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
const templates = ref<TemplateInfo[]>([
|
||||
{
|
||||
name: 'wan-video-exact',
|
||||
title: 'Wan Video Template',
|
||||
description: 'A template with Wan in title',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
date: '2024-01-01',
|
||||
usage: 10
|
||||
},
|
||||
{
|
||||
name: 'qwen-image-partial',
|
||||
title: 'Qwen Image Editor',
|
||||
description: 'A template that contains w, a, n scattered',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
date: '2024-01-01',
|
||||
usage: 1000 // Higher usage but worse search match
|
||||
},
|
||||
{
|
||||
name: 'wan-text-exact',
|
||||
title: 'Wan2.5: Text to Image',
|
||||
description: 'Another exact match for Wan',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
date: '2024-01-01',
|
||||
usage: 50
|
||||
}
|
||||
])
|
||||
|
||||
const { searchQuery, sortBy, filteredTemplates } =
|
||||
useTemplateFiltering(templates)
|
||||
|
||||
// Search for "Wan"
|
||||
searchQuery.value = 'Wan'
|
||||
sortBy.value = 'recommended'
|
||||
await nextTick()
|
||||
await vi.runOnlyPendingTimersAsync()
|
||||
await nextTick()
|
||||
|
||||
// Templates with "Wan" in title should rank higher than Qwen despite lower usage
|
||||
// because search relevance is now factored into the recommended sort
|
||||
const results = filteredTemplates.value.map((t) => t.name)
|
||||
|
||||
// Verify exact matches appear (Qwen might be filtered out by threshold)
|
||||
expect(results).toContain('wan-video-exact')
|
||||
expect(results).toContain('wan-text-exact')
|
||||
|
||||
// If Qwen appears, it should be ranked lower than exact matches
|
||||
if (results.includes('qwen-image-partial')) {
|
||||
const wanIndex = results.indexOf('wan-video-exact')
|
||||
const qwenIndex = results.indexOf('qwen-image-partial')
|
||||
expect(wanIndex).toBeLessThan(qwenIndex)
|
||||
}
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('preserves Fuse search order when using default sort', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
const templates = ref<TemplateInfo[]>([
|
||||
{
|
||||
name: 'portrait-basic',
|
||||
title: 'Basic Portrait',
|
||||
description: 'A basic template',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png'
|
||||
},
|
||||
{
|
||||
name: 'portrait-pro',
|
||||
title: 'Portrait Pro Edition',
|
||||
description: 'Advanced portrait features',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png'
|
||||
},
|
||||
{
|
||||
name: 'landscape-view',
|
||||
title: 'Landscape Generator',
|
||||
description: 'Generate landscapes',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png'
|
||||
}
|
||||
])
|
||||
|
||||
const { searchQuery, sortBy, filteredTemplates } =
|
||||
useTemplateFiltering(templates)
|
||||
|
||||
searchQuery.value = 'Portrait Pro'
|
||||
sortBy.value = 'default'
|
||||
await nextTick()
|
||||
await vi.runOnlyPendingTimersAsync()
|
||||
await nextTick()
|
||||
|
||||
const results = filteredTemplates.value.map((t) => t.name)
|
||||
|
||||
// With default sort, Fuse's relevance ordering is preserved
|
||||
// "Portrait Pro Edition" should be first as it's the best match
|
||||
expect(results[0]).toBe('portrait-pro')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -82,13 +82,31 @@ export function useTemplateFiltering(
|
||||
|
||||
const debouncedSearchQuery = refDebounced(searchQuery, 50)
|
||||
|
||||
const filteredBySearch = computed(() => {
|
||||
// Store Fuse search results with scores for use in sorting
|
||||
const fuseSearchResults = computed(() => {
|
||||
if (!debouncedSearchQuery.value.trim()) {
|
||||
return null
|
||||
}
|
||||
return fuse.value.search(debouncedSearchQuery.value)
|
||||
})
|
||||
|
||||
// Map of template name to search score (lower is better in Fuse, 0 = perfect match)
|
||||
const searchScoreMap = computed(() => {
|
||||
const map = new Map<string, number>()
|
||||
if (fuseSearchResults.value) {
|
||||
fuseSearchResults.value.forEach((result) => {
|
||||
// Store the score (0 = perfect match, 1 = worst match)
|
||||
map.set(result.item.name, result.score ?? 1)
|
||||
})
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const filteredBySearch = computed(() => {
|
||||
if (!fuseSearchResults.value) {
|
||||
return templatesArray.value
|
||||
}
|
||||
|
||||
const results = fuse.value.search(debouncedSearchQuery.value)
|
||||
return results.map((result) => result.item)
|
||||
return fuseSearchResults.value.map((result) => result.item)
|
||||
})
|
||||
|
||||
const filteredByModels = computed(() => {
|
||||
@@ -165,31 +183,66 @@ export function useTemplateFiltering(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Helper to get search relevance score (higher is better, 0-1 range)
|
||||
// Fuse returns scores where 0 = perfect match, 1 = worst match
|
||||
// We invert it so higher = better for combining with other scores
|
||||
const getSearchRelevance = (template: TemplateInfo): number => {
|
||||
const fuseScore = searchScoreMap.value.get(template.name)
|
||||
if (fuseScore === undefined) return 0 // Not in search results or no search
|
||||
return 1 - fuseScore // Invert: 0 (worst) -> 1 (best)
|
||||
}
|
||||
|
||||
const hasActiveSearch = computed(
|
||||
() => debouncedSearchQuery.value.trim() !== ''
|
||||
)
|
||||
|
||||
const sortedTemplates = computed(() => {
|
||||
const templates = [...filteredByRunsOn.value]
|
||||
|
||||
switch (sortBy.value) {
|
||||
case 'recommended':
|
||||
// Curated: usage × 0.5 + internal × 0.3 + freshness × 0.2
|
||||
// When searching, heavily weight search relevance
|
||||
// Formula with search: searchRelevance × 0.6 + (usage × 0.5 + internal × 0.3 + freshness × 0.2) × 0.4
|
||||
// Formula without search: usage × 0.5 + internal × 0.3 + freshness × 0.2
|
||||
return templates.sort((a, b) => {
|
||||
const scoreA = rankingStore.computeDefaultScore(
|
||||
const baseScoreA = rankingStore.computeDefaultScore(
|
||||
a.date,
|
||||
a.searchRank,
|
||||
a.usage
|
||||
)
|
||||
const scoreB = rankingStore.computeDefaultScore(
|
||||
const baseScoreB = rankingStore.computeDefaultScore(
|
||||
b.date,
|
||||
b.searchRank,
|
||||
b.usage
|
||||
)
|
||||
return scoreB - scoreA
|
||||
|
||||
if (hasActiveSearch.value) {
|
||||
const searchA = getSearchRelevance(a)
|
||||
const searchB = getSearchRelevance(b)
|
||||
const finalA = searchA * 0.6 + baseScoreA * 0.4
|
||||
const finalB = searchB * 0.6 + baseScoreB * 0.4
|
||||
return finalB - finalA
|
||||
}
|
||||
|
||||
return baseScoreB - baseScoreA
|
||||
})
|
||||
case 'popular':
|
||||
// User-driven: usage × 0.9 + freshness × 0.1
|
||||
// When searching, include search relevance
|
||||
// Formula with search: searchRelevance × 0.5 + (usage × 0.9 + freshness × 0.1) × 0.5
|
||||
// Formula without search: usage × 0.9 + freshness × 0.1
|
||||
return templates.sort((a, b) => {
|
||||
const scoreA = rankingStore.computePopularScore(a.date, a.usage)
|
||||
const scoreB = rankingStore.computePopularScore(b.date, b.usage)
|
||||
return scoreB - scoreA
|
||||
const baseScoreA = rankingStore.computePopularScore(a.date, a.usage)
|
||||
const baseScoreB = rankingStore.computePopularScore(b.date, b.usage)
|
||||
|
||||
if (hasActiveSearch.value) {
|
||||
const searchA = getSearchRelevance(a)
|
||||
const searchB = getSearchRelevance(b)
|
||||
const finalA = searchA * 0.5 + baseScoreA * 0.5
|
||||
const finalB = searchB * 0.5 + baseScoreB * 0.5
|
||||
return finalB - finalA
|
||||
}
|
||||
|
||||
return baseScoreB - baseScoreA
|
||||
})
|
||||
case 'alphabetical':
|
||||
return templates.sort((a, b) => {
|
||||
@@ -209,6 +262,12 @@ export function useTemplateFiltering(
|
||||
const vramB = getVramMetric(b)
|
||||
|
||||
if (vramA === vramB) {
|
||||
// Use search relevance as tiebreaker when searching
|
||||
if (hasActiveSearch.value) {
|
||||
const searchA = getSearchRelevance(a)
|
||||
const searchB = getSearchRelevance(b)
|
||||
if (searchA !== searchB) return searchB - searchA
|
||||
}
|
||||
const nameA = a.title || a.name || ''
|
||||
const nameB = b.title || b.name || ''
|
||||
return nameA.localeCompare(nameB)
|
||||
@@ -225,11 +284,20 @@ export function useTemplateFiltering(
|
||||
typeof a.size === 'number' ? a.size : Number.POSITIVE_INFINITY
|
||||
const sizeB =
|
||||
typeof b.size === 'number' ? b.size : Number.POSITIVE_INFINITY
|
||||
if (sizeA === sizeB) return 0
|
||||
if (sizeA === sizeB) {
|
||||
// Use search relevance as tiebreaker when searching
|
||||
if (hasActiveSearch.value) {
|
||||
const searchA = getSearchRelevance(a)
|
||||
const searchB = getSearchRelevance(b)
|
||||
if (searchA !== searchB) return searchB - searchA
|
||||
}
|
||||
return 0
|
||||
}
|
||||
return sizeA - sizeB
|
||||
})
|
||||
case 'default':
|
||||
default:
|
||||
// 'default' preserves Fuse's search order (already sorted by relevance)
|
||||
return templates
|
||||
}
|
||||
})
|
||||
|
||||
@@ -94,7 +94,7 @@ function dynamicComboWidget(
|
||||
const newSpec = value ? options[value] : undefined
|
||||
|
||||
const removedInputs = remove(node.inputs, isInGroup)
|
||||
remove(node.widgets, isInGroup)
|
||||
for (const widget of remove(node.widgets, isInGroup)) widget.onRemove?.()
|
||||
|
||||
if (!newSpec) return
|
||||
|
||||
@@ -341,10 +341,16 @@ function applyMatchType(node: LGraphNode, inputSpec: InputSpecV2) {
|
||||
//TODO: instead apply on output add?
|
||||
//ensure outputs get updated
|
||||
const index = node.inputs.length - 1
|
||||
const input = node.inputs.at(-1)!
|
||||
requestAnimationFrame(() =>
|
||||
node.onConnectionsChange(LiteGraph.INPUT, index, false, undefined, input)
|
||||
)
|
||||
requestAnimationFrame(() => {
|
||||
const input = node.inputs.at(index)!
|
||||
node.onConnectionsChange?.(
|
||||
LiteGraph.INPUT,
|
||||
index,
|
||||
!!input.link,
|
||||
input.link ? node.graph?.links?.[input.link] : undefined,
|
||||
input
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function autogrowOrdinalToName(
|
||||
@@ -482,7 +488,8 @@ function autogrowInputDisconnected(index: number, node: AutogrowNode) {
|
||||
for (const input of toRemove) {
|
||||
const widgetName = input?.widget?.name
|
||||
if (!widgetName) continue
|
||||
remove(node.widgets, (w) => w.name === widgetName)
|
||||
for (const widget of remove(node.widgets, (w) => w.name === widgetName))
|
||||
widget.onRemove?.()
|
||||
}
|
||||
node.size[1] = node.computeSize([...node.size])[1]
|
||||
}
|
||||
|
||||
@@ -196,8 +196,7 @@ export class GroupNodeConfig {
|
||||
primitiveToWidget: {}
|
||||
nodeInputs: {}
|
||||
outputVisibility: any[]
|
||||
// @ts-expect-error fixme ts strict error
|
||||
nodeDef: ComfyNodeDef
|
||||
nodeDef: (ComfyNodeDef & { [GROUP]: GroupNodeConfig }) | undefined
|
||||
// @ts-expect-error fixme ts strict error
|
||||
inputs: any[]
|
||||
// @ts-expect-error fixme ts strict error
|
||||
@@ -231,8 +230,7 @@ export class GroupNodeConfig {
|
||||
output: [],
|
||||
output_name: [],
|
||||
output_is_list: [],
|
||||
// @ts-expect-error Unused, doesn't exist
|
||||
output_is_hidden: [],
|
||||
output_node: false, // This is a lie (to satisfy the interface)
|
||||
name: source + SEPARATOR + this.name,
|
||||
display_name: this.name,
|
||||
category: 'group nodes' + (SEPARATOR + source),
|
||||
@@ -261,6 +259,7 @@ export class GroupNodeConfig {
|
||||
}
|
||||
// @ts-expect-error fixme ts strict error
|
||||
this.#convertedToProcess = null
|
||||
if (!this.nodeDef) return
|
||||
await app.registerNodeDef(`${PREFIX}${SEPARATOR}` + this.name, this.nodeDef)
|
||||
useNodeDefStore().addNodeDef(this.nodeDef)
|
||||
}
|
||||
|
||||
@@ -13,8 +13,6 @@ export class CameraManager implements CameraManagerInterface {
|
||||
orthographicCamera: THREE.OrthographicCamera
|
||||
activeCamera: THREE.Camera
|
||||
|
||||
// @ts-expect-error unused variable
|
||||
private renderer: THREE.WebGLRenderer
|
||||
private eventManager: EventManagerInterface
|
||||
|
||||
private controls: OrbitControls | null = null
|
||||
@@ -42,10 +40,9 @@ export class CameraManager implements CameraManagerInterface {
|
||||
}
|
||||
|
||||
constructor(
|
||||
renderer: THREE.WebGLRenderer,
|
||||
_renderer: THREE.WebGLRenderer,
|
||||
eventManager: EventManagerInterface
|
||||
) {
|
||||
this.renderer = renderer
|
||||
this.eventManager = eventManager
|
||||
|
||||
this.perspectiveCamera = new THREE.PerspectiveCamera(
|
||||
|
||||
@@ -156,8 +156,9 @@ class Load3DConfiguration {
|
||||
|
||||
return {
|
||||
upDirection: 'original',
|
||||
materialMode: 'original'
|
||||
} as ModelConfig
|
||||
materialMode: 'original',
|
||||
showSkeleton: false
|
||||
}
|
||||
}
|
||||
|
||||
private applySceneConfig(config: SceneConfig, bgImagePath?: string) {
|
||||
|
||||
@@ -727,6 +727,19 @@ class Load3d {
|
||||
return this.animationManager.animationClips.length > 0
|
||||
}
|
||||
|
||||
public hasSkeleton(): boolean {
|
||||
return this.modelManager.hasSkeleton()
|
||||
}
|
||||
|
||||
public setShowSkeleton(show: boolean): void {
|
||||
this.modelManager.setShowSkeleton(show)
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
public getShowSkeleton(): boolean {
|
||||
return this.modelManager.showSkeleton
|
||||
}
|
||||
|
||||
public getAnimationTime(): number {
|
||||
return this.animationManager.getAnimationTime()
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
|
||||
import { PLYLoader } from 'three/examples/jsm/loaders/PLYLoader'
|
||||
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
|
||||
import { MtlObjBridge, OBJLoader2Parallel } from 'wwobjloader2'
|
||||
import OBJLoader2WorkerUrl from 'wwobjloader2/worker?url'
|
||||
// Use pre-bundled worker module (has all dependencies included)
|
||||
// The unbundled 'wwobjloader2/worker' has ES imports that fail in production builds
|
||||
import OBJLoader2WorkerUrl from 'wwobjloader2/bundle/worker/module?url'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
@@ -27,13 +27,11 @@ export class SceneManager implements SceneManagerInterface {
|
||||
private renderer: THREE.WebGLRenderer
|
||||
|
||||
private getActiveCamera: () => THREE.Camera
|
||||
// @ts-expect-error unused variable
|
||||
private getControls: () => OrbitControls
|
||||
|
||||
constructor(
|
||||
renderer: THREE.WebGLRenderer,
|
||||
getActiveCamera: () => THREE.Camera,
|
||||
getControls: () => OrbitControls,
|
||||
_getControls: () => OrbitControls,
|
||||
eventManager: EventManagerInterface
|
||||
) {
|
||||
this.renderer = renderer
|
||||
@@ -41,7 +39,6 @@ export class SceneManager implements SceneManagerInterface {
|
||||
this.scene = new THREE.Scene()
|
||||
|
||||
this.getActiveCamera = getActiveCamera
|
||||
this.getControls = getControls
|
||||
|
||||
this.gridHelper = new THREE.GridHelper(20, 20)
|
||||
this.gridHelper.position.set(0, 0, 0)
|
||||
|
||||
@@ -30,6 +30,8 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
originalURL: string | null = null
|
||||
appliedTexture: THREE.Texture | null = null
|
||||
textureLoader: THREE.TextureLoader
|
||||
skeletonHelper: THREE.SkeletonHelper | null = null
|
||||
showSkeleton: boolean = false
|
||||
|
||||
private scene: THREE.Scene
|
||||
private renderer: THREE.WebGLRenderer
|
||||
@@ -414,9 +416,69 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
this.appliedTexture = null
|
||||
}
|
||||
|
||||
if (this.skeletonHelper) {
|
||||
this.scene.remove(this.skeletonHelper)
|
||||
this.skeletonHelper.dispose()
|
||||
this.skeletonHelper = null
|
||||
}
|
||||
this.showSkeleton = false
|
||||
|
||||
this.originalMaterials = new WeakMap()
|
||||
}
|
||||
|
||||
hasSkeleton(): boolean {
|
||||
if (!this.currentModel) return false
|
||||
let found = false
|
||||
this.currentModel.traverse((child) => {
|
||||
if (child instanceof THREE.SkinnedMesh && child.skeleton) {
|
||||
found = true
|
||||
}
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
setShowSkeleton(show: boolean): void {
|
||||
this.showSkeleton = show
|
||||
|
||||
if (show) {
|
||||
if (!this.skeletonHelper && this.currentModel) {
|
||||
let rootBone: THREE.Bone | null = null
|
||||
this.currentModel.traverse((child) => {
|
||||
if (child instanceof THREE.Bone && !rootBone) {
|
||||
if (!(child.parent instanceof THREE.Bone)) {
|
||||
rootBone = child
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (rootBone) {
|
||||
this.skeletonHelper = new THREE.SkeletonHelper(rootBone)
|
||||
this.scene.add(this.skeletonHelper)
|
||||
} else {
|
||||
let skinnedMesh: THREE.SkinnedMesh | null = null
|
||||
this.currentModel.traverse((child) => {
|
||||
if (child instanceof THREE.SkinnedMesh && !skinnedMesh) {
|
||||
skinnedMesh = child
|
||||
}
|
||||
})
|
||||
|
||||
if (skinnedMesh) {
|
||||
this.skeletonHelper = new THREE.SkeletonHelper(skinnedMesh)
|
||||
this.scene.add(this.skeletonHelper)
|
||||
}
|
||||
}
|
||||
} else if (this.skeletonHelper) {
|
||||
this.skeletonHelper.visible = true
|
||||
}
|
||||
} else {
|
||||
if (this.skeletonHelper) {
|
||||
this.skeletonHelper.visible = false
|
||||
}
|
||||
}
|
||||
|
||||
this.eventManager.emitEvent('skeletonVisibilityChange', show)
|
||||
}
|
||||
|
||||
addModelToScene(model: THREE.Object3D): void {
|
||||
this.currentModel = model
|
||||
model.name = 'MainModel'
|
||||
|
||||
@@ -14,16 +14,13 @@ export class ViewHelperManager implements ViewHelperManagerInterface {
|
||||
private getActiveCamera: () => THREE.Camera
|
||||
private getControls: () => OrbitControls
|
||||
private eventManager: EventManagerInterface
|
||||
// @ts-expect-error unused variable
|
||||
private renderer: THREE.WebGLRenderer
|
||||
|
||||
constructor(
|
||||
renderer: THREE.WebGLRenderer,
|
||||
_renderer: THREE.WebGLRenderer,
|
||||
getActiveCamera: () => THREE.Camera,
|
||||
getControls: () => OrbitControls,
|
||||
eventManager: EventManagerInterface
|
||||
) {
|
||||
this.renderer = renderer
|
||||
this.getActiveCamera = getActiveCamera
|
||||
this.getControls = getControls
|
||||
this.eventManager = eventManager
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface SceneConfig {
|
||||
export interface ModelConfig {
|
||||
upDirection: UpDirection
|
||||
materialMode: MaterialMode
|
||||
showSkeleton: boolean
|
||||
}
|
||||
|
||||
export interface CameraConfig {
|
||||
|
||||
@@ -5,16 +5,14 @@ import { LGraphButton, Rectangle } from '@/lib/litegraph/src/litegraph'
|
||||
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({})
|
||||
const button = new LGraphButton({ text: '' })
|
||||
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' })
|
||||
const button = new LGraphButton({ text: '', name: 'test_button' })
|
||||
expect(button.name).toBe('test_button')
|
||||
})
|
||||
|
||||
@@ -158,9 +156,8 @@ describe('LGraphButton', () => {
|
||||
const button = new LGraphButton({
|
||||
text: '→',
|
||||
fontSize: 20,
|
||||
// @ts-expect-error TODO: Fix after merge - color property not defined in type
|
||||
color: '#FFFFFF',
|
||||
backgroundColor: '#333333',
|
||||
fgColor: '#FFFFFF',
|
||||
bgColor: '#333333',
|
||||
xOffset: -10,
|
||||
yOffset: 5
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
LGraph,
|
||||
LGraphCanvas,
|
||||
LGraphNode,
|
||||
LiteGraph
|
||||
@@ -46,8 +47,8 @@ describe('LGraphCanvas Title Button Rendering', () => {
|
||||
|
||||
canvasElement.getContext = vi.fn().mockReturnValue(ctx)
|
||||
|
||||
// @ts-expect-error TODO: Fix after merge - LGraphCanvas constructor type issues
|
||||
canvas = new LGraphCanvas(canvasElement, null, {
|
||||
const graph = new LGraph()
|
||||
canvas = new LGraphCanvas(canvasElement, graph, {
|
||||
skip_render: true,
|
||||
skip_events: true
|
||||
})
|
||||
@@ -56,18 +57,9 @@ describe('LGraphCanvas Title Button Rendering', () => {
|
||||
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()
|
||||
@@ -75,24 +67,31 @@ describe('LGraphCanvas Title Button Rendering', () => {
|
||||
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)
|
||||
|
||||
const nodeWithMocks = node as LGraphNode & {
|
||||
drawTitleBarText: ReturnType<typeof vi.fn>
|
||||
drawToggles: ReturnType<typeof vi.fn>
|
||||
drawNodeShape: ReturnType<typeof vi.fn>
|
||||
drawContent: ReturnType<typeof vi.fn>
|
||||
isSelectable: ReturnType<typeof vi.fn>
|
||||
}
|
||||
nodeWithMocks.drawTitleBarText = vi.fn()
|
||||
nodeWithMocks.drawToggles = vi.fn()
|
||||
nodeWithMocks.drawNodeShape = vi.fn()
|
||||
nodeWithMocks.drawContent = vi.fn()
|
||||
nodeWithMocks.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
|
||||
text: 'A'
|
||||
})
|
||||
|
||||
const button2 = node.addTitleButton({
|
||||
name: 'button2',
|
||||
text: 'B',
|
||||
// @ts-expect-error TODO: Fix after merge - visible property not in LGraphButtonOptions
|
||||
visible: true
|
||||
text: 'B'
|
||||
})
|
||||
|
||||
// Mock button methods
|
||||
@@ -127,9 +126,7 @@ describe('LGraphCanvas Title Button Rendering', () => {
|
||||
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
|
||||
text: 'V'
|
||||
})
|
||||
|
||||
const invisibleButton = node.addTitleButton({
|
||||
@@ -171,9 +168,7 @@ describe('LGraphCanvas Title Button Rendering', () => {
|
||||
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
|
||||
text: String(i)
|
||||
})
|
||||
button.getWidth = vi.fn().mockReturnValue(15) // All same width for simplicity
|
||||
const spy = vi.spyOn(button, 'draw')
|
||||
@@ -196,18 +191,12 @@ describe('LGraphCanvas Title Button Rendering', () => {
|
||||
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
|
||||
text: 'T'
|
||||
})
|
||||
|
||||
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
|
||||
@@ -219,16 +208,12 @@ describe('LGraphCanvas Title Button Rendering', () => {
|
||||
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
|
||||
text: 'S'
|
||||
})
|
||||
|
||||
const largeButton = node.addTitleButton({
|
||||
name: 'large',
|
||||
text: 'LARGE',
|
||||
// @ts-expect-error TODO: Fix after merge - visible property not in LGraphButtonOptions
|
||||
visible: true
|
||||
text: 'LARGE'
|
||||
})
|
||||
|
||||
smallButton.getWidth = vi.fn().mockReturnValue(15)
|
||||
@@ -256,9 +241,7 @@ describe('LGraphCanvas Title Button Rendering', () => {
|
||||
|
||||
const button = node.addTitleButton({
|
||||
name: 'test',
|
||||
text: 'X',
|
||||
// @ts-expect-error TODO: Fix after merge - visible property not in LGraphButtonOptions
|
||||
visible: true
|
||||
text: 'X'
|
||||
})
|
||||
|
||||
button.getWidth = vi.fn().mockReturnValue(20)
|
||||
|
||||
@@ -969,10 +969,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
static onGroupAdd(
|
||||
// @ts-expect-error - unused parameter
|
||||
info: unknown,
|
||||
// @ts-expect-error - unused parameter
|
||||
entry: unknown,
|
||||
_info: unknown,
|
||||
_entry: unknown,
|
||||
mouse_event: MouseEvent
|
||||
): void {
|
||||
const canvas = LGraphCanvas.active_canvas
|
||||
@@ -1020,10 +1018,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
static onNodeAlign(
|
||||
// @ts-expect-error - unused parameter
|
||||
value: IContextMenuValue,
|
||||
// @ts-expect-error - unused parameter
|
||||
options: IContextMenuOptions,
|
||||
_value: IContextMenuValue,
|
||||
_options: IContextMenuOptions,
|
||||
event: MouseEvent,
|
||||
prev_menu: ContextMenu<string>,
|
||||
node: LGraphNode
|
||||
@@ -1046,10 +1042,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
static onGroupAlign(
|
||||
// @ts-expect-error - unused parameter
|
||||
value: IContextMenuValue,
|
||||
// @ts-expect-error - unused parameter
|
||||
options: IContextMenuOptions,
|
||||
_value: IContextMenuValue,
|
||||
_options: IContextMenuOptions,
|
||||
event: MouseEvent,
|
||||
prev_menu: ContextMenu<string>
|
||||
): void {
|
||||
@@ -1070,10 +1064,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
static createDistributeMenu(
|
||||
// @ts-expect-error - unused parameter
|
||||
value: IContextMenuValue,
|
||||
// @ts-expect-error - unused parameter
|
||||
options: IContextMenuOptions,
|
||||
_value: IContextMenuValue,
|
||||
_options: IContextMenuOptions,
|
||||
event: MouseEvent,
|
||||
prev_menu: ContextMenu<string>
|
||||
): void {
|
||||
@@ -1095,16 +1087,13 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
static onMenuAdd(
|
||||
// @ts-expect-error - unused parameter
|
||||
value: unknown,
|
||||
// @ts-expect-error - unused parameter
|
||||
options: unknown,
|
||||
_value: unknown,
|
||||
_options: unknown,
|
||||
e: MouseEvent,
|
||||
prev_menu?: ContextMenu<string>,
|
||||
callback?: (node: LGraphNode | null) => void
|
||||
): boolean | undefined {
|
||||
const canvas = LGraphCanvas.active_canvas
|
||||
const ref_window = canvas.getCanvasWindow()
|
||||
const { graph } = canvas
|
||||
if (!graph) return
|
||||
|
||||
@@ -1155,14 +1144,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
value: category_path,
|
||||
content: name,
|
||||
has_submenu: true,
|
||||
callback: function (
|
||||
value,
|
||||
// @ts-expect-error - unused parameter
|
||||
event,
|
||||
// @ts-expect-error - unused parameter
|
||||
mouseEvent,
|
||||
contextMenu
|
||||
) {
|
||||
callback: function (value, _event, _mouseEvent, contextMenu) {
|
||||
inner_onMenuAdded(value.value, contextMenu)
|
||||
}
|
||||
})
|
||||
@@ -1181,14 +1163,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
value: node.type,
|
||||
content: node.title,
|
||||
has_submenu: false,
|
||||
callback: function (
|
||||
value,
|
||||
// @ts-expect-error - unused parameter
|
||||
event,
|
||||
// @ts-expect-error - unused parameter
|
||||
mouseEvent,
|
||||
contextMenu
|
||||
) {
|
||||
callback: function (value, _event, _mouseEvent, contextMenu) {
|
||||
if (!canvas.graph) throw new NullGraphError()
|
||||
|
||||
const first_event = contextMenu.getFirstEvent()
|
||||
@@ -1213,12 +1188,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
entries.push(entry)
|
||||
}
|
||||
|
||||
new LiteGraph.ContextMenu(
|
||||
entries,
|
||||
{ event: e, parentMenu: prev_menu },
|
||||
// @ts-expect-error - extra parameter
|
||||
ref_window
|
||||
)
|
||||
new LiteGraph.ContextMenu(entries, { event: e, parentMenu: prev_menu })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1227,8 +1197,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
/** @param _options Parameter is never used */
|
||||
static showMenuNodeOptionalOutputs(
|
||||
// @ts-expect-error - unused parameter
|
||||
v: unknown,
|
||||
_v: unknown,
|
||||
/** Unused - immediately overwritten */
|
||||
_options: INodeOutputSlot[],
|
||||
e: MouseEvent,
|
||||
@@ -1312,8 +1281,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
/** @param value Parameter is never used */
|
||||
static onShowMenuNodeProperties(
|
||||
value: NodeProperty | undefined,
|
||||
// @ts-expect-error - unused parameter
|
||||
options: unknown,
|
||||
_options: unknown,
|
||||
e: MouseEvent,
|
||||
prev_menu: ContextMenu<string>,
|
||||
node: LGraphNode
|
||||
@@ -1321,7 +1289,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
if (!node || !node.properties) return
|
||||
|
||||
const canvas = LGraphCanvas.active_canvas
|
||||
const ref_window = canvas.getCanvasWindow()
|
||||
|
||||
const entries: IContextMenuValue<string>[] = []
|
||||
for (const i in node.properties) {
|
||||
@@ -1344,23 +1311,20 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
return
|
||||
}
|
||||
|
||||
new LiteGraph.ContextMenu<string>(
|
||||
entries,
|
||||
{
|
||||
event: e,
|
||||
callback: inner_clicked,
|
||||
parentMenu: prev_menu,
|
||||
allow_html: true,
|
||||
node
|
||||
},
|
||||
// @ts-expect-error Unused
|
||||
ref_window
|
||||
)
|
||||
new LiteGraph.ContextMenu<string>(entries, {
|
||||
event: e,
|
||||
callback: inner_clicked,
|
||||
parentMenu: prev_menu,
|
||||
node
|
||||
})
|
||||
|
||||
function inner_clicked(this: ContextMenuDivElement, v: { value: any }) {
|
||||
if (!node) return
|
||||
function inner_clicked(
|
||||
this: ContextMenu<string>,
|
||||
v?: string | IContextMenuValue<string>
|
||||
) {
|
||||
if (!node || typeof v === 'string' || !v?.value) return
|
||||
|
||||
const rect = this.getBoundingClientRect()
|
||||
const rect = this.root.getBoundingClientRect()
|
||||
canvas.showEditPropertyValue(node, v.value, {
|
||||
position: [rect.left, rect.top]
|
||||
})
|
||||
@@ -1377,14 +1341,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
static onMenuResizeNode(
|
||||
// @ts-expect-error - unused parameter
|
||||
value: IContextMenuValue,
|
||||
// @ts-expect-error - unused parameter
|
||||
options: IContextMenuOptions,
|
||||
// @ts-expect-error - unused parameter
|
||||
e: MouseEvent,
|
||||
// @ts-expect-error - unused parameter
|
||||
menu: ContextMenu,
|
||||
_value: IContextMenuValue,
|
||||
_options: IContextMenuOptions,
|
||||
_e: MouseEvent,
|
||||
_menu: ContextMenu,
|
||||
node: LGraphNode
|
||||
): void {
|
||||
if (!node) return
|
||||
@@ -1411,11 +1371,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
// TODO refactor :: this is used fot title but not for properties!
|
||||
static onShowPropertyEditor(
|
||||
item: { property: keyof LGraphNode; type: string },
|
||||
// @ts-expect-error - unused parameter
|
||||
options: IContextMenuOptions<string>,
|
||||
_options: IContextMenuOptions<string>,
|
||||
e: MouseEvent,
|
||||
// @ts-expect-error - unused parameter
|
||||
menu: ContextMenu<string>,
|
||||
_menu: ContextMenu<string>,
|
||||
node: LGraphNode
|
||||
): void {
|
||||
const property = item.property || 'title'
|
||||
@@ -1485,11 +1443,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
input.focus()
|
||||
|
||||
let dialogCloseTimer: number
|
||||
let dialogCloseTimer: ReturnType<typeof setTimeout> | undefined
|
||||
dialog.addEventListener('mouseleave', function () {
|
||||
if (LiteGraph.dialog_close_on_mouse_leave) {
|
||||
if (!dialog.is_modified && LiteGraph.dialog_close_on_mouse_leave) {
|
||||
// @ts-expect-error - setTimeout type
|
||||
dialogCloseTimer = setTimeout(
|
||||
dialog.close,
|
||||
LiteGraph.dialog_close_on_mouse_leave_delay
|
||||
@@ -1544,14 +1501,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
static onMenuNodeCollapse(
|
||||
// @ts-expect-error - unused parameter
|
||||
value: IContextMenuValue,
|
||||
// @ts-expect-error - unused parameter
|
||||
options: IContextMenuOptions,
|
||||
// @ts-expect-error - unused parameter
|
||||
e: MouseEvent,
|
||||
// @ts-expect-error - unused parameter
|
||||
menu: ContextMenu,
|
||||
_value: IContextMenuValue,
|
||||
_options: IContextMenuOptions,
|
||||
_e: MouseEvent,
|
||||
_menu: ContextMenu,
|
||||
node: LGraphNode
|
||||
): void {
|
||||
if (!node.graph) throw new NullGraphError()
|
||||
@@ -1578,14 +1531,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
static onMenuToggleAdvanced(
|
||||
// @ts-expect-error - unused parameter
|
||||
value: IContextMenuValue,
|
||||
// @ts-expect-error - unused parameter
|
||||
options: IContextMenuOptions,
|
||||
// @ts-expect-error - unused parameter
|
||||
e: MouseEvent,
|
||||
// @ts-expect-error - unused parameter
|
||||
menu: ContextMenu,
|
||||
_value: IContextMenuValue,
|
||||
_options: IContextMenuOptions,
|
||||
_e: MouseEvent,
|
||||
_menu: ContextMenu,
|
||||
node: LGraphNode
|
||||
): void {
|
||||
if (!node.graph) throw new NullGraphError()
|
||||
@@ -1610,10 +1559,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
static onMenuNodeMode(
|
||||
// @ts-expect-error - unused parameter
|
||||
value: IContextMenuValue,
|
||||
// @ts-expect-error - unused parameter
|
||||
options: IContextMenuOptions,
|
||||
_value: IContextMenuValue,
|
||||
_options: IContextMenuOptions,
|
||||
e: MouseEvent,
|
||||
menu: ContextMenu,
|
||||
node: LGraphNode
|
||||
@@ -1657,8 +1604,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
/** @param value Parameter is never used */
|
||||
static onMenuNodeColors(
|
||||
value: IContextMenuValue<string | null>,
|
||||
// @ts-expect-error - unused parameter
|
||||
options: IContextMenuOptions,
|
||||
_options: IContextMenuOptions,
|
||||
e: MouseEvent,
|
||||
menu: ContextMenu<string | null>,
|
||||
node: LGraphNode
|
||||
@@ -1719,10 +1665,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
|
||||
static onMenuNodeShapes(
|
||||
// @ts-expect-error - unused parameter
|
||||
value: IContextMenuValue<(typeof LiteGraph.VALID_SHAPES)[number]>,
|
||||
// @ts-expect-error - unused parameter
|
||||
options: IContextMenuOptions<(typeof LiteGraph.VALID_SHAPES)[number]>,
|
||||
_value: IContextMenuValue<(typeof LiteGraph.VALID_SHAPES)[number]>,
|
||||
_options: IContextMenuOptions<(typeof LiteGraph.VALID_SHAPES)[number]>,
|
||||
e: MouseEvent,
|
||||
menu?: ContextMenu<(typeof LiteGraph.VALID_SHAPES)[number]>,
|
||||
node?: LGraphNode
|
||||
@@ -3596,13 +3540,16 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
this.node_over?.onMouseUp?.(
|
||||
e,
|
||||
[x - this.node_over.pos[0], y - this.node_over.pos[1]],
|
||||
// @ts-expect-error - extra parameter
|
||||
this
|
||||
)
|
||||
this.node_capturing_input?.onMouseUp?.(e, [
|
||||
x - this.node_capturing_input.pos[0],
|
||||
y - this.node_capturing_input.pos[1]
|
||||
])
|
||||
this.node_capturing_input?.onMouseUp?.(
|
||||
e,
|
||||
[
|
||||
x - this.node_capturing_input.pos[0],
|
||||
y - this.node_capturing_input.pos[1]
|
||||
],
|
||||
this
|
||||
)
|
||||
}
|
||||
} else if (e.button === 1) {
|
||||
// middle button
|
||||
@@ -4599,9 +4546,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
/**
|
||||
* converts a coordinate from graph coordinates to canvas2D coordinates
|
||||
*/
|
||||
convertOffsetToCanvas(pos: Point, out: Point): Point {
|
||||
// @ts-expect-error Unused param
|
||||
return this.ds.convertOffsetToCanvas(pos, out)
|
||||
convertOffsetToCanvas(pos: Point, _out?: Point): Point {
|
||||
return this.ds.convertOffsetToCanvas(pos)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -6144,11 +6090,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
/**
|
||||
* draws every group area in the background
|
||||
*/
|
||||
drawGroups(
|
||||
// @ts-expect-error - unused parameter
|
||||
canvas: HTMLCanvasElement,
|
||||
ctx: CanvasRenderingContext2D
|
||||
): void {
|
||||
drawGroups(_canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D): void {
|
||||
if (!this.graph) return
|
||||
|
||||
const groups = this.graph._groups
|
||||
@@ -6242,8 +6184,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
function inner_clicked(
|
||||
this: LGraphCanvas,
|
||||
v: string,
|
||||
// @ts-expect-error - unused parameter
|
||||
options: unknown,
|
||||
_options: unknown,
|
||||
e: MouseEvent
|
||||
) {
|
||||
if (!graph) throw new NullGraphError()
|
||||
@@ -6762,13 +6703,12 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
if (this.ds.scale > 1) dialog.style.transform = `scale(${this.ds.scale})`
|
||||
|
||||
let dialogCloseTimer: number
|
||||
let dialogCloseTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let prevent_timeout = 0
|
||||
LiteGraph.pointerListenerAdd(dialog, 'leave', function () {
|
||||
if (prevent_timeout) return
|
||||
if (LiteGraph.dialog_close_on_mouse_leave) {
|
||||
if (!dialog.is_modified && LiteGraph.dialog_close_on_mouse_leave) {
|
||||
// @ts-expect-error - setTimeout type
|
||||
dialogCloseTimer = setTimeout(
|
||||
dialog.close,
|
||||
LiteGraph.dialog_close_on_mouse_leave_delay
|
||||
@@ -6957,7 +6897,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
if (options.hide_on_mouse_leave) {
|
||||
// FIXME: Remove "any" kludge
|
||||
let prevent_timeout: any = false
|
||||
let timeout_close: number | null = null
|
||||
let timeout_close: ReturnType<typeof setTimeout> | null = null
|
||||
LiteGraph.pointerListenerAdd(dialog, 'enter', function () {
|
||||
if (timeout_close) {
|
||||
clearTimeout(timeout_close)
|
||||
@@ -6969,7 +6909,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
const hideDelay = options.hide_on_mouse_leave
|
||||
const delay = typeof hideDelay === 'number' ? hideDelay : 500
|
||||
// @ts-expect-error - setTimeout type
|
||||
timeout_close = setTimeout(dialog.close, delay)
|
||||
})
|
||||
// if filtering, check focus changed to comboboxes and prevent closing
|
||||
@@ -7005,7 +6944,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
that.search_box = dialog
|
||||
|
||||
let first: string | null = null
|
||||
let timeout: number | null = null
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
let selected: ChildNode | null = null
|
||||
|
||||
const maybeInput = dialog.querySelector('input')
|
||||
@@ -7039,7 +6978,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
if (timeout) {
|
||||
clearInterval(timeout)
|
||||
}
|
||||
// @ts-expect-error - setTimeout type
|
||||
timeout = setTimeout(refreshHelper, 10)
|
||||
return
|
||||
}
|
||||
@@ -7314,9 +7252,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
options.show_general_after_typefiltered &&
|
||||
(sIn.value || sOut.value)
|
||||
) {
|
||||
// FIXME: Undeclared variable again
|
||||
// @ts-expect-error Variable declared without type annotation
|
||||
filtered_extra = []
|
||||
const filtered_extra: string[] = []
|
||||
for (const i in LiteGraph.registered_node_types) {
|
||||
if (
|
||||
inner_test_filter(i, {
|
||||
@@ -7324,11 +7260,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
outTypeOverride: sOut && sOut.value ? '*' : false
|
||||
})
|
||||
) {
|
||||
// @ts-expect-error Variable declared without type annotation
|
||||
filtered_extra.push(i)
|
||||
}
|
||||
}
|
||||
// @ts-expect-error Variable declared without type annotation
|
||||
for (const extraItem of filtered_extra) {
|
||||
addResult(extraItem, 'generic_type')
|
||||
if (
|
||||
@@ -7345,14 +7279,11 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
helper.childNodes.length == 0 &&
|
||||
options.show_general_if_none_on_typefilter
|
||||
) {
|
||||
// @ts-expect-error Variable declared without type annotation
|
||||
filtered_extra = []
|
||||
const filtered_extra: string[] = []
|
||||
for (const i in LiteGraph.registered_node_types) {
|
||||
if (inner_test_filter(i, { skipFilter: true }))
|
||||
// @ts-expect-error Variable declared without type annotation
|
||||
filtered_extra.push(i)
|
||||
}
|
||||
// @ts-expect-error Variable declared without type annotation
|
||||
for (const extraItem of filtered_extra) {
|
||||
addResult(extraItem, 'not_in_filter')
|
||||
if (
|
||||
@@ -7647,13 +7578,12 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
}
|
||||
}
|
||||
|
||||
let dialogCloseTimer: number
|
||||
let dialogCloseTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let prevent_timeout = 0
|
||||
dialog.addEventListener('mouseleave', function () {
|
||||
if (prevent_timeout) return
|
||||
|
||||
if (!dialog.is_modified && LiteGraph.dialog_close_on_mouse_leave) {
|
||||
// @ts-expect-error - setTimeout type
|
||||
dialogCloseTimer = setTimeout(
|
||||
dialog.close,
|
||||
LiteGraph.dialog_close_on_mouse_leave_delay
|
||||
@@ -7687,7 +7617,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
createPanel(title: string, options: ICreatePanelOptions) {
|
||||
options = options || {}
|
||||
|
||||
const ref_window = options.window || window
|
||||
// TODO: any kludge
|
||||
const root: any = document.createElement('div')
|
||||
root.className = 'litegraph dialog'
|
||||
@@ -7865,16 +7794,12 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
innerChange(propname, v)
|
||||
return false
|
||||
}
|
||||
new LiteGraph.ContextMenu(
|
||||
values,
|
||||
{
|
||||
event,
|
||||
className: 'dark',
|
||||
callback: inner_clicked
|
||||
},
|
||||
// @ts-expect-error ref_window parameter unused in ContextMenu constructor
|
||||
ref_window
|
||||
)
|
||||
new LiteGraph.ContextMenu(values, {
|
||||
event,
|
||||
className: 'dark',
|
||||
// @ts-expect-error fixme ts strict error - callback signature mismatch
|
||||
callback: inner_clicked
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8194,14 +8119,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
{
|
||||
content: 'Properties Panel',
|
||||
callback: function (
|
||||
// @ts-expect-error - unused parameter
|
||||
item: any,
|
||||
// @ts-expect-error - unused parameter
|
||||
options: any,
|
||||
// @ts-expect-error - unused parameter
|
||||
e: any,
|
||||
// @ts-expect-error - unused parameter
|
||||
menu: any,
|
||||
_item: any,
|
||||
_options: any,
|
||||
_e: any,
|
||||
_menu: any,
|
||||
node: LGraphNode
|
||||
) {
|
||||
LGraphCanvas.active_canvas.showShowNodePanel(node)
|
||||
@@ -8312,9 +8233,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
node: LGraphNode | undefined,
|
||||
event: CanvasPointerEvent
|
||||
): void {
|
||||
const canvas = LGraphCanvas.active_canvas
|
||||
const ref_window = canvas.getCanvasWindow()
|
||||
|
||||
// TODO: Remove type kludge
|
||||
let menu_info: (IContextMenuValue | string | null)[]
|
||||
const options: IContextMenuOptions = {
|
||||
@@ -8428,8 +8346,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
// show menu
|
||||
if (!menu_info) return
|
||||
|
||||
// @ts-expect-error Remove param ref_window - unused
|
||||
new LiteGraph.ContextMenu(menu_info, options, ref_window)
|
||||
new LiteGraph.ContextMenu(menu_info, options)
|
||||
|
||||
const createDialog = (options: IDialogOptions) =>
|
||||
this.createDialog(
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('LGraphNode', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
origLiteGraph = Object.assign({}, LiteGraph)
|
||||
// @ts-expect-error TODO: Fix after merge - Classes property not in type
|
||||
// @ts-expect-error Intended: Force remove an otherwise readonly non-optional property
|
||||
delete origLiteGraph.Classes
|
||||
|
||||
Object.assign(LiteGraph, {
|
||||
|
||||
@@ -35,11 +35,10 @@ describe('LGraphNode Title Buttons', () => {
|
||||
expect(node.title_buttons[2]).toBe(button3)
|
||||
})
|
||||
|
||||
it('should create buttons with default options', () => {
|
||||
it('should create buttons with minimal options', () => {
|
||||
const node = new LGraphNode('Test Node')
|
||||
|
||||
// @ts-expect-error TODO: Fix after merge - addTitleButton type issues
|
||||
const button = node.addTitleButton({})
|
||||
const button = node.addTitleButton({ text: '' })
|
||||
|
||||
expect(button).toBeInstanceOf(LGraphButton)
|
||||
expect(button.name).toBeUndefined()
|
||||
@@ -55,9 +54,7 @@ describe('LGraphNode Title Buttons', () => {
|
||||
|
||||
const button = node.addTitleButton({
|
||||
name: 'close_button',
|
||||
text: 'X',
|
||||
// @ts-expect-error TODO: Fix after merge - visible property not defined in type
|
||||
visible: true
|
||||
text: 'X'
|
||||
})
|
||||
|
||||
// Mock button methods
|
||||
@@ -112,9 +109,7 @@ describe('LGraphNode Title Buttons', () => {
|
||||
|
||||
const button = node.addTitleButton({
|
||||
name: 'test_button',
|
||||
text: 'T',
|
||||
// @ts-expect-error TODO: Fix after merge - visible property not defined in type
|
||||
visible: true
|
||||
text: 'T'
|
||||
})
|
||||
|
||||
button.getWidth = vi.fn().mockReturnValue(20)
|
||||
@@ -164,16 +159,12 @@ describe('LGraphNode Title Buttons', () => {
|
||||
|
||||
const button1 = node.addTitleButton({
|
||||
name: 'button1',
|
||||
text: 'A',
|
||||
// @ts-expect-error TODO: Fix after merge - visible property not defined in type
|
||||
visible: true
|
||||
text: 'A'
|
||||
})
|
||||
|
||||
const button2 = node.addTitleButton({
|
||||
name: 'button2',
|
||||
text: 'B',
|
||||
// @ts-expect-error TODO: Fix after merge - visible property not defined in type
|
||||
visible: true
|
||||
text: 'B'
|
||||
})
|
||||
|
||||
// Mock button methods
|
||||
@@ -297,8 +288,7 @@ describe('LGraphNode Title Buttons', () => {
|
||||
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 button = new LGraphButton({ name: 'test_button', text: 'X' })
|
||||
|
||||
const canvas = {
|
||||
dispatch: vi.fn()
|
||||
|
||||
@@ -679,7 +679,12 @@ export class LGraphNode
|
||||
this: LGraphNode,
|
||||
entries: (IContextMenuValue<INodeSlotContextItem> | null)[]
|
||||
): (IContextMenuValue<INodeSlotContextItem> | null)[]
|
||||
onMouseUp?(this: LGraphNode, e: CanvasPointerEvent, pos: Point): void
|
||||
onMouseUp?(
|
||||
this: LGraphNode,
|
||||
e: CanvasPointerEvent,
|
||||
pos: Point,
|
||||
canvas: LGraphCanvas
|
||||
): void
|
||||
onMouseEnter?(this: LGraphNode, e: CanvasPointerEvent): void
|
||||
/** Blocks drag if return value is truthy. @param pos Offset from {@link LGraphNode.pos}. */
|
||||
onMouseDown?(
|
||||
@@ -2769,8 +2774,7 @@ export class LGraphNode
|
||||
!LiteGraph.allow_multi_output_for_events
|
||||
) {
|
||||
graph.beforeChange()
|
||||
// @ts-expect-error Unused param
|
||||
this.disconnectOutput(slot, false, { doProcessChange: false })
|
||||
this.disconnectOutput(slot)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
ISerialisedGraph,
|
||||
ISerialisedNode,
|
||||
SerialisableGraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
@@ -19,12 +20,7 @@ export const oldSchemaGraph: ISerialisedGraph = {
|
||||
title: 'A group to test with'
|
||||
}
|
||||
],
|
||||
nodes: [
|
||||
// @ts-expect-error TODO: Fix after merge - missing required properties for test
|
||||
{
|
||||
id: 1
|
||||
}
|
||||
],
|
||||
nodes: [{ id: 1 } as Partial<ISerialisedNode> as ISerialisedNode],
|
||||
links: []
|
||||
}
|
||||
|
||||
@@ -65,11 +61,7 @@ export const basicSerialisableGraph: SerialisableGraph = {
|
||||
}
|
||||
],
|
||||
nodes: [
|
||||
// @ts-expect-error TODO: Fix after merge - missing required properties for test
|
||||
{
|
||||
id: 1,
|
||||
type: 'mustBeSet'
|
||||
}
|
||||
{ id: 1, type: 'mustBeSet' } as Partial<ISerialisedNode> as ISerialisedNode
|
||||
],
|
||||
links: []
|
||||
}
|
||||
|
||||
@@ -338,6 +338,7 @@ export interface INodeFlags {
|
||||
*/
|
||||
export interface IWidgetLocator {
|
||||
name: string
|
||||
type?: string
|
||||
}
|
||||
|
||||
export interface INodeInputSlot extends INodeSlot {
|
||||
|
||||
@@ -8,16 +8,19 @@ import {
|
||||
inputAsSerialisable,
|
||||
outputAsSerialisable
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
|
||||
|
||||
const boundingRect: ReadOnlyRect = [0, 0, 10, 10]
|
||||
|
||||
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: []
|
||||
links: [],
|
||||
boundingRect
|
||||
}
|
||||
// @ts-expect-error Argument type mismatch for test
|
||||
const serialized = outputAsSerialisable(slot)
|
||||
@@ -25,20 +28,14 @@ describe('NodeSlot', () => {
|
||||
})
|
||||
|
||||
it('removes pos from widget input slots', () => {
|
||||
// Minimal slot for serialization test - boundingRect is calculated at runtime, not serialized
|
||||
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']
|
||||
}
|
||||
}
|
||||
widget: { name: 'test-widget', type: 'combo' },
|
||||
boundingRect
|
||||
}
|
||||
|
||||
const serialized = inputAsSerialisable(widgetInputSlot)
|
||||
@@ -46,30 +43,27 @@ describe('NodeSlot', () => {
|
||||
})
|
||||
|
||||
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
|
||||
link: null,
|
||||
boundingRect
|
||||
}
|
||||
const serialized = inputAsSerialisable(normalSlot)
|
||||
expect(serialized).toHaveProperty('pos')
|
||||
})
|
||||
|
||||
it('preserves only widget name during serialization', () => {
|
||||
// Extra widget properties simulate real data that should be stripped during serialization
|
||||
const widgetInputSlot: INodeInputSlot = {
|
||||
name: 'test-id',
|
||||
type: 'STRING',
|
||||
link: null,
|
||||
boundingRect,
|
||||
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']
|
||||
}
|
||||
type: 'combo'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -86,10 +86,8 @@ describe.skip('ExecutableNodeDTO Creation', () => {
|
||||
|
||||
expect(dto.applyToGraph).toBeDefined()
|
||||
|
||||
// Test that wrapper calls original method
|
||||
const args = ['arg1', 'arg2']
|
||||
// @ts-expect-error TODO: Fix after merge - applyToGraph expects different arguments
|
||||
dto.applyToGraph!(args[0], args[1])
|
||||
;(dto.applyToGraph as (...args: unknown[]) => void)(args[0], args[1])
|
||||
|
||||
expect(mockApplyToGraph).toHaveBeenCalledWith(args[0], args[1])
|
||||
})
|
||||
|
||||
@@ -185,14 +185,10 @@ describe.skip('Subgraph Serialization', () => {
|
||||
|
||||
expect(serialized.inputs).toHaveLength(1)
|
||||
expect(serialized.outputs).toHaveLength(1)
|
||||
// @ts-expect-error TODO: Fix after merge - serialized.inputs possibly undefined
|
||||
expect(serialized.inputs[0].name).toBe('input')
|
||||
// @ts-expect-error TODO: Fix after merge - serialized.inputs possibly undefined
|
||||
expect(serialized.inputs[0].type).toBe('number')
|
||||
// @ts-expect-error TODO: Fix after merge - serialized.outputs possibly undefined
|
||||
expect(serialized.outputs[0].name).toBe('output')
|
||||
// @ts-expect-error TODO: Fix after merge - serialized.outputs possibly undefined
|
||||
expect(serialized.outputs[0].type).toBe('number')
|
||||
expect(serialized.inputs![0].name).toBe('input')
|
||||
expect(serialized.inputs![0].type).toBe('number')
|
||||
expect(serialized.outputs![0].name).toBe('output')
|
||||
expect(serialized.outputs![0].type).toBe('number')
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -350,8 +350,7 @@ describe.skip('SubgraphEdgeCases - Performance and Scale', () => {
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
// Simulate concurrent operations
|
||||
// @ts-expect-error TODO: Fix after merge - operations implicitly has any[] type
|
||||
const operations = []
|
||||
const operations: Array<() => void> = []
|
||||
for (let i = 0; i < 20; i++) {
|
||||
operations.push(
|
||||
() => {
|
||||
@@ -371,7 +370,6 @@ describe.skip('SubgraphEdgeCases - Performance and Scale', () => {
|
||||
|
||||
// Execute all operations - should not crash
|
||||
expect(() => {
|
||||
// @ts-expect-error TODO: Fix after merge - operations implicitly has any[] type
|
||||
for (const op of operations) op()
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
@@ -22,7 +22,6 @@ describe.skip('SubgraphEvents - Event Payload Verification', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// @ts-expect-error TODO: Fix after merge - detail is of type unknown
|
||||
expect(addedEvents[0].detail.input).toBe(input)
|
||||
}
|
||||
)
|
||||
@@ -44,7 +43,6 @@ describe.skip('SubgraphEvents - Event Payload Verification', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// @ts-expect-error TODO: Fix after merge - detail is of type unknown
|
||||
expect(addedEvents[0].detail.output).toBe(output)
|
||||
}
|
||||
)
|
||||
@@ -71,7 +69,6 @@ describe.skip('SubgraphEvents - Event Payload Verification', () => {
|
||||
index: 0
|
||||
})
|
||||
|
||||
// @ts-expect-error TODO: Fix after merge - detail is of type unknown
|
||||
expect(removingEvents[0].detail.input).toBe(input)
|
||||
}
|
||||
)
|
||||
@@ -98,7 +95,6 @@ describe.skip('SubgraphEvents - Event Payload Verification', () => {
|
||||
index: 0
|
||||
})
|
||||
|
||||
// @ts-expect-error TODO: Fix after merge - detail is of type unknown
|
||||
expect(removingEvents[0].detail.output).toBe(output)
|
||||
}
|
||||
)
|
||||
@@ -126,7 +122,6 @@ describe.skip('SubgraphEvents - Event Payload Verification', () => {
|
||||
newName: 'new_name'
|
||||
})
|
||||
|
||||
// @ts-expect-error TODO: Fix after merge - detail is of type unknown
|
||||
expect(renamingEvents[0].detail.input).toBe(input)
|
||||
|
||||
// Verify the label was updated after the event (renameInput sets label, not name)
|
||||
@@ -160,7 +155,6 @@ describe.skip('SubgraphEvents - Event Payload Verification', () => {
|
||||
newName: 'new_name'
|
||||
})
|
||||
|
||||
// @ts-expect-error TODO: Fix after merge - detail is of type unknown
|
||||
expect(renamingEvents[0].detail.output).toBe(output)
|
||||
|
||||
// Verify the label was updated after the event
|
||||
|
||||
@@ -28,8 +28,7 @@ describe('SubgraphIO - Input Slot Dual-Nature Behavior', () => {
|
||||
}).not.toThrow()
|
||||
|
||||
expect(
|
||||
// @ts-expect-error TODO: Fix after merge - link can be null
|
||||
externalNode.outputs[0].links?.includes(subgraphNode.inputs[0].link)
|
||||
externalNode.outputs[0].links?.includes(subgraphNode.inputs[0].link!)
|
||||
).toBe(true)
|
||||
expect(subgraphNode.inputs[0].link).not.toBe(null)
|
||||
}
|
||||
@@ -47,10 +46,8 @@ describe('SubgraphIO - Input Slot Dual-Nature Behavior', () => {
|
||||
expect(simpleSubgraph.inputs.length).toBe(initialInputCount + 1)
|
||||
|
||||
// The empty slot should be configurable
|
||||
const emptyInput = simpleSubgraph.inputs.at(-1)
|
||||
// @ts-expect-error TODO: Fix after merge - emptyInput possibly undefined
|
||||
const emptyInput = simpleSubgraph.inputs.at(-1)!
|
||||
expect(emptyInput.name).toBe('')
|
||||
// @ts-expect-error TODO: Fix after merge - emptyInput possibly undefined
|
||||
expect(emptyInput.type).toBe('*')
|
||||
}
|
||||
)
|
||||
@@ -149,8 +146,7 @@ describe('SubgraphIO - Output Slot Dual-Nature Behavior', () => {
|
||||
}).not.toThrow()
|
||||
|
||||
expect(
|
||||
// @ts-expect-error TODO: Fix after merge - link can be null
|
||||
subgraphNode.outputs[0].links?.includes(externalNode.inputs[0].link)
|
||||
subgraphNode.outputs[0].links?.includes(externalNode.inputs[0].link!)
|
||||
).toBe(true)
|
||||
expect(externalNode.inputs[0].link).not.toBe(null)
|
||||
}
|
||||
@@ -168,10 +164,8 @@ describe('SubgraphIO - Output Slot Dual-Nature Behavior', () => {
|
||||
expect(simpleSubgraph.outputs.length).toBe(initialOutputCount + 1)
|
||||
|
||||
// The empty slot should be configurable
|
||||
const emptyOutput = simpleSubgraph.outputs.at(-1)
|
||||
// @ts-expect-error TODO: Fix after merge - emptyOutput possibly undefined
|
||||
const emptyOutput = simpleSubgraph.outputs.at(-1)!
|
||||
expect(emptyOutput.name).toBe('')
|
||||
// @ts-expect-error TODO: Fix after merge - emptyOutput possibly undefined
|
||||
expect(emptyOutput.type).toBe('*')
|
||||
}
|
||||
)
|
||||
@@ -454,15 +448,11 @@ describe('SubgraphIO - Empty Slot Connection', () => {
|
||||
|
||||
// 3. A link should be established inside the subgraph
|
||||
expect(internalNode.inputs[0].link).not.toBe(null)
|
||||
const link = subgraph.links.get(internalNode.inputs[0].link!)
|
||||
const link = subgraph.links.get(internalNode.inputs[0].link!)!
|
||||
expect(link).toBeDefined()
|
||||
// @ts-expect-error TODO: Fix after merge - link possibly undefined
|
||||
expect(link.target_id).toBe(internalNode.id)
|
||||
// @ts-expect-error TODO: Fix after merge - link possibly undefined
|
||||
expect(link.target_slot).toBe(0)
|
||||
// @ts-expect-error TODO: Fix after merge - link possibly undefined
|
||||
expect(link.origin_id).toBe(subgraph.inputNode.id)
|
||||
// @ts-expect-error TODO: Fix after merge - link possibly undefined
|
||||
expect(link.origin_slot).toBe(1) // Should be the second slot
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import { subgraphTest } from './__fixtures__/subgraphFixtures'
|
||||
import {
|
||||
@@ -72,10 +73,10 @@ describe.skip('SubgraphNode Memory Management', () => {
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
// @ts-expect-error TODO: Fix after merge - properties not in ExportedSubgraphInstance
|
||||
properties: {},
|
||||
flags: {},
|
||||
mode: 0
|
||||
mode: 0,
|
||||
order: 0
|
||||
})
|
||||
}
|
||||
|
||||
@@ -93,12 +94,13 @@ describe.skip('SubgraphNode Memory Management', () => {
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
// Simulate widget promotion scenario
|
||||
const input = subgraphNode.inputs[0]
|
||||
const mockWidget = {
|
||||
type: 'number',
|
||||
name: 'promoted_widget',
|
||||
value: 123,
|
||||
options: {},
|
||||
y: 0,
|
||||
draw: vi.fn(),
|
||||
mouse: vi.fn(),
|
||||
computeSize: vi.fn(),
|
||||
@@ -107,21 +109,16 @@ describe.skip('SubgraphNode Memory Management', () => {
|
||||
name: 'promoted_widget',
|
||||
value: 123
|
||||
})
|
||||
}
|
||||
} as Partial<IWidget> as IWidget
|
||||
|
||||
// Simulate widget promotion
|
||||
// @ts-expect-error TODO: Fix after merge - mockWidget type mismatch
|
||||
input._widget = mockWidget
|
||||
input.widget = { name: 'promoted_widget' }
|
||||
// @ts-expect-error TODO: Fix after merge - mockWidget type mismatch
|
||||
subgraphNode.widgets.push(mockWidget)
|
||||
|
||||
expect(input._widget).toBe(mockWidget)
|
||||
expect(input.widget).toBeDefined()
|
||||
expect(subgraphNode.widgets).toContain(mockWidget)
|
||||
|
||||
// Remove widget (this should clean up references)
|
||||
// @ts-expect-error TODO: Fix after merge - mockWidget type mismatch
|
||||
subgraphNode.removeWidget(mockWidget)
|
||||
|
||||
// Widget should be removed from array
|
||||
@@ -146,10 +143,10 @@ describe.skip('SubgraphNode Memory Management', () => {
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
// @ts-expect-error TODO: Fix after merge - properties not in ExportedSubgraphInstance
|
||||
properties: {},
|
||||
flags: {},
|
||||
mode: 0
|
||||
mode: 0,
|
||||
order: 0
|
||||
})
|
||||
}
|
||||
|
||||
@@ -328,17 +325,26 @@ describe.skip('SubgraphMemory - Widget Reference Management', () => {
|
||||
|
||||
const initialWidgetCount = subgraphNode.widgets?.length || 0
|
||||
|
||||
// Add mock widgets
|
||||
const widget1 = { type: 'number', value: 1, name: 'widget1' }
|
||||
const widget2 = { type: 'string', value: 'test', name: 'widget2' }
|
||||
const widget1 = {
|
||||
type: 'number',
|
||||
value: 1,
|
||||
name: 'widget1',
|
||||
options: {},
|
||||
y: 0
|
||||
} as Partial<IWidget> as IWidget
|
||||
const widget2 = {
|
||||
type: 'string',
|
||||
value: 'test',
|
||||
name: 'widget2',
|
||||
options: {},
|
||||
y: 0
|
||||
} as Partial<IWidget> as IWidget
|
||||
|
||||
if (subgraphNode.widgets) {
|
||||
// @ts-expect-error TODO: Fix after merge - widget type mismatch
|
||||
subgraphNode.widgets.push(widget1, widget2)
|
||||
expect(subgraphNode.widgets.length).toBe(initialWidgetCount + 2)
|
||||
}
|
||||
|
||||
// Remove widgets
|
||||
if (subgraphNode.widgets) {
|
||||
subgraphNode.widgets.length = initialWidgetCount
|
||||
expect(subgraphNode.widgets.length).toBe(initialWidgetCount)
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { subgraphTest } from './__fixtures__/subgraphFixtures'
|
||||
@@ -210,10 +211,10 @@ describe.skip('SubgraphNode Lifecycle', () => {
|
||||
size: [180, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
// @ts-expect-error TODO: Fix after merge - properties not in ExportedSubgraphInstance
|
||||
properties: {},
|
||||
flags: {},
|
||||
mode: 0
|
||||
mode: 0,
|
||||
order: 0
|
||||
})
|
||||
|
||||
// Should reflect updated subgraph structure
|
||||
@@ -542,8 +543,6 @@ describe.skip('SubgraphNode Cleanup', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const subgraph = createTestSubgraph()
|
||||
|
||||
// Add and remove nodes multiple times
|
||||
// @ts-expect-error TODO: Fix after merge - SubgraphNode should be Subgraph
|
||||
const removedNodes: SubgraphNode[] = []
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const node = createTestSubgraphNode(subgraph)
|
||||
|
||||
@@ -28,6 +28,7 @@ import type {
|
||||
} from '@/lib/litegraph/src/types/serialisation'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import { AssetWidget } from '@/lib/litegraph/src/widgets/AssetWidget'
|
||||
import { toConcreteWidget } from '@/lib/litegraph/src/widgets/widgetMap'
|
||||
|
||||
import { ExecutableNodeDTO } from './ExecutableNodeDTO'
|
||||
@@ -333,6 +334,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
const promotedWidget = toConcreteWidget(widget, this).createCopyForNode(
|
||||
this
|
||||
)
|
||||
if (widget instanceof AssetWidget)
|
||||
promotedWidget.options.nodeType ??= widget.node.type
|
||||
|
||||
Object.assign(promotedWidget, {
|
||||
get name() {
|
||||
|
||||
@@ -134,9 +134,7 @@ describe.skip('SubgraphWidgetPromotion', () => {
|
||||
// Check event was fired
|
||||
const promotedEvents = eventCapture.getEventsByType('widget-promoted')
|
||||
expect(promotedEvents).toHaveLength(1)
|
||||
// @ts-expect-error Object is of type 'unknown'
|
||||
expect(promotedEvents[0].detail.widget).toBeDefined()
|
||||
// @ts-expect-error Object is of type 'unknown'
|
||||
expect(promotedEvents[0].detail.subgraphNode).toBe(subgraphNode)
|
||||
|
||||
eventCapture.cleanup()
|
||||
@@ -161,9 +159,7 @@ describe.skip('SubgraphWidgetPromotion', () => {
|
||||
// Check event was fired
|
||||
const demotedEvents = eventCapture.getEventsByType('widget-demoted')
|
||||
expect(demotedEvents).toHaveLength(1)
|
||||
// @ts-expect-error Object is of type 'unknown'
|
||||
expect(demotedEvents[0].detail.widget).toBeDefined()
|
||||
// @ts-expect-error Object is of type 'unknown'
|
||||
expect(demotedEvents[0].detail.subgraphNode).toBe(subgraphNode)
|
||||
|
||||
// Widget should be removed
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
import type { Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphEventMap } from '@/lib/litegraph/src/infrastructure/SubgraphEventMap'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
|
||||
import { test } from '../../__fixtures__/testExtensions'
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from './subgraphHelpers'
|
||||
import type { EventCapture } from './subgraphHelpers'
|
||||
|
||||
interface SubgraphFixtures {
|
||||
/** A minimal subgraph with no inputs, outputs, or nodes */
|
||||
@@ -41,7 +43,7 @@ interface SubgraphFixtures {
|
||||
/** Event capture system for testing subgraph events */
|
||||
eventCapture: {
|
||||
subgraph: Subgraph
|
||||
capture: ReturnType<typeof createEventCapture>
|
||||
capture: EventCapture<SubgraphEventMap>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,9 +61,7 @@ interface SubgraphFixtures {
|
||||
* ```
|
||||
*/
|
||||
export const subgraphTest = test.extend<SubgraphFixtures>({
|
||||
// @ts-expect-error TODO: Fix after merge - fixture use parameter type
|
||||
|
||||
emptySubgraph: async ({}, use: (value: unknown) => Promise<void>) => {
|
||||
emptySubgraph: async ({}, use) => {
|
||||
const subgraph = createTestSubgraph({
|
||||
name: 'Empty Test Subgraph',
|
||||
inputCount: 0,
|
||||
@@ -72,9 +72,7 @@ export const subgraphTest = test.extend<SubgraphFixtures>({
|
||||
await use(subgraph)
|
||||
},
|
||||
|
||||
// @ts-expect-error TODO: Fix after merge - fixture use parameter type
|
||||
|
||||
simpleSubgraph: async ({}, use: (value: unknown) => Promise<void>) => {
|
||||
simpleSubgraph: async ({}, use) => {
|
||||
const subgraph = createTestSubgraph({
|
||||
name: 'Simple Test Subgraph',
|
||||
inputs: [{ name: 'input', type: 'number' }],
|
||||
@@ -85,9 +83,7 @@ export const subgraphTest = test.extend<SubgraphFixtures>({
|
||||
await use(subgraph)
|
||||
},
|
||||
|
||||
// @ts-expect-error TODO: Fix after merge - fixture use parameter type
|
||||
|
||||
complexSubgraph: async ({}, use: (value: unknown) => Promise<void>) => {
|
||||
complexSubgraph: async ({}, use) => {
|
||||
const subgraph = createTestSubgraph({
|
||||
name: 'Complex Test Subgraph',
|
||||
inputs: [
|
||||
@@ -105,9 +101,7 @@ export const subgraphTest = test.extend<SubgraphFixtures>({
|
||||
await use(subgraph)
|
||||
},
|
||||
|
||||
// @ts-expect-error TODO: Fix after merge - fixture use parameter type
|
||||
|
||||
nestedSubgraph: async ({}, use: (value: unknown) => Promise<void>) => {
|
||||
nestedSubgraph: async ({}, use) => {
|
||||
const nested = createNestedSubgraphs({
|
||||
depth: 3,
|
||||
nodesPerLevel: 2,
|
||||
@@ -118,10 +112,7 @@ export const subgraphTest = test.extend<SubgraphFixtures>({
|
||||
await use(nested)
|
||||
},
|
||||
|
||||
// @ts-expect-error TODO: Fix after merge - fixture use parameter type
|
||||
|
||||
subgraphWithNode: async ({}, use: (value: unknown) => Promise<void>) => {
|
||||
// Create the subgraph definition
|
||||
subgraphWithNode: async ({}, use) => {
|
||||
const subgraph = createTestSubgraph({
|
||||
name: 'Subgraph With Node',
|
||||
inputs: [{ name: 'input', type: '*' }],
|
||||
@@ -129,14 +120,12 @@ export const subgraphTest = test.extend<SubgraphFixtures>({
|
||||
nodeCount: 1
|
||||
})
|
||||
|
||||
// Create the parent graph and subgraph node instance
|
||||
const parentGraph = new LGraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, {
|
||||
pos: [200, 200],
|
||||
size: [180, 80]
|
||||
})
|
||||
|
||||
// Add the subgraph node to the parent graph
|
||||
parentGraph.add(subgraphNode)
|
||||
|
||||
await use({
|
||||
@@ -146,15 +135,12 @@ export const subgraphTest = test.extend<SubgraphFixtures>({
|
||||
})
|
||||
},
|
||||
|
||||
// @ts-expect-error TODO: Fix after merge - fixture use parameter type
|
||||
|
||||
eventCapture: async ({}, use: (value: unknown) => Promise<void>) => {
|
||||
eventCapture: async ({}, use) => {
|
||||
const subgraph = createTestSubgraph({
|
||||
name: 'Event Test Subgraph'
|
||||
})
|
||||
|
||||
// Set up event capture for all subgraph events
|
||||
const capture = createEventCapture(subgraph.events, [
|
||||
const capture = createEventCapture<SubgraphEventMap>(subgraph.events, [
|
||||
'adding-input',
|
||||
'input-added',
|
||||
'removing-input',
|
||||
@@ -167,7 +153,6 @@ export const subgraphTest = test.extend<SubgraphFixtures>({
|
||||
|
||||
await use({ subgraph, capture })
|
||||
|
||||
// Cleanup event listeners
|
||||
capture.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -55,6 +55,16 @@ interface CapturedEvent<T = unknown> {
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
/** Return type for createEventCapture with typed getEventsByType */
|
||||
export interface EventCapture<TEventMap extends object> {
|
||||
events: CapturedEvent<TEventMap[keyof TEventMap]>[]
|
||||
clear: () => void
|
||||
cleanup: () => void
|
||||
getEventsByType: <K extends keyof TEventMap & string>(
|
||||
type: K
|
||||
) => CapturedEvent<TEventMap[K]>[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a test subgraph with specified inputs, outputs, and nodes.
|
||||
* This is the primary function for creating subgraphs in tests.
|
||||
@@ -91,34 +101,35 @@ export function createTestSubgraph(
|
||||
}
|
||||
const rootGraph = new LGraph()
|
||||
|
||||
// Create the base subgraph data
|
||||
const subgraphData: ExportedSubgraph = {
|
||||
// Basic graph properties
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: {
|
||||
lastNodeId: 0,
|
||||
lastLinkId: 0,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
nodes: [],
|
||||
// @ts-expect-error TODO: Fix after merge - links type mismatch
|
||||
links: {},
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
definitions: { subgraphs: [] },
|
||||
|
||||
// Subgraph-specific properties
|
||||
id: options.id || createUuidv4(),
|
||||
name: options.name || 'Test Subgraph',
|
||||
|
||||
// IO Nodes (required for subgraph functionality)
|
||||
inputNode: {
|
||||
id: -10, // SUBGRAPH_INPUT_ID
|
||||
bounding: [10, 100, 150, 126], // [x, y, width, height]
|
||||
id: -10,
|
||||
bounding: [10, 100, 150, 126],
|
||||
pinned: false
|
||||
},
|
||||
outputNode: {
|
||||
id: -20, // SUBGRAPH_OUTPUT_ID
|
||||
bounding: [400, 100, 140, 126], // [x, y, width, height]
|
||||
id: -20,
|
||||
bounding: [400, 100, 140, 126],
|
||||
pinned: false
|
||||
},
|
||||
|
||||
// IO definitions - will be populated by addInput/addOutput calls
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
widgets: []
|
||||
@@ -127,11 +138,9 @@ export function createTestSubgraph(
|
||||
// Create the subgraph
|
||||
const subgraph = new Subgraph(rootGraph, subgraphData)
|
||||
|
||||
// Add requested inputs
|
||||
if (options.inputs) {
|
||||
for (const input of options.inputs) {
|
||||
// @ts-expect-error TODO: Fix after merge - addInput parameter types
|
||||
subgraph.addInput(input.name, input.type)
|
||||
subgraph.addInput(input.name, String(input.type))
|
||||
}
|
||||
} else if (options.inputCount) {
|
||||
for (let i = 0; i < options.inputCount; i++) {
|
||||
@@ -139,11 +148,9 @@ export function createTestSubgraph(
|
||||
}
|
||||
}
|
||||
|
||||
// Add requested outputs
|
||||
if (options.outputs) {
|
||||
for (const output of options.outputs) {
|
||||
// @ts-expect-error TODO: Fix after merge - addOutput parameter types
|
||||
subgraph.addOutput(output.name, output.type)
|
||||
subgraph.addOutput(output.name, String(output.type))
|
||||
}
|
||||
} else if (options.outputCount) {
|
||||
for (let i = 0; i < options.outputCount; i++) {
|
||||
@@ -193,10 +200,10 @@ export function createTestSubgraphNode(
|
||||
size: options.size || [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
// @ts-expect-error TODO: Fix after merge - properties type mismatch
|
||||
properties: {},
|
||||
flags: {},
|
||||
mode: 0
|
||||
mode: 0,
|
||||
order: 0
|
||||
}
|
||||
|
||||
return new SubgraphNode(parentGraph, subgraph, instanceData)
|
||||
@@ -237,18 +244,11 @@ export function createNestedSubgraphs(options: NestedSubgraphOptions = {}) {
|
||||
|
||||
subgraphs.push(subgraph)
|
||||
|
||||
// Create instance in parent
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, {
|
||||
pos: [100 + level * 200, 100]
|
||||
})
|
||||
|
||||
if (currentParent instanceof LGraph) {
|
||||
currentParent.add(subgraphNode)
|
||||
} else {
|
||||
// @ts-expect-error TODO: Fix after merge - add method parameter types
|
||||
currentParent.add(subgraphNode)
|
||||
}
|
||||
|
||||
currentParent.add(subgraphNode)
|
||||
subgraphNodes.push(subgraphNode)
|
||||
|
||||
// Next level will be nested inside this subgraph
|
||||
@@ -353,9 +353,15 @@ export function createTestSubgraphData(
|
||||
): ExportedSubgraph {
|
||||
return {
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: {
|
||||
lastNodeId: 0,
|
||||
lastLinkId: 0,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
nodes: [],
|
||||
// @ts-expect-error TODO: Fix after merge - links type mismatch
|
||||
links: {},
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
definitions: { subgraphs: [] },
|
||||
@@ -386,13 +392,13 @@ export function createTestSubgraphData(
|
||||
* Creates an event capture system for testing event sequences.
|
||||
* @param eventTarget The event target to monitor
|
||||
* @param eventTypes Array of event types to capture
|
||||
* @returns Object with captured events and helper methods
|
||||
* @returns Object with captured events and typed getEventsByType method
|
||||
*/
|
||||
export function createEventCapture<T = unknown>(
|
||||
export function createEventCapture<TEventMap extends object = object>(
|
||||
eventTarget: EventTarget,
|
||||
eventTypes: string[]
|
||||
) {
|
||||
const capturedEvents: CapturedEvent<T>[] = []
|
||||
eventTypes: Array<keyof TEventMap & string>
|
||||
): EventCapture<TEventMap> {
|
||||
const capturedEvents: CapturedEvent<TEventMap[keyof TEventMap]>[] = []
|
||||
const listeners: Array<() => void> = []
|
||||
|
||||
// Set up listeners for each event type
|
||||
@@ -400,7 +406,7 @@ export function createEventCapture<T = unknown>(
|
||||
const listener = (event: Event) => {
|
||||
capturedEvents.push({
|
||||
type: eventType,
|
||||
detail: (event as CustomEvent<T>).detail,
|
||||
detail: (event as CustomEvent<TEventMap[typeof eventType]>).detail,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}
|
||||
@@ -418,7 +424,9 @@ export function createEventCapture<T = unknown>(
|
||||
// Remove all event listeners to prevent memory leaks
|
||||
for (const cleanup of listeners) cleanup()
|
||||
},
|
||||
getEventsByType: (type: string) =>
|
||||
capturedEvents.filter((e) => e.type === type)
|
||||
getEventsByType: <K extends keyof TEventMap & string>(type: K) =>
|
||||
capturedEvents.filter((e) => e.type === type) as CapturedEvent<
|
||||
TEventMap[K]
|
||||
>[]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,8 @@ export interface ExportedSubgraphInstance extends NodeSubgraphSharedProps {
|
||||
* @see {@link ExportedSubgraph.subgraphs}
|
||||
*/
|
||||
type: UUID
|
||||
/** Custom properties for this subgraph instance */
|
||||
properties?: Dictionary<NodeProperty | undefined>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface IWidgetOptions<TValues = unknown[]> {
|
||||
socketless?: boolean
|
||||
/** If `true`, the widget will not be rendered by the Vue renderer. */
|
||||
canvasOnly?: boolean
|
||||
/** Used as a temporary override for determining the asset type in vue mode*/
|
||||
nodeType?: string
|
||||
|
||||
values?: TValues
|
||||
/** Optional function to format values for display (e.g., hash → human-readable name) */
|
||||
|
||||
@@ -463,7 +463,8 @@ describe('ComboWidget', () => {
|
||||
.mockImplementation(function (_values, options) {
|
||||
capturedCallback = options.callback
|
||||
})
|
||||
LiteGraph.ContextMenu = mockContextMenu
|
||||
LiteGraph.ContextMenu =
|
||||
mockContextMenu as unknown as typeof LiteGraph.ContextMenu
|
||||
|
||||
const setValueSpy = vi.spyOn(widget, 'setValue')
|
||||
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
|
||||
@@ -505,7 +506,8 @@ describe('ComboWidget', () => {
|
||||
.mockImplementation(function (_values, options) {
|
||||
capturedCallback = options.callback
|
||||
})
|
||||
LiteGraph.ContextMenu = mockContextMenu
|
||||
LiteGraph.ContextMenu =
|
||||
mockContextMenu as unknown as typeof LiteGraph.ContextMenu
|
||||
|
||||
const setValueSpy = vi.spyOn(widget, 'setValue')
|
||||
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
|
||||
@@ -766,8 +768,8 @@ describe('ComboWidget', () => {
|
||||
.mockImplementation(function () {
|
||||
this.addItem = mockAddItem
|
||||
})
|
||||
LiteGraph.ContextMenu = mockContextMenu
|
||||
|
||||
LiteGraph.ContextMenu =
|
||||
mockContextMenu as unknown as typeof LiteGraph.ContextMenu
|
||||
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
|
||||
|
||||
// Should show formatted labels in dropdown
|
||||
@@ -829,7 +831,8 @@ describe('ComboWidget', () => {
|
||||
capturedCallback = options.callback
|
||||
this.addItem = mockAddItem
|
||||
})
|
||||
LiteGraph.ContextMenu = mockContextMenu
|
||||
LiteGraph.ContextMenu =
|
||||
mockContextMenu as unknown as typeof LiteGraph.ContextMenu
|
||||
|
||||
const setValueSpy = vi.spyOn(widget, 'setValue')
|
||||
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
|
||||
@@ -882,7 +885,8 @@ describe('ComboWidget', () => {
|
||||
capturedCallback = options.callback
|
||||
this.addItem = mockAddItem
|
||||
})
|
||||
LiteGraph.ContextMenu = mockContextMenu
|
||||
LiteGraph.ContextMenu =
|
||||
mockContextMenu as unknown as typeof LiteGraph.ContextMenu
|
||||
|
||||
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
|
||||
|
||||
@@ -960,7 +964,8 @@ describe('ComboWidget', () => {
|
||||
.mockImplementation(function () {
|
||||
this.addItem = mockAddItem
|
||||
})
|
||||
LiteGraph.ContextMenu = mockContextMenu
|
||||
LiteGraph.ContextMenu =
|
||||
mockContextMenu as unknown as typeof LiteGraph.ContextMenu
|
||||
|
||||
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
|
||||
|
||||
@@ -1007,7 +1012,8 @@ describe('ComboWidget', () => {
|
||||
node.size = [200, 30]
|
||||
|
||||
const mockContextMenu = vi.fn<typeof LiteGraph.ContextMenu>()
|
||||
LiteGraph.ContextMenu = mockContextMenu
|
||||
LiteGraph.ContextMenu =
|
||||
mockContextMenu as unknown as typeof LiteGraph.ContextMenu
|
||||
|
||||
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
|
||||
|
||||
|
||||
@@ -333,4 +333,4 @@
|
||||
"label": "Toggle Workflows Sidebar",
|
||||
"tooltip": "Workflows"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -378,6 +378,10 @@
|
||||
"warningTooltip": "This package may have compatibility issues with your current environment"
|
||||
}
|
||||
},
|
||||
"importFailed": {
|
||||
"title": "Import Failed",
|
||||
"copyError": "Copy Error"
|
||||
},
|
||||
"issueReport": {
|
||||
"helpFix": "Help Fix This"
|
||||
},
|
||||
@@ -738,6 +742,7 @@
|
||||
"filterCurrentWorkflow": "Current workflow",
|
||||
"sortJobs": "Sort jobs",
|
||||
"sortBy": "Sort by",
|
||||
"activeJobs": "{count} active job | {count} active jobs",
|
||||
"activeJobsSuffix": "active jobs",
|
||||
"jobQueue": "Job Queue",
|
||||
"expandCollapsedQueue": "Expand job queue",
|
||||
@@ -1167,6 +1172,7 @@
|
||||
"Canvas Performance": "Canvas Performance",
|
||||
"Help Center": "Help Center",
|
||||
"toggle linear mode": "toggle linear mode",
|
||||
"Toggle Queue Panel V2": "Toggle Queue Panel V2",
|
||||
"Toggle Theme (Dark/Light)": "Toggle Theme (Dark/Light)",
|
||||
"Undo": "Undo",
|
||||
"Open Sign In Dialog": "Open Sign In Dialog",
|
||||
@@ -1641,6 +1647,7 @@
|
||||
"loadingModel": "Loading 3D Model...",
|
||||
"upDirection": "Up Direction",
|
||||
"materialMode": "Material Mode",
|
||||
"showSkeleton": "Show Skeleton",
|
||||
"scene": "Scene",
|
||||
"model": "Model",
|
||||
"camera": "Camera",
|
||||
@@ -1911,7 +1918,7 @@
|
||||
"insufficientWorkflowMessage": "You don't have enough credits to run this workflow.",
|
||||
"creditsDescription": "Credits are used to run workflows or partner nodes.",
|
||||
"howManyCredits": "How many credits would you like to add?",
|
||||
"videosEstimate": "~{count} videos*",
|
||||
"videosEstimate": "~{count} videos",
|
||||
"templateNote": "*Generated with Wan Fun Control template",
|
||||
"buy": "Buy",
|
||||
"purchaseError": "Purchase Failed",
|
||||
@@ -2014,10 +2021,11 @@
|
||||
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
|
||||
"addCreditsLabel": "Add more credits whenever",
|
||||
"customLoRAsLabel": "Import your own LoRAs",
|
||||
"videoEstimateLabel": "Number of 5s videos generated with Wan Fun Control template",
|
||||
"videoEstimateHelp": "What is this?",
|
||||
"videoEstimateExplanation": "These estimates are based on the Wan Fun Control template for 5-second videos.",
|
||||
"videoEstimateTryTemplate": "Try the Wan Fun Control template →",
|
||||
"videoEstimateLabel": "Approx. amount of 5s videos generated with Wan 2.2 Image-to-Video template",
|
||||
"videoEstimateHelp": "More details on this template",
|
||||
"videoEstimateExplanation": "These estimates are based on the Wan 2.2 Image-to-Video template using default settings (5 seconds, 640x640, 16fps, 4-step sampling).",
|
||||
"videoEstimateTryTemplate": "Try this template",
|
||||
"videoTemplateBasedCredits": "Videos generated with Wan 2.2 Image to Video",
|
||||
"upgradePlan": "Upgrade Plan",
|
||||
"upgradeTo": "Upgrade to {plan}",
|
||||
"changeTo": "Change to {plan}",
|
||||
@@ -2369,6 +2377,8 @@
|
||||
"actions": {
|
||||
"inspect": "Inspect asset",
|
||||
"more": "More options",
|
||||
"zoom": "Zoom in",
|
||||
"moreOptions": "More options",
|
||||
"seeMoreOutputs": "See more outputs",
|
||||
"addToWorkflow": "Add to current workflow",
|
||||
"download": "Download",
|
||||
@@ -2471,4 +2481,4 @@
|
||||
"recentReleases": "Recent releases",
|
||||
"helpCenterMenu": "Help Center Menu"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,20 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
const mockAssetService = vi.hoisted(() => ({
|
||||
getAssetsForNodeType: vi.fn(),
|
||||
getAssetsByTag: vi.fn(),
|
||||
getAssetDetails: vi.fn((id: string) =>
|
||||
Promise.resolve({
|
||||
id,
|
||||
name: 'Test Model',
|
||||
user_metadata: {
|
||||
filename: 'Test Model'
|
||||
}
|
||||
})
|
||||
)
|
||||
}))
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string, params?: Record<string, string>) =>
|
||||
@@ -25,9 +12,15 @@ vi.mock('@/i18n', () => ({
|
||||
d: (date: Date) => date.toLocaleDateString()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/services/assetService', () => ({
|
||||
assetService: mockAssetService
|
||||
}))
|
||||
vi.mock('@/stores/assetsStore', () => {
|
||||
const store = {
|
||||
modelAssetsByNodeType: new Map<string, AssetItem[]>(),
|
||||
modelLoadingByNodeType: new Map<string, boolean>(),
|
||||
updateModelsForNodeType: vi.fn(),
|
||||
updateModelsForTag: vi.fn()
|
||||
}
|
||||
return { useAssetsStore: () => store }
|
||||
})
|
||||
|
||||
vi.mock('@/stores/modelToNodeStore', () => ({
|
||||
useModelToNodeStore: () => ({
|
||||
@@ -190,9 +183,12 @@ describe('AssetBrowserModal', () => {
|
||||
})
|
||||
}
|
||||
|
||||
const mockStore = useAssetsStore()
|
||||
|
||||
beforeEach(() => {
|
||||
mockAssetService.getAssetsForNodeType.mockReset()
|
||||
mockAssetService.getAssetsByTag.mockReset()
|
||||
vi.resetAllMocks()
|
||||
mockStore.modelAssetsByNodeType.clear()
|
||||
mockStore.modelLoadingByNodeType.clear()
|
||||
})
|
||||
|
||||
describe('Integration with useAssetBrowser', () => {
|
||||
@@ -201,7 +197,7 @@ describe('AssetBrowserModal', () => {
|
||||
createTestAsset('asset1', 'Model A', 'checkpoints'),
|
||||
createTestAsset('asset2', 'Model B', 'loras')
|
||||
]
|
||||
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce(assets)
|
||||
mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets)
|
||||
|
||||
const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' })
|
||||
await flushPromises()
|
||||
@@ -218,7 +214,7 @@ describe('AssetBrowserModal', () => {
|
||||
createTestAsset('c1', 'model.safetensors', 'checkpoints'),
|
||||
createTestAsset('l1', 'lora.pt', 'loras')
|
||||
]
|
||||
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce(assets)
|
||||
mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets)
|
||||
|
||||
const wrapper = createWrapper({
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
@@ -234,31 +230,54 @@ describe('AssetBrowserModal', () => {
|
||||
})
|
||||
|
||||
describe('Data fetching', () => {
|
||||
it('fetches assets for node type', async () => {
|
||||
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce([])
|
||||
|
||||
it('triggers store refresh for node type on mount', async () => {
|
||||
createWrapper({ nodeType: 'CheckpointLoaderSimple' })
|
||||
await flushPromises()
|
||||
|
||||
expect(mockAssetService.getAssetsForNodeType).toHaveBeenCalledWith(
|
||||
expect(mockStore.updateModelsForNodeType).toHaveBeenCalledWith(
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
})
|
||||
|
||||
it('fetches assets for tag when node type not provided', async () => {
|
||||
mockAssetService.getAssetsByTag.mockResolvedValueOnce([])
|
||||
it('displays cached assets immediately from store', async () => {
|
||||
const assets = [createTestAsset('asset1', 'Cached Model', 'checkpoints')]
|
||||
mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets)
|
||||
|
||||
createWrapper({ assetType: 'loras' })
|
||||
const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' })
|
||||
|
||||
const assetGrid = wrapper.findComponent({ name: 'AssetGrid' })
|
||||
const gridAssets = assetGrid.props('assets') as AssetItem[]
|
||||
|
||||
expect(gridAssets).toHaveLength(1)
|
||||
expect(gridAssets[0].name).toBe('Cached Model')
|
||||
})
|
||||
|
||||
it('triggers store refresh for asset type (tag) on mount', async () => {
|
||||
createWrapper({ assetType: 'models' })
|
||||
await flushPromises()
|
||||
|
||||
expect(mockAssetService.getAssetsByTag).toHaveBeenCalledWith('loras')
|
||||
expect(mockStore.updateModelsForTag).toHaveBeenCalledWith('models')
|
||||
})
|
||||
|
||||
it('uses tag: prefix for cache key when assetType is provided', async () => {
|
||||
const assets = [createTestAsset('asset1', 'Tagged Model', 'models')]
|
||||
mockStore.modelAssetsByNodeType.set('tag:models', assets)
|
||||
|
||||
const wrapper = createWrapper({ assetType: 'models' })
|
||||
await flushPromises()
|
||||
|
||||
const assetGrid = wrapper.findComponent({ name: 'AssetGrid' })
|
||||
const gridAssets = assetGrid.props('assets') as AssetItem[]
|
||||
|
||||
expect(gridAssets).toHaveLength(1)
|
||||
expect(gridAssets[0].name).toBe('Tagged Model')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Asset Selection', () => {
|
||||
it('emits asset-select event when asset is selected', async () => {
|
||||
const assets = [createTestAsset('asset1', 'Model A', 'checkpoints')]
|
||||
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce(assets)
|
||||
mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets)
|
||||
|
||||
const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' })
|
||||
await flushPromises()
|
||||
@@ -271,7 +290,7 @@ describe('AssetBrowserModal', () => {
|
||||
|
||||
it('executes onSelect callback when provided', async () => {
|
||||
const assets = [createTestAsset('asset1', 'Model A', 'checkpoints')]
|
||||
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce(assets)
|
||||
mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets)
|
||||
|
||||
const onSelect = vi.fn()
|
||||
const wrapper = createWrapper({
|
||||
@@ -289,8 +308,6 @@ describe('AssetBrowserModal', () => {
|
||||
|
||||
describe('Left Panel Conditional Logic', () => {
|
||||
it('hides left panel by default when showLeftPanel is undefined', async () => {
|
||||
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce([])
|
||||
|
||||
const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' })
|
||||
await flushPromises()
|
||||
|
||||
@@ -299,8 +316,6 @@ describe('AssetBrowserModal', () => {
|
||||
})
|
||||
|
||||
it('shows left panel when showLeftPanel prop is explicitly true', async () => {
|
||||
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce([])
|
||||
|
||||
const wrapper = createWrapper({
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
showLeftPanel: true
|
||||
@@ -318,7 +333,7 @@ describe('AssetBrowserModal', () => {
|
||||
createTestAsset('asset1', 'Model A', 'checkpoints'),
|
||||
createTestAsset('asset2', 'Model B', 'loras')
|
||||
]
|
||||
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce(assets)
|
||||
mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets)
|
||||
|
||||
const wrapper = createWrapper({
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
@@ -339,8 +354,6 @@ describe('AssetBrowserModal', () => {
|
||||
|
||||
describe('Title Management', () => {
|
||||
it('passes custom title to BaseModalLayout when title prop provided', async () => {
|
||||
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce([])
|
||||
|
||||
const wrapper = createWrapper({
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
title: 'Custom Title'
|
||||
@@ -353,7 +366,7 @@ describe('AssetBrowserModal', () => {
|
||||
|
||||
it('passes computed contentTitle to BaseModalLayout when no title prop', async () => {
|
||||
const assets = [createTestAsset('asset1', 'Model A', 'checkpoints')]
|
||||
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce(assets)
|
||||
mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets)
|
||||
|
||||
const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' })
|
||||
await flushPromises()
|
||||
|
||||
@@ -63,12 +63,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
breakpointsTailwind,
|
||||
useAsyncState,
|
||||
useBreakpoints
|
||||
} from '@vueuse/core'
|
||||
import { computed, provide, watch } from 'vue'
|
||||
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
|
||||
import { computed, provide } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
@@ -81,68 +77,68 @@ import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBro
|
||||
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import { useModelUpload } from '@/platform/assets/composables/useModelUpload'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { formatCategoryLabel } from '@/platform/assets/utils/categoryLabel'
|
||||
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
|
||||
const { t } = useI18n()
|
||||
const assetStore = useAssetsStore()
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
const breakpoints = useBreakpoints(breakpointsTailwind)
|
||||
|
||||
const props = defineProps<{
|
||||
nodeType?: string
|
||||
assetType?: string
|
||||
onSelect?: (asset: AssetItem) => void
|
||||
onClose?: () => void
|
||||
showLeftPanel?: boolean
|
||||
title?: string
|
||||
assetType?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'asset-select': [asset: AssetDisplayItem]
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const breakpoints = useBreakpoints(breakpointsTailwind)
|
||||
|
||||
provide(OnCloseKey, props.onClose ?? (() => {}))
|
||||
|
||||
const fetchAssets = async () => {
|
||||
// Compute the cache key based on nodeType or assetType
|
||||
const cacheKey = computed(() => {
|
||||
if (props.nodeType) return props.nodeType
|
||||
if (props.assetType) return `tag:${props.assetType}`
|
||||
return ''
|
||||
})
|
||||
|
||||
// Read directly from store cache - reactive to any store updates
|
||||
const fetchedAssets = computed(
|
||||
() => assetStore.modelAssetsByNodeType.get(cacheKey.value) ?? []
|
||||
)
|
||||
|
||||
const isStoreLoading = computed(
|
||||
() => assetStore.modelLoadingByNodeType.get(cacheKey.value) ?? false
|
||||
)
|
||||
|
||||
// Only show loading spinner when loading AND no cached data
|
||||
const isLoading = computed(
|
||||
() => isStoreLoading.value && fetchedAssets.value.length === 0
|
||||
)
|
||||
|
||||
async function refreshAssets(): Promise<AssetItem[]> {
|
||||
if (props.nodeType) {
|
||||
return (await assetService.getAssetsForNodeType(props.nodeType)) ?? []
|
||||
return await assetStore.updateModelsForNodeType(props.nodeType)
|
||||
}
|
||||
|
||||
if (props.assetType) {
|
||||
return (await assetService.getAssetsByTag(props.assetType)) ?? []
|
||||
return await assetStore.updateModelsForTag(props.assetType)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
const {
|
||||
state: fetchedAssets,
|
||||
isLoading,
|
||||
execute
|
||||
} = useAsyncState<AssetItem[]>(fetchAssets, [], { immediate: false })
|
||||
// Trigger background refresh on mount
|
||||
void refreshAssets()
|
||||
|
||||
watch(
|
||||
() => [props.nodeType, props.assetType],
|
||||
async () => {
|
||||
await execute()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const assetDownloadStore = useAssetDownloadStore()
|
||||
|
||||
watch(
|
||||
() => assetDownloadStore.hasActiveDownloads,
|
||||
async (currentlyActive, previouslyActive) => {
|
||||
if (previouslyActive && !currentlyActive) {
|
||||
await execute()
|
||||
}
|
||||
}
|
||||
)
|
||||
const { isUploadButtonEnabled, showUploadDialog } =
|
||||
useModelUpload(refreshAssets)
|
||||
|
||||
const {
|
||||
searchQuery,
|
||||
@@ -153,8 +149,6 @@ const {
|
||||
updateFilters
|
||||
} = useAssetBrowser(fetchedAssets)
|
||||
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
|
||||
const primaryCategoryTag = computed(() => {
|
||||
const assets = fetchedAssets.value ?? []
|
||||
const tagFromAssets = assets
|
||||
@@ -202,6 +196,4 @@ function handleAssetSelectAndEmit(asset: AssetDisplayItem) {
|
||||
// It handles the appropriate transformation (filename extraction or full asset)
|
||||
props.onSelect?.(asset)
|
||||
}
|
||||
|
||||
const { isUploadButtonEnabled, showUploadDialog } = useModelUpload(execute)
|
||||
</script>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
<AssetBadgeGroup :badges="asset.badges" />
|
||||
<IconGroup
|
||||
v-if="flags.assetUpdateOptionsEnabled && !(asset.is_immutable ?? true)"
|
||||
v-if="showAssetOptions"
|
||||
:class="
|
||||
cn(
|
||||
'absolute top-2 right-2 invisible group-hover:visible',
|
||||
@@ -44,6 +44,7 @@
|
||||
<MoreButton ref="dropdown-menu-button" size="sm">
|
||||
<template #default>
|
||||
<Button
|
||||
v-if="flags.assetRenameEnabled"
|
||||
variant="secondary"
|
||||
size="md"
|
||||
class="justify-start"
|
||||
@@ -53,6 +54,7 @@
|
||||
<span>{{ $t('g.rename') }}</span>
|
||||
</Button>
|
||||
<Button
|
||||
v-if="flags.assetDeletionEnabled"
|
||||
variant="secondary"
|
||||
size="md"
|
||||
class="justify-start"
|
||||
@@ -160,6 +162,12 @@ const deletedLocal = ref(false)
|
||||
|
||||
const displayName = computed(() => newNameRef.value ?? asset.name)
|
||||
|
||||
const showAssetOptions = computed(
|
||||
() =>
|
||||
(flags.assetDeletionEnabled || flags.assetRenameEnabled) &&
|
||||
!(asset.is_immutable ?? true)
|
||||
)
|
||||
|
||||
const tooltipDelay = computed<number>(() =>
|
||||
settingStore.get('LiteGraph.Node.TooltipDelay')
|
||||
)
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<MediaTitle :file-name="fileName" />
|
||||
<!-- TBD: File size will be provided by backend history API -->
|
||||
<div
|
||||
v-if="asset.size"
|
||||
class="flex items-center gap-2 text-xs text-zinc-400"
|
||||
>
|
||||
<span>{{ formatSize(asset.size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { formatSize, getFilenameDetails } from '@/utils/formatUtil'
|
||||
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
import MediaTitle from './MediaTitle.vue'
|
||||
|
||||
const { asset } = defineProps<{
|
||||
asset: AssetMeta
|
||||
}>()
|
||||
|
||||
const fileName = computed(() => {
|
||||
return getFilenameDetails(asset.name).filename
|
||||
})
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<CardContainer
|
||||
<div
|
||||
ref="cardContainerRef"
|
||||
role="button"
|
||||
:aria-label="
|
||||
@@ -11,105 +11,114 @@
|
||||
: $t('assetBrowser.ariaLabel.loadingAsset')
|
||||
"
|
||||
:tabindex="loading ? -1 : 0"
|
||||
size="mini"
|
||||
variant="ghost"
|
||||
rounded="lg"
|
||||
:class="containerClasses"
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col overflow-hidden cursor-pointer p-2 transition-colors duration-200 rounded-lg',
|
||||
'gap-2 select-none group',
|
||||
selected
|
||||
? 'ring-3 ring-inset ring-modal-card-border-highlighted'
|
||||
: 'hover:bg-modal-card-background-hovered'
|
||||
)
|
||||
"
|
||||
:data-selected="selected"
|
||||
@click.stop="$emit('click')"
|
||||
@contextmenu.prevent="handleContextMenu"
|
||||
>
|
||||
<template #top>
|
||||
<CardTop
|
||||
ratio="square"
|
||||
:bottom-left-class="durationChipClasses"
|
||||
:bottom-right-class="durationChipClasses"
|
||||
<!-- Top Area: Media Preview -->
|
||||
<div class="relative aspect-square overflow-hidden p-0">
|
||||
<!-- Loading State -->
|
||||
<div
|
||||
v-if="loading"
|
||||
class="size-full animate-pulse rounded-lg bg-modal-card-placeholder-background"
|
||||
/>
|
||||
|
||||
<!-- Content based on asset type -->
|
||||
<component
|
||||
:is="getTopComponent(fileKind)"
|
||||
v-else-if="asset && adaptedAsset"
|
||||
:asset="adaptedAsset"
|
||||
:context="{ type: assetType }"
|
||||
class="absolute inset-0"
|
||||
@view="handleZoomClick"
|
||||
@download="actions.downloadAsset()"
|
||||
@video-playing-state-changed="isVideoPlaying = $event"
|
||||
@video-controls-changed="showVideoControls = $event"
|
||||
@image-loaded="handleImageLoaded"
|
||||
/>
|
||||
|
||||
<!-- Action buttons overlay (top-left) -->
|
||||
<div
|
||||
v-if="showActionsOverlay"
|
||||
class="absolute top-2 left-2 flex flex-wrap justify-start gap-2"
|
||||
>
|
||||
<!-- Loading State -->
|
||||
<template v-if="loading">
|
||||
<IconGroup background-class="bg-white">
|
||||
<Button
|
||||
variant="overlay-white"
|
||||
size="icon"
|
||||
:aria-label="$t('mediaAsset.actions.zoom')"
|
||||
@click.stop="handleZoomClick"
|
||||
>
|
||||
<i class="icon-[lucide--zoom-in] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="overlay-white"
|
||||
size="icon"
|
||||
:aria-label="$t('mediaAsset.actions.moreOptions')"
|
||||
@click.stop="handleContextMenu"
|
||||
>
|
||||
<i class="icon-[lucide--ellipsis] size-4" />
|
||||
</Button>
|
||||
</IconGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Area: Media Info -->
|
||||
<div class="flex-1">
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="flex justify-between items-start">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div
|
||||
class="size-full animate-pulse rounded-lg bg-modal-card-placeholder-background"
|
||||
class="h-4 w-24 animate-pulse rounded bg-modal-card-background"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Content based on asset type -->
|
||||
<template v-else-if="asset && adaptedAsset">
|
||||
<component
|
||||
:is="getTopComponent(fileKind)"
|
||||
:asset="adaptedAsset"
|
||||
:context="{ type: assetType }"
|
||||
@view="handleZoomClick"
|
||||
@download="actions.downloadAsset()"
|
||||
@video-playing-state-changed="isVideoPlaying = $event"
|
||||
@video-controls-changed="showVideoControls = $event"
|
||||
@image-loaded="handleImageLoaded"
|
||||
<div
|
||||
class="h-3 w-20 animate-pulse rounded bg-modal-card-background"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<div class="h-6 w-12 animate-pulse rounded bg-modal-card-background" />
|
||||
</div>
|
||||
|
||||
<!-- Top-left slot: Duration/Format chips OR Media actions -->
|
||||
<template #top-left>
|
||||
<!-- Duration/Format chips - show when not hovered and not playing -->
|
||||
<div v-if="showStaticChips" class="flex flex-wrap items-center gap-1">
|
||||
<SquareChip
|
||||
v-if="formattedDuration"
|
||||
variant="gray"
|
||||
:label="formattedDuration"
|
||||
/>
|
||||
<!-- Content -->
|
||||
<div
|
||||
v-else-if="asset && adaptedAsset"
|
||||
class="flex justify-between items-end gap-1.5"
|
||||
>
|
||||
<!-- Left side: Media name and metadata -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<!-- Title -->
|
||||
<MediaTitle :file-name="fileName" />
|
||||
<!-- Metadata -->
|
||||
<div class="flex gap-1.5 text-xs text-muted-foreground">
|
||||
<span v-if="formattedDuration">{{ formattedDuration }}</span>
|
||||
<span v-if="metaInfo">{{ metaInfo }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Media actions - show on hover or when playing -->
|
||||
<IconGroup v-else-if="showActionsOverlay">
|
||||
<Button size="icon" @click.stop="handleZoomClick">
|
||||
<i class="icon-[lucide--zoom-in] size-4" />
|
||||
</Button>
|
||||
<Button size="icon" @click.stop="handleContextMenu">
|
||||
<i class="icon-[lucide--ellipsis] size-4" />
|
||||
</Button>
|
||||
</IconGroup>
|
||||
</template>
|
||||
|
||||
<!-- Output count (top-right) -->
|
||||
<template v-if="showOutputCount" #top-right>
|
||||
<!-- Right side: Output count -->
|
||||
<div v-if="showOutputCount" class="flex-shrink-0">
|
||||
<Button
|
||||
v-tooltip.top.pt:pointer-events-none="
|
||||
$t('mediaAsset.actions.seeMoreOutputs')
|
||||
"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@click.stop="handleOutputCountClick"
|
||||
>
|
||||
<i class="icon-[lucide--layers] size-4" />
|
||||
<span>{{ outputCount }}</span>
|
||||
</Button>
|
||||
</template>
|
||||
</CardTop>
|
||||
</template>
|
||||
|
||||
<template #bottom>
|
||||
<CardBottom>
|
||||
<!-- Loading State -->
|
||||
<template v-if="loading">
|
||||
<div class="flex flex-col items-center justify-between gap-1">
|
||||
<div
|
||||
class="h-4 w-2/3 animate-pulse rounded bg-modal-card-background"
|
||||
/>
|
||||
<div
|
||||
class="h-3 w-1/2 animate-pulse rounded bg-modal-card-background"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Content based on asset type -->
|
||||
<template v-else-if="asset && adaptedAsset">
|
||||
<component
|
||||
:is="getBottomComponent(fileKind)"
|
||||
:asset="adaptedAsset"
|
||||
:context="{ type: assetType }"
|
||||
/>
|
||||
</template>
|
||||
</CardBottom>
|
||||
</template>
|
||||
</CardContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MediaAssetContextMenu
|
||||
v-if="asset"
|
||||
@@ -128,12 +137,13 @@ import { useElementHover, whenever } from '@vueuse/core'
|
||||
import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue'
|
||||
|
||||
import IconGroup from '@/components/button/IconGroup.vue'
|
||||
import CardBottom from '@/components/card/CardBottom.vue'
|
||||
import CardContainer from '@/components/card/CardContainer.vue'
|
||||
import CardTop from '@/components/card/CardTop.vue'
|
||||
import SquareChip from '@/components/chip/SquareChip.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||
import {
|
||||
formatDuration,
|
||||
formatSize,
|
||||
getFilenameDetails,
|
||||
getMediaTypeFromFilename
|
||||
} from '@/utils/formatUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { getAssetType } from '../composables/media/assetMappers'
|
||||
@@ -142,6 +152,7 @@ import type { AssetItem } from '../schemas/assetSchema'
|
||||
import type { MediaKind } from '../schemas/mediaAssetSchema'
|
||||
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
||||
import MediaAssetContextMenu from './MediaAssetContextMenu.vue'
|
||||
import MediaTitle from './MediaTitle.vue'
|
||||
|
||||
const mediaComponents = {
|
||||
top: {
|
||||
@@ -149,12 +160,6 @@ const mediaComponents = {
|
||||
audio: defineAsyncComponent(() => import('./MediaAudioTop.vue')),
|
||||
image: defineAsyncComponent(() => import('./MediaImageTop.vue')),
|
||||
'3D': defineAsyncComponent(() => import('./Media3DTop.vue'))
|
||||
},
|
||||
bottom: {
|
||||
video: defineAsyncComponent(() => import('./MediaVideoBottom.vue')),
|
||||
audio: defineAsyncComponent(() => import('./MediaAudioBottom.vue')),
|
||||
image: defineAsyncComponent(() => import('./MediaImageBottom.vue')),
|
||||
'3D': defineAsyncComponent(() => import('./Media3DBottom.vue'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,10 +167,6 @@ function getTopComponent(kind: MediaKind) {
|
||||
return mediaComponents.top[kind] || mediaComponents.top.image
|
||||
}
|
||||
|
||||
function getBottomComponent(kind: MediaKind) {
|
||||
return mediaComponents.bottom[kind] || mediaComponents.bottom.image
|
||||
}
|
||||
|
||||
const {
|
||||
asset,
|
||||
loading,
|
||||
@@ -215,6 +216,11 @@ const fileKind = computed((): MediaKind => {
|
||||
return getMediaTypeFromFilename(asset?.name || '') as MediaKind
|
||||
})
|
||||
|
||||
// Get filename without extension
|
||||
const fileName = computed(() => {
|
||||
return getFilenameDetails(asset?.name || '').filename
|
||||
})
|
||||
|
||||
// Adapt AssetItem to legacy AssetMeta format for existing components
|
||||
const adaptedAsset = computed(() => {
|
||||
if (!asset) return undefined
|
||||
@@ -240,15 +246,6 @@ provide(MediaAssetKey, {
|
||||
showVideoControls
|
||||
})
|
||||
|
||||
const containerClasses = computed(() =>
|
||||
cn(
|
||||
'gap-1 select-none group',
|
||||
selected
|
||||
? 'ring-3 ring-inset ring-modal-card-border-highlighted'
|
||||
: 'hover:bg-modal-card-background-hovered'
|
||||
)
|
||||
)
|
||||
|
||||
const formattedDuration = computed(() => {
|
||||
// Check for execution time first (from history API)
|
||||
const executionTime = asset?.user_metadata?.executionTimeInSeconds
|
||||
@@ -262,30 +259,22 @@ const formattedDuration = computed(() => {
|
||||
return formatDuration(Number(duration))
|
||||
})
|
||||
|
||||
const durationChipClasses = computed(() => {
|
||||
if (fileKind.value === 'audio') {
|
||||
return '-translate-y-11'
|
||||
// Get metadata info based on file kind
|
||||
const metaInfo = computed(() => {
|
||||
if (!asset) return ''
|
||||
if (fileKind.value === 'image' && imageDimensions.value) {
|
||||
return `${imageDimensions.value.width}x${imageDimensions.value.height}`
|
||||
}
|
||||
if (fileKind.value === 'video' && showVideoControls.value) {
|
||||
return '-translate-y-16'
|
||||
if (asset.size && ['video', 'audio', '3D'].includes(fileKind.value)) {
|
||||
return formatSize(asset.size)
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
// Show static chips when NOT hovered and NOT playing (normal state)
|
||||
const showStaticChips = computed(
|
||||
() =>
|
||||
!loading &&
|
||||
!!asset &&
|
||||
!isHovered.value &&
|
||||
!isVideoPlaying.value &&
|
||||
formattedDuration.value
|
||||
)
|
||||
|
||||
// Show action overlay when hovered OR playing
|
||||
const showActionsOverlay = computed(
|
||||
() => !loading && !!asset && (isHovered.value || isVideoPlaying.value)
|
||||
)
|
||||
const showActionsOverlay = computed(() => {
|
||||
if (loading || !asset) return false
|
||||
return isHovered.value || selected || isVideoPlaying.value
|
||||
})
|
||||
|
||||
const handleZoomClick = () => {
|
||||
if (asset) {
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<MediaTitle :file-name="fileName" />
|
||||
<!-- TBD: File size will be provided by backend history API -->
|
||||
<div
|
||||
v-if="asset.size"
|
||||
class="flex items-center gap-2 text-xs text-zinc-400"
|
||||
>
|
||||
<span>{{ formatSize(asset.size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { formatSize, getFilenameDetails } from '@/utils/formatUtil'
|
||||
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
import MediaTitle from './MediaTitle.vue'
|
||||
|
||||
const { asset } = defineProps<{
|
||||
asset: AssetMeta
|
||||
}>()
|
||||
|
||||
const fileName = computed(() => {
|
||||
return getFilenameDetails(asset.name).filename
|
||||
})
|
||||
</script>
|
||||
@@ -1,27 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<MediaTitle :file-name="fileName" />
|
||||
<div class="flex items-center text-xs text-zinc-400">
|
||||
<span v-if="asset.dimensions"
|
||||
>{{ asset.dimensions?.width }}x{{ asset.dimensions?.height }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { getFilenameDetails } from '@/utils/formatUtil'
|
||||
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
import MediaTitle from './MediaTitle.vue'
|
||||
|
||||
const { asset } = defineProps<{
|
||||
asset: AssetMeta
|
||||
}>()
|
||||
|
||||
const fileName = computed(() => {
|
||||
return getFilenameDetails(asset.name).filename
|
||||
})
|
||||
</script>
|
||||
@@ -1,21 +1,14 @@
|
||||
<template>
|
||||
<h3
|
||||
class="m-0 line-clamp-1 text-sm font-bold text-base-foreground"
|
||||
:title="fullName"
|
||||
<p
|
||||
class="m-0 line-clamp-2 text-sm text-base-foreground leading-tight break-all"
|
||||
:title="fileName"
|
||||
>
|
||||
{{ displayName }}
|
||||
</h3>
|
||||
{{ fileName }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { truncateFilename } from '@/utils/formatUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
defineProps<{
|
||||
fileName: string
|
||||
}>()
|
||||
|
||||
const fullName = computed(() => props.fileName)
|
||||
const displayName = computed(() => truncateFilename(props.fileName))
|
||||
</script>
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<MediaTitle :file-name="fileName" />
|
||||
<!-- TBD: File size will be provided by backend history API -->
|
||||
<div v-if="asset.size" class="flex items-center text-xs text-zinc-400">
|
||||
<span>{{ formatSize(asset.size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { formatSize, getFilenameDetails } from '@/utils/formatUtil'
|
||||
|
||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||
import MediaTitle from './MediaTitle.vue'
|
||||
|
||||
const { asset } = defineProps<{
|
||||
asset: AssetMeta
|
||||
}>()
|
||||
|
||||
const fileName = computed(() => {
|
||||
return getFilenameDetails(asset.name).filename
|
||||
})
|
||||
</script>
|
||||
@@ -3,13 +3,11 @@ import UploadModelDialog from '@/platform/assets/components/UploadModelDialog.vu
|
||||
import UploadModelDialogHeader from '@/platform/assets/components/UploadModelDialogHeader.vue'
|
||||
import UploadModelUpgradeModal from '@/platform/assets/components/UploadModelUpgradeModal.vue'
|
||||
import UploadModelUpgradeModalHeader from '@/platform/assets/components/UploadModelUpgradeModalHeader.vue'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import type { UseAsyncStateReturn } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
export function useModelUpload(
|
||||
execute?: UseAsyncStateReturn<AssetItem[], [], true>['execute']
|
||||
onUploadSuccess?: () => Promise<unknown> | void
|
||||
) {
|
||||
const dialogStore = useDialogStore()
|
||||
const { flags } = useFeatureFlags()
|
||||
@@ -37,7 +35,7 @@ export function useModelUpload(
|
||||
component: UploadModelDialog,
|
||||
props: {
|
||||
onUploadSuccess: async () => {
|
||||
await execute?.()
|
||||
await onUploadSuccess?.()
|
||||
}
|
||||
},
|
||||
dialogComponentProps: {
|
||||
|
||||
@@ -127,53 +127,6 @@ export async function getSurveyCompletedStatus(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-expect-error - Unused function kept for future use
|
||||
async function postSurveyStatus(): Promise<void> {
|
||||
try {
|
||||
const response = await api.fetchApi(`/settings/${ONBOARDING_SURVEY_KEY}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ [ONBOARDING_SURVEY_KEY]: undefined })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = new Error(
|
||||
`Failed to post survey status: ${response.statusText}`
|
||||
)
|
||||
captureApiError(
|
||||
error,
|
||||
'/settings/{key}',
|
||||
'http_error',
|
||||
response.status,
|
||||
'post_survey_status',
|
||||
{
|
||||
route_template: '/settings/{key}',
|
||||
route_actual: `/settings/${ONBOARDING_SURVEY_KEY}`
|
||||
}
|
||||
)
|
||||
throw error
|
||||
}
|
||||
} catch (error) {
|
||||
// Only capture network errors (not HTTP errors we already captured)
|
||||
if (!isHttpError(error, 'Failed to post survey status:')) {
|
||||
captureApiError(
|
||||
error as Error,
|
||||
'/settings/{key}',
|
||||
'network_error',
|
||||
undefined,
|
||||
'post_survey_status',
|
||||
{
|
||||
route_template: '/settings/{key}',
|
||||
route_actual: `/settings/${ONBOARDING_SURVEY_KEY}`
|
||||
}
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function submitSurvey(
|
||||
survey: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
|
||||
@@ -157,7 +157,9 @@
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-row items-start justify-between">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-normal text-foreground">
|
||||
<span
|
||||
class="text-sm font-normal text-foreground leading-relaxed"
|
||||
>
|
||||
{{ t('subscription.videoEstimateLabel') }}
|
||||
</span>
|
||||
<div class="flex flex-row items-center gap-2 group pt-2">
|
||||
@@ -220,16 +222,19 @@
|
||||
}"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-sm text-base-foreground">
|
||||
<p class="text-sm text-base-foreground leading-normal">
|
||||
{{ t('subscription.videoEstimateExplanation') }}
|
||||
</p>
|
||||
<a
|
||||
href="https://cloud.comfy.org/?template=video_wan2_2_14B_fun_camera"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm text-azure-600 hover:text-azure-400 underline"
|
||||
class="text-sm text-azure-600 hover:text-azure-400 no-underline flex gap-1"
|
||||
>
|
||||
{{ t('subscription.videoEstimateTryTemplate') }}
|
||||
<span class="underline">
|
||||
{{ t('subscription.videoEstimateTryTemplate') }}
|
||||
</span>
|
||||
<span class="no-underline" v-html="'→'"></span>
|
||||
</a>
|
||||
</div>
|
||||
</Popover>
|
||||
|
||||
@@ -19,9 +19,9 @@ export interface TierPricing {
|
||||
}
|
||||
|
||||
export const TIER_PRICING: Record<Exclude<TierKey, 'founder'>, TierPricing> = {
|
||||
standard: { monthly: 20, yearly: 16, credits: 4200, videoEstimate: 164 },
|
||||
creator: { monthly: 35, yearly: 28, credits: 7400, videoEstimate: 288 },
|
||||
pro: { monthly: 100, yearly: 80, credits: 21100, videoEstimate: 821 }
|
||||
standard: { monthly: 20, yearly: 16, credits: 4200, videoEstimate: 120 },
|
||||
creator: { monthly: 35, yearly: 28, credits: 7400, videoEstimate: 211 },
|
||||
pro: { monthly: 100, yearly: 80, credits: 21100, videoEstimate: 600 }
|
||||
}
|
||||
|
||||
interface TierFeatures {
|
||||
|
||||
@@ -35,7 +35,8 @@ export type RemoteConfig = {
|
||||
firebase_config?: FirebaseRuntimeConfig
|
||||
telemetry_disabled_events?: TelemetryEventName[]
|
||||
model_upload_button_enabled?: boolean
|
||||
asset_update_options_enabled?: boolean
|
||||
asset_deletion_enabled?: boolean
|
||||
asset_rename_enabled?: boolean
|
||||
private_models_enabled?: boolean
|
||||
onboarding_survey_enabled?: boolean
|
||||
huggingface_model_import_enabled?: boolean
|
||||
|
||||
@@ -99,7 +99,10 @@
|
||||
</span>
|
||||
</div>
|
||||
<!-- Multiple Images Navigation -->
|
||||
<div v-if="hasMultipleImages" class="flex justify-center gap-1 pt-4">
|
||||
<div
|
||||
v-if="hasMultipleImages"
|
||||
class="flex flex-wrap justify-center gap-1 pt-4"
|
||||
>
|
||||
<button
|
||||
v-for="(_, index) in imageUrls"
|
||||
:key="index"
|
||||
|
||||
@@ -60,8 +60,9 @@ const combinedProps = computed(() => ({
|
||||
}))
|
||||
|
||||
const getAssetData = () => {
|
||||
if (props.isAssetMode && props.nodeType) {
|
||||
return useAssetWidgetData(toRef(() => props.nodeType))
|
||||
const nodeType = props.widget.options?.nodeType ?? props.nodeType
|
||||
if (props.isAssetMode && nodeType) {
|
||||
return useAssetWidgetData(toRef(nodeType))
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import ToggleSwitch from 'primevue/toggleswitch'
|
||||
import type { ToggleSwitchProps } from 'primevue/toggleswitch'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import WidgetToggleSwitch from './WidgetToggleSwitch.vue'
|
||||
@@ -11,9 +11,9 @@ import WidgetToggleSwitch from './WidgetToggleSwitch.vue'
|
||||
describe('WidgetToggleSwitch Value Binding', () => {
|
||||
const createMockWidget = (
|
||||
value: boolean = false,
|
||||
options: Partial<ToggleSwitchProps> = {},
|
||||
options: IWidgetOptions = {},
|
||||
callback?: (value: boolean) => void
|
||||
): SimplifiedWidget<boolean> => ({
|
||||
): SimplifiedWidget<boolean, IWidgetOptions> => ({
|
||||
name: 'test_toggle',
|
||||
type: 'boolean',
|
||||
value,
|
||||
@@ -149,4 +149,47 @@ describe('WidgetToggleSwitch Value Binding', () => {
|
||||
expect(emitted![3]).toContain(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Label Display (label_on/label_off)', () => {
|
||||
it('displays label_on when value is true', () => {
|
||||
const widget = createMockWidget(true, { on: 'inside', off: 'outside' })
|
||||
const wrapper = mountComponent(widget, true)
|
||||
|
||||
expect(wrapper.text()).toContain('inside')
|
||||
})
|
||||
|
||||
it('displays label_off when value is false', () => {
|
||||
const widget = createMockWidget(false, { on: 'inside', off: 'outside' })
|
||||
const wrapper = mountComponent(widget, false)
|
||||
|
||||
expect(wrapper.text()).toContain('outside')
|
||||
})
|
||||
|
||||
it('does not display label when no on/off options provided', () => {
|
||||
const widget = createMockWidget(false, {})
|
||||
const wrapper = mountComponent(widget, false)
|
||||
|
||||
expect(wrapper.find('span').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('updates label when value changes', async () => {
|
||||
const widget = createMockWidget(false, { on: 'enabled', off: 'disabled' })
|
||||
const wrapper = mountComponent(widget, false)
|
||||
|
||||
expect(wrapper.text()).toContain('disabled')
|
||||
|
||||
await wrapper.setProps({ modelValue: true })
|
||||
expect(wrapper.text()).toContain('enabled')
|
||||
})
|
||||
|
||||
it('falls back to true/false when only partial options provided', () => {
|
||||
const widgetOnOnly = createMockWidget(true, { on: 'active' })
|
||||
const wrapperOn = mountComponent(widgetOnOnly, true)
|
||||
expect(wrapperOn.text()).toContain('active')
|
||||
|
||||
const widgetOffOnly = createMockWidget(false, { off: 'inactive' })
|
||||
const wrapperOff = mountComponent(widgetOffOnly, false)
|
||||
expect(wrapperOff.text()).toContain('inactive')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
<template>
|
||||
<WidgetLayoutField :widget>
|
||||
<ToggleSwitch
|
||||
v-model="modelValue"
|
||||
v-bind="filteredProps"
|
||||
class="ml-auto block"
|
||||
:aria-label="widget.name"
|
||||
/>
|
||||
<div class="ml-auto flex w-fit items-center gap-2">
|
||||
<span
|
||||
v-if="stateLabel"
|
||||
:class="
|
||||
cn(
|
||||
'text-sm transition-colors',
|
||||
modelValue
|
||||
? 'text-node-component-slot-text'
|
||||
: 'text-node-component-slot-text/50'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ stateLabel }}
|
||||
</span>
|
||||
<ToggleSwitch
|
||||
v-model="modelValue"
|
||||
v-bind="filteredProps"
|
||||
:aria-label="widget.name"
|
||||
/>
|
||||
</div>
|
||||
</WidgetLayoutField>
|
||||
</template>
|
||||
|
||||
@@ -13,7 +27,9 @@
|
||||
import ToggleSwitch from 'primevue/toggleswitch'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import {
|
||||
STANDARD_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
@@ -22,7 +38,7 @@ import {
|
||||
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
|
||||
|
||||
const { widget } = defineProps<{
|
||||
widget: SimplifiedWidget<boolean>
|
||||
widget: SimplifiedWidget<boolean, IWidgetOptions>
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<boolean>()
|
||||
@@ -30,4 +46,10 @@ const modelValue = defineModel<boolean>()
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(widget.options, STANDARD_EXCLUDED_PROPS)
|
||||
)
|
||||
|
||||
const stateLabel = computed(() => {
|
||||
const options = widget.options
|
||||
if (!options?.on && !options?.off) return null
|
||||
return modelValue.value ? (options.on ?? 'true') : (options.off ?? 'false')
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -368,7 +368,8 @@ export const useImagePreviewWidget = () => {
|
||||
) => {
|
||||
return node.addCustomWidget(
|
||||
new ImagePreviewWidget(node, inputSpec.name, {
|
||||
serialize: false
|
||||
serialize: false,
|
||||
canvasOnly: true
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -30,6 +30,9 @@ import ManagerProgressFooter from '@/workbench/extensions/manager/components/Man
|
||||
import ManagerProgressHeader from '@/workbench/extensions/manager/components/ManagerProgressHeader.vue'
|
||||
import ManagerDialogContent from '@/workbench/extensions/manager/components/manager/ManagerDialogContent.vue'
|
||||
import ManagerHeader from '@/workbench/extensions/manager/components/manager/ManagerHeader.vue'
|
||||
import ImportFailedNodeContent from '@/workbench/extensions/manager/components/manager/ImportFailedNodeContent.vue'
|
||||
import ImportFailedNodeFooter from '@/workbench/extensions/manager/components/manager/ImportFailedNodeFooter.vue'
|
||||
import ImportFailedNodeHeader from '@/workbench/extensions/manager/components/manager/ImportFailedNodeHeader.vue'
|
||||
import NodeConflictDialogContent from '@/workbench/extensions/manager/components/manager/NodeConflictDialogContent.vue'
|
||||
import NodeConflictFooter from '@/workbench/extensions/manager/components/manager/NodeConflictFooter.vue'
|
||||
import NodeConflictHeader from '@/workbench/extensions/manager/components/manager/NodeConflictHeader.vue'
|
||||
@@ -482,6 +485,43 @@ export const useDialogService = () => {
|
||||
})
|
||||
}
|
||||
|
||||
function showImportFailedNodeDialog(
|
||||
options: {
|
||||
conflictedPackages?: ConflictDetectionResult[]
|
||||
dialogComponentProps?: DialogComponentProps
|
||||
} = {}
|
||||
) {
|
||||
const { dialogComponentProps, conflictedPackages } = options
|
||||
|
||||
return dialogStore.showDialog({
|
||||
key: 'global-import-failed',
|
||||
headerComponent: ImportFailedNodeHeader,
|
||||
footerComponent: ImportFailedNodeFooter,
|
||||
component: ImportFailedNodeContent,
|
||||
dialogComponentProps: {
|
||||
closable: true,
|
||||
pt: {
|
||||
root: { class: 'bg-base-background border-border-default' },
|
||||
header: { class: '!p-0 !m-0' },
|
||||
content: { class: '!p-0 overflow-y-hidden' },
|
||||
footer: { class: '!p-0' },
|
||||
pcCloseButton: {
|
||||
root: {
|
||||
class: '!w-7 !h-7 !border-none !outline-none !p-2 !m-1.5'
|
||||
}
|
||||
}
|
||||
},
|
||||
...dialogComponentProps
|
||||
},
|
||||
props: {
|
||||
conflictedPackages: conflictedPackages ?? []
|
||||
},
|
||||
footerProps: {
|
||||
conflictedPackages: conflictedPackages ?? []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function showNodeConflictDialog(
|
||||
options: {
|
||||
showAfterWhatsNew?: boolean
|
||||
@@ -561,6 +601,7 @@ export const useDialogService = () => {
|
||||
toggleManagerDialog,
|
||||
toggleManagerProgressDialog,
|
||||
showLayoutDialog,
|
||||
showImportFailedNodeDialog,
|
||||
showNodeConflictDialog
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useAsyncState } from '@vueuse/core'
|
||||
import { isEqual } from 'es-toolkit'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, shallowReactive, ref, watch } from 'vue'
|
||||
import {
|
||||
@@ -279,59 +280,81 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
new Map<string, ReturnType<typeof useAsyncState<AssetItem[]>>>()
|
||||
)
|
||||
|
||||
/**
|
||||
* Internal helper to fetch and cache assets with a given key and fetcher
|
||||
*/
|
||||
async function updateModelsForKey(
|
||||
key: string,
|
||||
fetcher: () => Promise<AssetItem[]>
|
||||
): Promise<AssetItem[]> {
|
||||
if (!stateByNodeType.has(key)) {
|
||||
stateByNodeType.set(
|
||||
key,
|
||||
useAsyncState(fetcher, [], {
|
||||
immediate: false,
|
||||
resetOnExecute: false,
|
||||
onError: (err) => {
|
||||
console.error(`Error fetching model assets for ${key}:`, err)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const state = stateByNodeType.get(key)!
|
||||
|
||||
modelLoadingByNodeType.set(key, true)
|
||||
modelErrorByNodeType.set(key, null)
|
||||
|
||||
try {
|
||||
await state.execute()
|
||||
} finally {
|
||||
modelLoadingByNodeType.set(key, state.isLoading.value)
|
||||
}
|
||||
|
||||
const assets = state.state.value
|
||||
const existingAssets = modelAssetsByNodeType.get(key)
|
||||
|
||||
if (!isEqual(existingAssets, assets)) {
|
||||
modelAssetsByNodeType.set(key, assets)
|
||||
}
|
||||
|
||||
modelErrorByNodeType.set(
|
||||
key,
|
||||
state.error.value instanceof Error ? state.error.value : null
|
||||
)
|
||||
|
||||
return assets
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and cache model assets for a specific node type
|
||||
* Uses VueUse's useAsyncState for automatic loading/error tracking
|
||||
* @param nodeType The node type to fetch assets for (e.g., 'CheckpointLoaderSimple')
|
||||
* @returns Promise resolving to the fetched assets
|
||||
*/
|
||||
async function updateModelsForNodeType(
|
||||
nodeType: string
|
||||
): Promise<AssetItem[]> {
|
||||
if (!stateByNodeType.has(nodeType)) {
|
||||
stateByNodeType.set(
|
||||
nodeType,
|
||||
useAsyncState(
|
||||
() => assetService.getAssetsForNodeType(nodeType),
|
||||
[],
|
||||
{
|
||||
immediate: false,
|
||||
resetOnExecute: false,
|
||||
onError: (err) => {
|
||||
console.error(
|
||||
`Error fetching model assets for ${nodeType}:`,
|
||||
err
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
return updateModelsForKey(nodeType, () =>
|
||||
assetService.getAssetsForNodeType(nodeType)
|
||||
)
|
||||
}
|
||||
|
||||
const state = stateByNodeType.get(nodeType)!
|
||||
|
||||
modelLoadingByNodeType.set(nodeType, true)
|
||||
modelErrorByNodeType.set(nodeType, null)
|
||||
|
||||
try {
|
||||
await state.execute()
|
||||
const assets = state.state.value
|
||||
modelAssetsByNodeType.set(nodeType, assets)
|
||||
modelErrorByNodeType.set(
|
||||
nodeType,
|
||||
state.error.value instanceof Error ? state.error.value : null
|
||||
)
|
||||
return assets
|
||||
} finally {
|
||||
modelLoadingByNodeType.set(nodeType, state.isLoading.value)
|
||||
}
|
||||
/**
|
||||
* Fetch and cache model assets for a specific tag
|
||||
* @param tag The tag to fetch assets for (e.g., 'models')
|
||||
* @returns Promise resolving to the fetched assets
|
||||
*/
|
||||
async function updateModelsForTag(tag: string): Promise<AssetItem[]> {
|
||||
const key = `tag:${tag}`
|
||||
return updateModelsForKey(key, () => assetService.getAssetsByTag(tag))
|
||||
}
|
||||
|
||||
return {
|
||||
modelAssetsByNodeType,
|
||||
modelLoadingByNodeType,
|
||||
modelErrorByNodeType,
|
||||
updateModelsForNodeType
|
||||
updateModelsForNodeType,
|
||||
updateModelsForTag
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,7 +362,8 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
modelAssetsByNodeType: shallowReactive(new Map<string, AssetItem[]>()),
|
||||
modelLoadingByNodeType: shallowReactive(new Map<string, boolean>()),
|
||||
modelErrorByNodeType: shallowReactive(new Map<string, Error | null>()),
|
||||
updateModelsForNodeType: async () => []
|
||||
updateModelsForNodeType: async () => [],
|
||||
updateModelsForTag: async () => []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,7 +371,8 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
modelAssetsByNodeType,
|
||||
modelLoadingByNodeType,
|
||||
modelErrorByNodeType,
|
||||
updateModelsForNodeType
|
||||
updateModelsForNodeType,
|
||||
updateModelsForTag
|
||||
} = getModelState()
|
||||
|
||||
// Watch for completed downloads and refresh model caches
|
||||
@@ -403,6 +428,7 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
modelAssetsByNodeType,
|
||||
modelLoadingByNodeType,
|
||||
modelErrorByNodeType,
|
||||
updateModelsForNodeType
|
||||
updateModelsForNodeType,
|
||||
updateModelsForTag
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div class="flex w-[490px] flex-col border-t-1 border-border-default">
|
||||
<div class="flex h-full w-full flex-col gap-4 p-4">
|
||||
<!-- Error Details -->
|
||||
<div v-if="importFailedPackages.length > 0" class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="pkg in importFailedPackages"
|
||||
:key="pkg.packageId"
|
||||
class="flex flex-col gap-2 max-h-60 overflow-x-hidden overflow-y-auto scrollbar-custom"
|
||||
role="region"
|
||||
:aria-label="`Error traceback for ${pkg.packageId}`"
|
||||
tabindex="0"
|
||||
>
|
||||
<!-- Error Message -->
|
||||
<div
|
||||
v-if="pkg.traceback || pkg.errorMessage"
|
||||
class="text-xs p-4 rounded-md bg-secondary-background font-mono"
|
||||
>
|
||||
{{ pkg.traceback || pkg.errorMessage }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { ConflictDetectionResult } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
|
||||
|
||||
const { conflictedPackages } = defineProps<{
|
||||
conflictedPackages: ConflictDetectionResult[]
|
||||
}>()
|
||||
|
||||
interface ImportFailedPackage {
|
||||
packageId: string
|
||||
packageName: string
|
||||
errorMessage: string
|
||||
traceback: string
|
||||
}
|
||||
|
||||
const importFailedPackages = computed((): ImportFailedPackage[] => {
|
||||
return conflictedPackages
|
||||
.filter((pkg) =>
|
||||
pkg.conflicts.some((conflict) => conflict.type === 'import_failed')
|
||||
)
|
||||
.map((pkg) => {
|
||||
const importFailedConflict = pkg.conflicts.find(
|
||||
(conflict) => conflict.type === 'import_failed'
|
||||
)
|
||||
if (!importFailedConflict) {
|
||||
return {
|
||||
packageId: pkg.package_id,
|
||||
packageName: pkg.package_name,
|
||||
errorMessage: 'Unknown import error',
|
||||
traceback: ''
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
packageId: pkg.package_id,
|
||||
packageName: pkg.package_name,
|
||||
errorMessage:
|
||||
importFailedConflict.current_value || 'Unknown import error',
|
||||
traceback: importFailedConflict.required_value || ''
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div class="flex w-full items-center justify-between px-3 pb-4">
|
||||
<div class="flex w-full items-start justify-end gap-2 pr-1">
|
||||
<Button variant="secondary" @click="handleCopyError">
|
||||
{{ $t('importFailed.copyError') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import type { ConflictDetectionResult } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
|
||||
|
||||
const { conflictedPackages = [] } = defineProps<{
|
||||
conflictedPackages?: ConflictDetectionResult[]
|
||||
}>()
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
|
||||
const formatErrorText = computed(() => {
|
||||
const errorParts: string[] = []
|
||||
|
||||
conflictedPackages.forEach((pkg) => {
|
||||
const importFailedConflict = pkg.conflicts.find(
|
||||
(conflict) => conflict.type === 'import_failed'
|
||||
)
|
||||
|
||||
if (importFailedConflict?.required_value) {
|
||||
errorParts.push(importFailedConflict.required_value)
|
||||
}
|
||||
})
|
||||
|
||||
return errorParts.join('\n\n')
|
||||
})
|
||||
|
||||
const handleCopyError = () => {
|
||||
copyToClipboard(formatErrorText.value)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<div class="flex w-full items-center justify-between p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="icon-[lucide--triangle-alert] text-gold-600"></i>
|
||||
<p class="m-0 text-sm">
|
||||
{{ $t('importFailed.title') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
@@ -245,7 +245,7 @@ describe('NodeConflictDialogContent', () => {
|
||||
await conflictsHeader.trigger('click')
|
||||
|
||||
// Should be expanded now
|
||||
const conflictItems = wrapper.findAll('.conflict-list-item')
|
||||
const conflictItems = wrapper.findAll('[aria-label*="Conflict:"]')
|
||||
expect(conflictItems.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
@@ -324,7 +324,7 @@ describe('NodeConflictDialogContent', () => {
|
||||
await conflictsHeader.trigger('click')
|
||||
|
||||
// Should display conflict messages (excluding import_failed)
|
||||
const conflictItems = wrapper.findAll('.conflict-list-item')
|
||||
const conflictItems = wrapper.findAll('[aria-label*="Conflict:"]')
|
||||
expect(conflictItems).toHaveLength(3) // 2 from Package1 + 1 from Package2
|
||||
})
|
||||
|
||||
@@ -338,7 +338,9 @@ describe('NodeConflictDialogContent', () => {
|
||||
await importFailedHeader.trigger('click')
|
||||
|
||||
// Should display only import failed package
|
||||
const importFailedItems = wrapper.findAll('.conflict-list-item')
|
||||
const importFailedItems = wrapper.findAll(
|
||||
'[aria-label*="Import failed package:"]'
|
||||
)
|
||||
expect(importFailedItems).toHaveLength(1)
|
||||
expect(importFailedItems[0].text()).toContain('Test Package 3')
|
||||
})
|
||||
|
||||
@@ -50,7 +50,8 @@
|
||||
<div
|
||||
v-for="(packageName, i) in importFailedConflicts"
|
||||
:key="i"
|
||||
class="conflict-list-item flex h-6 shrink-0 items-center justify-between px-4"
|
||||
:aria-label="`Import failed package: ${packageName}`"
|
||||
class="flex min-h-6 shrink-0 hover:bg-node-component-surface-hovered items-center justify-between px-4 py-1"
|
||||
>
|
||||
<span class="text-xs text-muted">
|
||||
{{ packageName }}
|
||||
@@ -98,7 +99,8 @@
|
||||
<div
|
||||
v-for="(conflict, i) in allConflictDetails"
|
||||
:key="i"
|
||||
class="conflict-list-item flex h-6 shrink-0 items-center justify-between px-4"
|
||||
:aria-label="`Conflict: ${getConflictMessage(conflict, t)}`"
|
||||
class="flex min-h-6 shrink-0 hover:bg-node-component-surface-hovered items-center justify-between px-4 py-1"
|
||||
>
|
||||
<span class="text-xs text-muted">{{
|
||||
getConflictMessage(conflict, t)
|
||||
@@ -146,7 +148,7 @@
|
||||
<div
|
||||
v-for="conflictResult in conflictData"
|
||||
:key="conflictResult.package_id"
|
||||
class="conflict-list-item flex h-6 shrink-0 items-center justify-between px-4"
|
||||
class="flex min-h-6 shrink-0 hover:bg-node-component-surface-hovered items-center justify-between px-4 py-1"
|
||||
>
|
||||
<span class="text-xs text-muted">
|
||||
{{ conflictResult.package_name }}
|
||||
@@ -236,8 +238,3 @@ const toggleExtensionsPanel = () => {
|
||||
importFailedExpanded.value = false
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.conflict-list-item:hover {
|
||||
background-color: rgb(0 122 255 / 0.2);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="flex h-12 w-full items-center justify-between pl-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Warning Icon -->
|
||||
<i class="pi pi-exclamation-triangle text-lg"></i>
|
||||
<i class="icon-[lucide--triangle-alert] text-gold-600"></i>
|
||||
<!-- Title -->
|
||||
<p class="text-base font-bold">
|
||||
{{ $t('manager.conflicts.title') }}
|
||||
|
||||
@@ -41,6 +41,8 @@ import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comf
|
||||
import { useConflictDetectionStore } from '@/workbench/extensions/manager/stores/conflictDetectionStore'
|
||||
import type { components as ManagerComponents } from '@/workbench/extensions/manager/types/generatedManagerTypes'
|
||||
|
||||
import { useImportFailedDetection } from '../../../composables/useImportFailedDetection'
|
||||
|
||||
const TOGGLE_DEBOUNCE_MS = 256
|
||||
|
||||
const { nodePack } = defineProps<{
|
||||
@@ -53,6 +55,7 @@ const { isPackEnabled, enablePack, disablePack, installedPacks } =
|
||||
const { getConflictsForPackageByID } = useConflictDetectionStore()
|
||||
const { showNodeConflictDialog } = useDialogService()
|
||||
const { acknowledgmentState, markConflictsAsSeen } = useConflictAcknowledgment()
|
||||
const { showImportFailedDialog } = useImportFailedDetection(nodePack.id || '')
|
||||
|
||||
const isLoading = ref(false)
|
||||
|
||||
@@ -81,23 +84,36 @@ const canToggleDirectly = computed(() => {
|
||||
const showConflictModal = (skipModalDismissed: boolean) => {
|
||||
let modal_dismissed = acknowledgmentState.value.modal_dismissed
|
||||
if (skipModalDismissed) modal_dismissed = false
|
||||
|
||||
if (packageConflict.value && !modal_dismissed) {
|
||||
showNodeConflictDialog({
|
||||
conflictedPackages: [packageConflict.value],
|
||||
buttonText: !isEnabled.value
|
||||
? t('manager.conflicts.enableAnyway')
|
||||
: t('manager.conflicts.understood'),
|
||||
onButtonClick: async () => {
|
||||
if (!isEnabled.value) {
|
||||
await handleEnable()
|
||||
// Check if there's an import failed conflict first
|
||||
const hasImportFailed = packageConflict.value.conflicts.some(
|
||||
(conflict) => conflict.type === 'import_failed'
|
||||
)
|
||||
if (hasImportFailed) {
|
||||
// Show import failed dialog instead of general conflict dialog
|
||||
showImportFailedDialog(() => {
|
||||
markConflictsAsSeen()
|
||||
})
|
||||
} else {
|
||||
// Show general conflict dialog for other types of conflicts
|
||||
showNodeConflictDialog({
|
||||
conflictedPackages: [packageConflict.value],
|
||||
buttonText: !isEnabled.value
|
||||
? t('manager.conflicts.enableAnyway')
|
||||
: t('manager.conflicts.understood'),
|
||||
onButtonClick: async () => {
|
||||
if (!isEnabled.value) {
|
||||
await handleEnable()
|
||||
}
|
||||
},
|
||||
dialogComponentProps: {
|
||||
onClose: () => {
|
||||
markConflictsAsSeen()
|
||||
}
|
||||
}
|
||||
},
|
||||
dialogComponentProps: {
|
||||
onClose: () => {
|
||||
markConflictsAsSeen()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,43 +1,37 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<button
|
||||
v-if="importFailedInfo"
|
||||
class="inline-flex cursor-pointer items-center justify-end gap-1 border-none bg-transparent outline-none"
|
||||
@click="showImportFailedDialog"
|
||||
>
|
||||
<i class="pi pi-code text-base"></i>
|
||||
<span class="text-sm text-base-foreground">{{
|
||||
t('serverStart.openLogs')
|
||||
}}</span>
|
||||
</button>
|
||||
<div
|
||||
v-for="(conflict, index) in conflictResult?.conflicts || []"
|
||||
:key="index"
|
||||
class="rounded-md bg-yellow-800/20 p-3"
|
||||
class="rounded-md bg-secondary-background/60 px-2 py-1"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1 text-sm break-words">
|
||||
<!-- Import failed conflicts show detailed error message -->
|
||||
<template v-if="conflict.type === 'import_failed'">
|
||||
<div
|
||||
v-if="conflict.required_value"
|
||||
class="max-h-64 overflow-x-hidden scrollbar-custom overflow-y-auto rounded px-2"
|
||||
>
|
||||
<p class="text-xs text-muted-foreground break-all font-mono">
|
||||
{{ conflict.required_value }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Other conflict types use standard message -->
|
||||
<template v-else>
|
||||
<div class="text-sm break-words">
|
||||
{{ getConflictMessage(conflict, $t) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import { useImportFailedDetection } from '@/workbench/extensions/manager/composables/useImportFailedDetection'
|
||||
import type { ConflictDetectionResult } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
|
||||
import { getConflictMessage } from '@/workbench/extensions/manager/utils/conflictMessageUtil'
|
||||
|
||||
const { nodePack, conflictResult } = defineProps<{
|
||||
nodePack: components['schemas']['Node']
|
||||
const { conflictResult } = defineProps<{
|
||||
conflictResult: ConflictDetectionResult | null | undefined
|
||||
}>()
|
||||
const packageId = computed(() => nodePack?.id || '')
|
||||
const { importFailedInfo, showImportFailedDialog } =
|
||||
useImportFailedDetection(packageId)
|
||||
</script>
|
||||
|
||||
@@ -410,7 +410,7 @@ describe('useConflictDetection', () => {
|
||||
mockComfyManagerService.getImportFailInfoBulk
|
||||
).mockResolvedValue({
|
||||
'fail-pack': {
|
||||
msg: 'Import error',
|
||||
error: 'Import error',
|
||||
name: 'fail-pack',
|
||||
path: '/path/to/pack'
|
||||
} as any // The actual API returns different structure than types
|
||||
@@ -428,7 +428,7 @@ describe('useConflictDetection', () => {
|
||||
// Import failure should match the actual implementation
|
||||
expect(result.results[0].conflicts).toContainEqual({
|
||||
type: 'import_failed',
|
||||
current_value: 'installed',
|
||||
current_value: 'Import error',
|
||||
required_value: 'Import error'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -389,7 +389,10 @@ export function useConflictDetection() {
|
||||
* @returns Array of conflict detection results for failed imports
|
||||
*/
|
||||
function detectImportFailConflicts(
|
||||
importFailInfo: Record<string, { msg: string; name: string; path: string }>
|
||||
importFailInfo: Record<
|
||||
string,
|
||||
{ error?: string; traceback?: string } | null
|
||||
>
|
||||
): ConflictDetectionResult[] {
|
||||
const results: ConflictDetectionResult[] = []
|
||||
if (!importFailInfo || typeof importFailInfo !== 'object') {
|
||||
@@ -400,8 +403,11 @@ export function useConflictDetection() {
|
||||
for (const [packageId, failureInfo] of Object.entries(importFailInfo)) {
|
||||
if (failureInfo && typeof failureInfo === 'object') {
|
||||
// Extract error information from Manager API response
|
||||
const errorMsg = failureInfo.msg || 'Unknown import error'
|
||||
const modulePath = failureInfo.path || ''
|
||||
const errorMsg = failureInfo.error || 'Unknown import error'
|
||||
const traceback = failureInfo.traceback || ''
|
||||
|
||||
// Combine error and traceback for display
|
||||
const fullErrorInfo = traceback || errorMsg
|
||||
|
||||
results.push({
|
||||
package_id: packageId,
|
||||
@@ -410,8 +416,8 @@ export function useConflictDetection() {
|
||||
conflicts: [
|
||||
{
|
||||
type: 'import_failed',
|
||||
current_value: 'installed',
|
||||
required_value: failureInfo.msg
|
||||
current_value: errorMsg,
|
||||
required_value: fullErrorInfo
|
||||
}
|
||||
],
|
||||
is_compatible: false
|
||||
@@ -420,8 +426,8 @@ export function useConflictDetection() {
|
||||
console.warn(
|
||||
`[ConflictDetection] Python import failure detected for ${packageId}:`,
|
||||
{
|
||||
path: modulePath,
|
||||
error: errorMsg
|
||||
error: errorMsg,
|
||||
hasTraceback: !!traceback
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -44,7 +44,8 @@ describe('useImportFailedDetection', () => {
|
||||
>
|
||||
|
||||
mockDialogService = {
|
||||
showErrorDialog: vi.fn()
|
||||
showErrorDialog: vi.fn(),
|
||||
showImportFailedNodeDialog: vi.fn()
|
||||
} as unknown as ReturnType<typeof dialogService.useDialogService>
|
||||
|
||||
vi.mocked(comfyManagerStore.useComfyManagerStore).mockReturnValue(
|
||||
@@ -226,13 +227,22 @@ describe('useImportFailedDetection', () => {
|
||||
|
||||
showImportFailedDialog()
|
||||
|
||||
expect(mockDialogService.showErrorDialog).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
{
|
||||
title: 'manager.failedToInstall',
|
||||
reportType: 'importFailedError'
|
||||
expect(mockDialogService.showImportFailedNodeDialog).toHaveBeenCalledWith({
|
||||
conflictedPackages: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
package_id: 'test-package',
|
||||
package_name: 'Test Package',
|
||||
conflicts: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: 'import_failed'
|
||||
})
|
||||
])
|
||||
})
|
||||
]),
|
||||
dialogComponentProps: {
|
||||
onClose: undefined
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle null packageId', () => {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { computed, unref } from 'vue'
|
||||
import type { ComputedRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
||||
import { useConflictDetectionStore } from '@/workbench/extensions/manager/stores/conflictDetectionStore'
|
||||
import type { ConflictDetail } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
|
||||
import type {
|
||||
ConflictDetail,
|
||||
ConflictDetectionResult
|
||||
} from '@/workbench/extensions/manager/types/conflictDetectionTypes'
|
||||
|
||||
/**
|
||||
* Extracting import failed conflicts from conflict list
|
||||
@@ -24,22 +26,18 @@ function extractImportFailedConflicts(conflicts?: ConflictDetail[] | null) {
|
||||
* Creating import failed dialog
|
||||
*/
|
||||
function createImportFailedDialog() {
|
||||
const { t } = useI18n()
|
||||
const { showErrorDialog } = useDialogService()
|
||||
const { showImportFailedNodeDialog } = useDialogService()
|
||||
|
||||
return (importFailedInfo: ConflictDetail[] | null) => {
|
||||
if (importFailedInfo) {
|
||||
const errorMessage =
|
||||
importFailedInfo
|
||||
.map((conflict) => conflict.required_value)
|
||||
.filter(Boolean)
|
||||
.join('\n') || t('manager.importFailedGenericError')
|
||||
|
||||
const error = new Error(errorMessage)
|
||||
|
||||
showErrorDialog(error, {
|
||||
title: t('manager.failedToInstall'),
|
||||
reportType: 'importFailedError'
|
||||
return (
|
||||
conflictedPackages: ConflictDetectionResult[] | null,
|
||||
onClose?: () => void
|
||||
) => {
|
||||
if (conflictedPackages && conflictedPackages.length > 0) {
|
||||
showImportFailedNodeDialog({
|
||||
conflictedPackages,
|
||||
dialogComponentProps: {
|
||||
onClose
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -74,13 +72,16 @@ export function useImportFailedDetection(
|
||||
return importFailedInfo.value !== null
|
||||
})
|
||||
|
||||
const showImportFailedDialog = createImportFailedDialog()
|
||||
const openDialog = createImportFailedDialog()
|
||||
|
||||
return {
|
||||
importFailedInfo,
|
||||
importFailed,
|
||||
showImportFailedDialog: () =>
|
||||
showImportFailedDialog(importFailedInfo.value),
|
||||
showImportFailedDialog: (onClose?: () => void) => {
|
||||
if (conflicts.value) {
|
||||
openDialog([conflicts.value], onClose)
|
||||
}
|
||||
},
|
||||
isInstalled
|
||||
}
|
||||
}
|
||||
|
||||
@@ -482,12 +482,6 @@ export default defineConfig({
|
||||
: []
|
||||
},
|
||||
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'happy-dom',
|
||||
setupFiles: ['./vitest.setup.ts']
|
||||
},
|
||||
|
||||
define: {
|
||||
__COMFYUI_FRONTEND_VERSION__: JSON.stringify(
|
||||
process.env.npm_package_version
|
||||
|
||||
Reference in New Issue
Block a user