mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 05:19:03 +00:00
Compare commits
7 Commits
feat/pylon
...
glary/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d46644277d | ||
|
|
329f7de20b | ||
|
|
07d86eff35 | ||
|
|
a24a9a4dff | ||
|
|
aa32844aea | ||
|
|
34e8fc8915 | ||
|
|
0b5e8c8fdc |
@@ -5,21 +5,30 @@ export class ContextMenu {
|
||||
public readonly primeVueMenu: Locator
|
||||
public readonly litegraphMenu: Locator
|
||||
public readonly litegraphContextMenu: Locator
|
||||
public readonly menuItems: Locator
|
||||
public readonly entries: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.primeVueMenu = page.locator('.p-contextmenu, .p-menu')
|
||||
this.litegraphMenu = page.locator('.litemenu')
|
||||
this.litegraphContextMenu = page.locator('.litecontextmenu')
|
||||
this.menuItems = page.locator('.p-menuitem, .litemenu-entry')
|
||||
this.entries = page.locator('.p-menuitem, .litemenu-entry')
|
||||
}
|
||||
|
||||
/** @deprecated Use {@link entries} instead. */
|
||||
get menuItems(): Locator {
|
||||
return this.entries
|
||||
}
|
||||
|
||||
getEntry(name: string, options: { exact?: boolean } = {}): Locator {
|
||||
return this.page.getByRole('menuitem', { name, exact: options.exact })
|
||||
}
|
||||
|
||||
async clickMenuItem(name: string): Promise<void> {
|
||||
await this.page.getByRole('menuitem', { name }).click()
|
||||
await this.getEntry(name).click()
|
||||
}
|
||||
|
||||
async clickMenuItemExact(name: string): Promise<void> {
|
||||
await this.page.getByRole('menuitem', { name, exact: true }).click()
|
||||
await this.getEntry(name, { exact: true }).click()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -95,6 +95,8 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
|
||||
public readonly allTab: Locator
|
||||
public readonly blueprintsTab: Locator
|
||||
public readonly sortButton: Locator
|
||||
public readonly nodeHelpContent: Locator
|
||||
public readonly helpBackButton: Locator
|
||||
|
||||
constructor(public override readonly page: Page) {
|
||||
super(page, 'node-library')
|
||||
@@ -103,6 +105,10 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
|
||||
this.allTab = this.getTab('All')
|
||||
this.blueprintsTab = this.getTab('Blueprints')
|
||||
this.sortButton = this.sidebarContent.getByRole('button', { name: 'Sort' })
|
||||
this.nodeHelpContent = this.sidebarContent.locator('.node-help-content')
|
||||
this.helpBackButton = this.sidebarContent.getByRole('button', {
|
||||
name: /back/i
|
||||
})
|
||||
}
|
||||
|
||||
getTab(name: string) {
|
||||
|
||||
@@ -85,7 +85,7 @@ async function setLocaleAndWaitForWorkflowReload(
|
||||
}, locale)
|
||||
}
|
||||
|
||||
test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
|
||||
test.describe('Node Help V1', { tag: ['@slow', '@ui'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
|
||||
})
|
||||
@@ -557,3 +557,94 @@ This is English documentation.
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Node Help V2 Sidebar', { tag: ['@slow', '@ui'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', true)
|
||||
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
})
|
||||
|
||||
async function openNodeInfoViMoreOptions(comfyPage: ComfyPage) {
|
||||
await expect(comfyPage.selectionToolbox).toBeVisible()
|
||||
|
||||
const moreOptionsBtn = comfyPage.selectionToolbox.getByTestId(
|
||||
'more-options-button'
|
||||
)
|
||||
await expect(moreOptionsBtn).toBeVisible()
|
||||
await moreOptionsBtn.click()
|
||||
|
||||
const nodeInfoEntry = comfyPage.contextMenu.getEntry('Node Info', {
|
||||
exact: true
|
||||
})
|
||||
await expect(nodeInfoEntry).toBeVisible()
|
||||
await nodeInfoEntry.click()
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
test('Should open node help in V2 sidebar when clicking Node Info', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
const ksamplerNodes = await comfyPage.nodeOps.getNodeRefsByType('KSampler')
|
||||
if (ksamplerNodes.length === 0) {
|
||||
throw new Error('No KSampler nodes found in the workflow')
|
||||
}
|
||||
|
||||
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
|
||||
await openNodeInfoViMoreOptions(comfyPage)
|
||||
|
||||
const sidebar = comfyPage.menu.nodeLibraryTabV2
|
||||
await expect(sidebar.sidebarContent).toBeVisible()
|
||||
await expect(sidebar.nodeHelpContent).toBeVisible()
|
||||
await expect(sidebar.sidebarContent).toContainText('KSampler')
|
||||
await expect(sidebar.searchInput).toBeHidden()
|
||||
})
|
||||
|
||||
test('Should return to V2 node library when clicking back from help page', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
const ksamplerNodes = await comfyPage.nodeOps.getNodeRefsByType('KSampler')
|
||||
if (ksamplerNodes.length === 0) {
|
||||
throw new Error('No KSampler nodes found in the workflow')
|
||||
}
|
||||
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
|
||||
await openNodeInfoViMoreOptions(comfyPage)
|
||||
|
||||
const sidebar = comfyPage.menu.nodeLibraryTabV2
|
||||
await expect(sidebar.nodeHelpContent).toBeVisible()
|
||||
|
||||
await sidebar.helpBackButton.click()
|
||||
|
||||
await expect(sidebar.searchInput).toBeVisible()
|
||||
await expect(sidebar.nodeHelpContent).toBeHidden()
|
||||
})
|
||||
|
||||
test('Should show correct node info when opening help for different node types', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: '# KSampler Help\n\nKSampler documentation content.'
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
const ksamplerNodes = await comfyPage.nodeOps.getNodeRefsByType('KSampler')
|
||||
if (ksamplerNodes.length === 0) {
|
||||
throw new Error('No KSampler nodes found in the workflow')
|
||||
}
|
||||
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
|
||||
await openNodeInfoViMoreOptions(comfyPage)
|
||||
|
||||
const sidebar = comfyPage.menu.nodeLibraryTabV2
|
||||
await expect(sidebar.sidebarContent).toContainText('KSampler Help')
|
||||
await expect(sidebar.sidebarContent).toContainText(
|
||||
'KSampler documentation content'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { ref } from 'vue'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
import NodeLibrarySidebarTabV2 from './NodeLibrarySidebarTabV2.vue'
|
||||
|
||||
@@ -17,12 +21,31 @@ vi.mock('@vueuse/core', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
const {
|
||||
mockStartDrag,
|
||||
mockOrganizeNodesByTab,
|
||||
mockGetSortingStrategies,
|
||||
mockSearchNode,
|
||||
mockVisibleNodeDefs
|
||||
} = vi.hoisted(() => ({
|
||||
mockStartDrag: vi.fn(),
|
||||
mockOrganizeNodesByTab: vi.fn(() => [] as unknown[]),
|
||||
mockGetSortingStrategies: vi.fn(() => [
|
||||
{
|
||||
id: 'alphabetical',
|
||||
label: 'sideToolbar.nodeLibraryTab.sortByAlphabetical'
|
||||
}
|
||||
]),
|
||||
mockSearchNode: vi.fn(() => [] as unknown[]),
|
||||
mockVisibleNodeDefs: { value: [] as unknown[] }
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
|
||||
useNodeDragToCanvas: () => ({
|
||||
isDragging: { value: false },
|
||||
draggedNode: { value: null },
|
||||
cursorPosition: { value: { x: 0, y: 0 } },
|
||||
startDrag: vi.fn(),
|
||||
startDrag: mockStartDrag,
|
||||
cancelDrag: vi.fn(),
|
||||
setupGlobalListeners: vi.fn(),
|
||||
cleanupGlobalListeners: vi.fn()
|
||||
@@ -33,16 +56,31 @@ vi.mock('@/services/nodeOrganizationService', () => ({
|
||||
DEFAULT_TAB_ID: 'essentials',
|
||||
DEFAULT_SORTING_ID: 'alphabetical',
|
||||
nodeOrganizationService: {
|
||||
organizeNodesByTab: vi.fn(() => []),
|
||||
getSortingStrategies: vi.fn(() => [])
|
||||
organizeNodesByTab: mockOrganizeNodesByTab,
|
||||
getSortingStrategies: mockGetSortingStrategies
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
buildNodeDefTree: vi.fn(() => ({ key: 'root', children: [] })),
|
||||
useNodeDefStore: () => ({
|
||||
nodeSearchService: { searchNode: mockSearchNode },
|
||||
visibleNodeDefs: mockVisibleNodeDefs.value
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./nodeLibrary/AllNodesPanel.vue', () => ({
|
||||
default: {
|
||||
name: 'AllNodesPanel',
|
||||
template: '<div data-testid="all-panel"><slot /></div>',
|
||||
props: ['sections', 'expandedKeys', 'fillNodeInfo']
|
||||
template: `
|
||||
<div data-testid="all-panel">
|
||||
<div data-testid="expanded-keys">{{ (expandedKeys ?? []).join(',') }}</div>
|
||||
<button data-testid="all-emit-node" @click="$emit('node-click', { type: 'node', data: { name: 'TestNode' } })">emit-node</button>
|
||||
<button data-testid="all-emit-folder" @click="$emit('node-click', { type: 'folder', key: 'folder-a' })">emit-folder</button>
|
||||
</div>
|
||||
`,
|
||||
props: ['sections', 'expandedKeys', 'fillNodeInfo', 'sortOrder'],
|
||||
emits: ['node-click']
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -50,7 +88,8 @@ vi.mock('./nodeLibrary/BlueprintsPanel.vue', () => ({
|
||||
default: {
|
||||
name: 'BlueprintsPanel',
|
||||
template: '<div data-testid="blueprints-panel"><slot /></div>',
|
||||
props: ['sections', 'expandedKeys']
|
||||
props: ['sections', 'expandedKeys'],
|
||||
emits: ['node-click']
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -58,7 +97,8 @@ vi.mock('./nodeLibrary/EssentialNodesPanel.vue', () => ({
|
||||
default: {
|
||||
name: 'EssentialNodesPanel',
|
||||
template: '<div data-testid="essential-panel"><slot /></div>',
|
||||
props: ['root', 'expandedKeys', 'flatNodes']
|
||||
props: ['root', 'expandedKeys', 'flatNodes'],
|
||||
emits: ['node-click']
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -72,8 +112,18 @@ vi.mock('./nodeLibrary/NodeDragPreview.vue', () => ({
|
||||
vi.mock('@/components/ui/search-input/SearchInput.vue', () => ({
|
||||
default: {
|
||||
name: 'SearchBox',
|
||||
template: '<input data-testid="search-box" />',
|
||||
template: `
|
||||
<div>
|
||||
<input
|
||||
data-testid="search-box"
|
||||
:value="modelValue"
|
||||
@input="(e) => $emit('update:modelValue', e.target.value)"
|
||||
/>
|
||||
<button data-testid="search-trigger" @click="$emit('search')">go</button>
|
||||
</div>
|
||||
`,
|
||||
props: ['modelValue', 'placeholder'],
|
||||
emits: ['update:modelValue', 'search'],
|
||||
setup() {
|
||||
return { focus: vi.fn() }
|
||||
},
|
||||
@@ -81,6 +131,29 @@ vi.mock('@/components/ui/search-input/SearchInput.vue', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
const mockCurrentHelpNode = ref<ComfyNodeDefImpl | null>(null)
|
||||
const mockIsHelpOpen = ref(false)
|
||||
const mockCloseHelp = vi.fn()
|
||||
|
||||
vi.mock('@/stores/workspace/nodeHelpStore', () => ({
|
||||
useNodeHelpStore: () => ({
|
||||
currentHelpNode: mockCurrentHelpNode,
|
||||
isHelpOpen: mockIsHelpOpen,
|
||||
openHelp: vi.fn(),
|
||||
closeHelp: mockCloseHelp
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./nodeLibrary/NodeHelpPage.vue', () => ({
|
||||
default: {
|
||||
name: 'NodeHelpPage',
|
||||
template:
|
||||
'<div data-testid="node-help-page">{{ node.display_name }}<button data-testid="help-close-btn" @click="$emit(\'close\')">Close</button></div>',
|
||||
props: ['node'],
|
||||
emits: ['close']
|
||||
}
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
@@ -90,6 +163,10 @@ const i18n = createI18n({
|
||||
describe('NodeLibrarySidebarTabV2', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCurrentHelpNode.value = null
|
||||
mockIsHelpOpen.value = false
|
||||
mockSearchNode.mockReturnValue([])
|
||||
mockOrganizeNodesByTab.mockReturnValue([])
|
||||
})
|
||||
|
||||
function renderComponent() {
|
||||
@@ -123,4 +200,193 @@ describe('NodeLibrarySidebarTabV2', () => {
|
||||
expect(screen.queryByTestId('all-panel')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('blueprints-panel')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('Node Help Integration', () => {
|
||||
beforeEach(() => {
|
||||
mockCurrentHelpNode.value = null
|
||||
mockIsHelpOpen.value = false
|
||||
})
|
||||
|
||||
it('should show node help page when currentHelpNode is set', async () => {
|
||||
mockCurrentHelpNode.value = fromPartial<ComfyNodeDefImpl>({
|
||||
name: 'KSampler',
|
||||
display_name: 'KSampler'
|
||||
})
|
||||
mockIsHelpOpen.value = true
|
||||
|
||||
renderComponent()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('node-help-page')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('node-help-page')).toHaveTextContent('KSampler')
|
||||
expect(screen.queryByTestId('search-box')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show normal node library when currentHelpNode is null', async () => {
|
||||
renderComponent()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.queryByTestId('node-help-page')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('search-box')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should switch from help to library when closeHelp clears currentHelpNode', async () => {
|
||||
mockCurrentHelpNode.value = fromPartial<ComfyNodeDefImpl>({
|
||||
name: 'KSampler',
|
||||
display_name: 'KSampler'
|
||||
})
|
||||
mockIsHelpOpen.value = true
|
||||
|
||||
renderComponent()
|
||||
await nextTick()
|
||||
expect(screen.getByTestId('node-help-page')).toBeInTheDocument()
|
||||
|
||||
mockCurrentHelpNode.value = null
|
||||
mockIsHelpOpen.value = false
|
||||
await nextTick()
|
||||
|
||||
expect(screen.queryByTestId('node-help-page')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('search-box')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call closeHelp when NodeHelpPage emits close', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockCurrentHelpNode.value = fromPartial<ComfyNodeDefImpl>({
|
||||
name: 'KSampler',
|
||||
display_name: 'KSampler'
|
||||
})
|
||||
mockIsHelpOpen.value = true
|
||||
|
||||
renderComponent()
|
||||
await nextTick()
|
||||
|
||||
await user.click(screen.getByTestId('help-close-btn'))
|
||||
expect(mockCloseHelp).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Node interaction', () => {
|
||||
async function selectAllTab(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(screen.getByRole('tab', { name: /all/i }))
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
it('should startDrag when a node is clicked in a panel', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
await selectAllTab(user)
|
||||
|
||||
await user.click(screen.getByTestId('all-emit-node'))
|
||||
|
||||
expect(mockStartDrag).toHaveBeenCalledWith({ name: 'TestNode' })
|
||||
})
|
||||
|
||||
it('should toggle expanded folder keys when a folder is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
await selectAllTab(user)
|
||||
|
||||
// First click expands
|
||||
await user.click(screen.getByTestId('all-emit-folder'))
|
||||
await nextTick()
|
||||
expect(screen.getByTestId('expanded-keys')).toHaveTextContent('folder-a')
|
||||
|
||||
// Second click collapses (covers both branches of handleNodeClick)
|
||||
await user.click(screen.getByTestId('all-emit-folder'))
|
||||
await nextTick()
|
||||
expect(screen.getByTestId('expanded-keys')).toHaveTextContent('')
|
||||
|
||||
// No drag emitted for folder clicks
|
||||
expect(mockStartDrag).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Search', () => {
|
||||
it('should clear expanded keys when search produces no results', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockSearchNode.mockReturnValue([])
|
||||
renderComponent()
|
||||
await user.click(screen.getByRole('tab', { name: /all/i }))
|
||||
await nextTick()
|
||||
|
||||
// Pre-expand a folder so we can verify clearing actually happens
|
||||
await user.click(screen.getByTestId('all-emit-folder'))
|
||||
await nextTick()
|
||||
expect(screen.getByTestId('expanded-keys')).toHaveTextContent('folder-a')
|
||||
|
||||
await user.type(screen.getByTestId('search-box'), 'nonexistent')
|
||||
await user.click(screen.getByTestId('search-trigger'))
|
||||
await nextTick()
|
||||
|
||||
expect(mockSearchNode).toHaveBeenCalled()
|
||||
expect(screen.getByTestId('expanded-keys')).toHaveTextContent('')
|
||||
})
|
||||
|
||||
it('should expand folder keys when search returns results', async () => {
|
||||
const user = userEvent.setup()
|
||||
const fakeDef = fromPartial<ComfyNodeDefImpl>({ name: 'Match' })
|
||||
mockSearchNode.mockReturnValue([fakeDef])
|
||||
mockOrganizeNodesByTab.mockReturnValue([
|
||||
{
|
||||
category: 'comfyNodes',
|
||||
title: 'Comfy',
|
||||
tree: {
|
||||
key: 'root',
|
||||
label: 'root',
|
||||
children: [
|
||||
{
|
||||
key: 'root/folder1',
|
||||
label: 'folder1',
|
||||
leaf: false,
|
||||
children: [
|
||||
{ key: 'root/folder1/Match', label: 'Match', leaf: true }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
])
|
||||
renderComponent()
|
||||
await user.click(screen.getByRole('tab', { name: /all/i }))
|
||||
await nextTick()
|
||||
|
||||
await user.type(screen.getByTestId('search-box'), 'Match')
|
||||
await user.click(screen.getByTestId('search-trigger'))
|
||||
await nextTick()
|
||||
|
||||
expect(mockSearchNode).toHaveBeenCalledWith(
|
||||
'Match',
|
||||
[],
|
||||
{ limit: 64 },
|
||||
{ matchWildcards: false }
|
||||
)
|
||||
expect(screen.getByTestId('expanded-keys')).toHaveTextContent(
|
||||
'root/folder1'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tab switching', () => {
|
||||
it('should render the BlueprintsPanel when blueprints tab is selected', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: /blueprints/i }))
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('blueprints-panel')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('all-panel')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the AllNodesPanel when all tab is selected', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: /all/i }))
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('all-panel')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('essential-panel')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,158 +1,168 @@
|
||||
<template>
|
||||
<SidebarTabTemplate :title="$t('sideToolbar.nodes')">
|
||||
<template #header>
|
||||
<SidebarTopArea bottom-divider>
|
||||
<SearchInput
|
||||
ref="searchBoxRef"
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('g.search') + '...'"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
<template #actions>
|
||||
<DropdownMenuRoot>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="$t('g.sort')"
|
||||
>
|
||||
<i class="icon-[lucide--arrow-up-down] size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
class="z-9999 min-w-32 rounded-lg border border-border-default bg-comfy-menu-bg p-1 shadow-lg"
|
||||
align="end"
|
||||
:side-offset="4"
|
||||
>
|
||||
<DropdownMenuRadioGroup v-model="sortOrder">
|
||||
<DropdownMenuRadioItem
|
||||
v-for="option in sortingOptions"
|
||||
:key="option.id"
|
||||
:value="option.id"
|
||||
<div class="h-full">
|
||||
<SidebarTabTemplate v-if="!isHelpOpen" :title="$t('sideToolbar.nodes')">
|
||||
<template #header>
|
||||
<SidebarTopArea bottom-divider>
|
||||
<SearchInput
|
||||
ref="searchBoxRef"
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('g.search') + '...'"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
<template #actions>
|
||||
<DropdownMenuRoot>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="$t('g.sort')"
|
||||
>
|
||||
<i class="icon-[lucide--arrow-up-down] size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
class="z-9999 min-w-32 rounded-lg border border-border-default bg-comfy-menu-bg p-1 shadow-lg"
|
||||
align="end"
|
||||
:side-offset="4"
|
||||
>
|
||||
<DropdownMenuRadioGroup v-model="sortOrder">
|
||||
<DropdownMenuRadioItem
|
||||
v-for="option in sortingOptions"
|
||||
:key="option.id"
|
||||
:value="option.id"
|
||||
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none hover:bg-comfy-input"
|
||||
>
|
||||
<span class="flex-1">{{ $t(option.label) }}</span>
|
||||
<DropdownMenuItemIndicator class="w-4">
|
||||
<i class="icon-[lucide--check] size-4" />
|
||||
</DropdownMenuItemIndicator>
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
<DropdownMenuRoot v-if="selectedTab === 'all'">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="$t('sideToolbar.nodeLibraryTab.filter')"
|
||||
>
|
||||
<i class="icon-[lucide--list-filter] size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
class="z-9999 min-w-32 rounded-lg border border-border-default bg-comfy-menu-bg p-1 shadow-lg"
|
||||
align="end"
|
||||
:side-offset="4"
|
||||
>
|
||||
<DropdownMenuCheckboxItem
|
||||
v-model="filterOptions.blueprints"
|
||||
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none hover:bg-comfy-input"
|
||||
>
|
||||
<span class="flex-1">{{ $t(option.label) }}</span>
|
||||
<span class="flex-1">{{
|
||||
$t('sideToolbar.nodeLibraryTab.filterOptions.blueprints')
|
||||
}}</span>
|
||||
<DropdownMenuItemIndicator class="w-4">
|
||||
<i class="icon-[lucide--check] size-4" />
|
||||
</DropdownMenuItemIndicator>
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
<DropdownMenuRoot v-if="selectedTab === 'all'">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="$t('sideToolbar.nodeLibraryTab.filter')"
|
||||
>
|
||||
<i class="icon-[lucide--list-filter] size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
class="z-9999 min-w-32 rounded-lg border border-border-default bg-comfy-menu-bg p-1 shadow-lg"
|
||||
align="end"
|
||||
:side-offset="4"
|
||||
>
|
||||
<DropdownMenuCheckboxItem
|
||||
v-model="filterOptions.blueprints"
|
||||
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none hover:bg-comfy-input"
|
||||
>
|
||||
<span class="flex-1">{{
|
||||
$t('sideToolbar.nodeLibraryTab.filterOptions.blueprints')
|
||||
}}</span>
|
||||
<DropdownMenuItemIndicator class="w-4">
|
||||
<i class="icon-[lucide--check] size-4" />
|
||||
</DropdownMenuItemIndicator>
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
v-model="filterOptions.partnerNodes"
|
||||
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none hover:bg-comfy-input"
|
||||
>
|
||||
<span class="flex-1">{{
|
||||
$t('sideToolbar.nodeLibraryTab.filterOptions.partnerNodes')
|
||||
}}</span>
|
||||
<DropdownMenuItemIndicator class="w-4">
|
||||
<i class="icon-[lucide--check] size-4" />
|
||||
</DropdownMenuItemIndicator>
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
v-model="filterOptions.comfyNodes"
|
||||
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none hover:bg-comfy-input"
|
||||
>
|
||||
<span class="flex-1">{{
|
||||
$t('sideToolbar.nodeLibraryTab.filterOptions.comfyNodes')
|
||||
}}</span>
|
||||
<DropdownMenuItemIndicator class="w-4">
|
||||
<i class="icon-[lucide--check] size-4" />
|
||||
</DropdownMenuItemIndicator>
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
v-model="filterOptions.extensions"
|
||||
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none hover:bg-comfy-input"
|
||||
>
|
||||
<span class="flex-1">{{
|
||||
$t('sideToolbar.nodeLibraryTab.filterOptions.extensions')
|
||||
}}</span>
|
||||
<DropdownMenuItemIndicator class="w-4">
|
||||
<i class="icon-[lucide--check] size-4" />
|
||||
</DropdownMenuItemIndicator>
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
</template>
|
||||
</SidebarTopArea>
|
||||
<div class="border-b border-comfy-input p-2 2xl:px-4">
|
||||
<TabList v-model="selectedTab">
|
||||
<Tab v-for="tab in tabs" :key="tab.value" :value="tab.value">
|
||||
{{ tab.label }}
|
||||
</Tab>
|
||||
</TabList>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<NodeDragPreview />
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="min-h-0 flex-1 overflow-y-auto py-2">
|
||||
<TabPanel
|
||||
v-if="flags.nodeLibraryEssentialsEnabled"
|
||||
:model-value="selectedTab"
|
||||
value="essentials"
|
||||
>
|
||||
<EssentialNodesPanel
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
:root="renderedEssentialRoot"
|
||||
:flat-nodes="essentialFlatNodes"
|
||||
@node-click="handleNodeClick"
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel :model-value="selectedTab" value="all">
|
||||
<AllNodesPanel
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
:sections="renderedSections"
|
||||
:fill-node-info="fillNodeInfo"
|
||||
:sort-order="sortOrder"
|
||||
@node-click="handleNodeClick"
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel :model-value="selectedTab" value="blueprints">
|
||||
<BlueprintsPanel
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
:sections="renderedBlueprintsSections"
|
||||
@node-click="handleNodeClick"
|
||||
/>
|
||||
</TabPanel>
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
v-model="filterOptions.partnerNodes"
|
||||
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none hover:bg-comfy-input"
|
||||
>
|
||||
<span class="flex-1">{{
|
||||
$t(
|
||||
'sideToolbar.nodeLibraryTab.filterOptions.partnerNodes'
|
||||
)
|
||||
}}</span>
|
||||
<DropdownMenuItemIndicator class="w-4">
|
||||
<i class="icon-[lucide--check] size-4" />
|
||||
</DropdownMenuItemIndicator>
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
v-model="filterOptions.comfyNodes"
|
||||
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none hover:bg-comfy-input"
|
||||
>
|
||||
<span class="flex-1">{{
|
||||
$t('sideToolbar.nodeLibraryTab.filterOptions.comfyNodes')
|
||||
}}</span>
|
||||
<DropdownMenuItemIndicator class="w-4">
|
||||
<i class="icon-[lucide--check] size-4" />
|
||||
</DropdownMenuItemIndicator>
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
v-model="filterOptions.extensions"
|
||||
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none hover:bg-comfy-input"
|
||||
>
|
||||
<span class="flex-1">{{
|
||||
$t('sideToolbar.nodeLibraryTab.filterOptions.extensions')
|
||||
}}</span>
|
||||
<DropdownMenuItemIndicator class="w-4">
|
||||
<i class="icon-[lucide--check] size-4" />
|
||||
</DropdownMenuItemIndicator>
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
</template>
|
||||
</SidebarTopArea>
|
||||
<div class="border-b border-comfy-input p-2 2xl:px-4">
|
||||
<TabList v-model="selectedTab">
|
||||
<Tab v-for="tab in tabs" :key="tab.value" :value="tab.value">
|
||||
{{ tab.label }}
|
||||
</Tab>
|
||||
</TabList>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SidebarTabTemplate>
|
||||
</template>
|
||||
<template #body>
|
||||
<NodeDragPreview />
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="min-h-0 flex-1 overflow-y-auto py-2">
|
||||
<TabPanel
|
||||
v-if="flags.nodeLibraryEssentialsEnabled"
|
||||
:model-value="selectedTab"
|
||||
value="essentials"
|
||||
>
|
||||
<EssentialNodesPanel
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
:root="renderedEssentialRoot"
|
||||
:flat-nodes="essentialFlatNodes"
|
||||
@node-click="handleNodeClick"
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel :model-value="selectedTab" value="all">
|
||||
<AllNodesPanel
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
:sections="renderedSections"
|
||||
:fill-node-info="fillNodeInfo"
|
||||
:sort-order="sortOrder"
|
||||
@node-click="handleNodeClick"
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel :model-value="selectedTab" value="blueprints">
|
||||
<BlueprintsPanel
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
:sections="renderedBlueprintsSections"
|
||||
@node-click="handleNodeClick"
|
||||
/>
|
||||
</TabPanel>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SidebarTabTemplate>
|
||||
<NodeHelpPage
|
||||
v-else-if="currentHelpNode"
|
||||
:node="currentHelpNode"
|
||||
@close="closeHelp"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import {
|
||||
DropdownMenuCheckboxItem,
|
||||
@@ -189,6 +199,7 @@ import { getProviderIcon } from '@/utils/categoryUtil'
|
||||
import { flattenTree, sortedTree, unwrapTreeRoot } from '@/utils/treeUtil'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { buildNodeDefTree, useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
|
||||
import type {
|
||||
NodeCategoryId,
|
||||
NodeSection,
|
||||
@@ -205,10 +216,15 @@ import AllNodesPanel from './nodeLibrary/AllNodesPanel.vue'
|
||||
import BlueprintsPanel from './nodeLibrary/BlueprintsPanel.vue'
|
||||
import EssentialNodesPanel from './nodeLibrary/EssentialNodesPanel.vue'
|
||||
import NodeDragPreview from './nodeLibrary/NodeDragPreview.vue'
|
||||
import NodeHelpPage from './nodeLibrary/NodeHelpPage.vue'
|
||||
import SidebarTabTemplate from './SidebarTabTemplate.vue'
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
const nodeHelpStore = useNodeHelpStore()
|
||||
const { currentHelpNode, isHelpOpen } = storeToRefs(nodeHelpStore)
|
||||
const { closeHelp } = nodeHelpStore
|
||||
|
||||
const selectedTab = useLocalStorage<TabId>(
|
||||
'Comfy.NodeLibrary.Tab',
|
||||
DEFAULT_TAB_ID
|
||||
|
||||
Reference in New Issue
Block a user