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:
Yourz
2026-02-21 14:06:09 +08:00
committed by GitHub
parent fd2ffb7100
commit 6ee3803770
22 changed files with 489 additions and 121 deletions

View File

@@ -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)')
})

View File

@@ -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', () => {

View File

@@ -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()

View File

@@ -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)'

View File

@@ -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',

View 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')
})
})

View 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>

View 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')
})
})

View 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>

View File

@@ -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'

View File

@@ -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', () => {

View File

@@ -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'

View File

@@ -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', () => {

View File

@@ -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()

View File

@@ -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"
/>

View File

@@ -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>

View 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'])
})
})

View 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
}
})
}

View File

@@ -804,7 +804,9 @@
"alphabeticalDesc": "Sort alphabetically within groups"
},
"sections": {
"favorites": "Favorites"
"favorites": "Favorites",
"favoriteNode": "Favorite Node",
"unfavoriteNode": "Unfavorite Node"
}
},
"modelLibrary": "Model Library",

View File

@@ -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"

View 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',
''
])
})
})

View 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)]
}