mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 14:30:41 +00:00
feat: App mode - Switch to Nodes 2.0 when entering builder (#10337)
## Summary We've had some reports of issues selecting inputs/nodes when trying to use the builder in LiteGraph mode and due to the complexity of the canvas system, we're going to enable Nodes 2.0 when entering the builder to ensure the best experience. ## Changes - **What**: - When entering builder select mode automatically switch to Nodes 2.0 - Extract reusable component from features toast - Show popup telling user the mode was changed - Add hidden setting for storing "don't show again" on the switch popup ## Review Focus - I have not removed the LiteGraph selection code in case someone still manages to enter the builder in LiteGraph mode, this should be cleaned up in future ## Screenshots (if applicable) <img width="423" height="224" alt="image" src="https://github.com/user-attachments/assets/cc2591bc-e5dc-47ef-a3c6-91ca7b6066ff" /> ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-10337-feat-App-mode-Switch-to-Nodes-2-0-when-entering-builder-3296d73d3650818e9f3cdaac59d15609) by [Unito](https://www.unito.io)
This commit is contained in:
@@ -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 ({
|
||||
|
||||
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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user