mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-21 15:24:09 +00:00
feat: implement NodeLibrarySidebarTabV2 with Reka UI components (#8548)
## Summary Implement a redesigned Node Library sidebar using Reka UI components with virtualized tree rendering and improved UX. ## Changes - **What**: - Add three-tab structure (Essential, All, Custom) using Reka UI Tabs - Implement TreeExplorerV2 with virtualized tree using TreeRoot/TreeVirtualizer for performance - Add node hover preview with teleport to show NodePreview component - Implement context menu for toggling favorites on nodes - Add search functionality that auto-expands matching folders - Create panel components: EssentialNodesPanel, AllNodesPanel, CustomNodesPanel - Add 'Open Manager' button in CustomNodesPanel - Use custom icons: comfy--node for nodes, ph--folder-fill for folders - New node preview component: `NodePreviewCard` - Api node folder icon - Node drag preview - **Feature Flag**: Enabled via URL parameter `?nodeRedesign=true` ## Review Focus - TreeExplorerV2.vue uses `[...expandedKeys]` to prevent internal mutation by Reka UI TreeRoot - Context menu injection key is exported from TreeExplorerV2Node.vue and imported by TreeExplorerV2.vue - Hover preview uses teleport to `#node-library-node-preview-container-v2` ## Screenshots (if applicable) | Feature | Screenshot | |---|---| | All nodes tab |<img width="323" height="761" alt="image" src="https://github.com/user-attachments/assets/1976222b-83dc-4a1b-838a-2d49aedea3b8" />| | Custom nodes tab | <img width="308" height="748" alt="image" src="https://github.com/user-attachments/assets/2c23bffb-bdaa-4c6c-8cac-7610fb7f3fb7" />| |Api nodes icon | <img width="299" height="523" alt="image" src="https://github.com/user-attachments/assets/e9ca05b0-1143-44cf-b227-6462173c7cd0" />| | node preview|<img width="499" height="544" alt="image" src="https://github.com/user-attachments/assets/8961a7b4-77ae-4e57-99cf-62d9e4e17088" />| | node drag preview | <img width="434" height="289" alt="image" src="https://github.com/user-attachments/assets/b5838c90-65d4-4bee-b2b3-c41b57870da8" />| Test by adding `?nodeRedesign=true` to the URL ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8548-WIP-feat-implement-NodeLibrarySidebarTabV2-with-Reka-UI-components-2fb6d73d36508134b7e0f75a2c9b976a) by [Unito](https://www.unito.io) --------- Co-authored-by: Amp <amp@ampcode.com> Co-authored-by: GitHub Action <action@github.com> Co-authored-by: bymyself <cbyrne@comfy.org>
This commit is contained in:
@@ -10,6 +10,7 @@ import type { NodeReference } from '../fixtures/utils/litegraphUtils'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
|
||||
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
|
||||
})
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setup()
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
|
||||
})
|
||||
|
||||
test.describe('Selection Toolbox', () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { TestIds } from '../../fixtures/selectors'
|
||||
|
||||
test.describe('Properties panel position', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
|
||||
// Open a sidebar tab to ensure sidebar is visible
|
||||
await comfyPage.menu.nodeLibraryTab.open()
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
|
||||
@@ -53,6 +53,7 @@ test.describe('Remote COMBO Widget', { tag: '@widget' }, () => {
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'v1 (legacy)'
|
||||
|
||||
@@ -5,6 +5,7 @@ import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
test.describe('Node library sidebar', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [])
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeLibrary.BookmarksCustomization',
|
||||
|
||||
22
src/components/common/MarqueeLine.test.ts
Normal file
22
src/components/common/MarqueeLine.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import MarqueeLine from './MarqueeLine.vue'
|
||||
|
||||
describe(MarqueeLine, () => {
|
||||
it('renders slot content', () => {
|
||||
const wrapper = mount(MarqueeLine, {
|
||||
slots: { default: 'Hello World' }
|
||||
})
|
||||
expect(wrapper.text()).toBe('Hello World')
|
||||
})
|
||||
|
||||
it('renders content inside a span within the container', () => {
|
||||
const wrapper = mount(MarqueeLine, {
|
||||
slots: { default: 'Test Text' }
|
||||
})
|
||||
const span = wrapper.find('span')
|
||||
expect(span.exists()).toBe(true)
|
||||
expect(span.text()).toBe('Test Text')
|
||||
})
|
||||
})
|
||||
24
src/components/common/MarqueeLine.vue
Normal file
24
src/components/common/MarqueeLine.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div
|
||||
class="overflow-hidden [container-type:inline-size] [mask-image:linear-gradient(to_right,black_70%,transparent)] motion-safe:group-hover:[mask-image:none]"
|
||||
>
|
||||
<span
|
||||
class="whitespace-nowrap inline-block min-w-full text-center [--_marquee-end:min(calc(-100%+100cqw),0px)] motion-safe:group-hover:[animation:marquee-scroll_3s_linear_infinite_alternate]"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@keyframes marquee-scroll {
|
||||
0%,
|
||||
20% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
80%,
|
||||
100% {
|
||||
transform: translateX(var(--_marquee-end));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
105
src/components/common/TextTickerMultiLine.test.ts
Normal file
105
src/components/common/TextTickerMultiLine.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import MarqueeLine from './MarqueeLine.vue'
|
||||
import TextTickerMultiLine from './TextTickerMultiLine.vue'
|
||||
|
||||
type Callback = () => void
|
||||
|
||||
const resizeCallbacks: Callback[] = []
|
||||
const mutationCallbacks: Callback[] = []
|
||||
|
||||
vi.mock('@vueuse/core', async () => {
|
||||
const actual = await vi.importActual('@vueuse/core')
|
||||
return {
|
||||
...actual,
|
||||
useResizeObserver: (_target: unknown, cb: Callback) => {
|
||||
resizeCallbacks.push(cb)
|
||||
return { stop: vi.fn() }
|
||||
},
|
||||
useMutationObserver: (_target: unknown, cb: Callback) => {
|
||||
mutationCallbacks.push(cb)
|
||||
return { stop: vi.fn() }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function mockElementSize(
|
||||
el: HTMLElement,
|
||||
clientWidth: number,
|
||||
scrollWidth: number
|
||||
) {
|
||||
Object.defineProperty(el, 'clientWidth', {
|
||||
value: clientWidth,
|
||||
configurable: true
|
||||
})
|
||||
Object.defineProperty(el, 'scrollWidth', {
|
||||
value: scrollWidth,
|
||||
configurable: true
|
||||
})
|
||||
}
|
||||
|
||||
describe(TextTickerMultiLine, () => {
|
||||
let wrapper: ReturnType<typeof mount>
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
resizeCallbacks.length = 0
|
||||
mutationCallbacks.length = 0
|
||||
})
|
||||
|
||||
function mountComponent(text: string) {
|
||||
wrapper = mount(TextTickerMultiLine, {
|
||||
slots: { default: text }
|
||||
})
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function getMeasureEl(): HTMLElement {
|
||||
return wrapper.find('[aria-hidden="true"]').element as HTMLElement
|
||||
}
|
||||
|
||||
async function triggerSplitLines() {
|
||||
resizeCallbacks.forEach((cb) => cb())
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
it('renders slot content', () => {
|
||||
mountComponent('Load Checkpoint')
|
||||
expect(wrapper.text()).toContain('Load Checkpoint')
|
||||
})
|
||||
|
||||
it('renders a single MarqueeLine when text fits', async () => {
|
||||
mountComponent('Short')
|
||||
mockElementSize(getMeasureEl(), 200, 100)
|
||||
await triggerSplitLines()
|
||||
|
||||
expect(wrapper.findAllComponents(MarqueeLine)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('renders two MarqueeLines when text overflows', async () => {
|
||||
mountComponent('Load Checkpoint Loader Simple')
|
||||
mockElementSize(getMeasureEl(), 100, 300)
|
||||
await triggerSplitLines()
|
||||
|
||||
expect(wrapper.findAllComponents(MarqueeLine)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('splits text at word boundary when overflowing', async () => {
|
||||
mountComponent('Load Checkpoint Loader')
|
||||
mockElementSize(getMeasureEl(), 100, 200)
|
||||
await triggerSplitLines()
|
||||
|
||||
const lines = wrapper.findAllComponents(MarqueeLine)
|
||||
expect(lines[0].text()).toBe('Load')
|
||||
expect(lines[1].text()).toBe('Checkpoint Loader')
|
||||
})
|
||||
|
||||
it('has hidden measurement element with aria-hidden', () => {
|
||||
mountComponent('Test')
|
||||
const measureEl = wrapper.find('[aria-hidden="true"]')
|
||||
expect(measureEl.exists()).toBe(true)
|
||||
expect(measureEl.classes()).toContain('invisible')
|
||||
})
|
||||
})
|
||||
66
src/components/common/TextTickerMultiLine.vue
Normal file
66
src/components/common/TextTickerMultiLine.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Hidden single-line measurement element for overflow detection -->
|
||||
<div
|
||||
ref="measureRef"
|
||||
class="invisible absolute inset-x-0 top-0 overflow-hidden whitespace-nowrap pointer-events-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<MarqueeLine v-if="!secondLine">
|
||||
<slot />
|
||||
</MarqueeLine>
|
||||
|
||||
<div v-else class="flex flex-col w-full">
|
||||
<MarqueeLine>{{ firstLine }}</MarqueeLine>
|
||||
<MarqueeLine>{{ secondLine }}</MarqueeLine>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMutationObserver, useResizeObserver } from '@vueuse/core'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import MarqueeLine from './MarqueeLine.vue'
|
||||
import { splitTextAtWordBoundary } from '@/utils/textTickerUtils'
|
||||
|
||||
const measureRef = ref<HTMLElement | null>(null)
|
||||
const firstLine = ref('')
|
||||
const secondLine = ref('')
|
||||
|
||||
function splitLines() {
|
||||
const el = measureRef.value
|
||||
const text = el?.textContent?.trim()
|
||||
if (!el || !text) {
|
||||
firstLine.value = ''
|
||||
secondLine.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
const containerWidth = el.clientWidth
|
||||
const textWidth = el.scrollWidth
|
||||
|
||||
if (textWidth <= containerWidth) {
|
||||
firstLine.value = text
|
||||
secondLine.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
const [first, second] = splitTextAtWordBoundary(
|
||||
text,
|
||||
containerWidth / textWidth
|
||||
)
|
||||
firstLine.value = first
|
||||
secondLine.value = second
|
||||
}
|
||||
|
||||
useResizeObserver(measureRef, splitLines)
|
||||
useMutationObserver(measureRef, splitLines, {
|
||||
childList: true,
|
||||
characterData: true,
|
||||
subtree: true
|
||||
})
|
||||
</script>
|
||||
@@ -1,37 +1,41 @@
|
||||
<template>
|
||||
<ContextMenuRoot>
|
||||
<TreeRoot
|
||||
:expanded="[...expandedKeys]"
|
||||
:items="root.children ?? []"
|
||||
:get-key="(item) => item.key"
|
||||
:get-children="
|
||||
(item) => (item.children?.length ? item.children : undefined)
|
||||
"
|
||||
class="m-0 p-0 pb-6"
|
||||
>
|
||||
<TreeVirtualizer
|
||||
v-slot="{ item }"
|
||||
:estimate-size="36"
|
||||
:text-content="(item) => item.value.label ?? ''"
|
||||
<ContextMenuTrigger :disabled="!showContextMenu" as-child>
|
||||
<TreeRoot
|
||||
:expanded="[...expandedKeys]"
|
||||
:items="root.children ?? []"
|
||||
:get-key="(item) => item.key"
|
||||
:get-children="
|
||||
(item) => (item.children?.length ? item.children : undefined)
|
||||
"
|
||||
class="m-0 p-0 pb-6"
|
||||
>
|
||||
<TreeExplorerV2Node
|
||||
:item="
|
||||
item as FlattenedItem<RenderedTreeExplorerNode<ComfyNodeDefImpl>>
|
||||
"
|
||||
@node-click="
|
||||
(node: RenderedTreeExplorerNode<ComfyNodeDefImpl>, e: MouseEvent) =>
|
||||
emit('nodeClick', node, e)
|
||||
"
|
||||
<TreeVirtualizer
|
||||
v-slot="{ item }"
|
||||
:estimate-size="36"
|
||||
:text-content="(item) => item.value.label ?? ''"
|
||||
>
|
||||
<template #folder="{ node }">
|
||||
<slot name="folder" :node="node" />
|
||||
</template>
|
||||
<template #node="{ node }">
|
||||
<slot name="node" :node="node" />
|
||||
</template>
|
||||
</TreeExplorerV2Node>
|
||||
</TreeVirtualizer>
|
||||
</TreeRoot>
|
||||
<TreeExplorerV2Node
|
||||
:item="
|
||||
item as FlattenedItem<RenderedTreeExplorerNode<ComfyNodeDefImpl>>
|
||||
"
|
||||
@node-click="
|
||||
(
|
||||
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>,
|
||||
e: MouseEvent
|
||||
) => emit('nodeClick', node, e)
|
||||
"
|
||||
>
|
||||
<template #folder="{ node }">
|
||||
<slot name="folder" :node="node" />
|
||||
</template>
|
||||
<template #node="{ node }">
|
||||
<slot name="node" :node="node" />
|
||||
</template>
|
||||
</TreeExplorerV2Node>
|
||||
</TreeVirtualizer>
|
||||
</TreeRoot>
|
||||
</ContextMenuTrigger>
|
||||
|
||||
<ContextMenuPortal v-if="showContextMenu">
|
||||
<ContextMenuContent
|
||||
@@ -49,7 +53,11 @@
|
||||
"
|
||||
class="size-4"
|
||||
/>
|
||||
{{ $t('sideToolbar.nodeLibraryTab.sections.favorites') }}
|
||||
{{
|
||||
isCurrentNodeBookmarked
|
||||
? $t('sideToolbar.nodeLibraryTab.sections.unfavoriteNode')
|
||||
: $t('sideToolbar.nodeLibraryTab.sections.favoriteNode')
|
||||
}}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenuPortal>
|
||||
@@ -63,6 +71,7 @@ import {
|
||||
ContextMenuItem,
|
||||
ContextMenuPortal,
|
||||
ContextMenuRoot,
|
||||
ContextMenuTrigger,
|
||||
TreeRoot,
|
||||
TreeVirtualizer
|
||||
} from 'reka-ui'
|
||||
|
||||
@@ -80,10 +80,6 @@ describe('TreeExplorerV2Node', () => {
|
||||
global: {
|
||||
stubs: {
|
||||
TreeItem: treeItemStub.stub,
|
||||
ContextMenuTrigger: {
|
||||
name: 'ContextMenuTrigger',
|
||||
template: '<div data-testid="context-menu-trigger"><slot /></div>'
|
||||
},
|
||||
Teleport: { template: '<div />' }
|
||||
},
|
||||
provide: {
|
||||
@@ -145,36 +141,12 @@ describe('TreeExplorerV2Node', () => {
|
||||
})
|
||||
|
||||
describe('context menu', () => {
|
||||
it('renders ContextMenuTrigger when showContextMenu is true for nodes', () => {
|
||||
const { wrapper } = mountComponent({
|
||||
item: createMockItem('node'),
|
||||
showContextMenu: true
|
||||
})
|
||||
|
||||
expect(
|
||||
wrapper.find('[data-testid="context-menu-trigger"]').exists()
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not render ContextMenuTrigger for folder items', () => {
|
||||
const { wrapper } = mountComponent({
|
||||
item: createMockItem('folder')
|
||||
})
|
||||
|
||||
expect(
|
||||
wrapper.find('[data-testid="context-menu-trigger"]').exists()
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('sets contextMenuNode when contextmenu event is triggered', async () => {
|
||||
it('sets contextMenuNode when contextmenu event is triggered on node', async () => {
|
||||
const contextMenuNode = ref<RenderedTreeExplorerNode | null>(null)
|
||||
const nodeItem = createMockItem('node')
|
||||
|
||||
const { wrapper } = mountComponent(
|
||||
{
|
||||
item: nodeItem,
|
||||
showContextMenu: true
|
||||
},
|
||||
{ item: nodeItem },
|
||||
{
|
||||
provide: {
|
||||
[InjectKeyContextMenuNode as symbol]: contextMenuNode
|
||||
@@ -187,6 +159,24 @@ describe('TreeExplorerV2Node', () => {
|
||||
|
||||
expect(contextMenuNode.value).toEqual(nodeItem.value)
|
||||
})
|
||||
|
||||
it('does not set contextMenuNode for folder items', async () => {
|
||||
const contextMenuNode = ref<RenderedTreeExplorerNode | null>(null)
|
||||
|
||||
const { wrapper } = mountComponent(
|
||||
{ item: createMockItem('folder') },
|
||||
{
|
||||
provide: {
|
||||
[InjectKeyContextMenuNode as symbol]: contextMenuNode
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const folderDiv = wrapper.find('div.group\\/tree-node')
|
||||
await folderDiv.trigger('contextmenu')
|
||||
|
||||
expect(contextMenuNode.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
|
||||
@@ -5,27 +5,26 @@
|
||||
:level="item.level"
|
||||
as-child
|
||||
>
|
||||
<!-- Node with context menu -->
|
||||
<ContextMenuTrigger v-if="item.value.type === 'node'" as-child>
|
||||
<div
|
||||
:class="cn(ROW_CLASS, isSelected && 'bg-comfy-input')"
|
||||
:style="rowStyle"
|
||||
draggable="true"
|
||||
@click.stop="handleClick($event, handleToggle, handleSelect)"
|
||||
@contextmenu="handleContextMenu"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@dragstart="handleDragStart"
|
||||
@dragend="handleDragEnd"
|
||||
>
|
||||
<i class="icon-[comfy--node] size-4 shrink-0 text-muted-foreground" />
|
||||
<span class="min-w-0 flex-1 truncate text-sm text-foreground">
|
||||
<slot name="node" :node="item.value">
|
||||
{{ item.value.label }}
|
||||
</slot>
|
||||
</span>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<!-- Node -->
|
||||
<div
|
||||
v-if="item.value.type === 'node'"
|
||||
:class="cn(ROW_CLASS, isSelected && 'bg-comfy-input')"
|
||||
:style="rowStyle"
|
||||
draggable="true"
|
||||
@click.stop="handleClick($event, handleToggle, handleSelect)"
|
||||
@contextmenu="handleContextMenu"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@dragstart="handleDragStart"
|
||||
@dragend="handleDragEnd"
|
||||
>
|
||||
<i class="icon-[comfy--node] size-4 shrink-0 text-muted-foreground" />
|
||||
<span class="min-w-0 flex-1 truncate text-sm text-foreground">
|
||||
<slot name="node" :node="item.value">
|
||||
{{ item.value.label }}
|
||||
</slot>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Folder -->
|
||||
<div
|
||||
@@ -69,7 +68,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FlattenedItem } from 'reka-ui'
|
||||
import { ContextMenuTrigger, TreeItem } from 'reka-ui'
|
||||
import { TreeItem } from 'reka-ui'
|
||||
import { computed, inject } from 'vue'
|
||||
|
||||
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
|
||||
|
||||
@@ -113,7 +113,7 @@ describe('NodeLibrarySidebarTabV2', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
const triggers = wrapper.findAllComponents(TabsTrigger)
|
||||
expect(triggers.length).toBe(3)
|
||||
expect(triggers).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should render search box', () => {
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import {
|
||||
DropdownMenuContent,
|
||||
@@ -114,6 +114,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBoxV2.vue'
|
||||
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
||||
import { usePerTabState } from '@/composables/usePerTabState'
|
||||
import {
|
||||
DEFAULT_SORTING_ID,
|
||||
DEFAULT_TAB_ID,
|
||||
@@ -148,15 +149,7 @@ const sortOrderByTab = useLocalStorage<Record<TabId, SortingStrategyId>>(
|
||||
custom: 'alphabetical'
|
||||
}
|
||||
)
|
||||
const sortOrder = computed({
|
||||
get: () => sortOrderByTab.value[selectedTab.value],
|
||||
set: (value) => {
|
||||
sortOrderByTab.value = {
|
||||
...sortOrderByTab.value,
|
||||
[selectedTab.value]: value
|
||||
}
|
||||
}
|
||||
})
|
||||
const sortOrder = usePerTabState(selectedTab, sortOrderByTab)
|
||||
|
||||
const sortingOptions = computed(() =>
|
||||
nodeOrganizationService.getSortingStrategies().map((strategy) => ({
|
||||
@@ -174,12 +167,7 @@ const expandedKeysByTab = ref<Record<TabId, string[]>>({
|
||||
all: [],
|
||||
custom: []
|
||||
})
|
||||
const expandedKeys = computed({
|
||||
get: () => expandedKeysByTab.value[selectedTab.value],
|
||||
set: (value) => {
|
||||
expandedKeysByTab.value[selectedTab.value] = value
|
||||
}
|
||||
})
|
||||
const expandedKeys = usePerTabState(selectedTab, expandedKeysByTab)
|
||||
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<TreeExplorerV2
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
:root="favoritesRoot"
|
||||
show-context-menu
|
||||
@node-click="(node) => emit('nodeClick', node)"
|
||||
@add-to-favorites="handleAddToFavorites"
|
||||
/>
|
||||
@@ -26,6 +27,7 @@
|
||||
<TreeExplorerV2
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
:root="section.root"
|
||||
show-context-menu
|
||||
@node-click="(node) => emit('nodeClick', node)"
|
||||
@add-to-favorites="handleAddToFavorites"
|
||||
/>
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col items-center justify-center py-4 px-2 rounded-2xl cursor-pointer select-none transition-colors duration-150 box-content',
|
||||
'bg-component-node-background hover:bg-secondary-background-hover border border-component-node-border',
|
||||
'aspect-square'
|
||||
)
|
||||
"
|
||||
:data-node-name="nodeDef?.display_name"
|
||||
class="group relative flex flex-col items-center justify-center py-4 px-2 rounded-2xl cursor-pointer select-none transition-colors duration-150 box-content bg-component-node-background hover:bg-secondary-background-hover border border-component-node-border aspect-square"
|
||||
:data-node-name="node.data?.display_name"
|
||||
draggable="true"
|
||||
@click="handleClick"
|
||||
@dragstart="handleDragStart"
|
||||
@@ -18,11 +12,12 @@
|
||||
<div class="flex flex-1 items-center justify-center">
|
||||
<i :class="cn(nodeIcon, 'size-14 text-muted-foreground')" />
|
||||
</div>
|
||||
<span
|
||||
class="shrink-0 h-8 text-sm font-bold text-center text-foreground line-clamp-2 leading-4"
|
||||
|
||||
<TextTickerMultiLine
|
||||
class="shrink-0 h-8 w-full text-xs font-bold text-foreground leading-4"
|
||||
>
|
||||
{{ nodeDef?.display_name }}
|
||||
</span>
|
||||
{{ node.data?.display_name }}
|
||||
</TextTickerMultiLine>
|
||||
</div>
|
||||
|
||||
<Teleport v-if="showPreview" to="body">
|
||||
@@ -30,7 +25,10 @@
|
||||
:ref="(el) => (previewRef = el as HTMLElement)"
|
||||
:style="nodePreviewStyle"
|
||||
>
|
||||
<NodePreviewCard :node-def="nodeDef!" :show-inputs-and-outputs="false" />
|
||||
<NodePreviewCard
|
||||
:node-def="node.data!"
|
||||
:show-inputs-and-outputs="false"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -39,6 +37,7 @@
|
||||
import { kebabCase } from 'es-toolkit/string'
|
||||
import { computed, inject } from 'vue'
|
||||
|
||||
import TextTickerMultiLine from '@/components/common/TextTickerMultiLine.vue'
|
||||
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
|
||||
import { SidebarContainerKey } from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
|
||||
import { useNodePreviewAndDrag } from '@/composables/node/useNodePreviewAndDrag'
|
||||
@@ -54,9 +53,8 @@ const emit = defineEmits<{
|
||||
click: [node: RenderedTreeExplorerNode<ComfyNodeDefImpl>]
|
||||
}>()
|
||||
|
||||
const nodeDef = computed(() => node.data)
|
||||
|
||||
const panelRef = inject(SidebarContainerKey, undefined)
|
||||
const nodeDef = computed(() => node.data)
|
||||
|
||||
const {
|
||||
previewRef,
|
||||
@@ -69,13 +67,13 @@ const {
|
||||
} = useNodePreviewAndDrag(nodeDef, { panelRef })
|
||||
|
||||
const nodeIcon = computed(() => {
|
||||
const nodeName = nodeDef.value?.name
|
||||
const nodeName = node.data?.name
|
||||
const iconName = nodeName ? kebabCase(nodeName) : 'node'
|
||||
return `icon-[comfy--${iconName}]`
|
||||
})
|
||||
|
||||
function handleClick() {
|
||||
if (!nodeDef.value) return
|
||||
if (!node.data) return
|
||||
emit('click', node)
|
||||
}
|
||||
</script>
|
||||
|
||||
73
src/composables/usePerTabState.test.ts
Normal file
73
src/composables/usePerTabState.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { ref } from 'vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { usePerTabState } from './usePerTabState'
|
||||
|
||||
type TabId = 'a' | 'b' | 'c'
|
||||
|
||||
describe('usePerTabState', () => {
|
||||
function setup(initialTab: TabId = 'a') {
|
||||
const selectedTab = ref<TabId>(initialTab)
|
||||
const stateByTab = ref<Record<TabId, string[]>>({
|
||||
a: [],
|
||||
b: [],
|
||||
c: []
|
||||
})
|
||||
const state = usePerTabState(selectedTab, stateByTab)
|
||||
return { selectedTab, stateByTab, state }
|
||||
}
|
||||
|
||||
it('should return state for the current tab', () => {
|
||||
const { selectedTab, stateByTab, state } = setup()
|
||||
|
||||
stateByTab.value.a = ['key1', 'key2']
|
||||
stateByTab.value.b = ['key3']
|
||||
|
||||
expect(state.value).toEqual(['key1', 'key2'])
|
||||
|
||||
selectedTab.value = 'b'
|
||||
expect(state.value).toEqual(['key3'])
|
||||
})
|
||||
|
||||
it('should set state only for the current tab', () => {
|
||||
const { stateByTab, state } = setup()
|
||||
|
||||
state.value = ['new-key1', 'new-key2']
|
||||
|
||||
expect(stateByTab.value.a).toEqual(['new-key1', 'new-key2'])
|
||||
expect(stateByTab.value.b).toEqual([])
|
||||
expect(stateByTab.value.c).toEqual([])
|
||||
})
|
||||
|
||||
it('should preserve state when switching tabs', () => {
|
||||
const { selectedTab, stateByTab, state } = setup()
|
||||
|
||||
state.value = ['a-key']
|
||||
selectedTab.value = 'b'
|
||||
state.value = ['b-key']
|
||||
selectedTab.value = 'c'
|
||||
state.value = ['c-key']
|
||||
|
||||
expect(stateByTab.value.a).toEqual(['a-key'])
|
||||
expect(stateByTab.value.b).toEqual(['b-key'])
|
||||
expect(stateByTab.value.c).toEqual(['c-key'])
|
||||
|
||||
selectedTab.value = 'a'
|
||||
expect(state.value).toEqual(['a-key'])
|
||||
})
|
||||
|
||||
it('should not share state between tabs', () => {
|
||||
const { selectedTab, state } = setup()
|
||||
|
||||
state.value = ['only-a']
|
||||
|
||||
selectedTab.value = 'b'
|
||||
expect(state.value).toEqual([])
|
||||
|
||||
selectedTab.value = 'c'
|
||||
expect(state.value).toEqual([])
|
||||
|
||||
selectedTab.value = 'a'
|
||||
expect(state.value).toEqual(['only-a'])
|
||||
})
|
||||
})
|
||||
14
src/composables/usePerTabState.ts
Normal file
14
src/composables/usePerTabState.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
export function usePerTabState<K extends string, V>(
|
||||
selectedTab: Ref<K>,
|
||||
stateByTab: Ref<Record<K, V>>
|
||||
) {
|
||||
return computed({
|
||||
get: () => stateByTab.value[selectedTab.value],
|
||||
set: (value) => {
|
||||
stateByTab.value[selectedTab.value] = value
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -804,7 +804,9 @@
|
||||
"alphabeticalDesc": "Sort alphabetically within groups"
|
||||
},
|
||||
"sections": {
|
||||
"favorites": "Favorites"
|
||||
"favorites": "Favorites",
|
||||
"favoriteNode": "Favorite Node",
|
||||
"unfavoriteNode": "Unfavorite Node"
|
||||
}
|
||||
},
|
||||
"modelLibrary": "Model Library",
|
||||
|
||||
@@ -314,9 +314,12 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
// Bookmarks are stored in the settings store.
|
||||
{
|
||||
id: 'Comfy.NodeLibrary.NewDesign',
|
||||
name: 'Use new node library design',
|
||||
type: 'hidden',
|
||||
defaultValue: false,
|
||||
category: ['Comfy', 'Node Library', 'NewDesign'],
|
||||
name: 'New Node Library Design',
|
||||
type: 'boolean',
|
||||
tooltip:
|
||||
'Enable the redesigned node library sidebar with tabs (Essential, All, Custom), improved search, and hover previews.',
|
||||
defaultValue: true,
|
||||
experimental: true
|
||||
},
|
||||
// Bookmarks are in format of category/display_name. e.g. "conditioning/CLIPTextEncode"
|
||||
|
||||
58
src/utils/textTickerUtils.test.ts
Normal file
58
src/utils/textTickerUtils.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { splitTextAtWordBoundary } from '@/utils/textTickerUtils'
|
||||
|
||||
describe('splitTextAtWordBoundary', () => {
|
||||
it('returns full text when ratio >= 1 (fits in one line)', () => {
|
||||
expect(splitTextAtWordBoundary('Load Checkpoint', 1)).toEqual([
|
||||
'Load Checkpoint',
|
||||
''
|
||||
])
|
||||
expect(splitTextAtWordBoundary('Load Checkpoint', 1.5)).toEqual([
|
||||
'Load Checkpoint',
|
||||
''
|
||||
])
|
||||
})
|
||||
|
||||
it('splits at last word boundary before estimated break', () => {
|
||||
// "Load Checkpoint Loader" = 22 chars, ratio 0.5 → estimate at char 11
|
||||
// lastIndexOf(' ', 11) → 15? No: "Load Checkpoint Loader"
|
||||
// 0123456789...
|
||||
// ' ' at index 4 and 15
|
||||
// lastIndexOf(' ', 11) → 4
|
||||
expect(splitTextAtWordBoundary('Load Checkpoint Loader', 0.5)).toEqual([
|
||||
'Load',
|
||||
'Checkpoint Loader'
|
||||
])
|
||||
})
|
||||
|
||||
it('splits longer text proportionally', () => {
|
||||
// ratio 0.7 → estimate at char 15
|
||||
// lastIndexOf(' ', 15) → 15 (the space between "Checkpoint" and "Loader")
|
||||
expect(splitTextAtWordBoundary('Load Checkpoint Loader', 0.7)).toEqual([
|
||||
'Load Checkpoint',
|
||||
'Loader'
|
||||
])
|
||||
})
|
||||
|
||||
it('returns full text when no word boundary found', () => {
|
||||
expect(splitTextAtWordBoundary('Superlongwordwithoutspaces', 0.5)).toEqual([
|
||||
'Superlongwordwithoutspaces',
|
||||
''
|
||||
])
|
||||
})
|
||||
|
||||
it('handles single word text', () => {
|
||||
expect(splitTextAtWordBoundary('Checkpoint', 0.5)).toEqual([
|
||||
'Checkpoint',
|
||||
''
|
||||
])
|
||||
})
|
||||
|
||||
it('handles ratio near zero', () => {
|
||||
expect(splitTextAtWordBoundary('Load Checkpoint Loader', 0.1)).toEqual([
|
||||
'Load Checkpoint Loader',
|
||||
''
|
||||
])
|
||||
})
|
||||
})
|
||||
10
src/utils/textTickerUtils.ts
Normal file
10
src/utils/textTickerUtils.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export function splitTextAtWordBoundary(
|
||||
text: string,
|
||||
ratio: number
|
||||
): [string, string] {
|
||||
if (ratio >= 1) return [text, '']
|
||||
const estimate = Math.floor(text.length * ratio)
|
||||
const breakIndex = text.lastIndexOf(' ', estimate)
|
||||
if (breakIndex <= 0) return [text, '']
|
||||
return [text.substring(0, breakIndex), text.substring(breakIndex + 1)]
|
||||
}
|
||||
Reference in New Issue
Block a user