Compare commits
57 Commits
fix/load-a
...
cloud/1.41
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
765ae28c53 | ||
|
|
ab5e360391 | ||
|
|
3d4d324019 | ||
|
|
1829fe32da | ||
|
|
0de694ce8d | ||
|
|
03d192e605 | ||
|
|
8ca6a1799b | ||
|
|
02d6ecf897 | ||
|
|
d9db335f58 | ||
|
|
d1827eecf3 | ||
|
|
73435ee2d9 | ||
|
|
fa8716572d | ||
|
|
951a26775c | ||
|
|
80155ee06c | ||
|
|
9764c80116 | ||
|
|
d13061b943 | ||
|
|
c0ea8f1e31 | ||
|
|
2c8ad1380f | ||
|
|
af8d502d81 | ||
|
|
c48953839f | ||
|
|
86e67d163d | ||
|
|
7ad2f195e4 | ||
|
|
eedf03a709 | ||
|
|
4b91e35d1d | ||
|
|
32f757b54a | ||
|
|
37b9593b9a | ||
|
|
e3baf6df0b | ||
|
|
34953a7f98 | ||
|
|
b1bfe5fb46 | ||
|
|
a5e5e4813a | ||
|
|
1d05f08edd | ||
|
|
936291eb85 | ||
|
|
fd352a4a8f | ||
|
|
99db641662 | ||
|
|
4e933d504c | ||
|
|
e3dc4a96e8 | ||
|
|
ce40e5ef85 | ||
|
|
7302f550bd | ||
|
|
37be2eca91 | ||
|
|
ca66943826 | ||
|
|
64e1983231 | ||
|
|
2c8974d250 | ||
|
|
a77b452186 | ||
|
|
9b3d80955b | ||
|
|
c1cfe6ac73 | ||
|
|
653c278f91 | ||
|
|
165856e1a0 | ||
|
|
f27da404f0 | ||
|
|
0f4ad8098d | ||
|
|
f4828c4a25 | ||
|
|
af17ab440d | ||
|
|
444816053f | ||
|
|
0483c6819e | ||
|
|
df9b359cc3 | ||
|
|
1c9edeb604 | ||
|
|
859070f3fe | ||
|
|
65ac0e586d |
@@ -38,6 +38,9 @@ TEST_COMFYUI_DIR=/home/ComfyUI
|
||||
ALGOLIA_APP_ID=4E0RO38HS8
|
||||
ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
|
||||
|
||||
# Enable PostHog debug logging in the browser console.
|
||||
# VITE_POSTHOG_DEBUG=true
|
||||
|
||||
# Sentry ENV vars replace with real ones for debugging
|
||||
# SENTRY_AUTH_TOKEN=private-token # get from sentry
|
||||
# SENTRY_ORG=comfy-org
|
||||
|
||||
1
.gitignore
vendored
@@ -26,6 +26,7 @@ dist-ssr
|
||||
.claude/*.local.json
|
||||
.claude/*.local.md
|
||||
.claude/*.local.txt
|
||||
.claude/worktrees
|
||||
CLAUDE.local.md
|
||||
|
||||
# Editor directories and files
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<template v-if="filter.tasks.length === 0">
|
||||
<!-- Empty filter -->
|
||||
<Divider />
|
||||
<p class="text-neutral-400 w-full text-center">
|
||||
<p class="w-full text-center text-neutral-400">
|
||||
{{ $t('maintenance.allOk') }}
|
||||
</p>
|
||||
</template>
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
<!-- Display: Cards -->
|
||||
<template v-else>
|
||||
<div class="flex flex-wrap justify-evenly gap-8 pad-y my-4">
|
||||
<div class="pad-y my-4 flex flex-wrap justify-evenly gap-8">
|
||||
<TaskCard
|
||||
v-for="task in filter.tasks"
|
||||
:key="task.id"
|
||||
@@ -45,7 +45,8 @@ import { useConfirm, useToast } from 'primevue'
|
||||
import ConfirmPopup from 'primevue/confirmpopup'
|
||||
import Divider from 'primevue/divider'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
|
||||
import type {
|
||||
MaintenanceFilter,
|
||||
@@ -55,6 +56,7 @@ import type {
|
||||
import TaskCard from './TaskCard.vue'
|
||||
import TaskListItem from './TaskListItem.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
const taskStore = useMaintenanceTaskStore()
|
||||
@@ -80,8 +82,7 @@ const executeTask = async (task: MaintenanceTask) => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('maintenance.error.toastTitle'),
|
||||
detail: message ?? t('maintenance.error.defaultDescription'),
|
||||
life: 10_000
|
||||
detail: message ?? t('maintenance.error.defaultDescription')
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -189,8 +189,7 @@ const completeValidation = async () => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('maintenance.error.cannotContinue'),
|
||||
life: 5_000
|
||||
detail: t('maintenance.error.cannotContinue')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<BaseViewTemplate dark hide-language-selector>
|
||||
<div class="h-full p-8 2xl:p-16 flex flex-col items-center justify-center">
|
||||
<div class="flex h-full flex-col items-center justify-center p-8 2xl:p-16">
|
||||
<div
|
||||
class="bg-neutral-800 rounded-lg shadow-lg p-6 w-full max-w-[600px] flex flex-col gap-6"
|
||||
class="flex w-full max-w-[600px] flex-col gap-6 rounded-lg bg-neutral-800 p-6 shadow-lg"
|
||||
>
|
||||
<h2 class="text-3xl font-semibold text-neutral-100">
|
||||
{{ $t('install.helpImprove') }}
|
||||
@@ -15,7 +15,7 @@
|
||||
<a
|
||||
href="https://comfy.org/privacy"
|
||||
target="_blank"
|
||||
class="text-blue-400 hover:text-blue-300 underline"
|
||||
class="text-blue-400 underline hover:text-blue-300"
|
||||
>
|
||||
{{ $t('install.privacyPolicy') }} </a
|
||||
>.
|
||||
@@ -33,7 +33,7 @@
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex pt-6 justify-end">
|
||||
<div class="flex justify-end pt-6">
|
||||
<Button
|
||||
:label="$t('g.ok')"
|
||||
icon="pi pi-check"
|
||||
@@ -72,8 +72,7 @@ const updateConsent = async () => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('install.settings.errorUpdatingConsent'),
|
||||
detail: t('install.settings.errorUpdatingConsentDetail'),
|
||||
life: 3000
|
||||
detail: t('install.settings.errorUpdatingConsentDetail')
|
||||
})
|
||||
} finally {
|
||||
isUpdating.value = false
|
||||
|
||||
47
browser_tests/assets/nodes/load_image_with_ksampler.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 50],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{ "name": "IMAGE", "type": "IMAGE", "links": null },
|
||||
{ "name": "MASK", "type": "MASK", "links": null }
|
||||
],
|
||||
"properties": { "Node name for S&R": "LoadImage" },
|
||||
"widgets_values": ["example.png", "image"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "KSampler",
|
||||
"pos": [500, 50],
|
||||
"size": [315, 262],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": null }],
|
||||
"properties": { "Node name for S&R": "KSampler" },
|
||||
"widgets_values": [0, "randomize", 20, 8, "euler", "normal", 1]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": { "offset": [0, 0], "scale": 1 }
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
44
browser_tests/tests/groupCopyPaste.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Group Copy Paste', { tag: ['@canvas'] }, () => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.canvasOps.resetView()
|
||||
})
|
||||
|
||||
test('Pasted group is offset from original position', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('groups/single_group_only')
|
||||
|
||||
const titlePos = await comfyPage.page.evaluate(() => {
|
||||
const app = window.app!
|
||||
const group = app.graph.groups[0]
|
||||
const clientPos = app.canvasPosToClientPos([
|
||||
group.pos[0] + 50,
|
||||
group.pos[1] + 15
|
||||
])
|
||||
return { x: clientPos[0], y: clientPos[1] }
|
||||
})
|
||||
await comfyPage.canvas.click({ position: titlePos })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.clipboard.copy()
|
||||
await comfyPage.clipboard.paste()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const positions = await comfyPage.page.evaluate(() =>
|
||||
window.app!.graph.groups.map((g: { pos: number[] }) => ({
|
||||
x: g.pos[0],
|
||||
y: g.pos[1]
|
||||
}))
|
||||
)
|
||||
|
||||
expect(positions).toHaveLength(2)
|
||||
const dx = Math.abs(positions[0].x - positions[1].x)
|
||||
const dy = Math.abs(positions[0].y - positions[1].y)
|
||||
expect(dx).toBeCloseTo(50, 0)
|
||||
expect(dy).toBeCloseTo(15, 0)
|
||||
})
|
||||
})
|
||||
58
browser_tests/tests/imagePastePriority.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe(
|
||||
'Image paste priority over stale node metadata',
|
||||
{ tag: ['@node'] },
|
||||
() => {
|
||||
test('Should not paste copied node when a LoadImage node is selected and clipboard has stale node metadata', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/load_image_with_ksampler')
|
||||
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
expect(initialCount).toBe(2)
|
||||
|
||||
// Copy the KSampler node (puts data-metadata in clipboard)
|
||||
const ksamplerNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
|
||||
await ksamplerNodes[0].copy()
|
||||
|
||||
// Select the LoadImage node
|
||||
const loadImageNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
await loadImageNodes[0].click('title')
|
||||
|
||||
// Simulate pasting when clipboard has stale node metadata (text/html
|
||||
// with data-metadata) but no image file items. This replicates the bug
|
||||
// scenario: user copied a node, then copied a web image (which replaces
|
||||
// clipboard files but may leave stale text/html with node metadata).
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const nodeData = { nodes: [{ type: 'KSampler', id: 99 }] }
|
||||
const base64 = btoa(JSON.stringify(nodeData))
|
||||
const html =
|
||||
'<meta charset="utf-8"><div><span data-metadata="' +
|
||||
base64 +
|
||||
'"></span></div><span style="white-space:pre-wrap;">Text</span>'
|
||||
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.setData('text/html', html)
|
||||
|
||||
const event = new ClipboardEvent('paste', {
|
||||
clipboardData: dataTransfer,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
document.dispatchEvent(event)
|
||||
})
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Node count should remain the same — stale node metadata should NOT
|
||||
// be deserialized when a media node is selected.
|
||||
const finalCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
expect(finalCount).toBe(initialCount)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 107 KiB |
102
browser_tests/tests/vueNodes/widgets/advancedWidgets.spec.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Advanced Widget Visibility', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Node.AlwaysShowAdvancedWidgets',
|
||||
false
|
||||
)
|
||||
|
||||
// Add a ModelSamplingFlux node which has both advanced (max_shift,
|
||||
// base_shift) and non-advanced (width, height) widgets.
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window.LiteGraph!.createNode('ModelSamplingFlux')!
|
||||
node.pos = [500, 200]
|
||||
window.app!.graph.add(node)
|
||||
})
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
function getNode(
|
||||
comfyPage: Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
|
||||
) {
|
||||
return comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux')
|
||||
}
|
||||
|
||||
function getWidgets(
|
||||
comfyPage: Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
|
||||
) {
|
||||
return getNode(comfyPage).locator('.lg-node-widget')
|
||||
}
|
||||
|
||||
test('should hide advanced widgets by default', async ({ comfyPage }) => {
|
||||
const node = getNode(comfyPage)
|
||||
const widgets = getWidgets(comfyPage)
|
||||
|
||||
// Non-advanced widgets (width, height) should be visible
|
||||
await expect(widgets).toHaveCount(2)
|
||||
await expect(node.getByLabel('width', { exact: true })).toBeVisible()
|
||||
await expect(node.getByLabel('height', { exact: true })).toBeVisible()
|
||||
|
||||
// Advanced widgets should not be rendered
|
||||
await expect(
|
||||
node.getByLabel('max_shift', { exact: true })
|
||||
).not.toBeVisible()
|
||||
await expect(
|
||||
node.getByLabel('base_shift', { exact: true })
|
||||
).not.toBeVisible()
|
||||
|
||||
// "Show advanced inputs" button should be present
|
||||
await expect(node.getByText('Show advanced inputs')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show advanced widgets when per-node toggle is clicked', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = getNode(comfyPage)
|
||||
const widgets = getWidgets(comfyPage)
|
||||
|
||||
await expect(widgets).toHaveCount(2)
|
||||
|
||||
// Click the toggle button to show advanced widgets
|
||||
await node.getByText('Show advanced inputs').click()
|
||||
|
||||
await expect(widgets).toHaveCount(4)
|
||||
await expect(node.getByLabel('max_shift', { exact: true })).toBeVisible()
|
||||
await expect(node.getByLabel('base_shift', { exact: true })).toBeVisible()
|
||||
|
||||
// Button text should change to "Hide advanced inputs"
|
||||
await expect(node.getByText('Hide advanced inputs')).toBeVisible()
|
||||
|
||||
// Click again to hide
|
||||
await node.getByText('Hide advanced inputs').click()
|
||||
await expect(widgets).toHaveCount(2)
|
||||
})
|
||||
|
||||
test('should show advanced widgets when global setting is enabled', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = getNode(comfyPage)
|
||||
const widgets = getWidgets(comfyPage)
|
||||
|
||||
await expect(widgets).toHaveCount(2)
|
||||
|
||||
// Enable the global setting
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Node.AlwaysShowAdvancedWidgets',
|
||||
true
|
||||
)
|
||||
|
||||
// All 4 widgets should now be visible
|
||||
await expect(widgets).toHaveCount(4)
|
||||
await expect(node.getByLabel('max_shift', { exact: true })).toBeVisible()
|
||||
await expect(node.getByLabel('base_shift', { exact: true })).toBeVisible()
|
||||
|
||||
// The toggle button should not be shown when global setting is active
|
||||
await expect(node.getByText('Show advanced inputs')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -24,6 +24,7 @@ const extraFileExtensions = ['.vue']
|
||||
const commonGlobals = {
|
||||
...globals.browser,
|
||||
__COMFYUI_FRONTEND_VERSION__: 'readonly',
|
||||
__COMFYUI_FRONTEND_COMMIT__: 'readonly',
|
||||
__DISTRIBUTION__: 'readonly',
|
||||
__IS_NIGHTLY__: 'readonly'
|
||||
} as const
|
||||
|
||||
2
global.d.ts
vendored
@@ -1,4 +1,5 @@
|
||||
declare const __COMFYUI_FRONTEND_VERSION__: string
|
||||
declare const __COMFYUI_FRONTEND_COMMIT__: string
|
||||
declare const __SENTRY_ENABLED__: boolean
|
||||
declare const __SENTRY_DSN__: string
|
||||
declare const __ALGOLIA_APP_ID__: string
|
||||
@@ -35,6 +36,7 @@ interface Window {
|
||||
mixpanel_token?: string
|
||||
posthog_project_token?: string
|
||||
posthog_api_host?: string
|
||||
posthog_config?: Record<string, unknown>
|
||||
require_whitelist?: boolean
|
||||
subscription_required?: boolean
|
||||
max_upload_size?: number
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
@plugin "./lucideStrokePlugin.js";
|
||||
|
||||
/* Safelist dynamic comfy icons for node library folders */
|
||||
@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow}]");
|
||||
@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow}]");
|
||||
|
||||
/* Safelist dynamic comfy icons for essential nodes (kebab-case of node names) */
|
||||
@source inline("icon-[comfy--{save-image,load-video,save-video,load-3-d,save-glb,image-batch,batch-images-node,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,lora-loader-model-only,primitive-string-multiline,get-video-components,video-slice,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,subgraph-blueprint-canny-to-video-ltx-2-0,subgraph-blueprint-pose-to-video-ltx-2-0,preview-image,image-and-mask-preview,layer-mask-mask-preview,mask-preview,image-preview-from-latent}]");
|
||||
|
||||
1
packages/design-system/src/icons/elevenlabs.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ElevenLabs</title><path d="M5 0h5v24H5V0zM14 0h5v24h-5V0z"></path></svg>
|
||||
|
After Width: | Height: | Size: 236 B |
10
packages/design-system/src/icons/reve.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg width="182" height="148" viewBox="0 0 182 148" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_2902_38097)">
|
||||
<path d="M116 16H110V0H116V16ZM105 96H99V132H105V96ZM105 24H99V36H105V24ZM149 96H143V128H149V96ZM160 100H154V116H160V100ZM105 52H99V76H105V52ZM116 40H110V84H116V40ZM160 16H154V72H160V16ZM171 20H165V52H171V20ZM182 24H176V36H182V24ZM94 44H88V112H94V44ZM94 44H88V112H94V44ZM127 32H121V48H127V32ZM138 24H132V40H138V24ZM149 16H143V48H149V16ZM127 60H121V84H127V60ZM138 68H132V84H138V68ZM149 60H143V84H149V60ZM116 92H110V112H116V92ZM127 92H121V104H127V92ZM138 92H132V112H138V92ZM116 120H110V144H116V120ZM127 128H121V148H127V128ZM138 120H132V140H138V120ZM72 0H66V16H72V0ZM83 96H77V132H83V96ZM83 24H77V36H83V24ZM39 96H33V128H39V96ZM28 100H22V116H28V100ZM83 52H77V76H83V52ZM72 40H66V84H72V40ZM28 16H22V72H28V16ZM17 20H11V52H17V20ZM6 24H0V36H6V24ZM61 32H55V48H61V32ZM50 24H44V40H50V24ZM39 16H33V48H39V16ZM61 60H55V84H61V60ZM50 68H44V84H50V68ZM39 60H33V84H39V60ZM72 92H66V112H72V92ZM61 92H55V104H61V92ZM50 92H44V112H50V92ZM72 120H66V144H72V120ZM61 128H55V148H61V128ZM50 120H44V140H50V120Z" fill="black"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2902_38097">
|
||||
<rect width="182" height="148" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -166,13 +166,22 @@ describe('TopMenuSection', () => {
|
||||
})
|
||||
|
||||
describe('authentication state', () => {
|
||||
function createLegacyTabBarWrapper() {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||
const settingStore = useSettingStore(pinia)
|
||||
vi.mocked(settingStore.get).mockImplementation((key) =>
|
||||
key === 'Comfy.UI.TabBarLayout' ? 'Legacy' : undefined
|
||||
)
|
||||
return createWrapper({ pinia })
|
||||
}
|
||||
|
||||
describe('when user is logged in', () => {
|
||||
beforeEach(() => {
|
||||
mockData.isLoggedIn = true
|
||||
})
|
||||
|
||||
it('should display CurrentUserButton and not display LoginButton', () => {
|
||||
const wrapper = createWrapper()
|
||||
const wrapper = createLegacyTabBarWrapper()
|
||||
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(true)
|
||||
expect(wrapper.findComponent(LoginButton).exists()).toBe(false)
|
||||
})
|
||||
@@ -186,7 +195,7 @@ describe('TopMenuSection', () => {
|
||||
describe('on desktop platform', () => {
|
||||
it('should display LoginButton and not display CurrentUserButton', () => {
|
||||
mockData.isDesktop = true
|
||||
const wrapper = createWrapper()
|
||||
const wrapper = createLegacyTabBarWrapper()
|
||||
expect(wrapper.findComponent(LoginButton).exists()).toBe(true)
|
||||
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(false)
|
||||
})
|
||||
@@ -194,7 +203,7 @@ describe('TopMenuSection', () => {
|
||||
|
||||
describe('on web platform', () => {
|
||||
it('should not display CurrentUserButton and not display LoginButton', () => {
|
||||
const wrapper = createWrapper()
|
||||
const wrapper = createLegacyTabBarWrapper()
|
||||
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(false)
|
||||
expect(wrapper.findComponent(LoginButton).exists()).toBe(false)
|
||||
})
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
@click="() => openShareDialog().catch(toastErrorHandler)"
|
||||
@pointerenter="prefetchShareDialog"
|
||||
>
|
||||
<i class="icon-[lucide--share-2] size-4" />
|
||||
<i class="icon-[comfy--send] size-4" />
|
||||
<span class="not-md:hidden">
|
||||
{{ t('actionbar.share') }}
|
||||
</span>
|
||||
@@ -183,7 +183,7 @@ const isActionbarFloating = computed(
|
||||
() => isActionbarEnabled.value && !isActionbarDocked.value
|
||||
)
|
||||
const isIntegratedTabBar = computed(
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') !== 'Legacy'
|
||||
)
|
||||
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
|
||||
useQueueFeatureFlags()
|
||||
|
||||
@@ -46,71 +46,74 @@ function showApps() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pointer-events-auto flex flex-col gap-2">
|
||||
<WorkflowActionsDropdown source="app_mode_toolbar" />
|
||||
|
||||
<Button
|
||||
v-if="enableAppBuilder"
|
||||
v-tooltip.right="{
|
||||
value: t('linearMode.appModeToolbar.appBuilder'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
:disabled="!hasNodes"
|
||||
:aria-label="t('linearMode.appModeToolbar.appBuilder')"
|
||||
class="size-10 rounded-lg"
|
||||
@click="enterBuilder"
|
||||
>
|
||||
<i class="icon-[lucide--hammer] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isCloud && flags.workflowSharingEnabled"
|
||||
v-tooltip.right="{
|
||||
value: t('actionbar.shareTooltip'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
:aria-label="t('actionbar.shareTooltip')"
|
||||
class="size-10 rounded-lg"
|
||||
@click="() => openShareDialog().catch(toastErrorHandler)"
|
||||
@pointerenter="prefetchShareDialog"
|
||||
>
|
||||
<i class="icon-[lucide--send] size-4" />
|
||||
</Button>
|
||||
|
||||
<div
|
||||
class="flex w-10 flex-col overflow-hidden rounded-lg bg-secondary-background"
|
||||
>
|
||||
<div class="pointer-events-auto flex flex-row items-start gap-2">
|
||||
<div class="pointer-events-auto flex flex-col gap-2">
|
||||
<Button
|
||||
v-if="enableAppBuilder"
|
||||
v-tooltip.right="{
|
||||
value: t('sideToolbar.mediaAssets.title'),
|
||||
value: t('linearMode.appModeToolbar.appBuilder'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="textonly"
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
:aria-label="t('sideToolbar.mediaAssets.title')"
|
||||
:class="
|
||||
cn('size-10', isAssetsActive && 'bg-secondary-background-hover')
|
||||
"
|
||||
@click="openAssets"
|
||||
:disabled="!hasNodes"
|
||||
:aria-label="t('linearMode.appModeToolbar.appBuilder')"
|
||||
class="size-10 rounded-lg"
|
||||
@click="enterBuilder"
|
||||
>
|
||||
<i class="icon-[comfy--image-ai-edit] size-4" />
|
||||
<i class="icon-[lucide--hammer] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isCloud && flags.workflowSharingEnabled"
|
||||
v-tooltip.right="{
|
||||
value: t('linearMode.appModeToolbar.apps'),
|
||||
value: t('actionbar.shareTooltip'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="textonly"
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
:aria-label="t('linearMode.appModeToolbar.apps')"
|
||||
:class="cn('size-10', isAppsActive && 'bg-secondary-background-hover')"
|
||||
@click="showApps"
|
||||
:aria-label="t('actionbar.shareTooltip')"
|
||||
class="size-10 rounded-lg"
|
||||
@click="() => openShareDialog().catch(toastErrorHandler)"
|
||||
@pointerenter="prefetchShareDialog"
|
||||
>
|
||||
<i class="icon-[lucide--panels-top-left] size-4" />
|
||||
<i class="icon-[lucide--send] size-4" />
|
||||
</Button>
|
||||
|
||||
<div
|
||||
class="flex w-10 flex-col overflow-hidden rounded-lg bg-secondary-background"
|
||||
>
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: t('sideToolbar.mediaAssets.title'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:aria-label="t('sideToolbar.mediaAssets.title')"
|
||||
:class="
|
||||
cn('size-10', isAssetsActive && 'bg-secondary-background-hover')
|
||||
"
|
||||
@click="openAssets"
|
||||
>
|
||||
<i class="icon-[comfy--image-ai-edit] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: t('linearMode.appModeToolbar.apps'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:aria-label="t('linearMode.appModeToolbar.apps')"
|
||||
:class="
|
||||
cn('size-10', isAppsActive && 'bg-secondary-background-hover')
|
||||
"
|
||||
@click="showApps"
|
||||
>
|
||||
<i class="icon-[lucide--panels-top-left] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<WorkflowActionsDropdown source="app_mode_toolbar" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -8,9 +8,9 @@ import DraggableList from '@/components/common/DraggableList.vue'
|
||||
import IoItem from '@/components/builder/IoItem.vue'
|
||||
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
|
||||
import WidgetItem from '@/components/rightSidePanel/parameters/WidgetItem.vue'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import {
|
||||
LGraphEventMode,
|
||||
@@ -25,10 +25,10 @@ import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteracti
|
||||
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
|
||||
import { app } from '@/scripts/app'
|
||||
import { DOMWidgetImpl } from '@/scripts/domWidget'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { promptRenameWidget } from '@/utils/widgetUtil'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { resolveNode } from '@/utils/litegraphUtil'
|
||||
import { nodeTypeValidForApp, useAppModeStore } from '@/stores/appModeStore'
|
||||
import { resolveNodeWidget } from '@/utils/litegraphUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
||||
|
||||
@@ -53,18 +53,15 @@ workflowStore.activeWorkflow?.changeTracker?.reset()
|
||||
const arrangeInputs = computed(() =>
|
||||
appModeStore.selectedInputs
|
||||
.map(([nodeId, widgetName]) => {
|
||||
const node = resolveNode(nodeId)
|
||||
if (!node) return null
|
||||
const widget = node.widgets?.find((w) => w.name === widgetName)
|
||||
return { nodeId, widgetName, node, widget }
|
||||
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
return node ? { nodeId, widgetName, node, widget } : null
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => item !== null)
|
||||
)
|
||||
|
||||
const inputsWithState = computed(() =>
|
||||
appModeStore.selectedInputs.map(([nodeId, widgetName]) => {
|
||||
const node = resolveNode(nodeId)
|
||||
const widget = node?.widgets?.find((w) => w.name === widgetName)
|
||||
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
if (!node || !widget) {
|
||||
return {
|
||||
nodeId,
|
||||
@@ -73,15 +70,12 @@ const inputsWithState = computed(() =>
|
||||
}
|
||||
}
|
||||
|
||||
const input = node.inputs.find((i) => i.widget?.name === widget.name)
|
||||
const rename = input && (() => renameWidget(widget, input))
|
||||
|
||||
return {
|
||||
nodeId,
|
||||
widgetName,
|
||||
label: widget.label,
|
||||
subLabel: node.title,
|
||||
rename
|
||||
rename: () => promptRenameWidget(widget, node, t)
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -92,20 +86,6 @@ const outputsWithState = computed<[NodeId, string][]>(() =>
|
||||
])
|
||||
)
|
||||
|
||||
async function renameWidget(widget: IBaseWidget, input: INodeInputSlot) {
|
||||
const newLabel = await useDialogService().prompt({
|
||||
title: t('g.rename'),
|
||||
message: t('g.enterNewNamePrompt'),
|
||||
defaultValue: widget.label,
|
||||
placeholder: widget.name
|
||||
})
|
||||
if (newLabel === null) return
|
||||
widget.label = newLabel || undefined
|
||||
input.label = newLabel || undefined
|
||||
widget.callback?.(widget.value)
|
||||
useCanvasStore().canvas?.setDirty(true)
|
||||
}
|
||||
|
||||
function getHovered(
|
||||
e: MouseEvent
|
||||
): undefined | [LGraphNode, undefined] | [LGraphNode, IBaseWidget] {
|
||||
@@ -126,7 +106,7 @@ function getHovered(
|
||||
|
||||
function getBounding(nodeId: NodeId, widgetName?: string) {
|
||||
if (settingStore.get('Comfy.VueNodes.Enabled')) return undefined
|
||||
const node = app.rootGraph.getNodeById(nodeId)
|
||||
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
if (!node) return
|
||||
|
||||
const titleOffset =
|
||||
@@ -139,7 +119,6 @@ function getBounding(nodeId: NodeId, widgetName?: string) {
|
||||
left: `${node.pos[0]}px`,
|
||||
top: `${node.pos[1] - titleOffset}px`
|
||||
}
|
||||
const widget = node.widgets?.find((w) => w.name === widgetName)
|
||||
if (!widget) return
|
||||
|
||||
const margin = widget instanceof DOMWidgetImpl ? widget.margin : undefined
|
||||
@@ -162,7 +141,11 @@ function handleDown(e: MouseEvent) {
|
||||
}
|
||||
function handleClick(e: MouseEvent) {
|
||||
const [node, widget] = getHovered(e) ?? []
|
||||
if (node?.mode !== LGraphEventMode.ALWAYS)
|
||||
if (
|
||||
node?.mode !== LGraphEventMode.ALWAYS ||
|
||||
!nodeTypeValidForApp(node.type) ||
|
||||
node.has_errors
|
||||
)
|
||||
return canvasInteractions.forwardEventToCanvas(e)
|
||||
|
||||
if (!widget) {
|
||||
@@ -174,12 +157,16 @@ function handleClick(e: MouseEvent) {
|
||||
else appModeStore.selectedOutputs.splice(index, 1)
|
||||
return
|
||||
}
|
||||
if (!isSelectInputsMode.value) return
|
||||
if (!isSelectInputsMode.value || widget.options.canvasOnly) return
|
||||
|
||||
const storeId = isPromotedWidgetView(widget) ? widget.sourceNodeId : node.id
|
||||
const storeName = isPromotedWidgetView(widget)
|
||||
? widget.sourceWidgetName
|
||||
: widget.name
|
||||
const index = appModeStore.selectedInputs.findIndex(
|
||||
([nodeId, widgetName]) => node.id == nodeId && widget.name === widgetName
|
||||
([nodeId, widgetName]) => storeId == nodeId && storeName === widgetName
|
||||
)
|
||||
if (index === -1) appModeStore.selectedInputs.push([node.id, widget.name])
|
||||
if (index === -1) appModeStore.selectedInputs.push([storeId, storeName])
|
||||
else appModeStore.selectedInputs.splice(index, 1)
|
||||
}
|
||||
|
||||
@@ -198,7 +185,9 @@ const renderedOutputs = computed(() => {
|
||||
return canvas
|
||||
.graph!.nodes.filter(
|
||||
(n) =>
|
||||
n.constructor.nodeData?.output_node && n.mode === LGraphEventMode.ALWAYS
|
||||
n.constructor.nodeData?.output_node &&
|
||||
n.mode === LGraphEventMode.ALWAYS &&
|
||||
!n.has_errors
|
||||
)
|
||||
.map(nodeToDisplayTuple)
|
||||
})
|
||||
@@ -260,7 +249,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
<template #label>
|
||||
<div class="flex gap-3">
|
||||
{{ t('nodeHelpPage.inputs') }}
|
||||
<i class="icon-[lucide--circle-alert] bg-muted-foreground" />
|
||||
<i class="icon-[lucide--info] bg-muted-foreground" />
|
||||
</div>
|
||||
</template>
|
||||
<template #empty>
|
||||
@@ -315,7 +304,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
<template #label>
|
||||
<div class="flex gap-3">
|
||||
{{ t('nodeHelpPage.outputs') }}
|
||||
<i class="icon-[lucide--circle-alert] bg-muted-foreground" />
|
||||
<i class="icon-[lucide--info] bg-muted-foreground" />
|
||||
</div>
|
||||
</template>
|
||||
<template #empty>
|
||||
|
||||
@@ -72,7 +72,7 @@ const menuItems = computed(() => [
|
||||
},
|
||||
{
|
||||
label: t('builderMenu.exitAppBuilder'),
|
||||
icon: 'icon-[lucide--square-pen]',
|
||||
icon: 'icon-[lucide--x]',
|
||||
action: onExitBuilder
|
||||
}
|
||||
])
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
@@ -7,6 +7,13 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const titleTooltip = ref<string | null>(null)
|
||||
const subTitleTooltip = ref<string | null>(null)
|
||||
|
||||
function isTruncated(e: MouseEvent): boolean {
|
||||
const el = e.currentTarget as HTMLElement
|
||||
return el.scrollWidth > el.clientWidth
|
||||
}
|
||||
const { rename, remove } = defineProps<{
|
||||
title: string
|
||||
subTitle?: string
|
||||
@@ -32,15 +39,28 @@ const entries = computed(() => {
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div class="my-2 flex items-center-safe gap-2 rounded-lg p-2">
|
||||
<div
|
||||
class="drag-handle mr-auto inline max-w-max min-w-0 flex-[4_1_0%] truncate"
|
||||
v-text="title"
|
||||
/>
|
||||
<div
|
||||
class="drag-handle inline max-w-max min-w-0 flex-[2_1_0%] truncate text-end text-muted-foreground"
|
||||
v-text="subTitle"
|
||||
/>
|
||||
<div
|
||||
class="my-2 flex items-center-safe gap-2 rounded-lg p-2"
|
||||
data-testid="builder-io-item"
|
||||
>
|
||||
<div class="drag-handle mr-auto flex min-w-0 flex-col gap-1">
|
||||
<div
|
||||
v-tooltip.left="{ value: titleTooltip, showDelay: 300 }"
|
||||
class="drag-handle truncate text-sm"
|
||||
data-testid="builder-io-item-title"
|
||||
@mouseenter="titleTooltip = isTruncated($event) ? title : null"
|
||||
v-text="title"
|
||||
/>
|
||||
<div
|
||||
v-tooltip.left="{ value: subTitleTooltip, showDelay: 300 }"
|
||||
class="drag-handle truncate text-xs text-muted-foreground"
|
||||
data-testid="builder-io-item-subtitle"
|
||||
@mouseenter="
|
||||
subTitleTooltip = isTruncated($event) ? (subTitle ?? null) : null
|
||||
"
|
||||
v-text="subTitle"
|
||||
/>
|
||||
</div>
|
||||
<Popover :entries>
|
||||
<template #button>
|
||||
<Button variant="muted-textonly">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
@@ -42,6 +43,9 @@ export function useAppSetDefaultView() {
|
||||
const extra = (app.rootGraph.extra ??= {})
|
||||
extra.linearMode = openAsApp
|
||||
workflow.changeTracker?.checkState()
|
||||
useTelemetry()?.trackDefaultViewSet({
|
||||
default_view: openAsApp ? 'app' : 'graph'
|
||||
})
|
||||
closeDialog()
|
||||
showAppliedDialog(openAsApp)
|
||||
}
|
||||
@@ -54,6 +58,7 @@ export function useAppSetDefaultView() {
|
||||
appliedAsApp,
|
||||
onViewApp: () => {
|
||||
closeAppliedDialog()
|
||||
useTelemetry()?.trackEnterLinear({ source: 'app_builder' })
|
||||
setMode('app')
|
||||
},
|
||||
onExitToWorkflow: () => {
|
||||
|
||||
51
src/components/common/Dialogue.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogRoot,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from 'reka-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
defineProps<{ title?: string; to?: string | HTMLElement }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
<template>
|
||||
<DialogRoot v-slot="{ close }">
|
||||
<DialogTrigger as-child>
|
||||
<slot name="button" />
|
||||
</DialogTrigger>
|
||||
<DialogPortal :to>
|
||||
<DialogOverlay
|
||||
class="data-[state=open]:animate-overlayShow fixed inset-0 z-30 bg-black/70"
|
||||
/>
|
||||
<DialogContent
|
||||
v-bind="$attrs"
|
||||
class="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] z-1700 max-h-[85vh] w-[90vw] max-w-[450px] translate-x-[-50%] translate-y-[-50%] rounded-2xl border border-border-subtle bg-base-background p-2 shadow-sm"
|
||||
>
|
||||
<div
|
||||
v-if="title"
|
||||
class="flex w-full items-center justify-between border-b border-border-subtle px-4"
|
||||
>
|
||||
<DialogTitle class="text-sm">{{ title }}</DialogTitle>
|
||||
<DialogClose as-child>
|
||||
<Button
|
||||
:aria-label="t('g.close')"
|
||||
size="icon"
|
||||
variant="muted-textonly"
|
||||
>
|
||||
<i class="icon-[lucide--x]" />
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
<slot :close />
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
</template>
|
||||
@@ -54,11 +54,12 @@ defineProps<{ itemClass: string; contentClass: string; item: MenuItem }>()
|
||||
:disabled="toValue(item.disabled) ?? !item.command"
|
||||
@select="item.command?.({ originalEvent: $event, item })"
|
||||
>
|
||||
<i class="size-5" :class="item.icon" />
|
||||
{{ item.label }}
|
||||
<i class="size-5 shrink-0" :class="item.icon" />
|
||||
<div class="mr-auto truncate" v-text="item.label" />
|
||||
<i v-if="item.checked" class="icon-[lucide--check] shrink-0" />
|
||||
<div
|
||||
v-if="item.new"
|
||||
class="ml-auto flex items-center rounded-full bg-primary-background px-1 text-xxs leading-none font-bold"
|
||||
v-else-if="item.new"
|
||||
class="flex shrink-0 items-center rounded-full bg-primary-background px-1 text-xxs leading-none font-bold"
|
||||
v-text="t('contextMenu.new')"
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -27,7 +27,7 @@ const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
|
||||
|
||||
const itemClass = computed(() =>
|
||||
cn(
|
||||
'm-1 flex cursor-pointer gap-1 rounded-lg p-2 leading-none data-disabled:pointer-events-none data-disabled:text-muted-foreground data-highlighted:bg-secondary-background-hover',
|
||||
'm-1 flex cursor-pointer items-center-safe gap-1 rounded-lg p-2 leading-none data-disabled:pointer-events-none data-disabled:text-muted-foreground data-highlighted:bg-secondary-background-hover',
|
||||
itemProp
|
||||
)
|
||||
)
|
||||
|
||||
@@ -33,19 +33,20 @@
|
||||
spellcheck="false"
|
||||
@blur="handleBlur"
|
||||
@keyup.enter="handleBlur"
|
||||
@dragstart.prevent
|
||||
@keydown.up.prevent="updateValueBy(step)"
|
||||
@keydown.down.prevent="updateValueBy(-step)"
|
||||
@keydown.page-up.prevent="updateValueBy(10 * step)"
|
||||
@keydown.page-down.prevent="updateValueBy(-10 * step)"
|
||||
/>
|
||||
<div
|
||||
ref="swipeElement"
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-0 z-10 cursor-ew-resize',
|
||||
'absolute inset-0 z-10 cursor-ew-resize touch-pan-y',
|
||||
textEdit && 'pointer-events-none hidden'
|
||||
)
|
||||
"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
@pointerup="handlePointerUp"
|
||||
@pointercancel="resetDrag"
|
||||
/>
|
||||
</div>
|
||||
<slot />
|
||||
@@ -65,7 +66,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { onClickOutside, usePointerSwipe, whenever } from '@vueuse/core'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -73,8 +74,8 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
min,
|
||||
max,
|
||||
min = -Number.MAX_VALUE,
|
||||
max = Number.MAX_VALUE,
|
||||
step = 1,
|
||||
disabled = false,
|
||||
hideButtons = false,
|
||||
@@ -96,6 +97,7 @@ const modelValue = defineModel<number>({ default: 0 })
|
||||
|
||||
const container = useTemplateRef<HTMLDivElement>('container')
|
||||
const inputField = useTemplateRef<HTMLInputElement>('inputField')
|
||||
const swipeElement = useTemplateRef('swipeElement')
|
||||
const textEdit = ref(false)
|
||||
|
||||
onClickOutside(container, () => {
|
||||
@@ -103,21 +105,11 @@ onClickOutside(container, () => {
|
||||
})
|
||||
|
||||
function clamp(value: number): number {
|
||||
const lo = min ?? -Infinity
|
||||
const hi = max ?? Infinity
|
||||
return Math.min(hi, Math.max(lo, value))
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
const canDecrement = computed(
|
||||
() => modelValue.value > (min ?? -Infinity) && !disabled
|
||||
)
|
||||
const canIncrement = computed(
|
||||
() => modelValue.value < (max ?? Infinity) && !disabled
|
||||
)
|
||||
|
||||
const dragging = ref(false)
|
||||
const dragDelta = ref(0)
|
||||
const hasDragged = ref(false)
|
||||
const canDecrement = computed(() => modelValue.value > min && !disabled)
|
||||
const canIncrement = computed(() => modelValue.value < max && !disabled)
|
||||
|
||||
function handleBlur(e: Event) {
|
||||
const target = e.target as HTMLInputElement
|
||||
@@ -135,41 +127,27 @@ function handleBlur(e: Event) {
|
||||
textEdit.value = false
|
||||
}
|
||||
|
||||
function handlePointerDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
if (disabled) return
|
||||
const target = e.target as HTMLElement
|
||||
target.setPointerCapture(e.pointerId)
|
||||
dragging.value = true
|
||||
dragDelta.value = 0
|
||||
hasDragged.value = false
|
||||
}
|
||||
|
||||
function handlePointerMove(e: PointerEvent) {
|
||||
if (!dragging.value) return
|
||||
dragDelta.value += e.movementX
|
||||
const steps = (dragDelta.value / 10) | 0
|
||||
if (steps === 0) return
|
||||
hasDragged.value = true
|
||||
const unclipped = modelValue.value + steps * step
|
||||
dragDelta.value %= 10
|
||||
modelValue.value = clamp(unclipped)
|
||||
}
|
||||
|
||||
let dragDelta = 0
|
||||
function handlePointerUp() {
|
||||
if (!dragging.value) return
|
||||
if (isSwiping.value) return
|
||||
|
||||
if (!hasDragged.value) {
|
||||
textEdit.value = true
|
||||
inputField.value?.focus()
|
||||
inputField.value?.select()
|
||||
}
|
||||
|
||||
resetDrag()
|
||||
textEdit.value = true
|
||||
inputField.value?.focus()
|
||||
inputField.value?.select()
|
||||
}
|
||||
|
||||
function resetDrag() {
|
||||
dragging.value = false
|
||||
dragDelta.value = 0
|
||||
const { distanceX, isSwiping } = usePointerSwipe(swipeElement, {
|
||||
onSwipeEnd: () => (dragDelta = 0)
|
||||
})
|
||||
|
||||
whenever(distanceX, () => {
|
||||
if (disabled) return
|
||||
const delta = ((distanceX.value - dragDelta) / 10) | 0
|
||||
dragDelta += delta * 10
|
||||
modelValue.value = clamp(modelValue.value - delta * step)
|
||||
})
|
||||
|
||||
function updateValueBy(delta: number) {
|
||||
modelValue.value = Math.min(max, Math.max(min, modelValue.value + delta))
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
<template>
|
||||
<div class="system-stats">
|
||||
<div class="mb-6">
|
||||
<h2 class="mb-4 text-2xl font-semibold">
|
||||
{{ $t('g.systemInfo') }}
|
||||
</h2>
|
||||
<div class="mb-4 flex items-center gap-2">
|
||||
<h2 class="text-2xl font-semibold">
|
||||
{{ $t('g.systemInfo') }}
|
||||
</h2>
|
||||
<Button variant="secondary" @click="copySystemInfo">
|
||||
<i class="pi pi-copy" />
|
||||
{{ $t('g.copySystemInfo') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<template v-for="col in systemColumns" :key="col.field">
|
||||
<div :class="cn('font-medium', isOutdated(col) && 'text-danger-100')">
|
||||
@@ -46,15 +52,21 @@ import TabView from 'primevue/tabview'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import DeviceInfo from '@/components/common/DeviceInfo.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { SystemStats } from '@/schemas/apiSchema'
|
||||
import { formatCommitHash, formatSize } from '@/utils/formatUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const frontendCommit = __COMFYUI_FRONTEND_COMMIT__
|
||||
|
||||
const props = defineProps<{
|
||||
stats: SystemStats
|
||||
}>()
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
|
||||
const systemInfo = computed(() => ({
|
||||
...props.stats.system,
|
||||
argv: props.stats.system.argv.join(' ')
|
||||
@@ -67,6 +79,7 @@ type SystemInfoKey = keyof SystemStats['system']
|
||||
type ColumnDef = {
|
||||
field: SystemInfoKey
|
||||
header: string
|
||||
getValue?: () => string
|
||||
format?: (value: string) => string
|
||||
formatNumber?: (value: number) => string
|
||||
}
|
||||
@@ -94,6 +107,7 @@ const cloudColumns: ColumnDef[] = [
|
||||
{
|
||||
field: 'comfyui_frontend_version',
|
||||
header: 'Frontend Version',
|
||||
getValue: () => frontendCommit,
|
||||
format: formatCommitHash
|
||||
},
|
||||
{ field: 'workflow_templates_version', header: 'Templates Version' }
|
||||
@@ -108,8 +122,10 @@ function isOutdated(column: ColumnDef): boolean {
|
||||
return !!installed && !!required && installed !== required
|
||||
}
|
||||
|
||||
const getDisplayValue = (column: ColumnDef) => {
|
||||
const value = systemInfo.value[column.field]
|
||||
function getDisplayValue(column: ColumnDef) {
|
||||
const value = column.getValue
|
||||
? column.getValue()
|
||||
: systemInfo.value[column.field]
|
||||
if (column.formatNumber && typeof value === 'number') {
|
||||
return column.formatNumber(value)
|
||||
}
|
||||
@@ -118,4 +134,33 @@ const getDisplayValue = (column: ColumnDef) => {
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function formatSystemInfoText(): string {
|
||||
const lines: string[] = ['## System Info']
|
||||
|
||||
for (const col of systemColumns.value) {
|
||||
const display = getDisplayValue(col)
|
||||
if (display !== undefined && display !== '') {
|
||||
lines.push(`${col.header}: ${display}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (hasDevices.value) {
|
||||
lines.push('')
|
||||
lines.push('## Devices')
|
||||
for (const device of props.stats.devices) {
|
||||
lines.push(`- ${device.name} (${device.type})`)
|
||||
lines.push(` VRAM Total: ${formatSize(device.vram_total)}`)
|
||||
lines.push(` VRAM Free: ${formatSize(device.vram_free)}`)
|
||||
lines.push(` Torch VRAM Total: ${formatSize(device.torch_vram_total)}`)
|
||||
lines.push(` Torch VRAM Free: ${formatSize(device.torch_vram_free)}`)
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function copySystemInfo() {
|
||||
copyToClipboard(formatSystemInfoText())
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
:get-children="
|
||||
(item) => (item.children?.length ? item.children : undefined)
|
||||
"
|
||||
class="m-0 p-0 pb-6"
|
||||
class="m-0 min-w-0 p-0 pb-6"
|
||||
>
|
||||
<TreeVirtualizer
|
||||
v-slot="{ item }"
|
||||
|
||||
@@ -99,7 +99,7 @@ import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const ROW_CLASS =
|
||||
'group/tree-node flex cursor-pointer select-none items-center gap-3 overflow-hidden py-2 outline-none hover:bg-comfy-input mx-2 rounded'
|
||||
'group/tree-node flex w-full min-w-0 cursor-pointer select-none items-center gap-3 overflow-hidden py-2 outline-none hover:bg-comfy-input mx-2 rounded'
|
||||
|
||||
const { item } = defineProps<{
|
||||
item: FlattenedItem<RenderedTreeExplorerNode<ComfyNodeDefImpl>>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<Avatar
|
||||
class="bg-interface-panel-selected-surface"
|
||||
class="aspect-square bg-interface-panel-selected-surface"
|
||||
:image="photoUrl ?? undefined"
|
||||
:icon="hasAvatar ? undefined : 'icon-[lucide--user]'"
|
||||
:pt:icon:class="{ 'size-4': !hasAvatar }"
|
||||
|
||||
@@ -178,7 +178,7 @@
|
||||
v-show="isTemplateVisibleOnDistribution(template)"
|
||||
:key="template.name"
|
||||
ref="cardRefs"
|
||||
size="compact"
|
||||
size="tall"
|
||||
variant="ghost"
|
||||
rounded="lg"
|
||||
:data-testid="`template-workflow-${template.name}`"
|
||||
@@ -318,6 +318,20 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<span
|
||||
class="text-neutral flex items-center gap-1.5 text-xs font-bold"
|
||||
>
|
||||
<template v-if="isAppTemplate(template)">
|
||||
<i class="icon-[lucide--panels-top-left]" />
|
||||
{{ $t('builderToolbar.app', 'App') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
<i class="icon-[lucide--workflow]" />
|
||||
{{ $t('builderToolbar.nodeGraph', 'Node Graph') }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardBottom>
|
||||
</template>
|
||||
@@ -483,6 +497,8 @@ const {
|
||||
const getEffectiveSourceModule = (template: TemplateInfo) =>
|
||||
template.sourceModule || 'default'
|
||||
|
||||
const isAppTemplate = (template: TemplateInfo) => template.name.endsWith('.app')
|
||||
|
||||
const getBaseThumbnailSrc = (template: TemplateInfo) => {
|
||||
const sm = getEffectiveSourceModule(template)
|
||||
return getTemplateThumbnailUrl(template, sm, sm === 'default' ? '1' : '')
|
||||
|
||||
@@ -138,8 +138,7 @@ onMounted(async () => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('toastMessages.failedToFetchLogs'),
|
||||
life: 5000
|
||||
detail: t('toastMessages.failedToFetchLogs')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -275,8 +275,7 @@ async function handleBuy() {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('credits.topUp.purchaseError'),
|
||||
detail: t('credits.topUp.purchaseErrorDetail', { error: errorMessage }),
|
||||
life: 5000
|
||||
detail: t('credits.topUp.purchaseErrorDetail', { error: errorMessage })
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
|
||||
@@ -98,8 +98,7 @@ async function onConfirmCancel() {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('subscription.cancelDialog.failed'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError'),
|
||||
life: 5000
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError')
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
|
||||
@@ -50,7 +50,9 @@
|
||||
{{ t('g.dismiss') }}
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" @click="seeErrors">
|
||||
{{ t('errorOverlay.seeErrors') }}
|
||||
{{
|
||||
appMode ? t('linearMode.error.goto') : t('errorOverlay.seeErrors')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,6 +71,8 @@ import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useErrorGroups } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
|
||||
defineProps<{ appMode?: boolean }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
@@ -94,6 +98,7 @@ function dismiss() {
|
||||
}
|
||||
|
||||
function seeErrors() {
|
||||
canvasStore.linearMode = false
|
||||
if (canvasStore.canvas) {
|
||||
canvasStore.canvas.deselectAll()
|
||||
canvasStore.updateSelectedItems()
|
||||
|
||||
@@ -535,7 +535,7 @@ onMounted(async () => {
|
||||
|
||||
// Restore saved workflow and workflow tabs state
|
||||
await workflowPersistence.initializeWorkflow()
|
||||
workflowPersistence.restoreWorkflowTabsState()
|
||||
await workflowPersistence.restoreWorkflowTabsState()
|
||||
|
||||
const sharedWorkflowLoadStatus =
|
||||
await workflowPersistence.loadSharedWorkflowFromUrlIfPresent()
|
||||
|
||||
@@ -579,8 +579,7 @@ const onUpdateComfyUI = async (): Promise<void> => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: error.value || t('helpCenter.updateComfyUIFailed'),
|
||||
life: 5000
|
||||
detail: error.value || t('helpCenter.updateComfyUIFailed')
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -597,8 +596,7 @@ const onUpdateComfyUI = async (): Promise<void> => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: err instanceof Error ? err.message : t('g.unknownError'),
|
||||
life: 5000
|
||||
detail: err instanceof Error ? err.message : t('g.unknownError')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,8 @@
|
||||
v-if="isHelpCenterVisible"
|
||||
class="help-center-popup"
|
||||
:class="{
|
||||
'sidebar-left':
|
||||
triggerLocation === 'sidebar' && sidebarLocation === 'left',
|
||||
'sidebar-right':
|
||||
triggerLocation === 'sidebar' && sidebarLocation === 'right',
|
||||
'topbar-right': triggerLocation === 'topbar',
|
||||
'sidebar-left': sidebarLocation === 'left',
|
||||
'sidebar-right': sidebarLocation === 'right',
|
||||
'small-sidebar': isSmall
|
||||
}"
|
||||
>
|
||||
@@ -63,7 +60,6 @@ const { isSmall = false } = defineProps<{
|
||||
|
||||
const {
|
||||
isHelpCenterVisible,
|
||||
triggerLocation,
|
||||
sidebarLocation,
|
||||
closeHelpCenter,
|
||||
handleWhatsNewDismissed
|
||||
@@ -101,25 +97,6 @@ const {
|
||||
right: 1rem;
|
||||
}
|
||||
|
||||
.help-center-popup.topbar-right {
|
||||
top: 2rem;
|
||||
right: 1rem;
|
||||
bottom: auto;
|
||||
animation: slideInDown 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideInDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, reactive, ref, toValue, watch } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import Fuse from 'fuse.js'
|
||||
import type { IFuseOptions } from 'fuse.js'
|
||||
|
||||
@@ -227,7 +227,7 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
|
||||
}
|
||||
|
||||
export function useErrorGroups(
|
||||
searchQuery: Ref<string>,
|
||||
searchQuery: MaybeRefOrGetter<string>,
|
||||
t: (key: string) => string
|
||||
) {
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
@@ -584,7 +584,7 @@ export function useErrorGroups(
|
||||
})
|
||||
|
||||
const filteredGroups = computed<ErrorGroup[]>(() => {
|
||||
const query = searchQuery.value.trim()
|
||||
const query = toValue(searchQuery).trim()
|
||||
return searchErrorGroups(tabErrorGroups.value, query)
|
||||
})
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
||||
|
||||
import { GetNodeParentGroupKey } from '../shared'
|
||||
import WidgetItem from './WidgetItem.vue'
|
||||
import { getStableWidgetRenderKey } from '@/core/graph/subgraph/widgetRenderKey'
|
||||
|
||||
const {
|
||||
label,
|
||||
@@ -272,7 +273,7 @@ defineExpose({
|
||||
<TransitionGroup name="list-scale">
|
||||
<WidgetItem
|
||||
v-for="{ widget, node } in widgets"
|
||||
:key="`${node.id}-${widget.name}-${widget.type}`"
|
||||
:key="getStableWidgetRenderKey(widget)"
|
||||
:widget="widget"
|
||||
:node="node"
|
||||
:is-draggable="isDraggable"
|
||||
|
||||
@@ -15,10 +15,9 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import { getWidgetDefaultValue } from '@/utils/widgetUtil'
|
||||
import { getWidgetDefaultValue, promptWidgetLabel } from '@/utils/widgetUtil'
|
||||
import type { WidgetValue } from '@/utils/widgetUtil'
|
||||
|
||||
const {
|
||||
@@ -42,7 +41,6 @@ const label = defineModel<string>('label', { required: true })
|
||||
const canvasStore = useCanvasStore()
|
||||
const favoritedWidgetsStore = useFavoritedWidgetsStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const dialogService = useDialogService()
|
||||
const { t } = useI18n()
|
||||
|
||||
const hasParents = computed(() => parents?.length > 0)
|
||||
@@ -67,15 +65,8 @@ const isCurrentValueDefault = computed(() => {
|
||||
})
|
||||
|
||||
async function handleRename() {
|
||||
const newLabel = await dialogService.prompt({
|
||||
title: t('g.rename'),
|
||||
message: t('g.enterNewNamePrompt'),
|
||||
defaultValue: widget.label,
|
||||
placeholder: widget.name
|
||||
})
|
||||
|
||||
if (newLabel === null) return
|
||||
label.value = newLabel
|
||||
const newLabel = await promptWidgetLabel(widget, t)
|
||||
if (newLabel !== null) label.value = newLabel
|
||||
}
|
||||
|
||||
function handleHideInput() {
|
||||
|
||||
@@ -11,12 +11,13 @@
|
||||
}"
|
||||
@click="onLogoMenuClick($event)"
|
||||
>
|
||||
<div class="flex size-8 items-center justify-center rounded-lg bg-black">
|
||||
<div class="flex items-center gap-0.5">
|
||||
<ComfyLogo
|
||||
alt="ComfyUI Logo"
|
||||
class="comfyui-logo h-[18px] w-[18px] text-white"
|
||||
class="comfyui-logo h-[18px] w-[18px]"
|
||||
mode="fill"
|
||||
/>
|
||||
<i class="icon-[lucide--chevron-down] size-3 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
v-if="userStore.isMultiUserServer"
|
||||
:is-small="isSmall"
|
||||
/>
|
||||
<SidebarHelpCenterIcon v-if="!isIntegratedTabBar" :is-small="isSmall" />
|
||||
<SidebarHelpCenterIcon :is-small="isSmall" />
|
||||
<SidebarBottomPanelToggleButton v-if="!isCloud" :is-small="isSmall" />
|
||||
<SidebarShortcutsToggleButton :is-small="isSmall" />
|
||||
<SidebarSettingsButton :is-small="isSmall" />
|
||||
@@ -95,9 +95,6 @@ const sidebarLocation = computed<'left' | 'right'>(() =>
|
||||
settingStore.get('Comfy.Sidebar.Location')
|
||||
)
|
||||
const sidebarStyle = computed(() => settingStore.get('Comfy.Sidebar.Style'))
|
||||
const isIntegratedTabBar = computed(
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
|
||||
)
|
||||
const isConnected = computed(
|
||||
() =>
|
||||
selectedTab.value ||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
:icon-badge="shouldShowRedDot ? '•' : ''"
|
||||
badge-class="-top-1 -right-1 min-w-2 w-2 h-2 p-0 rounded-full text-[0px] bg-[#ff3b30]"
|
||||
:is-small="isSmall"
|
||||
@click="toggleHelpCenter"
|
||||
@click="toggleHelpCenter()"
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
tabindex="0"
|
||||
:aria-label="
|
||||
t('assetBrowser.ariaLabel.assetCard', {
|
||||
name: item.asset.name,
|
||||
name: getAssetDisplayName(item.asset),
|
||||
type: getAssetMediaType(item.asset)
|
||||
})
|
||||
"
|
||||
@@ -44,7 +44,7 @@
|
||||
)
|
||||
"
|
||||
:preview-url="getAssetPreviewUrl(item.asset)"
|
||||
:preview-alt="item.asset.name"
|
||||
:preview-alt="getAssetDisplayName(item.asset)"
|
||||
:icon-name="iconForMediaType(getAssetMediaType(item.asset))"
|
||||
:is-video-preview="isVideoAsset(item.asset)"
|
||||
:primary-text="getAssetPrimaryText(item.asset)"
|
||||
@@ -133,8 +133,12 @@ const listGridStyle = {
|
||||
gap: '0.5rem'
|
||||
}
|
||||
|
||||
function getAssetDisplayName(asset: AssetItem): string {
|
||||
return asset.display_name || asset.name
|
||||
}
|
||||
|
||||
function getAssetPrimaryText(asset: AssetItem): string {
|
||||
return truncateFilename(asset.name)
|
||||
return truncateFilename(getAssetDisplayName(asset))
|
||||
}
|
||||
|
||||
function getAssetMediaType(asset: AssetItem) {
|
||||
|
||||
@@ -569,7 +569,7 @@ const handleZoomClick = (asset: AssetItem) => {
|
||||
const dialogStore = useDialogStore()
|
||||
dialogStore.showDialog({
|
||||
key: 'asset-3d-viewer',
|
||||
title: asset.name,
|
||||
title: asset.display_name || asset.name,
|
||||
component: Load3dViewerContent,
|
||||
props: {
|
||||
modelUrl: asset.preview_url || ''
|
||||
@@ -615,8 +615,7 @@ const enterFolderView = async (asset: AssetItem) => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('sideToolbar.folderView.errorSummary'),
|
||||
detail: t('sideToolbar.folderView.errorDetail'),
|
||||
life: 5000
|
||||
detail: t('sideToolbar.folderView.errorDetail')
|
||||
})
|
||||
exitFolderView()
|
||||
}
|
||||
@@ -662,8 +661,7 @@ const copyJobId = async () => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('mediaAsset.jobIdToast.error'),
|
||||
detail: t('mediaAsset.jobIdToast.jobIdCopyFailed'),
|
||||
life: 3000
|
||||
detail: t('mediaAsset.jobIdToast.jobIdCopyFailed')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,18 @@ import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import CurrentUserButton from './CurrentUserButton.vue'
|
||||
|
||||
const mockFeatureFlags = vi.hoisted(() => ({
|
||||
teamWorkspacesEnabled: false
|
||||
}))
|
||||
|
||||
const mockTeamWorkspaceStore = vi.hoisted(() => ({
|
||||
workspaceName: { value: '' },
|
||||
initState: { value: 'idle' },
|
||||
isInPersonalWorkspace: { value: false }
|
||||
}))
|
||||
|
||||
const mockIsCloud = vi.hoisted(() => ({ value: false }))
|
||||
|
||||
// Mock all firebase modules
|
||||
vi.mock('firebase/app', () => ({
|
||||
initializeApp: vi.fn(),
|
||||
@@ -32,16 +44,19 @@ vi.mock('pinia', () => ({
|
||||
// Mock the useFeatureFlags composable
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: vi.fn(() => ({
|
||||
flags: { teamWorkspacesEnabled: false }
|
||||
flags: mockFeatureFlags
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock the useTeamWorkspaceStore
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: vi.fn(() => ({
|
||||
workspaceName: { value: '' },
|
||||
initState: { value: 'idle' }
|
||||
}))
|
||||
useTeamWorkspaceStore: vi.fn(() => mockTeamWorkspaceStore)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return mockIsCloud.value
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock the useCurrentUser composable
|
||||
@@ -64,6 +79,16 @@ vi.mock('@/components/common/UserAvatar.vue', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock the WorkspaceProfilePic component
|
||||
vi.mock('@/platform/workspace/components/WorkspaceProfilePic.vue', () => ({
|
||||
default: {
|
||||
name: 'WorkspaceProfilePicMock',
|
||||
render() {
|
||||
return h('div', 'WorkspaceProfilePic')
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock the CurrentUserPopoverLegacy component
|
||||
vi.mock('./CurrentUserPopoverLegacy.vue', () => ({
|
||||
default: {
|
||||
@@ -78,9 +103,15 @@ vi.mock('./CurrentUserPopoverLegacy.vue', () => ({
|
||||
describe('CurrentUserButton', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFeatureFlags.teamWorkspacesEnabled = false
|
||||
mockTeamWorkspaceStore.workspaceName.value = ''
|
||||
mockTeamWorkspaceStore.initState.value = 'idle'
|
||||
mockTeamWorkspaceStore.isInPersonalWorkspace.value = false
|
||||
mockIsCloud.value = false
|
||||
})
|
||||
|
||||
const mountComponent = (): VueWrapper => {
|
||||
const mountComponent = (options?: { stubButton?: boolean }): VueWrapper => {
|
||||
const { stubButton = true } = options ?? {}
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
@@ -99,7 +130,7 @@ describe('CurrentUserButton', () => {
|
||||
hide: vi.fn()
|
||||
}
|
||||
},
|
||||
Button: true
|
||||
...(stubButton ? { Button: true } : {})
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -137,4 +168,27 @@ describe('CurrentUserButton', () => {
|
||||
// Verify that popover.hide was called
|
||||
expect(popoverHideSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows UserAvatar in personal workspace', () => {
|
||||
mockIsCloud.value = true
|
||||
mockFeatureFlags.teamWorkspacesEnabled = true
|
||||
mockTeamWorkspaceStore.initState.value = 'ready'
|
||||
mockTeamWorkspaceStore.isInPersonalWorkspace.value = true
|
||||
|
||||
const wrapper = mountComponent({ stubButton: false })
|
||||
expect(wrapper.html()).toContain('Avatar')
|
||||
expect(wrapper.html()).not.toContain('WorkspaceProfilePic')
|
||||
})
|
||||
|
||||
it('shows WorkspaceProfilePic in team workspace', () => {
|
||||
mockIsCloud.value = true
|
||||
mockFeatureFlags.teamWorkspacesEnabled = true
|
||||
mockTeamWorkspaceStore.initState.value = 'ready'
|
||||
mockTeamWorkspaceStore.isInPersonalWorkspace.value = false
|
||||
mockTeamWorkspaceStore.workspaceName.value = 'My Team'
|
||||
|
||||
const wrapper = mountComponent({ stubButton: false })
|
||||
expect(wrapper.html()).toContain('WorkspaceProfilePic')
|
||||
expect(wrapper.html()).not.toContain('Avatar')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<UserAvatar
|
||||
v-else
|
||||
:photo-url="photoURL"
|
||||
:class="compact && 'size-full'"
|
||||
:class="compact && 'h-full w-auto'"
|
||||
/>
|
||||
|
||||
<i v-if="showArrow" class="icon-[lucide--chevron-down] size-4 px-1" />
|
||||
@@ -98,15 +98,21 @@ const photoURL = computed<string | undefined>(
|
||||
() => userPhotoUrl.value ?? undefined
|
||||
)
|
||||
|
||||
const { workspaceName: teamWorkspaceName, initState } = storeToRefs(
|
||||
useTeamWorkspaceStore()
|
||||
)
|
||||
const {
|
||||
workspaceName: teamWorkspaceName,
|
||||
initState,
|
||||
isInPersonalWorkspace
|
||||
} = storeToRefs(useTeamWorkspaceStore())
|
||||
|
||||
const showWorkspaceSkeleton = computed(
|
||||
() => isCloud && teamWorkspacesEnabled.value && initState.value === 'loading'
|
||||
)
|
||||
const showWorkspaceIcon = computed(
|
||||
() => isCloud && teamWorkspacesEnabled.value && initState.value === 'ready'
|
||||
() =>
|
||||
isCloud &&
|
||||
teamWorkspacesEnabled.value &&
|
||||
initState.value === 'ready' &&
|
||||
!isInPersonalWorkspace.value
|
||||
)
|
||||
|
||||
const workspaceName = computed(() => {
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
<template>
|
||||
<Button
|
||||
class="comfy-help-center-btn relative text-base-foreground"
|
||||
variant="textonly"
|
||||
@click="toggleHelpCenter"
|
||||
>
|
||||
<div class="not-md:hidden">{{ $t('menu.helpAndFeedback') }}</div>
|
||||
<i class="ml-0.5 icon-[lucide--circle-help]" />
|
||||
<span
|
||||
v-if="shouldShowRedDot"
|
||||
class="absolute top-[7px] right-[7px] size-1.5 rounded-full bg-[#ff3b30]"
|
||||
/>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useHelpCenter } from '@/composables/useHelpCenter'
|
||||
|
||||
const { shouldShowRedDot, toggleHelpCenter } = useHelpCenter('topbar')
|
||||
</script>
|
||||
@@ -10,8 +10,9 @@
|
||||
@mouseup="handleMouseUp"
|
||||
@click="handleClick"
|
||||
>
|
||||
<i v-if="isBuilderState" class="bg-text-subtle icon-[lucide--hammer]" />
|
||||
<i
|
||||
v-if="workflowOption.workflow.initialMode === 'app'"
|
||||
v-else-if="workflowOption.workflow.initialMode === 'app'"
|
||||
class="icon-[lucide--panels-top-left] bg-primary-background"
|
||||
/>
|
||||
<span
|
||||
@@ -149,6 +150,11 @@ const shouldShowStatusIndicator = computed(() => {
|
||||
return false
|
||||
})
|
||||
|
||||
const isBuilderState = computed(() => {
|
||||
const currentMode = props.workflowOption.workflow.activeMode
|
||||
return typeof currentMode === 'string' && currentMode.startsWith('builder:')
|
||||
})
|
||||
|
||||
const isActiveTab = computed(() => {
|
||||
return workflowStore.activeWorkflow?.key === props.workflowOption.workflow.key
|
||||
})
|
||||
|
||||
@@ -83,13 +83,18 @@
|
||||
v-if="isIntegratedTabBar"
|
||||
class="ml-auto flex shrink-0 items-center gap-2 px-2"
|
||||
>
|
||||
<TopMenuHelpButton />
|
||||
<CurrentUserButton
|
||||
v-if="isLoggedIn"
|
||||
:show-arrow="false"
|
||||
compact
|
||||
class="grid w-10 shrink-0 p-1"
|
||||
/>
|
||||
<Button
|
||||
v-if="isCloud || isNightly"
|
||||
v-tooltip="{ value: $t('actionbar.feedbackTooltip'), showDelay: 300 }"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
class="shrink-0 text-base-foreground"
|
||||
:aria-label="$t('actionbar.feedback')"
|
||||
@click="openFeedback"
|
||||
>
|
||||
<i class="icon-[lucide--message-square-text]" />
|
||||
</Button>
|
||||
<CurrentUserButton v-if="showCurrentUser" compact class="shrink-0 p-1" />
|
||||
<LoginButton v-else-if="isDesktop" class="p-1" />
|
||||
</div>
|
||||
<div v-if="isDesktop" class="window-actions-spacer app-drag shrink-0" />
|
||||
@@ -102,21 +107,20 @@ import ScrollPanel from 'primevue/scrollpanel'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import { computed, nextTick, onUpdated, ref, watch } from 'vue'
|
||||
import type { WatchStopHandle } from 'vue'
|
||||
|
||||
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
||||
import LoginButton from '@/components/topbar/LoginButton.vue'
|
||||
import TopMenuHelpButton from '@/components/topbar/TopMenuHelpButton.vue'
|
||||
import WorkflowTab from '@/components/topbar/WorkflowTab.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { buildFeedbackUrl } from '@/platform/support/config'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
|
||||
import { whileMouseDown } from '@/utils/mouseDownUtil'
|
||||
|
||||
import WorkflowOverflowMenu from './WorkflowOverflowMenu.vue'
|
||||
@@ -138,8 +142,14 @@ const commandStore = useCommandStore()
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
|
||||
const isIntegratedTabBar = computed(
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') !== 'Legacy'
|
||||
)
|
||||
const showCurrentUser = computed(() => isCloud || isLoggedIn.value)
|
||||
|
||||
const feedbackUrl = buildFeedbackUrl()
|
||||
function openFeedback() {
|
||||
window.open(feedbackUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const showOverflowArrows = ref(false)
|
||||
|
||||
@@ -24,7 +24,7 @@ function handleWheel(e: WheelEvent) {
|
||||
|
||||
let dragging = false
|
||||
function handleDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
if (e.button !== 0 && e.button !== 1) return
|
||||
|
||||
const zoomPaneEl = zoomPane.value
|
||||
if (!zoomPaneEl) return
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { VariantProps } from 'cva'
|
||||
import { cva } from 'cva'
|
||||
|
||||
export const buttonVariants = cva({
|
||||
base: 'relative inline-flex items-center justify-center gap-2 cursor-pointer whitespace-nowrap appearance-none border-none rounded-md text-sm font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([width]):not([height])]:size-4 [&_svg]:shrink-0',
|
||||
base: 'relative inline-flex items-center justify-center gap-2 cursor-pointer touch-manipulation whitespace-nowrap appearance-none border-none rounded-md text-sm font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([width]):not([height])]:size-4 [&_svg]:shrink-0',
|
||||
variants: {
|
||||
variant: {
|
||||
secondary:
|
||||
|
||||
@@ -238,6 +238,160 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
|
||||
expect(widgetData?.slotMetadata?.linked).toBe(false)
|
||||
})
|
||||
|
||||
it('clears stale slotMetadata when input no longer matches widget', async () => {
|
||||
const { graph, node } = createWidgetInputGraph()
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
|
||||
const nodeData = vueNodeData.get(String(node.id))!
|
||||
const widgetData = nodeData.widgets!.find((w) => w.name === 'prompt')!
|
||||
|
||||
expect(widgetData.slotMetadata?.linked).toBe(true)
|
||||
|
||||
node.inputs[0].name = 'other'
|
||||
node.inputs[0].widget = { name: 'other' }
|
||||
node.inputs[0].link = null
|
||||
|
||||
graph.trigger('node:slot-links:changed', {
|
||||
nodeId: node.id,
|
||||
slotType: NodeSlotType.INPUT,
|
||||
slotIndex: 0,
|
||||
connected: false,
|
||||
linkId: 42
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(widgetData.slotMetadata).toBeUndefined()
|
||||
})
|
||||
|
||||
it('prefers exact _widget input matches before same-name fallbacks for promoted widgets', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'seed', type: '*' },
|
||||
{ name: 'seed', type: '*' }
|
||||
]
|
||||
})
|
||||
|
||||
const firstNode = new LGraphNode('FirstNode')
|
||||
const firstInput = firstNode.addInput('seed', '*')
|
||||
firstNode.addWidget('number', 'seed', 1, () => undefined, {})
|
||||
firstInput.widget = { name: 'seed' }
|
||||
subgraph.add(firstNode)
|
||||
|
||||
const secondNode = new LGraphNode('SecondNode')
|
||||
const secondInput = secondNode.addInput('seed', '*')
|
||||
secondNode.addWidget('number', 'seed', 2, () => undefined, {})
|
||||
secondInput.widget = { name: 'seed' }
|
||||
subgraph.add(secondNode)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
|
||||
subgraph.inputNode.slots[1].connect(secondInput, secondNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 124 })
|
||||
const graph = subgraphNode.graph
|
||||
if (!graph) throw new Error('Expected subgraph node graph')
|
||||
graph.add(subgraphNode)
|
||||
|
||||
const promotedViews = subgraphNode.widgets
|
||||
const secondPromotedView = promotedViews[1]
|
||||
if (!secondPromotedView) throw new Error('Expected second promoted view')
|
||||
|
||||
;(
|
||||
secondPromotedView as unknown as {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
}
|
||||
).sourceNodeId = '9999'
|
||||
;(
|
||||
secondPromotedView as unknown as {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
}
|
||||
).sourceWidgetName = 'stale_widget'
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(String(subgraphNode.id))
|
||||
const secondMappedWidget = nodeData?.widgets?.find(
|
||||
(widget) => widget.slotMetadata?.index === 1
|
||||
)
|
||||
if (!secondMappedWidget)
|
||||
throw new Error('Expected mapped widget for slot 1')
|
||||
|
||||
expect(secondMappedWidget.name).not.toBe('stale_widget')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Subgraph output slot label reactivity', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('updates output slot labels when node:slot-label:changed is triggered', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
node.addOutput('original_name', 'STRING')
|
||||
node.addOutput('other_name', 'STRING')
|
||||
graph.add(node)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeId = String(node.id)
|
||||
const nodeData = vueNodeData.get(nodeId)
|
||||
if (!nodeData?.outputs) throw new Error('Expected output data to exist')
|
||||
|
||||
expect(nodeData.outputs[0].label).toBeUndefined()
|
||||
expect(nodeData.outputs[1].label).toBeUndefined()
|
||||
|
||||
// Simulate what SubgraphNode does: set the label, then fire the trigger
|
||||
node.outputs[0].label = 'custom_label'
|
||||
graph.trigger('node:slot-label:changed', {
|
||||
nodeId: node.id,
|
||||
slotType: NodeSlotType.OUTPUT
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const updatedData = vueNodeData.get(nodeId)
|
||||
expect(updatedData?.outputs?.[0]?.label).toBe('custom_label')
|
||||
expect(updatedData?.outputs?.[1]?.label).toBeUndefined()
|
||||
})
|
||||
|
||||
it('updates input slot labels when node:slot-label:changed is triggered', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
node.addInput('original_name', 'STRING')
|
||||
graph.add(node)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeId = String(node.id)
|
||||
const nodeData = vueNodeData.get(nodeId)
|
||||
if (!nodeData?.inputs) throw new Error('Expected input data to exist')
|
||||
|
||||
expect(nodeData.inputs[0].label).toBeUndefined()
|
||||
|
||||
node.inputs[0].label = 'custom_label'
|
||||
graph.trigger('node:slot-label:changed', {
|
||||
nodeId: node.id,
|
||||
slotType: NodeSlotType.INPUT
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const updatedData = vueNodeData.get(nodeId)
|
||||
expect(updatedData?.inputs?.[0]?.label).toBe('custom_label')
|
||||
})
|
||||
|
||||
it('ignores node:slot-label:changed for unknown node ids', () => {
|
||||
const graph = new LGraph()
|
||||
useGraphNodeManager(graph)
|
||||
|
||||
expect(() =>
|
||||
graph.trigger('node:slot-label:changed', {
|
||||
nodeId: 'missing-node',
|
||||
slotType: NodeSlotType.OUTPUT
|
||||
})
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Subgraph Promoted Pseudo Widgets', () => {
|
||||
@@ -315,4 +469,54 @@ describe('Nested promoted widget mapping', () => {
|
||||
`${subgraphNodeB.subgraph.id}:${innerNode.id}`
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps linked and independent same-name promotions as distinct sources', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
})
|
||||
|
||||
const linkedNode = new LGraphNode('LinkedNode')
|
||||
const linkedInput = linkedNode.addInput('string_a', '*')
|
||||
linkedNode.addWidget('text', 'string_a', 'linked', () => undefined, {})
|
||||
linkedInput.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNode)
|
||||
subgraph.inputNode.slots[0].connect(linkedInput, linkedNode)
|
||||
|
||||
const independentNode = new LGraphNode('IndependentNode')
|
||||
independentNode.addWidget(
|
||||
'text',
|
||||
'string_a',
|
||||
'independent',
|
||||
() => undefined,
|
||||
{}
|
||||
)
|
||||
subgraph.add(independentNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 109 })
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
usePromotionStore().promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(independentNode.id),
|
||||
'string_a'
|
||||
)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(String(subgraphNode.id))
|
||||
const promotedWidgets = nodeData?.widgets?.filter(
|
||||
(widget) => widget.name === 'string_a'
|
||||
)
|
||||
|
||||
expect(promotedWidgets).toHaveLength(2)
|
||||
expect(
|
||||
new Set(promotedWidgets?.map((widget) => widget.storeNodeId))
|
||||
).toEqual(
|
||||
new Set([
|
||||
`${subgraph.id}:${linkedNode.id}`,
|
||||
`${subgraph.id}:${independentNode.id}`
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import { reactive, shallowReactive } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
|
||||
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
|
||||
@@ -204,7 +205,7 @@ function safeWidgetMapper(
|
||||
|
||||
return {
|
||||
canvasOnly: widget.options.canvasOnly,
|
||||
advanced: widget.advanced,
|
||||
advanced: widget.options?.advanced ?? widget.advanced,
|
||||
hidden: widget.options.hidden,
|
||||
read_only: widget.options.read_only
|
||||
}
|
||||
@@ -234,16 +235,17 @@ function safeWidgetMapper(
|
||||
}
|
||||
}
|
||||
|
||||
const promotedInputName = node.inputs?.find((input) => {
|
||||
if (input.name === widget.name) return true
|
||||
if (input._widget === widget) return true
|
||||
return false
|
||||
})?.name
|
||||
const matchedInput = matchPromotedInput(node.inputs, widget)
|
||||
const promotedInputName = matchedInput?.name
|
||||
const displayName = promotedInputName ?? widget.name
|
||||
const promotedSource = resolvePromotedSourceByInputName(displayName) ?? {
|
||||
const directSource = {
|
||||
sourceNodeId: widget.sourceNodeId,
|
||||
sourceWidgetName: widget.sourceWidgetName
|
||||
}
|
||||
const promotedSource =
|
||||
matchedInput?._widget === widget
|
||||
? (resolvePromotedSourceByInputName(displayName) ?? directSource)
|
||||
: directSource
|
||||
|
||||
return {
|
||||
displayName,
|
||||
@@ -467,8 +469,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
|
||||
// Update only widgets with new slot metadata, keeping other widget data intact
|
||||
for (const widget of currentData.widgets ?? []) {
|
||||
const slotInfo = slotMetadata.get(widget.slotName ?? widget.name)
|
||||
if (slotInfo) widget.slotMetadata = slotInfo
|
||||
widget.slotMetadata = slotMetadata.get(widget.slotName ?? widget.name)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,8 +80,12 @@ export function showNodeOptions(
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the node options popover
|
||||
* Check if the node options menu is currently open
|
||||
*/
|
||||
export function isNodeOptionsOpen(): boolean {
|
||||
return nodeOptionsInstance?.isOpen.value ?? false
|
||||
}
|
||||
|
||||
interface NodeOptionsInstance {
|
||||
toggle: (event: Event) => void
|
||||
show: (event: MouseEvent) => void
|
||||
|
||||
183
src/composables/maskeditor/useMaskEditorSaver.test.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { app } from '@/scripts/app'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { useMaskEditorSaver } from './useMaskEditorSaver'
|
||||
|
||||
// ---- Module Mocks ----
|
||||
|
||||
const mockDataStore: Record<string, unknown> = {
|
||||
sourceNode: null,
|
||||
inputData: null,
|
||||
outputData: null
|
||||
}
|
||||
|
||||
vi.mock('@/stores/maskEditorDataStore', () => ({
|
||||
useMaskEditorDataStore: vi.fn(() => mockDataStore)
|
||||
}))
|
||||
|
||||
function createMockCtx(): CanvasRenderingContext2D {
|
||||
return {
|
||||
drawImage: vi.fn(),
|
||||
getImageData: vi.fn(() => ({
|
||||
data: new Uint8ClampedArray(4 * 4 * 4),
|
||||
width: 4,
|
||||
height: 4
|
||||
})),
|
||||
putImageData: vi.fn(),
|
||||
globalCompositeOperation: 'source-over'
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
}
|
||||
|
||||
function createMockCanvas(): HTMLCanvasElement {
|
||||
return {
|
||||
width: 4,
|
||||
height: 4,
|
||||
getContext: vi.fn(() => createMockCtx()),
|
||||
toBlob: vi.fn((cb: BlobCallback) => {
|
||||
cb(new Blob(['x'], { type: 'image/png' }))
|
||||
}),
|
||||
toDataURL: vi.fn(() => 'data:image/png;base64,mock')
|
||||
} as unknown as HTMLCanvasElement
|
||||
}
|
||||
|
||||
const mockEditorStore: Record<string, HTMLCanvasElement | null> = {
|
||||
maskCanvas: null,
|
||||
rgbCanvas: null,
|
||||
imgCanvas: null
|
||||
}
|
||||
|
||||
vi.mock('@/stores/maskEditorStore', () => ({
|
||||
useMaskEditorStore: vi.fn(() => mockEditorStore)
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fetchApi: vi.fn(),
|
||||
apiURL: vi.fn((route: string) => `http://localhost:8188${route}`)
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
canvas: { setDirty: vi.fn() },
|
||||
nodeOutputs: {} as Record<string, unknown>,
|
||||
nodePreviewImages: {} as Record<string, string[]>,
|
||||
getPreviewFormatParam: vi.fn(() => ''),
|
||||
getRandParam: vi.fn(() => '')
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({ isCloud: false }))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: vi.fn(() => ({
|
||||
nodeIdToNodeLocatorId: vi.fn((id: string | number) => String(id)),
|
||||
nodeToNodeLocatorId: vi.fn((node: { id: number }) => String(node.id))
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
executionIdToNodeLocatorId: vi.fn((_rootGraph: unknown, id: string) => id)
|
||||
}))
|
||||
|
||||
describe('useMaskEditorSaver', () => {
|
||||
let mockNode: LGraphNode
|
||||
const originalCreateElement = document.createElement.bind(document)
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
|
||||
app.nodeOutputs = {}
|
||||
app.nodePreviewImages = {}
|
||||
|
||||
mockNode = {
|
||||
id: 42,
|
||||
type: 'LoadImage',
|
||||
images: [],
|
||||
imgs: undefined,
|
||||
widgets: [
|
||||
{ name: 'image', value: 'original.png [input]', callback: vi.fn() }
|
||||
],
|
||||
widgets_values: ['original.png [input]'],
|
||||
properties: { image: 'original.png [input]' },
|
||||
graph: { setDirtyCanvas: vi.fn() }
|
||||
} as unknown as LGraphNode
|
||||
|
||||
mockDataStore.sourceNode = mockNode
|
||||
mockDataStore.inputData = {
|
||||
baseLayer: { image: {} as HTMLImageElement, url: 'base.png' },
|
||||
maskLayer: { image: {} as HTMLImageElement, url: 'mask.png' },
|
||||
sourceRef: { filename: 'original.png', subfolder: '', type: 'input' },
|
||||
nodeId: 42
|
||||
}
|
||||
mockDataStore.outputData = null
|
||||
|
||||
mockEditorStore.maskCanvas = createMockCanvas()
|
||||
mockEditorStore.rgbCanvas = createMockCanvas()
|
||||
mockEditorStore.imgCanvas = createMockCanvas()
|
||||
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
name: 'clipspace-painted-masked-123.png',
|
||||
subfolder: 'clipspace',
|
||||
type: 'input'
|
||||
})
|
||||
} as Response)
|
||||
|
||||
vi.spyOn(document, 'createElement').mockImplementation(
|
||||
(tagName: string, options?: ElementCreationOptions) => {
|
||||
if (tagName === 'canvas')
|
||||
return createMockCanvas() as unknown as HTMLCanvasElement
|
||||
return originalCreateElement(tagName, options)
|
||||
}
|
||||
)
|
||||
|
||||
// Mock Image constructor so loadImageFromUrl resolves
|
||||
vi.stubGlobal(
|
||||
'Image',
|
||||
class MockImage {
|
||||
crossOrigin = ''
|
||||
onload: ((ev: Event) => void) | null = null
|
||||
onerror: ((ev: unknown) => void) | null = null
|
||||
private _src = ''
|
||||
get src() {
|
||||
return this._src
|
||||
}
|
||||
set src(value: string) {
|
||||
this._src = value
|
||||
queueMicrotask(() => this.onload?.(new Event('load')))
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('registers node outputs in store after save for node without prior execution outputs', async () => {
|
||||
const store = useNodeOutputStore()
|
||||
const locatorId = String(mockNode.id)
|
||||
|
||||
// Precondition: node has never been executed, no outputs exist
|
||||
expect(app.nodeOutputs[locatorId]).toBeUndefined()
|
||||
|
||||
const { save } = useMaskEditorSaver()
|
||||
await save()
|
||||
|
||||
// After mask editor save, the node must have outputs in the store
|
||||
// so the image preview displays correctly (not blank).
|
||||
// Bug: the old code used updateNodeImages which silently no-ops
|
||||
// when there are no pre-existing outputs for the node.
|
||||
expect(store.nodeOutputs[locatorId]).toBeDefined()
|
||||
expect(store.nodeOutputs[locatorId]?.images?.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { createAnnotatedPath } from '@/utils/createAnnotatedPath'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
// Private layer filename functions
|
||||
@@ -347,11 +348,15 @@ export function useMaskEditorSaver() {
|
||||
node.widgets_values[widgetIndex] = widgetValue
|
||||
}
|
||||
}
|
||||
|
||||
imageWidget.callback?.(widgetValue)
|
||||
}
|
||||
|
||||
nodeOutputStore.updateNodeImages(node)
|
||||
node.imgs = undefined
|
||||
const annotatedPath = createAnnotatedPath(mainRef.filename, {
|
||||
subfolder: mainRef.subfolder,
|
||||
rootFolder: mainRef.type
|
||||
})
|
||||
nodeOutputStore.setNodeOutputs(node, annotatedPath, { folder: 'input' })
|
||||
node.graph?.setDirtyCanvas(true)
|
||||
}
|
||||
|
||||
function loadImageFromUrl(url: string): Promise<HTMLImageElement> {
|
||||
|
||||
@@ -397,8 +397,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
if (app.canvas.empty) {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('toastMessages.emptyCanvas'),
|
||||
life: 3000
|
||||
summary: t('toastMessages.emptyCanvas')
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -557,8 +556,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('toastMessages.nothingToQueue'),
|
||||
detail: t('toastMessages.pleaseSelectOutputNodes'),
|
||||
life: 3000
|
||||
detail: t('toastMessages.pleaseSelectOutputNodes')
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -571,8 +569,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('toastMessages.failedToQueue'),
|
||||
detail: t('toastMessages.failedExecutionPathResolution'),
|
||||
life: 3000
|
||||
detail: t('toastMessages.failedExecutionPathResolution')
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -602,8 +599,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('toastMessages.nothingToGroup'),
|
||||
detail: t('toastMessages.pleaseSelectNodesToGroup'),
|
||||
life: 3000
|
||||
detail: t('toastMessages.pleaseSelectNodesToGroup')
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -962,8 +958,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('manager.notAvailable'),
|
||||
life: 3000
|
||||
detail: t('manager.notAvailable')
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -1048,8 +1043,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('toastMessages.cannotCreateSubgraph'),
|
||||
detail: t('toastMessages.failedToConvertToSubgraph'),
|
||||
life: 3000
|
||||
detail: t('toastMessages.failedToConvertToSubgraph')
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -1258,8 +1252,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
summary: t('g.error'),
|
||||
detail: t('g.commandProhibited', {
|
||||
command: 'Comfy.Memory.UnloadModels'
|
||||
}),
|
||||
life: 3000
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -1278,8 +1271,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
summary: t('g.error'),
|
||||
detail: t('g.commandProhibited', {
|
||||
command: 'Comfy.Memory.UnloadModelsAndExecutionCache'
|
||||
}),
|
||||
life: 3000
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -5,19 +5,15 @@ import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
||||
import { useHelpCenterStore } from '@/stores/helpCenterStore'
|
||||
import type { HelpCenterTriggerLocation } from '@/stores/helpCenterStore'
|
||||
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
|
||||
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
|
||||
import { useNodeConflictDialog } from '@/workbench/extensions/manager/composables/useNodeConflictDialog'
|
||||
|
||||
export function useHelpCenter(
|
||||
triggerFrom: HelpCenterTriggerLocation = 'sidebar'
|
||||
) {
|
||||
export function useHelpCenter() {
|
||||
const settingStore = useSettingStore()
|
||||
const releaseStore = useReleaseStore()
|
||||
const helpCenterStore = useHelpCenterStore()
|
||||
const { isVisible: isHelpCenterVisible, triggerLocation } =
|
||||
storeToRefs(helpCenterStore)
|
||||
const { isVisible: isHelpCenterVisible } = storeToRefs(helpCenterStore)
|
||||
const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
|
||||
|
||||
const conflictDetection = useConflictDetection()
|
||||
@@ -42,9 +38,9 @@ export function useHelpCenter(
|
||||
*/
|
||||
const toggleHelpCenter = () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: `${triggerFrom}_help_center_toggled`
|
||||
button_id: 'sidebar_help_center_toggled'
|
||||
})
|
||||
helpCenterStore.toggle(triggerFrom)
|
||||
helpCenterStore.toggle()
|
||||
}
|
||||
|
||||
const closeHelpCenter = () => {
|
||||
@@ -90,7 +86,6 @@ export function useHelpCenter(
|
||||
|
||||
return {
|
||||
isHelpCenterVisible,
|
||||
triggerLocation,
|
||||
shouldShowRedDot,
|
||||
sidebarLocation,
|
||||
toggleHelpCenter,
|
||||
|
||||
@@ -595,6 +595,34 @@ describe('usePaste', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should skip node metadata paste when a media node is selected', async () => {
|
||||
const mockNode = createMockLGraphNode({
|
||||
is_selected: true,
|
||||
pasteFile: vi.fn(),
|
||||
pasteFiles: vi.fn()
|
||||
})
|
||||
mockCanvas.current_node = mockNode
|
||||
vi.mocked(isImageNode).mockReturnValue(true)
|
||||
|
||||
usePaste()
|
||||
|
||||
const nodeData = { nodes: [{ type: 'KSampler' }] }
|
||||
const encoded = btoa(JSON.stringify(nodeData))
|
||||
const html = `<div data-metadata="${encoded}"></div>`
|
||||
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.setData('text/html', html)
|
||||
dataTransfer.setData('text/plain', 'some text')
|
||||
|
||||
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
|
||||
document.dispatchEvent(event)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockCanvas._deserializeItems).not.toHaveBeenCalled()
|
||||
expect(mockCanvas.pasteFromClipboard).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('cloneDataTransfer', () => {
|
||||
|
||||
@@ -229,7 +229,10 @@ export const usePaste = () => {
|
||||
return
|
||||
}
|
||||
}
|
||||
if (pasteClipboardItems(data)) return
|
||||
|
||||
const isMediaNodeSelected =
|
||||
isImageNodeSelected || isVideoNodeSelected || isAudioNodeSelected
|
||||
if (!isMediaNodeSelected && pasteClipboardItems(data)) return
|
||||
|
||||
// No image found. Look for node data
|
||||
data = data.getData('text/plain')
|
||||
|
||||
77
src/core/graph/subgraph/matchPromotedInput.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import { matchPromotedInput } from './matchPromotedInput'
|
||||
|
||||
type MockInput = {
|
||||
name: string
|
||||
_widget?: IBaseWidget
|
||||
}
|
||||
|
||||
function createWidget(name: string): IBaseWidget {
|
||||
return {
|
||||
name,
|
||||
type: 'text'
|
||||
} as IBaseWidget
|
||||
}
|
||||
|
||||
describe(matchPromotedInput, () => {
|
||||
it('prefers exact _widget matches before same-name inputs', () => {
|
||||
const targetWidget = createWidget('seed')
|
||||
const aliasWidget = createWidget('seed')
|
||||
|
||||
const aliasInput: MockInput = {
|
||||
name: 'seed',
|
||||
_widget: aliasWidget
|
||||
}
|
||||
const exactInput: MockInput = {
|
||||
name: 'seed',
|
||||
_widget: targetWidget
|
||||
}
|
||||
|
||||
const matched = matchPromotedInput(
|
||||
[aliasInput, exactInput] as unknown as Array<{
|
||||
name: string
|
||||
_widget?: IBaseWidget
|
||||
}>,
|
||||
targetWidget
|
||||
)
|
||||
|
||||
expect(matched).toBe(exactInput)
|
||||
})
|
||||
|
||||
it('falls back to same-name matching when no exact widget match exists', () => {
|
||||
const targetWidget = createWidget('seed')
|
||||
const aliasInput: MockInput = {
|
||||
name: 'seed'
|
||||
}
|
||||
|
||||
const matched = matchPromotedInput(
|
||||
[aliasInput] as unknown as Array<{ name: string; _widget?: IBaseWidget }>,
|
||||
targetWidget
|
||||
)
|
||||
|
||||
expect(matched).toBe(aliasInput)
|
||||
})
|
||||
|
||||
it('does not guess when multiple same-name inputs exist without an exact match', () => {
|
||||
const targetWidget = createWidget('seed')
|
||||
const firstAliasInput: MockInput = {
|
||||
name: 'seed'
|
||||
}
|
||||
const secondAliasInput: MockInput = {
|
||||
name: 'seed'
|
||||
}
|
||||
|
||||
const matched = matchPromotedInput(
|
||||
[firstAliasInput, secondAliasInput] as unknown as Array<{
|
||||
name: string
|
||||
_widget?: IBaseWidget
|
||||
}>,
|
||||
targetWidget
|
||||
)
|
||||
|
||||
expect(matched).toBeUndefined()
|
||||
})
|
||||
})
|
||||
19
src/core/graph/subgraph/matchPromotedInput.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
type PromotedInputLike = {
|
||||
name: string
|
||||
_widget?: IBaseWidget
|
||||
}
|
||||
|
||||
export function matchPromotedInput(
|
||||
inputs: PromotedInputLike[] | undefined,
|
||||
widget: IBaseWidget
|
||||
): PromotedInputLike | undefined {
|
||||
if (!inputs) return undefined
|
||||
|
||||
const exactMatch = inputs.find((input) => input._widget === widget)
|
||||
if (exactMatch) return exactMatch
|
||||
|
||||
const sameNameMatches = inputs.filter((input) => input.name === widget.name)
|
||||
return sameNameMatches.length === 1 ? sameNameMatches[0] : undefined
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
// Barrel import must come first to avoid circular dependency
|
||||
// (promotedWidgetView → widgetMap → BaseWidget → LegacyWidget → barrel)
|
||||
@@ -11,19 +11,26 @@ import {
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
CanvasPointerEvent,
|
||||
LGraphCanvas,
|
||||
SubgraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import { createPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import {
|
||||
stripGraphPrefix,
|
||||
useWidgetValueStore
|
||||
} from '@/stores/widgetValueStore'
|
||||
|
||||
import {
|
||||
cleanupComplexPromotionFixtureNodeType,
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
createTestSubgraphNode,
|
||||
setupComplexPromotionFixture
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
@@ -78,6 +85,22 @@ function firstInnerNode(innerNodes: LGraphNode[]): LGraphNode {
|
||||
return innerNode
|
||||
}
|
||||
|
||||
function promotedWidgets(node: SubgraphNode): PromotedWidgetView[] {
|
||||
return node.widgets as PromotedWidgetView[]
|
||||
}
|
||||
|
||||
function callSyncPromotions(node: SubgraphNode) {
|
||||
;(
|
||||
node as unknown as {
|
||||
_syncPromotions: () => void
|
||||
}
|
||||
)._syncPromotions()
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanupComplexPromotionFixtureNodeType()
|
||||
})
|
||||
|
||||
describe(createPromotedWidgetView, () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
@@ -263,6 +286,31 @@ describe(createPromotedWidgetView, () => {
|
||||
expect(fallbackWidget.value).toBe('updated')
|
||||
})
|
||||
|
||||
test('value setter falls back to host widget when linked states are unavailable', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 124 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const linkedNode = new LGraphNode('LinkedNode')
|
||||
const linkedInput = linkedNode.addInput('string_a', '*')
|
||||
linkedNode.addWidget('text', 'string_a', 'initial', () => {})
|
||||
linkedInput.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNode)
|
||||
subgraph.inputNode.slots[0].connect(linkedInput, linkedNode)
|
||||
|
||||
const linkedView = promotedWidgets(subgraphNode)[0]
|
||||
if (!linkedView) throw new Error('Expected a linked promoted widget')
|
||||
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
vi.spyOn(widgetValueStore, 'getWidget').mockReturnValue(undefined)
|
||||
|
||||
linkedView.value = 'updated'
|
||||
|
||||
expect(linkedNode.widgets?.[0].value).toBe('updated')
|
||||
})
|
||||
|
||||
test('label falls back to displayName then widgetName', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const innerNode = firstInnerNode(innerNodes)
|
||||
@@ -495,6 +543,185 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
])
|
||||
})
|
||||
|
||||
test('renders all promoted widgets when duplicate input names are connected to different nodes', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'seed', type: '*' },
|
||||
{ name: 'seed', type: '*' }
|
||||
]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 94 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const firstNode = new LGraphNode('FirstSeedNode')
|
||||
const firstInput = firstNode.addInput('seed', '*')
|
||||
firstNode.addWidget('number', 'seed', 1, () => {})
|
||||
firstInput.widget = { name: 'seed' }
|
||||
subgraph.add(firstNode)
|
||||
|
||||
const secondNode = new LGraphNode('SecondSeedNode')
|
||||
const secondInput = secondNode.addInput('seed', '*')
|
||||
secondNode.addWidget('number', 'seed', 2, () => {})
|
||||
secondInput.widget = { name: 'seed' }
|
||||
subgraph.add(secondNode)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
|
||||
subgraph.inputNode.slots[1].connect(secondInput, secondNode)
|
||||
|
||||
const widgets = promotedWidgets(subgraphNode)
|
||||
expect(widgets).toHaveLength(2)
|
||||
expect(widgets.map((widget) => widget.sourceNodeId)).toStrictEqual([
|
||||
String(firstNode.id),
|
||||
String(secondNode.id)
|
||||
])
|
||||
})
|
||||
|
||||
test('input-linked same-name widgets share value state while store-promoted peer stays independent', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 95 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const linkedNodeA = new LGraphNode('LinkedNodeA')
|
||||
const linkedInputA = linkedNodeA.addInput('string_a', '*')
|
||||
linkedNodeA.addWidget('text', 'string_a', 'a', () => {})
|
||||
linkedInputA.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNodeA)
|
||||
|
||||
const linkedNodeB = new LGraphNode('LinkedNodeB')
|
||||
const linkedInputB = linkedNodeB.addInput('string_a', '*')
|
||||
linkedNodeB.addWidget('text', 'string_a', 'b', () => {})
|
||||
linkedInputB.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNodeB)
|
||||
|
||||
const promotedNode = new LGraphNode('PromotedNode')
|
||||
promotedNode.addWidget('text', 'string_a', 'independent', () => {})
|
||||
subgraph.add(promotedNode)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(linkedInputA, linkedNodeA)
|
||||
subgraph.inputNode.slots[0].connect(linkedInputB, linkedNodeB)
|
||||
|
||||
usePromotionStore().promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(promotedNode.id),
|
||||
'string_a'
|
||||
)
|
||||
usePromotionStore().promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(linkedNodeA.id),
|
||||
'string_a'
|
||||
)
|
||||
|
||||
const widgets = promotedWidgets(subgraphNode)
|
||||
expect(widgets).toHaveLength(2)
|
||||
|
||||
const linkedView = widgets.find(
|
||||
(widget) => widget.sourceNodeId === String(linkedNodeA.id)
|
||||
)
|
||||
const promotedView = widgets.find(
|
||||
(widget) => widget.sourceNodeId === String(promotedNode.id)
|
||||
)
|
||||
if (!linkedView || !promotedView)
|
||||
throw new Error(
|
||||
'Expected linked and store-promoted widgets to be present'
|
||||
)
|
||||
|
||||
linkedView.value = 'shared-value'
|
||||
|
||||
const widgetStore = useWidgetValueStore()
|
||||
const graphId = subgraphNode.rootGraph.id
|
||||
expect(
|
||||
widgetStore.getWidget(
|
||||
graphId,
|
||||
stripGraphPrefix(String(linkedNodeA.id)),
|
||||
'string_a'
|
||||
)?.value
|
||||
).toBe('shared-value')
|
||||
expect(
|
||||
widgetStore.getWidget(
|
||||
graphId,
|
||||
stripGraphPrefix(String(linkedNodeB.id)),
|
||||
'string_a'
|
||||
)?.value
|
||||
).toBe('shared-value')
|
||||
expect(
|
||||
widgetStore.getWidget(
|
||||
graphId,
|
||||
stripGraphPrefix(String(promotedNode.id)),
|
||||
'string_a'
|
||||
)?.value
|
||||
).toBe('independent')
|
||||
|
||||
promotedView.value = 'independent-updated'
|
||||
|
||||
expect(
|
||||
widgetStore.getWidget(
|
||||
graphId,
|
||||
stripGraphPrefix(String(linkedNodeA.id)),
|
||||
'string_a'
|
||||
)?.value
|
||||
).toBe('shared-value')
|
||||
expect(
|
||||
widgetStore.getWidget(
|
||||
graphId,
|
||||
stripGraphPrefix(String(linkedNodeB.id)),
|
||||
'string_a'
|
||||
)?.value
|
||||
).toBe('shared-value')
|
||||
expect(
|
||||
widgetStore.getWidget(
|
||||
graphId,
|
||||
stripGraphPrefix(String(promotedNode.id)),
|
||||
'string_a'
|
||||
)?.value
|
||||
).toBe('independent-updated')
|
||||
})
|
||||
|
||||
test('duplicate-name promoted views map slot linkage by view identity', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 109 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const linkedNode = new LGraphNode('LinkedNode')
|
||||
const linkedInput = linkedNode.addInput('string_a', '*')
|
||||
linkedNode.addWidget('text', 'string_a', 'linked', () => {})
|
||||
linkedInput.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNode)
|
||||
|
||||
const independentNode = new LGraphNode('IndependentNode')
|
||||
independentNode.addWidget('text', 'string_a', 'independent', () => {})
|
||||
subgraph.add(independentNode)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(linkedInput, linkedNode)
|
||||
usePromotionStore().promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(independentNode.id),
|
||||
'string_a'
|
||||
)
|
||||
|
||||
const widgets = promotedWidgets(subgraphNode)
|
||||
const linkedView = widgets.find(
|
||||
(widget) => widget.sourceNodeId === String(linkedNode.id)
|
||||
)
|
||||
const independentView = widgets.find(
|
||||
(widget) => widget.sourceNodeId === String(independentNode.id)
|
||||
)
|
||||
if (!linkedView || !independentView)
|
||||
throw new Error('Expected linked and independent promoted views')
|
||||
|
||||
const linkedSlot = subgraphNode.getSlotFromWidget(linkedView)
|
||||
const independentSlot = subgraphNode.getSlotFromWidget(independentView)
|
||||
|
||||
expect(linkedSlot).toBeDefined()
|
||||
expect(independentSlot).toBeUndefined()
|
||||
})
|
||||
|
||||
test('returns empty array when no proxyWidgets', () => {
|
||||
const [subgraphNode] = setupSubgraph()
|
||||
expect(subgraphNode.widgets).toEqual([])
|
||||
@@ -558,6 +785,273 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
])
|
||||
})
|
||||
|
||||
test('full linked coverage does not prune unresolved independent fallback promotions', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'widgetA', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 125 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const liveNode = new LGraphNode('LiveNode')
|
||||
const liveInput = liveNode.addInput('widgetA', '*')
|
||||
liveNode.addWidget('text', 'widgetA', 'a', () => {})
|
||||
liveInput.widget = { name: 'widgetA' }
|
||||
subgraph.add(liveNode)
|
||||
subgraph.inputNode.slots[0].connect(liveInput, liveNode)
|
||||
|
||||
setPromotions(subgraphNode, [
|
||||
[String(liveNode.id), 'widgetA'],
|
||||
['9999', 'widgetA']
|
||||
])
|
||||
|
||||
callSyncPromotions(subgraphNode)
|
||||
|
||||
const promotions = usePromotionStore().getPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id
|
||||
)
|
||||
expect(promotions).toStrictEqual([
|
||||
{ interiorNodeId: String(liveNode.id), widgetName: 'widgetA' },
|
||||
{ interiorNodeId: '9999', widgetName: 'widgetA' }
|
||||
])
|
||||
})
|
||||
|
||||
test('input-added existing-input path tolerates missing link metadata', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'widgetA', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 126 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const existingSlot = subgraph.inputNode.slots[0]
|
||||
if (!existingSlot) throw new Error('Expected subgraph input slot')
|
||||
|
||||
expect(() => {
|
||||
subgraph.events.dispatch('input-added', { input: existingSlot })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
test('syncPromotions prunes stale connected entries but keeps independent promotions', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 96 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const linkedNodeA = new LGraphNode('LinkedNodeA')
|
||||
const linkedInputA = linkedNodeA.addInput('string_a', '*')
|
||||
linkedNodeA.addWidget('text', 'string_a', 'a', () => {})
|
||||
linkedInputA.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNodeA)
|
||||
|
||||
const linkedNodeB = new LGraphNode('LinkedNodeB')
|
||||
const linkedInputB = linkedNodeB.addInput('string_a', '*')
|
||||
linkedNodeB.addWidget('text', 'string_a', 'b', () => {})
|
||||
linkedInputB.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNodeB)
|
||||
|
||||
const independentNode = new LGraphNode('IndependentNode')
|
||||
independentNode.addWidget('text', 'string_a', 'independent', () => {})
|
||||
subgraph.add(independentNode)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(linkedInputA, linkedNodeA)
|
||||
subgraph.inputNode.slots[0].connect(linkedInputB, linkedNodeB)
|
||||
|
||||
setPromotions(subgraphNode, [
|
||||
[String(independentNode.id), 'string_a'],
|
||||
[String(linkedNodeA.id), 'string_a'],
|
||||
[String(linkedNodeB.id), 'string_a']
|
||||
])
|
||||
|
||||
callSyncPromotions(subgraphNode)
|
||||
|
||||
const promotions = usePromotionStore().getPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id
|
||||
)
|
||||
expect(promotions).toStrictEqual([
|
||||
{ interiorNodeId: String(linkedNodeA.id), widgetName: 'string_a' },
|
||||
{ interiorNodeId: String(independentNode.id), widgetName: 'string_a' }
|
||||
])
|
||||
})
|
||||
|
||||
test('syncPromotions prunes stale deep-alias entries for nested linked promotions', () => {
|
||||
const { subgraphNodeB } = createTwoLevelNestedSubgraph()
|
||||
const linkedView = promotedWidgets(subgraphNodeB)[0]
|
||||
if (!linkedView)
|
||||
throw new Error(
|
||||
'Expected nested subgraph to expose a linked promoted view'
|
||||
)
|
||||
|
||||
const concrete = resolveConcretePromotedWidget(
|
||||
subgraphNodeB,
|
||||
linkedView.sourceNodeId,
|
||||
linkedView.sourceWidgetName
|
||||
)
|
||||
if (concrete.status !== 'resolved')
|
||||
throw new Error(
|
||||
'Expected nested promoted view to resolve to concrete widget'
|
||||
)
|
||||
|
||||
const linkedEntry = [
|
||||
linkedView.sourceNodeId,
|
||||
linkedView.sourceWidgetName
|
||||
] as [string, string]
|
||||
const deepAliasEntry = [
|
||||
String(concrete.resolved.node.id),
|
||||
concrete.resolved.widget.name
|
||||
] as [string, string]
|
||||
|
||||
// Guardrail: this test specifically validates host/deep alias cleanup.
|
||||
expect(deepAliasEntry).not.toStrictEqual(linkedEntry)
|
||||
|
||||
setPromotions(subgraphNodeB, [linkedEntry, deepAliasEntry])
|
||||
|
||||
callSyncPromotions(subgraphNodeB)
|
||||
|
||||
const promotions = usePromotionStore().getPromotions(
|
||||
subgraphNodeB.rootGraph.id,
|
||||
subgraphNodeB.id
|
||||
)
|
||||
expect(promotions).toStrictEqual([
|
||||
{
|
||||
interiorNodeId: linkedEntry[0],
|
||||
widgetName: linkedEntry[1]
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
test('configure prunes stale disconnected host aliases that resolve to the active linked concrete widget', () => {
|
||||
const nestedSubgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
})
|
||||
|
||||
const concreteNode = new LGraphNode('ConcreteNode')
|
||||
const concreteInput = concreteNode.addInput('string_a', '*')
|
||||
concreteNode.addWidget('text', 'string_a', 'value', () => {})
|
||||
concreteInput.widget = { name: 'string_a' }
|
||||
nestedSubgraph.add(concreteNode)
|
||||
nestedSubgraph.inputNode.slots[0].connect(concreteInput, concreteNode)
|
||||
|
||||
const hostSubgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
})
|
||||
|
||||
const activeAliasNode = createTestSubgraphNode(nestedSubgraph, { id: 118 })
|
||||
const staleAliasNode = createTestSubgraphNode(nestedSubgraph, { id: 119 })
|
||||
hostSubgraph.add(activeAliasNode)
|
||||
hostSubgraph.add(staleAliasNode)
|
||||
|
||||
activeAliasNode._internalConfigureAfterSlots()
|
||||
staleAliasNode._internalConfigureAfterSlots()
|
||||
hostSubgraph.inputNode.slots[0].connect(
|
||||
activeAliasNode.inputs[0],
|
||||
activeAliasNode
|
||||
)
|
||||
|
||||
const hostSubgraphNode = createTestSubgraphNode(hostSubgraph, { id: 120 })
|
||||
hostSubgraphNode.graph?.add(hostSubgraphNode)
|
||||
|
||||
setPromotions(hostSubgraphNode, [
|
||||
[String(activeAliasNode.id), 'string_a'],
|
||||
[String(staleAliasNode.id), 'string_a']
|
||||
])
|
||||
|
||||
const serialized = hostSubgraphNode.serialize()
|
||||
const restoredNode = createTestSubgraphNode(hostSubgraph, { id: 121 })
|
||||
restoredNode.configure({
|
||||
...serialized,
|
||||
id: restoredNode.id,
|
||||
type: hostSubgraph.id,
|
||||
inputs: []
|
||||
})
|
||||
|
||||
const restoredPromotions = usePromotionStore().getPromotions(
|
||||
restoredNode.rootGraph.id,
|
||||
restoredNode.id
|
||||
)
|
||||
expect(restoredPromotions).toStrictEqual([
|
||||
{
|
||||
interiorNodeId: String(activeAliasNode.id),
|
||||
widgetName: 'string_a'
|
||||
}
|
||||
])
|
||||
|
||||
const restoredWidgets = promotedWidgets(restoredNode)
|
||||
expect(restoredWidgets).toHaveLength(1)
|
||||
expect(restoredWidgets[0].sourceNodeId).toBe(String(activeAliasNode.id))
|
||||
})
|
||||
|
||||
test('serialize syncs duplicate-name linked inputs by subgraph slot identity', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'seed', type: '*' },
|
||||
{ name: 'seed', type: '*' }
|
||||
]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 127 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const firstNode = new LGraphNode('FirstNode')
|
||||
const firstInput = firstNode.addInput('seed', '*')
|
||||
firstNode.addWidget('text', 'seed', 'first-initial', () => {})
|
||||
firstInput.widget = { name: 'seed' }
|
||||
subgraph.add(firstNode)
|
||||
|
||||
const secondNode = new LGraphNode('SecondNode')
|
||||
const secondInput = secondNode.addInput('seed', '*')
|
||||
secondNode.addWidget('text', 'seed', 'second-initial', () => {})
|
||||
secondInput.widget = { name: 'seed' }
|
||||
subgraph.add(secondNode)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
|
||||
subgraph.inputNode.slots[1].connect(secondInput, secondNode)
|
||||
|
||||
const widgets = promotedWidgets(subgraphNode)
|
||||
const firstView = widgets[0]
|
||||
const secondView = widgets[1]
|
||||
if (!firstView || !secondView)
|
||||
throw new Error('Expected two linked promoted views')
|
||||
|
||||
firstView.value = 'first-updated'
|
||||
secondView.value = 'second-updated'
|
||||
|
||||
expect(firstNode.widgets?.[0].value).toBe('first-updated')
|
||||
expect(secondNode.widgets?.[0].value).toBe('second-updated')
|
||||
|
||||
subgraphNode.serialize()
|
||||
|
||||
expect(firstNode.widgets?.[0].value).toBe('first-updated')
|
||||
expect(secondNode.widgets?.[0].value).toBe('second-updated')
|
||||
})
|
||||
|
||||
test('renaming an input updates linked promoted view display names', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'seed', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 128 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const linkedNode = new LGraphNode('LinkedNode')
|
||||
const linkedInput = linkedNode.addInput('seed', '*')
|
||||
linkedNode.addWidget('text', 'seed', 'value', () => {})
|
||||
linkedInput.widget = { name: 'seed' }
|
||||
subgraph.add(linkedNode)
|
||||
subgraph.inputNode.slots[0].connect(linkedInput, linkedNode)
|
||||
|
||||
const beforeRename = promotedWidgets(subgraphNode)[0]
|
||||
if (!beforeRename) throw new Error('Expected linked promoted view')
|
||||
expect(beforeRename.name).toBe('seed')
|
||||
|
||||
const inputToRename = subgraph.inputs[0]
|
||||
if (!inputToRename) throw new Error('Expected input to rename')
|
||||
subgraph.renameInput(inputToRename, 'seed_renamed')
|
||||
|
||||
const afterRename = promotedWidgets(subgraphNode)[0]
|
||||
if (!afterRename) throw new Error('Expected linked promoted view')
|
||||
expect(afterRename.name).toBe('seed_renamed')
|
||||
})
|
||||
|
||||
test('caches view objects across getter calls (stable references)', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
@@ -701,6 +1195,236 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
])
|
||||
})
|
||||
|
||||
test('configure with empty serialized inputs keeps linked filtering active', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 97 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const linkedNodeA = new LGraphNode('LinkedNodeA')
|
||||
const linkedInputA = linkedNodeA.addInput('string_a', '*')
|
||||
linkedNodeA.addWidget('text', 'string_a', 'a', () => {})
|
||||
linkedInputA.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNodeA)
|
||||
|
||||
const linkedNodeB = new LGraphNode('LinkedNodeB')
|
||||
const linkedInputB = linkedNodeB.addInput('string_a', '*')
|
||||
linkedNodeB.addWidget('text', 'string_a', 'b', () => {})
|
||||
linkedInputB.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNodeB)
|
||||
|
||||
const storeOnlyNode = new LGraphNode('StoreOnlyNode')
|
||||
storeOnlyNode.addWidget('text', 'string_a', 'independent', () => {})
|
||||
subgraph.add(storeOnlyNode)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(linkedInputA, linkedNodeA)
|
||||
subgraph.inputNode.slots[0].connect(linkedInputB, linkedNodeB)
|
||||
|
||||
setPromotions(subgraphNode, [
|
||||
[String(linkedNodeA.id), 'string_a'],
|
||||
[String(linkedNodeB.id), 'string_a'],
|
||||
[String(storeOnlyNode.id), 'string_a']
|
||||
])
|
||||
|
||||
const serialized = subgraphNode.serialize()
|
||||
const restoredNode = createTestSubgraphNode(subgraph, { id: 98 })
|
||||
restoredNode.configure({
|
||||
...serialized,
|
||||
id: restoredNode.id,
|
||||
type: subgraph.id,
|
||||
inputs: []
|
||||
})
|
||||
|
||||
const restoredWidgets = promotedWidgets(restoredNode)
|
||||
expect(restoredWidgets).toHaveLength(2)
|
||||
|
||||
const linkedViewCount = restoredWidgets.filter((widget) =>
|
||||
[String(linkedNodeA.id), String(linkedNodeB.id)].includes(
|
||||
widget.sourceNodeId
|
||||
)
|
||||
).length
|
||||
expect(linkedViewCount).toBe(1)
|
||||
expect(
|
||||
restoredWidgets.some(
|
||||
(widget) => widget.sourceNodeId === String(storeOnlyNode.id)
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('configure with serialized inputs rebinds subgraph slots for linked filtering', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 107 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const linkedNodeA = new LGraphNode('LinkedNodeA')
|
||||
const linkedInputA = linkedNodeA.addInput('string_a', '*')
|
||||
linkedNodeA.addWidget('text', 'string_a', 'a', () => {})
|
||||
linkedInputA.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNodeA)
|
||||
|
||||
const linkedNodeB = new LGraphNode('LinkedNodeB')
|
||||
const linkedInputB = linkedNodeB.addInput('string_a', '*')
|
||||
linkedNodeB.addWidget('text', 'string_a', 'b', () => {})
|
||||
linkedInputB.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNodeB)
|
||||
|
||||
const storeOnlyNode = new LGraphNode('StoreOnlyNode')
|
||||
storeOnlyNode.addWidget('text', 'string_a', 'independent', () => {})
|
||||
subgraph.add(storeOnlyNode)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(linkedInputA, linkedNodeA)
|
||||
subgraph.inputNode.slots[0].connect(linkedInputB, linkedNodeB)
|
||||
|
||||
setPromotions(subgraphNode, [
|
||||
[String(linkedNodeA.id), 'string_a'],
|
||||
[String(linkedNodeB.id), 'string_a'],
|
||||
[String(storeOnlyNode.id), 'string_a']
|
||||
])
|
||||
|
||||
const serialized = subgraphNode.serialize()
|
||||
const restoredNode = createTestSubgraphNode(subgraph, { id: 108 })
|
||||
restoredNode.configure({
|
||||
...serialized,
|
||||
id: restoredNode.id,
|
||||
type: subgraph.id,
|
||||
inputs: [
|
||||
{
|
||||
name: 'string_a',
|
||||
type: '*',
|
||||
link: null
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const restoredWidgets = promotedWidgets(restoredNode)
|
||||
expect(restoredWidgets).toHaveLength(2)
|
||||
|
||||
const linkedViewCount = restoredWidgets.filter((widget) =>
|
||||
[String(linkedNodeA.id), String(linkedNodeB.id)].includes(
|
||||
widget.sourceNodeId
|
||||
)
|
||||
).length
|
||||
expect(linkedViewCount).toBe(1)
|
||||
expect(
|
||||
restoredWidgets.some(
|
||||
(widget) => widget.sourceNodeId === String(storeOnlyNode.id)
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('fixture keeps earliest linked representative and independent promotion only', () => {
|
||||
const { graph, hostNode } = setupComplexPromotionFixture()
|
||||
|
||||
const hostWidgets = promotedWidgets(hostNode)
|
||||
expect(hostWidgets).toHaveLength(2)
|
||||
expect(hostWidgets.map((widget) => widget.sourceNodeId)).toStrictEqual([
|
||||
'20',
|
||||
'19'
|
||||
])
|
||||
|
||||
const promotions = usePromotionStore().getPromotions(graph.id, hostNode.id)
|
||||
expect(promotions).toStrictEqual([
|
||||
{ interiorNodeId: '20', widgetName: 'string_a' },
|
||||
{ interiorNodeId: '19', widgetName: 'string_a' }
|
||||
])
|
||||
|
||||
const linkedView = hostWidgets[0]
|
||||
const independentView = hostWidgets[1]
|
||||
if (!linkedView || !independentView)
|
||||
throw new Error('Expected linked and independent promoted widgets')
|
||||
|
||||
independentView.value = 'independent-value'
|
||||
linkedView.value = 'shared-linked'
|
||||
|
||||
const widgetStore = useWidgetValueStore()
|
||||
const getValue = (nodeId: string) =>
|
||||
widgetStore.getWidget(graph.id, stripGraphPrefix(nodeId), 'string_a')
|
||||
?.value
|
||||
|
||||
expect(getValue('20')).toBe('shared-linked')
|
||||
expect(getValue('18')).toBe('shared-linked')
|
||||
expect(getValue('19')).toBe('independent-value')
|
||||
})
|
||||
|
||||
test('fixture refreshes duplicate fallback after linked representative recovers', () => {
|
||||
const { subgraph, hostNode } = setupComplexPromotionFixture()
|
||||
|
||||
const earliestLinkedNode = subgraph.getNodeById(20)
|
||||
if (!earliestLinkedNode?.widgets)
|
||||
throw new Error('Expected fixture to contain node 20 with widgets')
|
||||
|
||||
const originalWidgets = earliestLinkedNode.widgets
|
||||
earliestLinkedNode.widgets = originalWidgets.filter(
|
||||
(widget) => widget.name !== 'string_a'
|
||||
)
|
||||
|
||||
const unresolvedWidgets = promotedWidgets(hostNode)
|
||||
expect(
|
||||
unresolvedWidgets.map((widget) => widget.sourceNodeId)
|
||||
).toStrictEqual(['18', '20', '19'])
|
||||
|
||||
earliestLinkedNode.widgets = originalWidgets
|
||||
|
||||
const restoredWidgets = promotedWidgets(hostNode)
|
||||
expect(restoredWidgets.map((widget) => widget.sourceNodeId)).toStrictEqual([
|
||||
'20',
|
||||
'19'
|
||||
])
|
||||
})
|
||||
|
||||
test('fixture converges external widgets and keeps rendered value isolation after transient linked fallback churn', () => {
|
||||
const { subgraph, hostNode } = setupComplexPromotionFixture()
|
||||
|
||||
const initialWidgets = promotedWidgets(hostNode)
|
||||
expect(initialWidgets.map((widget) => widget.sourceNodeId)).toStrictEqual([
|
||||
'20',
|
||||
'19'
|
||||
])
|
||||
|
||||
const earliestLinkedNode = subgraph.getNodeById(20)
|
||||
if (!earliestLinkedNode?.widgets)
|
||||
throw new Error('Expected fixture to contain node 20 with widgets')
|
||||
|
||||
const originalWidgets = earliestLinkedNode.widgets
|
||||
earliestLinkedNode.widgets = originalWidgets.filter(
|
||||
(widget) => widget.name !== 'string_a'
|
||||
)
|
||||
|
||||
const transientWidgets = promotedWidgets(hostNode)
|
||||
expect(transientWidgets.map((widget) => widget.sourceNodeId)).toStrictEqual(
|
||||
['18', '20', '19']
|
||||
)
|
||||
|
||||
earliestLinkedNode.widgets = originalWidgets
|
||||
|
||||
const finalWidgets = promotedWidgets(hostNode)
|
||||
expect(finalWidgets).toHaveLength(2)
|
||||
expect(finalWidgets.map((widget) => widget.sourceNodeId)).toStrictEqual([
|
||||
'20',
|
||||
'19'
|
||||
])
|
||||
|
||||
const finalLinkedView = finalWidgets.find(
|
||||
(widget) => widget.sourceNodeId === '20'
|
||||
)
|
||||
const finalIndependentView = finalWidgets.find(
|
||||
(widget) => widget.sourceNodeId === '19'
|
||||
)
|
||||
if (!finalLinkedView || !finalIndependentView)
|
||||
throw new Error('Expected final rendered linked and independent views')
|
||||
|
||||
finalIndependentView.value = 'independent-final'
|
||||
expect(finalIndependentView.value).toBe('independent-final')
|
||||
expect(finalLinkedView.value).not.toBe('independent-final')
|
||||
|
||||
finalLinkedView.value = 'linked-final'
|
||||
expect(finalLinkedView.value).toBe('linked-final')
|
||||
expect(finalIndependentView.value).toBe('independent-final')
|
||||
})
|
||||
|
||||
test('clone output preserves proxyWidgets for promotion hydration', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const innerNode = firstInnerNode(innerNodes)
|
||||
@@ -751,6 +1475,103 @@ describe('widgets getter caching', () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
test('reconciles at most once per canvas frame across repeated widgets reads', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
setPromotions(subgraphNode, [['1', 'widgetA']])
|
||||
|
||||
const fakeCanvas = { frame: 12 } as Pick<LGraphCanvas, 'frame'>
|
||||
subgraphNode.rootGraph.primaryCanvas = fakeCanvas as LGraphCanvas
|
||||
|
||||
const reconcileSpy = vi.spyOn(
|
||||
subgraphNode as unknown as {
|
||||
_buildPromotionReconcileState: (
|
||||
entries: Array<{ interiorNodeId: string; widgetName: string }>,
|
||||
linkedEntries: Array<{
|
||||
inputName: string
|
||||
inputKey: string
|
||||
interiorNodeId: string
|
||||
widgetName: string
|
||||
}>
|
||||
) => unknown
|
||||
},
|
||||
'_buildPromotionReconcileState'
|
||||
)
|
||||
|
||||
void subgraphNode.widgets
|
||||
void subgraphNode.widgets
|
||||
void subgraphNode.widgets
|
||||
|
||||
expect(reconcileSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('does not re-run reconciliation when only canvas frame advances', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
setPromotions(subgraphNode, [['1', 'widgetA']])
|
||||
|
||||
const fakeCanvas = { frame: 24 } as Pick<LGraphCanvas, 'frame'>
|
||||
subgraphNode.rootGraph.primaryCanvas = fakeCanvas as LGraphCanvas
|
||||
|
||||
const reconcileSpy = vi.spyOn(
|
||||
subgraphNode as unknown as {
|
||||
_buildPromotionReconcileState: (
|
||||
entries: Array<{ interiorNodeId: string; widgetName: string }>,
|
||||
linkedEntries: Array<{
|
||||
inputName: string
|
||||
inputKey: string
|
||||
interiorNodeId: string
|
||||
widgetName: string
|
||||
}>
|
||||
) => unknown
|
||||
},
|
||||
'_buildPromotionReconcileState'
|
||||
)
|
||||
|
||||
void subgraphNode.widgets
|
||||
fakeCanvas.frame += 1
|
||||
void subgraphNode.widgets
|
||||
|
||||
expect(reconcileSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('does not re-resolve linked entries when linked input state is unchanged', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 97 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const linkedNodeA = new LGraphNode('LinkedNodeA')
|
||||
const linkedInputA = linkedNodeA.addInput('string_a', '*')
|
||||
linkedNodeA.addWidget('text', 'string_a', 'a', () => {})
|
||||
linkedInputA.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNodeA)
|
||||
|
||||
const linkedNodeB = new LGraphNode('LinkedNodeB')
|
||||
const linkedInputB = linkedNodeB.addInput('string_a', '*')
|
||||
linkedNodeB.addWidget('text', 'string_a', 'b', () => {})
|
||||
linkedInputB.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNodeB)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(linkedInputA, linkedNodeA)
|
||||
subgraph.inputNode.slots[0].connect(linkedInputB, linkedNodeB)
|
||||
|
||||
const resolveSpy = vi.spyOn(
|
||||
subgraphNode as unknown as {
|
||||
_resolveLinkedPromotionBySubgraphInput: (...args: unknown[]) => unknown
|
||||
},
|
||||
'_resolveLinkedPromotionBySubgraphInput'
|
||||
)
|
||||
|
||||
void subgraphNode.widgets
|
||||
const initialResolveCount = resolveSpy.mock.calls.length
|
||||
expect(initialResolveCount).toBeLessThanOrEqual(1)
|
||||
|
||||
void subgraphNode.widgets
|
||||
expect(resolveSpy).toHaveBeenCalledTimes(initialResolveCount)
|
||||
})
|
||||
|
||||
test('preserves view identities when promotion order changes', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
|
||||
import type { Point } from '@/lib/litegraph/src/interfaces'
|
||||
@@ -13,11 +13,15 @@ import {
|
||||
stripGraphPrefix,
|
||||
useWidgetValueStore
|
||||
} from '@/stores/widgetValueStore'
|
||||
import type { WidgetState } from '@/stores/widgetValueStore'
|
||||
import {
|
||||
resolveConcretePromotedWidget,
|
||||
resolvePromotedWidgetAtHost
|
||||
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
|
||||
import { hasWidgetNode } from '@/core/graph/subgraph/widgetNodeTypeGuard'
|
||||
|
||||
import { isPromotedWidgetView } from './promotedWidgetTypes'
|
||||
import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidgetTypes'
|
||||
|
||||
export type { PromotedWidgetView } from './promotedWidgetTypes'
|
||||
@@ -131,6 +135,38 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
}
|
||||
|
||||
set value(value: IBaseWidget['value']) {
|
||||
const linkedWidgets = this.getLinkedInputWidgets()
|
||||
if (linkedWidgets.length > 0) {
|
||||
const widgetStore = useWidgetValueStore()
|
||||
let didUpdateState = false
|
||||
for (const linkedWidget of linkedWidgets) {
|
||||
const state = widgetStore.getWidget(
|
||||
this.graphId,
|
||||
linkedWidget.nodeId,
|
||||
linkedWidget.widgetName
|
||||
)
|
||||
if (state) {
|
||||
state.value = value
|
||||
didUpdateState = true
|
||||
}
|
||||
}
|
||||
|
||||
const resolved = this.resolveDeepest()
|
||||
if (resolved) {
|
||||
const resolvedState = widgetStore.getWidget(
|
||||
this.graphId,
|
||||
stripGraphPrefix(String(resolved.node.id)),
|
||||
resolved.widget.name
|
||||
)
|
||||
if (resolvedState) {
|
||||
resolvedState.value = value
|
||||
didUpdateState = true
|
||||
}
|
||||
}
|
||||
|
||||
if (didUpdateState) return
|
||||
}
|
||||
|
||||
const state = this.getWidgetState()
|
||||
if (state) {
|
||||
state.value = value
|
||||
@@ -278,6 +314,9 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
}
|
||||
|
||||
private getWidgetState() {
|
||||
const linkedState = this.getLinkedInputWidgetStates()[0]
|
||||
if (linkedState) return linkedState
|
||||
|
||||
const resolved = this.resolveDeepest()
|
||||
if (!resolved) return undefined
|
||||
return useWidgetValueStore().getWidget(
|
||||
@@ -287,6 +326,57 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
)
|
||||
}
|
||||
|
||||
private getLinkedInputWidgets(): Array<{
|
||||
nodeId: NodeId
|
||||
widgetName: string
|
||||
widget: IBaseWidget
|
||||
}> {
|
||||
const linkedInputSlot = this.subgraphNode.inputs.find((input) => {
|
||||
if (!input._subgraphSlot) return false
|
||||
if (matchPromotedInput([input], this) !== input) return false
|
||||
|
||||
const boundWidget = input._widget
|
||||
if (boundWidget === this) return true
|
||||
|
||||
if (boundWidget && isPromotedWidgetView(boundWidget)) {
|
||||
return (
|
||||
boundWidget.sourceNodeId === this.sourceNodeId &&
|
||||
boundWidget.sourceWidgetName === this.sourceWidgetName
|
||||
)
|
||||
}
|
||||
|
||||
return input._subgraphSlot
|
||||
.getConnectedWidgets()
|
||||
.filter(hasWidgetNode)
|
||||
.some(
|
||||
(widget) =>
|
||||
String(widget.node.id) === this.sourceNodeId &&
|
||||
widget.name === this.sourceWidgetName
|
||||
)
|
||||
})
|
||||
const linkedInput = linkedInputSlot?._subgraphSlot
|
||||
if (!linkedInput) return []
|
||||
|
||||
return linkedInput
|
||||
.getConnectedWidgets()
|
||||
.filter(hasWidgetNode)
|
||||
.map((widget) => ({
|
||||
nodeId: stripGraphPrefix(String(widget.node.id)),
|
||||
widgetName: widget.name,
|
||||
widget
|
||||
}))
|
||||
}
|
||||
|
||||
private getLinkedInputWidgetStates(): WidgetState[] {
|
||||
const widgetStore = useWidgetValueStore()
|
||||
|
||||
return this.getLinkedInputWidgets()
|
||||
.map(({ nodeId, widgetName }) =>
|
||||
widgetStore.getWidget(this.graphId, nodeId, widgetName)
|
||||
)
|
||||
.filter((state): state is WidgetState => state !== undefined)
|
||||
}
|
||||
|
||||
private getProjectedWidget(resolved: {
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
|
||||
@@ -81,8 +81,7 @@ function getParentNodes(): SubgraphNode[] {
|
||||
useToastStore().add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('subgraphStore.promoteOutsideSubgraph'),
|
||||
life: 2000
|
||||
detail: t('subgraphStore.promoteOutsideSubgraph')
|
||||
})
|
||||
return []
|
||||
}
|
||||
|
||||
161
src/core/graph/subgraph/resolveSubgraphInputTarget.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({})
|
||||
}))
|
||||
vi.mock('@/stores/domWidgetStore', () => ({
|
||||
useDomWidgetStore: () => ({ widgetStates: new Map() })
|
||||
}))
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
||||
}))
|
||||
|
||||
function createOuterSubgraphSetup(inputNames: string[]): {
|
||||
outerSubgraph: Subgraph
|
||||
outerSubgraphNode: SubgraphNode
|
||||
} {
|
||||
const outerSubgraph = createTestSubgraph({
|
||||
inputs: inputNames.map((name) => ({ name, type: '*' }))
|
||||
})
|
||||
const outerSubgraphNode = createTestSubgraphNode(outerSubgraph, { id: 1 })
|
||||
return { outerSubgraph, outerSubgraphNode }
|
||||
}
|
||||
|
||||
function addLinkedNestedSubgraphNode(
|
||||
outerSubgraph: Subgraph,
|
||||
inputName: string,
|
||||
linkedInputName: string,
|
||||
options: { widget?: string } = {}
|
||||
): { innerSubgraphNode: SubgraphNode } {
|
||||
const innerSubgraph = createTestSubgraph({
|
||||
inputs: [{ name: linkedInputName, type: '*' }]
|
||||
})
|
||||
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, { id: 819 })
|
||||
outerSubgraph.add(innerSubgraphNode)
|
||||
|
||||
const inputSlot = outerSubgraph.inputNode.slots.find(
|
||||
(slot) => slot.name === inputName
|
||||
)
|
||||
if (!inputSlot) throw new Error(`Missing subgraph input slot: ${inputName}`)
|
||||
|
||||
const input = innerSubgraphNode.addInput(linkedInputName, '*')
|
||||
if (options.widget) {
|
||||
innerSubgraphNode.addWidget('number', options.widget, 0, () => undefined)
|
||||
input.widget = { name: options.widget }
|
||||
}
|
||||
inputSlot.connect(input, innerSubgraphNode)
|
||||
|
||||
if (input.link == null) {
|
||||
throw new Error(`Expected link to be created for input ${linkedInputName}`)
|
||||
}
|
||||
|
||||
return { innerSubgraphNode }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('resolveSubgraphInputTarget', () => {
|
||||
test('returns target for widget-backed input on nested SubgraphNode', () => {
|
||||
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
|
||||
'width'
|
||||
])
|
||||
addLinkedNestedSubgraphNode(outerSubgraph, 'width', 'width', {
|
||||
widget: 'width'
|
||||
})
|
||||
|
||||
const result = resolveSubgraphInputTarget(outerSubgraphNode, 'width')
|
||||
|
||||
expect(result).toMatchObject({
|
||||
nodeId: '819',
|
||||
widgetName: 'width'
|
||||
})
|
||||
})
|
||||
|
||||
test('returns undefined for non-widget input on nested SubgraphNode', () => {
|
||||
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
|
||||
'audio'
|
||||
])
|
||||
addLinkedNestedSubgraphNode(outerSubgraph, 'audio', 'audio')
|
||||
|
||||
const result = resolveSubgraphInputTarget(outerSubgraphNode, 'audio')
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
test('resolves widget inputs but not non-widget inputs on the same nested SubgraphNode', () => {
|
||||
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
|
||||
'width',
|
||||
'audio'
|
||||
])
|
||||
addLinkedNestedSubgraphNode(outerSubgraph, 'width', 'width', {
|
||||
widget: 'width'
|
||||
})
|
||||
addLinkedNestedSubgraphNode(outerSubgraph, 'audio', 'audio')
|
||||
|
||||
expect(
|
||||
resolveSubgraphInputTarget(outerSubgraphNode, 'width')
|
||||
).toMatchObject({
|
||||
nodeId: '819',
|
||||
widgetName: 'width'
|
||||
})
|
||||
expect(
|
||||
resolveSubgraphInputTarget(outerSubgraphNode, 'audio')
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
test('returns target for widget-backed input on plain interior node', () => {
|
||||
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
|
||||
'seed'
|
||||
])
|
||||
|
||||
const inputSlot = outerSubgraph.inputNode.slots.find(
|
||||
(slot) => slot.name === 'seed'
|
||||
)!
|
||||
const node = new LGraphNode('Interior-seed')
|
||||
node.id = 42
|
||||
const input = node.addInput('seed_input', '*')
|
||||
node.addWidget('number', 'seed', 0, () => undefined)
|
||||
input.widget = { name: 'seed' }
|
||||
outerSubgraph.add(node)
|
||||
inputSlot.connect(input, node)
|
||||
|
||||
const result = resolveSubgraphInputTarget(outerSubgraphNode, 'seed')
|
||||
|
||||
expect(result).toMatchObject({
|
||||
nodeId: '42',
|
||||
widgetName: 'seed'
|
||||
})
|
||||
})
|
||||
|
||||
test('returns undefined for non-widget input on plain interior node', () => {
|
||||
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
|
||||
'image'
|
||||
])
|
||||
|
||||
const inputSlot = outerSubgraph.inputNode.slots.find(
|
||||
(slot) => slot.name === 'image'
|
||||
)!
|
||||
const node = new LGraphNode('Interior-image')
|
||||
const input = node.addInput('image_input', '*')
|
||||
outerSubgraph.add(node)
|
||||
inputSlot.connect(input, node)
|
||||
|
||||
const result = resolveSubgraphInputTarget(outerSubgraphNode, 'image')
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -16,6 +16,9 @@ export function resolveSubgraphInputTarget(
|
||||
inputName,
|
||||
({ inputNode, targetInput, getTargetWidget }) => {
|
||||
if (inputNode.isSubgraphNode()) {
|
||||
const targetWidget = getTargetWidget()
|
||||
if (!targetWidget) return undefined
|
||||
|
||||
return {
|
||||
nodeId: String(inputNode.id),
|
||||
widgetName: targetInput.name
|
||||
|
||||
8
src/core/graph/subgraph/widgetNodeTypeGuard.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
export function hasWidgetNode(
|
||||
widget: IBaseWidget
|
||||
): widget is IBaseWidget & { node: LGraphNode } {
|
||||
return 'node' in widget && !!widget.node
|
||||
}
|
||||
34
src/core/graph/subgraph/widgetRenderKey.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import { getStableWidgetRenderKey } from './widgetRenderKey'
|
||||
|
||||
function createWidget(overrides: Partial<IBaseWidget> = {}): IBaseWidget {
|
||||
return {
|
||||
name: 'seed',
|
||||
type: 'number',
|
||||
...overrides
|
||||
} as IBaseWidget
|
||||
}
|
||||
|
||||
describe(getStableWidgetRenderKey, () => {
|
||||
it('returns a stable key for the same widget instance', () => {
|
||||
const widget = createWidget()
|
||||
|
||||
const first = getStableWidgetRenderKey(widget)
|
||||
const second = getStableWidgetRenderKey(widget)
|
||||
|
||||
expect(second).toBe(first)
|
||||
})
|
||||
|
||||
it('returns distinct keys for distinct widget instances', () => {
|
||||
const firstWidget = createWidget()
|
||||
const secondWidget = createWidget()
|
||||
|
||||
const firstKey = getStableWidgetRenderKey(firstWidget)
|
||||
const secondKey = getStableWidgetRenderKey(secondWidget)
|
||||
|
||||
expect(secondKey).not.toBe(firstKey)
|
||||
})
|
||||
})
|
||||
17
src/core/graph/subgraph/widgetRenderKey.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
|
||||
const widgetRenderKeys = new WeakMap<IBaseWidget, string>()
|
||||
let nextWidgetRenderKeyId = 0
|
||||
|
||||
export function getStableWidgetRenderKey(widget: IBaseWidget): string {
|
||||
const cachedKey = widgetRenderKeys.get(widget)
|
||||
if (cachedKey) return cachedKey
|
||||
|
||||
const prefix = isPromotedWidgetView(widget) ? 'promoted' : 'widget'
|
||||
const key = `${prefix}:${nextWidgetRenderKeyId++}`
|
||||
|
||||
widgetRenderKeys.set(widget, key)
|
||||
return key
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { computed, watch } from 'vue'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
import { t } from '@/i18n'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import type { TopbarBadge } from '@/types/comfy'
|
||||
|
||||
@@ -17,16 +18,20 @@ const badges = computed<TopbarBadge[]>(() => {
|
||||
tooltip: alert.tooltip
|
||||
})
|
||||
}
|
||||
|
||||
// Always add cloud badge last (furthest right)
|
||||
result.push({
|
||||
icon: 'icon-[lucide--cloud]',
|
||||
text: 'Comfy Cloud'
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
watch(
|
||||
() => canvasStore.canvas,
|
||||
(canvas) => {
|
||||
if (canvas) {
|
||||
canvas.info_text = t('g.comfyCloud')
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.Cloud.Badges',
|
||||
get topbarBadges() {
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
import { t } from '@/i18n'
|
||||
import { getDistribution, ZENDESK_FIELDS } from '@/platform/support/config'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { buildFeedbackUrl } from '@/platform/support/config'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import type { ActionBarButton } from '@/types/comfy'
|
||||
|
||||
const ZENDESK_BASE_URL = 'https://support.comfy.org/hc/en-us/requests/new'
|
||||
const ZENDESK_FEEDBACK_FORM_ID = '43066738713236'
|
||||
|
||||
const distribution = getDistribution()
|
||||
const params = new URLSearchParams({
|
||||
ticket_form_id: ZENDESK_FEEDBACK_FORM_ID,
|
||||
[ZENDESK_FIELDS.DISTRIBUTION]: distribution
|
||||
})
|
||||
const feedbackUrl = `${ZENDESK_BASE_URL}?${params.toString()}`
|
||||
const feedbackUrl = buildFeedbackUrl()
|
||||
|
||||
const buttons: ActionBarButton[] = [
|
||||
{
|
||||
icon: 'icon-[lucide--message-circle-question-mark]',
|
||||
icon: 'icon-[lucide--message-square-text]',
|
||||
label: t('actionbar.feedback'),
|
||||
tooltip: t('actionbar.feedbackTooltip'),
|
||||
onClick: () => {
|
||||
@@ -25,6 +18,10 @@ const buttons: ActionBarButton[] = [
|
||||
]
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.Cloud.FeedbackButton',
|
||||
actionBarButtons: buttons
|
||||
name: 'Comfy.FeedbackButton',
|
||||
get actionBarButtons() {
|
||||
return useSettingStore().get('Comfy.UI.TabBarLayout') === 'Legacy'
|
||||
? buttons
|
||||
: []
|
||||
}
|
||||
})
|
||||
|
||||