Compare commits

...

27 Commits

Author SHA1 Message Date
Yourz
8668e67e64 fix: favorite subgraphs 2026-02-21 14:33:06 +08:00
bymyself
3077520aae fix: context menu shows Favorite/Unfavorite Node text
Amp-Thread-ID: https://ampcode.com/threads/T-019c7e35-c7f6-722f-8289-dff29a926487
2026-02-20 21:42:42 -08:00
bymyself
102c7d7410 fix: move ContextMenuTrigger to wrap entire TreeRoot
Reka UI ContextMenu expects a single ContextMenuTrigger per root.
The previous implementation placed a ContextMenuTrigger inside each
tree node, which prevented the context menu from appearing.

Move the trigger to TreeExplorerV2 wrapping the TreeRoot and use
the disabled prop to control it. The per-node @contextmenu handler
still tracks which node was right-clicked.

Amp-Thread-ID: https://ampcode.com/threads/T-019c7e35-c7f6-722f-8289-dff29a926487
2026-02-20 21:35:54 -08:00
bymyself
51eef9f38d fix: pin browser tests to old node library design
Set Comfy.NodeLibrary.NewDesign to false in beforeEach of all browser
tests that interact with the node library sidebar to prevent failures
when the new design is enabled.

Amp-Thread-ID: https://ampcode.com/threads/T-019c7e35-c7f6-722f-8289-dff29a926487
2026-02-20 20:11:42 -08:00
Christian Byrne
88e47c7b29 Update coreSettings.ts 2026-02-20 19:36:52 -08:00
bymyself
ed3e7758aa feat: enable favorites context menu and expose new design setting
- Add show-context-menu to TreeExplorerV2 in AllNodesPanel for both
  favorites and node sections
- Change Comfy.NodeLibrary.NewDesign from hidden to boolean with
  category and tooltip so users can toggle it in Settings

Amp-Thread-ID: https://ampcode.com/threads/T-019c7e35-c7f6-722f-8289-dff29a926487
2026-02-20 19:33:42 -08:00
GitHub Action
2722f336ec [automated] Apply ESLint and Oxfmt fixes 2026-02-21 03:04:00 +00:00
Yourz
b6f0fd57c2 fix: unit tests 2026-02-21 11:01:06 +08:00
Yourz
d75fd0671d fix: reviews update 2026-02-21 10:45:46 +08:00
Yourz
2674897596 fix: font cut 2026-02-21 10:45:46 +08:00
Yourz
17972669c8 fix: remove unnecessary code 2026-02-21 10:45:46 +08:00
Yourz
520d6782ec fix: rebase master 2026-02-21 10:45:46 +08:00
Yourz
80caa70386 fix: marquee for essentials display name 2026-02-21 10:45:46 +08:00
Yourz
ebe20b6f63 fix: lower font size of essentials node card name 2026-02-21 10:45:46 +08:00
Yourz
0ad63175b0 fix: update for design reviews, and add setting toggle to switch node library design 2026-02-21 10:45:46 +08:00
Yourz
3ac479bbb3 fix: update for design reviews 2026-02-21 10:45:46 +08:00
Yourz
d9b9522acb feat: essentials tab 2026-02-21 10:45:46 +08:00
Yourz
9e92f61435 fix: update for reviews 2026-02-21 10:45:46 +08:00
Yourz
c5b8a0396b fix: coderabbit reviews 2026-02-21 10:45:46 +08:00
Yourz
c0de802da4 fix: coderabbit reviews 2026-02-21 10:45:46 +08:00
Yourz
8af020de75 fix: coderabbit reviews 2026-02-21 10:45:45 +08:00
Yourz
68d527fb62 fix: coderabbit reviews 2026-02-21 10:45:45 +08:00
Yourz
ebd8117dc3 feat: api nodes icon 2026-02-21 10:45:45 +08:00
Yourz
f25f8ae825 fix: ci 2026-02-21 10:45:45 +08:00
Yourz
1290311733 fix: preview card, icon 2026-02-21 10:45:45 +08:00
Yourz
c9ab2f955f feat: previewCard and BadgPill and api node logo 2026-02-21 10:45:45 +08:00
Yourz
34136a4915 feat: implement NodeLibrarySidebarTabV2 with Reka UI components
- Add three-tab structure (Essential, All, Custom) using Reka UI Tabs
- Implement TreeExplorerV2 with virtualized tree using TreeRoot/TreeVirtualizer
- 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

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019c1ee5-bb3c-70fb-8c24-33966d8dbef8
2026-02-21 10:45:45 +08:00
24 changed files with 499 additions and 122 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

@@ -803,7 +803,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

@@ -53,7 +53,7 @@ export const useNodeBookmarkStore = defineStore('nodeBookmark', () => {
const parts = bookmark.split('/')
const name = parts.pop() ?? ''
const category = parts.join('/')
const srcNodeDef = nodeDefStore.nodeDefsByName[name]
const srcNodeDef = nodeDefStore.allNodeDefsByName[name]
if (!srcNodeDef) {
return null
}

View File

@@ -368,6 +368,14 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
}
return types
})
const allNodeDefsByName = computed(() => {
const map: Record<string, ComfyNodeDefImpl> = {}
for (const nodeDef of nodeDefs.value) {
map[nodeDef.name] = nodeDef
}
return map
})
const visibleNodeDefs = computed(() => {
return nodeDefs.value.filter((nodeDef) =>
nodeDefFilters.value.every((filter) => filter.predicate(nodeDef))
@@ -493,6 +501,7 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
return {
nodeDefsByName,
nodeDefsByDisplayName,
allNodeDefsByName,
showDeprecated,
showExperimental,
showDevOnly,

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