mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
Merge branch 'main' into feat/websocket-reconnect-telemetry
This commit is contained in:
@@ -15,7 +15,7 @@ type ValidationState = InstallValidation['basePath']
|
||||
type IndexedUpdate = InstallValidation & Record<string, ValidationState>
|
||||
|
||||
/** State of a maintenance task, managed by the maintenance task store. */
|
||||
export class MaintenanceTaskRunner {
|
||||
class MaintenanceTaskRunner {
|
||||
constructor(readonly task: MaintenanceTask) {}
|
||||
|
||||
private _state?: MaintenanceTaskState
|
||||
|
||||
@@ -281,6 +281,14 @@ export class NodeReference {
|
||||
getType(): Promise<string> {
|
||||
return this.getProperty('type')
|
||||
}
|
||||
async centerOnNode(): Promise<void> {
|
||||
await this.comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
if (!node) throw new Error(`Node ${id} not found`)
|
||||
window.app!.canvas.centerOnNode(node)
|
||||
}, this.id)
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
async getPosition(): Promise<Position> {
|
||||
const pos = await this.comfyPage.canvasOps.convertOffsetToCanvas(
|
||||
await this.getProperty<[number, number]>('pos')
|
||||
|
||||
@@ -30,10 +30,18 @@ async function setupSubgraphBuilder(comfyPage: ComfyPage) {
|
||||
await appMode.enterBuilder()
|
||||
await appMode.goToInputs()
|
||||
|
||||
// Reset zoom to 1 and center on the subgraph node so click coords are accurate
|
||||
await comfyPage.canvasOps.setScale(1)
|
||||
await subgraphNode.centerOnNode()
|
||||
|
||||
// Click the promoted seed widget on the canvas to select it
|
||||
const seedWidgetRef = await subgraphNode.getWidget(0)
|
||||
const seedPos = await seedWidgetRef.getPosition()
|
||||
await page.mouse.click(seedPos.x, seedPos.y)
|
||||
const titleHeight = await page.evaluate(
|
||||
() => window.LiteGraph!['NODE_TITLE_HEIGHT'] as number
|
||||
)
|
||||
|
||||
await page.mouse.click(seedPos.x, seedPos.y + titleHeight)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Select an output node
|
||||
@@ -48,9 +56,15 @@ async function setupSubgraphBuilder(comfyPage: ComfyPage) {
|
||||
)
|
||||
)
|
||||
const saveImageRef = await comfyPage.nodeOps.getNodeRefById(saveImageNodeId)
|
||||
const saveImagePos = await saveImageRef.getPosition()
|
||||
// Click left edge — the right side is hidden by the builder panel
|
||||
await page.mouse.click(saveImagePos.x + 10, saveImagePos.y - 10)
|
||||
await saveImageRef.centerOnNode()
|
||||
|
||||
// Node is centered on screen, so click the canvas center
|
||||
const canvasBox = await page.locator('#graph-canvas').boundingBox()
|
||||
if (!canvasBox) throw new Error('Canvas not found')
|
||||
await page.mouse.click(
|
||||
canvasBox.x + canvasBox.width / 2,
|
||||
canvasBox.y + canvasBox.height / 2
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
return subgraphNode
|
||||
@@ -80,6 +94,10 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
|
||||
}
|
||||
})
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.AppBuilder.VueNodeSwitchDismissed',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('Rename from builder input-select sidebar via menu', async ({
|
||||
|
||||
@@ -6,7 +6,6 @@ const config: KnipConfig = {
|
||||
entry: [
|
||||
'{build,scripts}/**/*.{js,ts}',
|
||||
'src/assets/css/style.css',
|
||||
'src/main.ts',
|
||||
'src/scripts/ui/menu/index.ts',
|
||||
'src/types/index.ts',
|
||||
'src/storybook/mocks/**/*.ts'
|
||||
@@ -14,25 +13,23 @@ const config: KnipConfig = {
|
||||
project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}', '!.claude/**']
|
||||
},
|
||||
'apps/desktop-ui': {
|
||||
entry: ['src/main.ts', 'src/i18n.ts'],
|
||||
entry: ['src/i18n.ts'],
|
||||
project: ['src/**/*.{js,ts,vue}']
|
||||
},
|
||||
'packages/tailwind-utils': {
|
||||
project: ['src/**/*.{js,ts}']
|
||||
},
|
||||
'packages/shared-frontend-utils': {
|
||||
project: ['src/**/*.{js,ts}'],
|
||||
entry: ['src/formatUtil.ts', 'src/networkUtil.ts']
|
||||
project: ['src/**/*.{js,ts}']
|
||||
},
|
||||
'packages/registry-types': {
|
||||
project: ['src/**/*.{js,ts}']
|
||||
},
|
||||
'packages/ingest-types': {
|
||||
project: ['src/**/*.{js,ts}'],
|
||||
entry: ['src/index.ts']
|
||||
project: ['src/**/*.{js,ts}']
|
||||
}
|
||||
},
|
||||
ignoreBinaries: ['python3', 'gh', 'generate'],
|
||||
ignoreBinaries: ['python3'],
|
||||
ignoreDependencies: [
|
||||
// Weird importmap things
|
||||
'@iconify-json/lucide',
|
||||
@@ -40,19 +37,12 @@ const config: KnipConfig = {
|
||||
'@primeuix/forms',
|
||||
'@primeuix/styled',
|
||||
'@primeuix/utils',
|
||||
'@primevue/icons',
|
||||
// Used by lucideStrokePlugin.js (CSS @plugin)
|
||||
'@iconify/utils'
|
||||
'@primevue/icons'
|
||||
],
|
||||
ignore: [
|
||||
// Auto generated API types
|
||||
'src/workbench/extensions/manager/types/generatedManagerTypes.ts',
|
||||
'packages/registry-types/src/comfyRegistryTypes.ts',
|
||||
'packages/ingest-types/src/types.gen.ts',
|
||||
'packages/ingest-types/src/zod.gen.ts',
|
||||
'packages/ingest-types/openapi-ts.config.ts',
|
||||
// Used by a custom node (that should move off of this)
|
||||
'src/scripts/ui/components/splitButton.ts',
|
||||
// Used by stacked PR (feat/glsl-live-preview)
|
||||
'src/renderer/glsl/useGLSLRenderer.ts',
|
||||
// Workflow files contain license names that knip misinterprets as binaries
|
||||
@@ -60,17 +50,8 @@ const config: KnipConfig = {
|
||||
// Pending integration in stacked PR
|
||||
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue',
|
||||
// Agent review check config, not part of the build
|
||||
'.agents/checks/eslint.strict.config.js',
|
||||
// Loaded via @plugin directive in CSS, not detected by knip
|
||||
'packages/design-system/src/css/lucideStrokePlugin.js'
|
||||
'.agents/checks/eslint.strict.config.js'
|
||||
],
|
||||
compilers: {
|
||||
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199
|
||||
css: (text: string) =>
|
||||
[...text.replaceAll('plugin', 'import').matchAll(/(?<=@)import[^;]+/g)]
|
||||
.map((match) => match[0].replace(/url\(['"]?([^'"()]+)['"]?\)/, '$1'))
|
||||
.join('\n')
|
||||
},
|
||||
vite: {
|
||||
config: ['vite?(.*).config.mts']
|
||||
},
|
||||
|
||||
@@ -177,9 +177,7 @@
|
||||
"storybook": "catalog:",
|
||||
"stylelint": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"tailwindcss-primeui": "catalog:",
|
||||
"tsx": "catalog:",
|
||||
"tw-animate-css": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"typescript-eslint": "catalog:",
|
||||
"unplugin-icons": "catalog:",
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
"dependencies": {
|
||||
"@iconify-json/lucide": "catalog:",
|
||||
"@iconify/tailwind4": "catalog:",
|
||||
"@iconify/utils": "catalog:"
|
||||
"@iconify/utils": "catalog:",
|
||||
"tailwindcss-primeui": "catalog:",
|
||||
"tw-animate-css": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tailwindcss": "catalog:",
|
||||
|
||||
@@ -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,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}]");
|
||||
@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,quiver-ai}]");
|
||||
|
||||
/* 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,preview-image,image-and-mask-preview,layer-mask-mask-preview,mask-preview,image-preview-from-latent,i-tools-preview-image,i-tools-compare-image,canny-to-image,image-edit,text-to-image,pose-to-image,depth-to-video,image-to-image,canny-to-video,depth-to-image,image-to-video,pose-to-video,text-to-video,image-inpainting,image-outpainting}]");
|
||||
|
||||
3
packages/design-system/src/icons/quiver-ai.svg
Normal file
3
packages/design-system/src/icons/quiver-ai.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="281" height="281" viewBox="0 0 281 281" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M140.069 0C217.427 0.000220786 280.138 62.7116 280.138 140.069V280.138H140.069C62.7116 280.138 0.000220844 217.427 0 140.069C0 62.7114 62.7114 0 140.069 0ZM74.961 66.6054C69.8263 64.8847 64.9385 69.7815 66.6687 74.913L123.558 243.619C125.929 250.65 136.321 248.945 136.321 241.524V135.823H241.329C248.756 135.823 250.453 125.416 243.41 123.056L74.961 66.6054Z" fill="#F8F8F8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 534 B |
883
pnpm-lock.yaml
generated
883
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -13,10 +13,10 @@ catalog:
|
||||
'@iconify/utils': ^3.1.0
|
||||
'@intlify/eslint-plugin-vue-i18n': ^4.1.1
|
||||
'@lobehub/i18n-cli': ^1.26.1
|
||||
'@nx/eslint': 22.5.2
|
||||
'@nx/playwright': 22.5.2
|
||||
'@nx/storybook': 22.5.2
|
||||
'@nx/vite': 22.5.2
|
||||
'@nx/eslint': 22.6.1
|
||||
'@nx/playwright': 22.6.1
|
||||
'@nx/storybook': 22.6.1
|
||||
'@nx/vite': 22.6.1
|
||||
'@pinia/testing': ^1.0.3
|
||||
'@playwright/test': ^1.58.1
|
||||
'@primeuix/forms': 0.0.2
|
||||
@@ -79,11 +79,11 @@ catalog:
|
||||
jsdom: ^27.4.0
|
||||
jsonata: ^2.1.0
|
||||
jsondiffpatch: ^0.7.3
|
||||
knip: ^5.75.1
|
||||
knip: ^6.0.1
|
||||
lint-staged: ^16.2.7
|
||||
markdown-table: ^3.0.4
|
||||
mixpanel-browser: ^2.71.0
|
||||
nx: 22.5.2
|
||||
nx: 22.6.1
|
||||
oxfmt: ^0.40.0
|
||||
oxlint: ^1.55.0
|
||||
oxlint-tsgolint: ^0.17.0
|
||||
|
||||
55
src/components/builder/VueNodeSwitchPopup.vue
Normal file
55
src/components/builder/VueNodeSwitchPopup.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<NotificationPopup
|
||||
v-if="appModeStore.showVueNodeSwitchPopup"
|
||||
:title="$t('appBuilder.vueNodeSwitch.title')"
|
||||
show-close
|
||||
position="bottom-left"
|
||||
@close="dismiss"
|
||||
>
|
||||
{{ $t('appBuilder.vueNodeSwitch.content') }}
|
||||
|
||||
<template #footer-start>
|
||||
<label
|
||||
class="flex cursor-pointer items-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<input
|
||||
v-model="dontShowAgain"
|
||||
type="checkbox"
|
||||
class="accent-primary-background"
|
||||
/>
|
||||
{{ $t('appBuilder.vueNodeSwitch.dontShowAgain') }}
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<template #footer-end>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
class="font-normal"
|
||||
@click="dismiss"
|
||||
>
|
||||
{{ $t('appBuilder.vueNodeSwitch.dismiss') }}
|
||||
</Button>
|
||||
</template>
|
||||
</NotificationPopup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import NotificationPopup from '@/components/common/NotificationPopup.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
|
||||
const appModeStore = useAppModeStore()
|
||||
const settingStore = useSettingStore()
|
||||
const dontShowAgain = ref(false)
|
||||
|
||||
function dismiss() {
|
||||
if (dontShowAgain.value) {
|
||||
void settingStore.set('Comfy.AppBuilder.VueNodeSwitchDismissed', true)
|
||||
}
|
||||
appModeStore.showVueNodeSwitchPopup = false
|
||||
}
|
||||
</script>
|
||||
78
src/components/common/NotificationPopup.test.ts
Normal file
78
src/components/common/NotificationPopup.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import NotificationPopup from './NotificationPopup.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: { g: { close: 'Close' } }
|
||||
}
|
||||
})
|
||||
|
||||
function mountPopup(
|
||||
props: ComponentProps<typeof NotificationPopup> = {
|
||||
title: 'Test'
|
||||
},
|
||||
slots: Record<string, string> = {}
|
||||
) {
|
||||
return mount(NotificationPopup, {
|
||||
global: { plugins: [i18n] },
|
||||
props,
|
||||
slots
|
||||
})
|
||||
}
|
||||
|
||||
describe('NotificationPopup', () => {
|
||||
it('renders title', () => {
|
||||
const wrapper = mountPopup({ title: 'Hello World' })
|
||||
expect(wrapper.text()).toContain('Hello World')
|
||||
})
|
||||
|
||||
it('has role="status" for accessibility', () => {
|
||||
const wrapper = mountPopup()
|
||||
expect(wrapper.find('[role="status"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders subtitle when provided', () => {
|
||||
const wrapper = mountPopup({ title: 'T', subtitle: 'v1.2.3' })
|
||||
expect(wrapper.text()).toContain('v1.2.3')
|
||||
})
|
||||
|
||||
it('renders icon when provided', () => {
|
||||
const wrapper = mountPopup({
|
||||
title: 'T',
|
||||
icon: 'icon-[lucide--rocket]'
|
||||
})
|
||||
expect(wrapper.find('i.icon-\\[lucide--rocket\\]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('emits close when close button clicked', async () => {
|
||||
const wrapper = mountPopup({ title: 'T', showClose: true })
|
||||
await wrapper.find('[aria-label="Close"]').trigger('click')
|
||||
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('renders default slot content', () => {
|
||||
const wrapper = mountPopup({ title: 'T' }, { default: 'Body text here' })
|
||||
expect(wrapper.text()).toContain('Body text here')
|
||||
})
|
||||
|
||||
it('renders footer slots', () => {
|
||||
const wrapper = mountPopup(
|
||||
{ title: 'T' },
|
||||
{ 'footer-start': 'Left side', 'footer-end': 'Right side' }
|
||||
)
|
||||
expect(wrapper.text()).toContain('Left side')
|
||||
expect(wrapper.text()).toContain('Right side')
|
||||
})
|
||||
|
||||
it('positions bottom-right when specified', () => {
|
||||
const wrapper = mountPopup({ title: 'T', position: 'bottom-right' })
|
||||
const root = wrapper.find('[role="status"]')
|
||||
expect(root.attributes('data-position')).toBe('bottom-right')
|
||||
})
|
||||
})
|
||||
87
src/components/common/NotificationPopup.vue
Normal file
87
src/components/common/NotificationPopup.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div
|
||||
role="status"
|
||||
:data-position="position"
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-auto absolute z-1000 flex max-h-96 w-96 flex-col rounded-lg border border-border-default bg-base-background shadow-interface',
|
||||
position === 'bottom-left' && 'bottom-4 left-4',
|
||||
position === 'bottom-right' && 'right-4 bottom-4'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-4 p-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
v-if="icon"
|
||||
class="flex shrink-0 items-center justify-center rounded-lg bg-primary-background-hover p-3"
|
||||
>
|
||||
<i :class="cn('size-4 text-white', icon)" />
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-1">
|
||||
<div class="text-sm leading-[1.429] font-normal text-base-foreground">
|
||||
{{ title }}
|
||||
</div>
|
||||
<div
|
||||
v-if="subtitle"
|
||||
class="text-sm leading-[1.21] font-normal text-muted-foreground"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
v-if="showClose"
|
||||
class="size-6 shrink-0 self-start"
|
||||
size="icon-sm"
|
||||
variant="muted-textonly"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="$slots.default"
|
||||
class="min-h-0 flex-1 overflow-y-auto text-sm text-muted-foreground"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="$slots['footer-start'] || $slots['footer-end']"
|
||||
class="flex items-center justify-between px-4 pb-4"
|
||||
>
|
||||
<div>
|
||||
<slot name="footer-start" />
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<slot name="footer-end" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
icon,
|
||||
title,
|
||||
subtitle,
|
||||
showClose = false,
|
||||
position = 'bottom-left'
|
||||
} = defineProps<{
|
||||
icon?: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
showClose?: boolean
|
||||
position?: 'bottom-left' | 'bottom-right'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
</script>
|
||||
@@ -97,6 +97,7 @@
|
||||
|
||||
<NodeTooltip v-if="tooltipEnabled" />
|
||||
<NodeSearchboxPopover ref="nodeSearchboxPopoverRef" />
|
||||
<VueNodeSwitchPopup />
|
||||
|
||||
<!-- Initialize components after comfyApp is ready. useAbsolutePosition requires
|
||||
canvasStore.canvas to be initialized. -->
|
||||
@@ -128,6 +129,7 @@ import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitter
|
||||
import TopMenuSection from '@/components/TopMenuSection.vue'
|
||||
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
|
||||
import AppBuilder from '@/components/builder/AppBuilder.vue'
|
||||
import VueNodeSwitchPopup from '@/components/builder/VueNodeSwitchPopup.vue'
|
||||
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||
import DomWidgets from '@/components/graph/DomWidgets.vue'
|
||||
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
|
||||
|
||||
@@ -17,11 +17,7 @@
|
||||
<!-- Release Notification Toast positioned within canvas area -->
|
||||
<Teleport to="#graph-canvas-container">
|
||||
<ReleaseNotificationToast
|
||||
:class="{
|
||||
'sidebar-left': sidebarLocation === 'left',
|
||||
'sidebar-right': sidebarLocation === 'right',
|
||||
'small-sidebar': isSmall
|
||||
}"
|
||||
:position="sidebarLocation === 'right' ? 'bottom-right' : 'bottom-left'"
|
||||
/>
|
||||
</Teleport>
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ export const buttonVariants = cva({
|
||||
'bg-transparent text-muted-foreground hover:bg-secondary-background-hover',
|
||||
'destructive-textonly':
|
||||
'bg-transparent text-destructive-background hover:bg-destructive-background/10',
|
||||
link: 'bg-transparent text-muted-foreground hover:text-base-foreground',
|
||||
'overlay-white': 'bg-white text-gray-600 hover:bg-white/90',
|
||||
base: 'bg-base-background text-base-foreground hover:bg-secondary-background-hover',
|
||||
gradient:
|
||||
@@ -51,6 +52,7 @@ const variants = [
|
||||
'textonly',
|
||||
'muted-textonly',
|
||||
'destructive-textonly',
|
||||
'link',
|
||||
'base',
|
||||
'overlay-white',
|
||||
'gradient'
|
||||
|
||||
@@ -2,7 +2,7 @@ import { app } from '../../scripts/app'
|
||||
import { ComfyApp } from '../../scripts/app'
|
||||
import { $el, ComfyDialog } from '../../scripts/ui'
|
||||
|
||||
export class ClipspaceDialog extends ComfyDialog {
|
||||
class ClipspaceDialog extends ComfyDialog {
|
||||
static items: Array<
|
||||
HTMLButtonElement & {
|
||||
contextPredicate?: () => boolean
|
||||
|
||||
@@ -3232,6 +3232,14 @@
|
||||
"desc": "– More flexible workflows, powerful new widgets, built for extensibility",
|
||||
"tryItOut": "Try it out"
|
||||
},
|
||||
"appBuilder": {
|
||||
"vueNodeSwitch": {
|
||||
"title": "Switched over to Nodes 2.0",
|
||||
"content": "For the best experience, App builder uses Nodes 2.0. You can switch back after building the app from the main menu.",
|
||||
"dontShowAgain": "Don't show again",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
},
|
||||
"vueNodesMigration": {
|
||||
"message": "Prefer the legacy design?",
|
||||
"button": "Switch back"
|
||||
|
||||
@@ -1198,6 +1198,12 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
experimental: true,
|
||||
versionAdded: '1.27.1'
|
||||
},
|
||||
{
|
||||
id: 'Comfy.AppBuilder.VueNodeSwitchDismissed',
|
||||
name: 'App Builder Vue Node switch dismissed',
|
||||
type: 'hidden',
|
||||
defaultValue: false
|
||||
},
|
||||
{
|
||||
id: 'Comfy.VueNodes.AutoScaleLayout',
|
||||
category: ['Comfy', 'Nodes 2.0', 'AutoScaleLayout'],
|
||||
|
||||
@@ -134,7 +134,7 @@ describe('ReleaseNotificationToast', () => {
|
||||
} as ReleaseNote
|
||||
|
||||
wrapper = mountComponent()
|
||||
expect(wrapper.find('.icon-\\[lucide--rocket\\]').exists()).toBe(true)
|
||||
expect(wrapper.find('.release-toast-popup').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('displays release version', () => {
|
||||
|
||||
@@ -1,40 +1,17 @@
|
||||
<template>
|
||||
<div v-if="shouldShow" class="release-toast-popup">
|
||||
<div
|
||||
class="flex max-h-96 w-96 flex-col rounded-lg border border-border-default bg-base-background shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
|
||||
<NotificationPopup
|
||||
icon="icon-[lucide--rocket]"
|
||||
:title="$t('releaseToast.newVersionAvailable')"
|
||||
:subtitle="latestRelease?.version"
|
||||
:position
|
||||
>
|
||||
<!-- Main content -->
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-4 p-4">
|
||||
<!-- Header section with icon and text -->
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="flex shrink-0 items-center justify-center rounded-lg bg-primary-background-hover p-3"
|
||||
>
|
||||
<i class="icon-[lucide--rocket] size-4 text-white" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div
|
||||
class="text-sm leading-[1.429] font-normal text-base-foreground"
|
||||
>
|
||||
{{ $t('releaseToast.newVersionAvailable') }}
|
||||
</div>
|
||||
<div
|
||||
class="text-sm leading-[1.21] font-normal text-muted-foreground"
|
||||
>
|
||||
{{ latestRelease?.version }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="pl-14 text-sm leading-[1.21] font-normal text-muted-foreground"
|
||||
v-html="formattedContent"
|
||||
></div>
|
||||
|
||||
<!-- Description section -->
|
||||
<div
|
||||
class="min-h-0 flex-1 overflow-y-auto pl-14 text-sm leading-[1.21] font-normal text-muted-foreground"
|
||||
v-html="formattedContent"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Footer section -->
|
||||
<div class="flex items-center justify-between px-4 pb-4">
|
||||
<template #footer-start>
|
||||
<a
|
||||
class="flex items-center gap-2 py-1 text-sm font-normal text-muted-foreground hover:text-base-foreground"
|
||||
:href="changelogUrl"
|
||||
@@ -45,22 +22,27 @@
|
||||
<i class="icon-[lucide--external-link] size-4"></i>
|
||||
{{ $t('releaseToast.whatsNew') }}
|
||||
</a>
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
class="h-6 cursor-pointer border-none bg-transparent px-0 text-sm font-normal text-muted-foreground hover:text-base-foreground"
|
||||
@click="handleSkip"
|
||||
>
|
||||
{{ $t('releaseToast.skip') }}
|
||||
</button>
|
||||
<button
|
||||
class="h-10 cursor-pointer rounded-lg border-none bg-secondary-background px-4 text-sm font-normal text-base-foreground hover:bg-secondary-background-hover"
|
||||
@click="handleUpdate"
|
||||
>
|
||||
{{ $t('releaseToast.update') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer-end>
|
||||
<Button
|
||||
variant="link"
|
||||
size="unset"
|
||||
class="h-6 px-0 text-sm font-normal"
|
||||
@click="handleSkip"
|
||||
>
|
||||
{{ $t('releaseToast.skip') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
class="font-normal"
|
||||
@click="handleUpdate"
|
||||
>
|
||||
{{ $t('releaseToast.update') }}
|
||||
</Button>
|
||||
</template>
|
||||
</NotificationPopup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -69,6 +51,8 @@ import { default as DOMPurify } from 'dompurify'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import NotificationPopup from '@/components/common/NotificationPopup.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
@@ -79,6 +63,10 @@ import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
|
||||
import type { ReleaseNote } from '../common/releaseService'
|
||||
import { useReleaseStore } from '../common/releaseStore'
|
||||
|
||||
const { position = 'bottom-left' } = defineProps<{
|
||||
position?: 'bottom-left' | 'bottom-right'
|
||||
}>()
|
||||
|
||||
const { buildDocsUrl } = useExternalLink()
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
const releaseStore = useReleaseStore()
|
||||
@@ -218,23 +206,3 @@ defineExpose({
|
||||
handleUpdate
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Toast popup - positioning handled by parent */
|
||||
.release-toast-popup {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
z-index: 1000;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Sidebar positioning classes applied by parent - matching help center */
|
||||
.release-toast-popup.sidebar-left,
|
||||
.release-toast-popup.sidebar-left.small-sidebar {
|
||||
left: 1rem;
|
||||
}
|
||||
|
||||
.release-toast-popup.sidebar-right {
|
||||
right: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,6 +9,7 @@ defineOptions({ inheritAttrs: false })
|
||||
const { src } = defineProps<{
|
||||
src: string
|
||||
mobile?: boolean
|
||||
label?: string
|
||||
}>()
|
||||
|
||||
const imageRef = useTemplateRef('imageRef')
|
||||
@@ -48,5 +49,8 @@ const height = ref('')
|
||||
}
|
||||
"
|
||||
/>
|
||||
<span class="self-center md:z-10" v-text="`${width} x ${height}`" />
|
||||
<span class="self-center md:z-10">
|
||||
{{ `${width} x ${height}` }}
|
||||
<template v-if="label"> | {{ label }}</template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { defineAsyncComponent, useAttrs } from 'vue'
|
||||
import { computed, defineAsyncComponent, useAttrs } from 'vue'
|
||||
|
||||
import ImagePreview from '@/renderer/extensions/linearMode/ImagePreview.vue'
|
||||
import VideoPreview from '@/renderer/extensions/linearMode/VideoPreview.vue'
|
||||
@@ -19,40 +19,56 @@ const { output } = defineProps<{
|
||||
}>()
|
||||
|
||||
const attrs = useAttrs()
|
||||
const mediaType = computed(() => getMediaType(output))
|
||||
const outputLabel = computed(
|
||||
() => output.display_name?.trim() || output.filename
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<ImagePreview
|
||||
v-if="getMediaType(output) === 'images'"
|
||||
:class="attrs.class as string"
|
||||
:mobile
|
||||
:src="output.url"
|
||||
/>
|
||||
<VideoPreview
|
||||
v-else-if="getMediaType(output) === 'video'"
|
||||
:src="output.url"
|
||||
:class="
|
||||
cn('flex-1 object-contain md:p-3 md:contain-size', attrs.class as string)
|
||||
"
|
||||
/>
|
||||
<audio
|
||||
v-else-if="getMediaType(output) === 'audio'"
|
||||
:class="cn('m-auto w-full', attrs.class as string)"
|
||||
controls
|
||||
:src="output.url"
|
||||
/>
|
||||
<article
|
||||
v-else-if="getMediaType(output) === 'text'"
|
||||
:class="
|
||||
cn(
|
||||
'm-auto my-12 size-full max-w-2xl scroll-shadows-secondary-background overflow-y-auto rounded-lg bg-secondary-background p-4 whitespace-pre-wrap',
|
||||
attrs.class as string
|
||||
)
|
||||
"
|
||||
v-text="output.content"
|
||||
/>
|
||||
<Preview3d
|
||||
v-else-if="getMediaType(output) === '3d'"
|
||||
:class="attrs.class as string"
|
||||
:model-url="output.url"
|
||||
/>
|
||||
<template v-if="mediaType === 'images' || mediaType === 'video'">
|
||||
<ImagePreview
|
||||
v-if="mediaType === 'images'"
|
||||
:class="attrs.class as string"
|
||||
:mobile
|
||||
:src="output.url"
|
||||
:label="outputLabel"
|
||||
/>
|
||||
<VideoPreview
|
||||
v-else
|
||||
:src="output.url"
|
||||
:label="outputLabel"
|
||||
:class="
|
||||
cn(
|
||||
'flex-1 object-contain md:p-3 md:contain-size',
|
||||
attrs.class as string
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<audio
|
||||
v-if="mediaType === 'audio'"
|
||||
:class="cn('m-auto w-full', attrs.class as string)"
|
||||
controls
|
||||
:src="output.url"
|
||||
/>
|
||||
<article
|
||||
v-else-if="mediaType === 'text'"
|
||||
:class="
|
||||
cn(
|
||||
'm-auto my-12 size-full max-w-2xl scroll-shadows-secondary-background overflow-y-auto rounded-lg bg-secondary-background p-4 whitespace-pre-wrap',
|
||||
attrs.class as string
|
||||
)
|
||||
"
|
||||
v-text="output.content"
|
||||
/>
|
||||
<Preview3d
|
||||
v-else-if="mediaType === '3d'"
|
||||
:class="attrs.class as string"
|
||||
:model-url="output.url"
|
||||
/>
|
||||
<span v-if="outputLabel" class="self-center text-sm">
|
||||
{{ outputLabel }}
|
||||
</span>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, useTemplateRef } from 'vue'
|
||||
|
||||
const { src } = defineProps<{
|
||||
src: string
|
||||
label?: string
|
||||
}>()
|
||||
|
||||
const videoRef = useTemplateRef('videoRef')
|
||||
@@ -23,5 +24,8 @@ const height = ref('')
|
||||
}
|
||||
"
|
||||
/>
|
||||
<span class="z-10 self-center" v-text="`${width} x ${height}`" />
|
||||
<span class="z-10 self-center">
|
||||
{{ `${width} x ${height}` }}
|
||||
<template v-if="label"> | {{ label }}</template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -415,6 +415,7 @@ const zSettings = z.object({
|
||||
'Comfy.Canvas.LeftMouseClickBehavior': z.string(),
|
||||
'Comfy.Canvas.MouseWheelScroll': z.string(),
|
||||
'Comfy.VueNodes.Enabled': z.boolean(),
|
||||
'Comfy.AppBuilder.VueNodeSwitchDismissed': z.boolean(),
|
||||
'Comfy.VueNodes.AutoScaleLayout': z.boolean(),
|
||||
'Comfy.Assets.UseAssetAPI': z.boolean(),
|
||||
'Comfy.Queue.QPOV2': z.boolean(),
|
||||
|
||||
@@ -110,7 +110,7 @@ interface Load3DNode extends LGraphNode {
|
||||
|
||||
const viewerInstances = new Map<NodeId, ReturnType<UseLoad3dViewerFn>>()
|
||||
|
||||
export class Load3dService {
|
||||
class Load3dService {
|
||||
private static instance: Load3dService
|
||||
|
||||
private constructor() {}
|
||||
|
||||
@@ -47,6 +47,24 @@ vi.mock('@/components/builder/useEmptyWorkflowDialog', () => ({
|
||||
useEmptyWorkflowDialog: () => mockEmptyWorkflowDialog
|
||||
}))
|
||||
|
||||
const mockSettings = vi.hoisted(() => {
|
||||
const store: Record<string, unknown> = {}
|
||||
return {
|
||||
store,
|
||||
get: vi.fn((key: string) => store[key] ?? false),
|
||||
set: vi.fn(async (key: string, value: unknown) => {
|
||||
store[key] = value
|
||||
}),
|
||||
reset() {
|
||||
for (const key of Object.keys(store)) delete store[key]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => mockSettings
|
||||
}))
|
||||
|
||||
import { useAppModeStore } from './appModeStore'
|
||||
|
||||
function createBuilderWorkflow(
|
||||
@@ -72,6 +90,7 @@ describe('appModeStore', () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.mocked(app.rootGraph).extra = {}
|
||||
mockResolveNode.mockReturnValue(undefined)
|
||||
mockSettings.reset()
|
||||
vi.mocked(app.rootGraph).nodes = [{ id: 1 } as LGraphNode]
|
||||
workflowStore = useWorkflowStore()
|
||||
store = useAppModeStore()
|
||||
@@ -326,4 +345,69 @@ describe('appModeStore', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('autoEnableVueNodes', () => {
|
||||
it('enables Vue nodes when entering select mode with them disabled', async () => {
|
||||
mockSettings.store['Comfy.VueNodes.Enabled'] = false
|
||||
workflowStore.activeWorkflow = createBuilderWorkflow('graph')
|
||||
|
||||
store.enterBuilder()
|
||||
await nextTick()
|
||||
|
||||
expect(mockSettings.set).toHaveBeenCalledWith(
|
||||
'Comfy.VueNodes.Enabled',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('does not enable Vue nodes when already enabled', async () => {
|
||||
mockSettings.store['Comfy.VueNodes.Enabled'] = true
|
||||
workflowStore.activeWorkflow = createBuilderWorkflow('graph')
|
||||
|
||||
store.enterBuilder()
|
||||
await nextTick()
|
||||
|
||||
expect(mockSettings.set).not.toHaveBeenCalledWith(
|
||||
'Comfy.VueNodes.Enabled',
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('shows popup when Vue nodes are switched on and not dismissed', async () => {
|
||||
mockSettings.store['Comfy.VueNodes.Enabled'] = false
|
||||
mockSettings.store['Comfy.AppBuilder.VueNodeSwitchDismissed'] = false
|
||||
workflowStore.activeWorkflow = createBuilderWorkflow('graph')
|
||||
|
||||
store.enterBuilder()
|
||||
await nextTick()
|
||||
|
||||
expect(store.showVueNodeSwitchPopup).toBe(true)
|
||||
})
|
||||
|
||||
it('does not show popup when previously dismissed', async () => {
|
||||
mockSettings.store['Comfy.VueNodes.Enabled'] = false
|
||||
mockSettings.store['Comfy.AppBuilder.VueNodeSwitchDismissed'] = true
|
||||
workflowStore.activeWorkflow = createBuilderWorkflow('graph')
|
||||
|
||||
store.enterBuilder()
|
||||
await nextTick()
|
||||
|
||||
expect(store.showVueNodeSwitchPopup).toBe(false)
|
||||
})
|
||||
|
||||
it('does not enable Vue nodes when entering builder:arrange', async () => {
|
||||
mockSettings.store['Comfy.VueNodes.Enabled'] = false
|
||||
workflowStore.activeWorkflow = createBuilderWorkflow('app')
|
||||
store.selectedOutputs.push(1)
|
||||
|
||||
store.enterBuilder()
|
||||
await nextTick()
|
||||
|
||||
expect(workflowStore.activeWorkflow!.activeMode).toBe('builder:arrange')
|
||||
expect(mockSettings.set).not.toHaveBeenCalledWith(
|
||||
'Comfy.VueNodes.Enabled',
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useEmptyWorkflowDialog } from '@/components/builder/useEmptyWorkflowDia
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LinearData } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
@@ -21,10 +22,13 @@ export function nodeTypeValidForApp(type: string) {
|
||||
|
||||
export const useAppModeStore = defineStore('appMode', () => {
|
||||
const { getCanvas } = useCanvasStore()
|
||||
const settingStore = useSettingStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { mode, setMode, isBuilderMode, isSelectMode } = useAppMode()
|
||||
const emptyWorkflowDialog = useEmptyWorkflowDialog()
|
||||
|
||||
const showVueNodeSwitchPopup = ref(false)
|
||||
|
||||
const selectedInputs = ref<[NodeId, string][]>([])
|
||||
const selectedOutputs = ref<NodeId[]>([])
|
||||
const hasOutputs = computed(() => !!selectedOutputs.value.length)
|
||||
@@ -89,17 +93,33 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
let unwatch: () => void | undefined
|
||||
watch(isSelectMode, (inSelect) => {
|
||||
let unwatchReadOnly: (() => void) | undefined
|
||||
function enforceReadOnly(inSelect: boolean) {
|
||||
const { state } = getCanvas()
|
||||
if (!state) return
|
||||
state.readOnly = inSelect
|
||||
unwatch?.()
|
||||
unwatchReadOnly?.()
|
||||
if (inSelect)
|
||||
unwatch = watch(
|
||||
unwatchReadOnly = watch(
|
||||
() => state.readOnly,
|
||||
() => (state.readOnly = true)
|
||||
)
|
||||
}
|
||||
|
||||
function autoEnableVueNodes(inSelect: boolean) {
|
||||
if (!inSelect) return
|
||||
if (!settingStore.get('Comfy.VueNodes.Enabled')) {
|
||||
void settingStore.set('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
if (!settingStore.get('Comfy.AppBuilder.VueNodeSwitchDismissed')) {
|
||||
showVueNodeSwitchPopup.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(isSelectMode, (inSelect) => {
|
||||
enforceReadOnly(inSelect)
|
||||
autoEnableVueNodes(inSelect)
|
||||
})
|
||||
|
||||
function enterBuilder() {
|
||||
@@ -146,6 +166,7 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
removeSelectedInput,
|
||||
resetSelectedToWorkflow,
|
||||
selectedInputs,
|
||||
selectedOutputs
|
||||
selectedOutputs,
|
||||
showVueNodeSwitchPopup
|
||||
}
|
||||
})
|
||||
|
||||
@@ -73,6 +73,7 @@ const PROVIDER_COLORS: Record<string, string | [string, string]> = {
|
||||
'moonvalley-marey': '#DAD9C5',
|
||||
openai: '#B6B6B6',
|
||||
pixverse: ['#B465E6', '#E8632A'],
|
||||
'quiver-ai': '#B6B6B6',
|
||||
recraft: '#B6B6B6',
|
||||
reve: '#B6B6B6',
|
||||
rodin: '#F7F7F7',
|
||||
|
||||
Reference in New Issue
Block a user