diff --git a/browser_tests/tests/groupNode.spec.ts b/browser_tests/tests/groupNode.spec.ts index 90234e110..e546629d0 100644 --- a/browser_tests/tests/groupNode.spec.ts +++ b/browser_tests/tests/groupNode.spec.ts @@ -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)') }) diff --git a/browser_tests/tests/nodeHelp.spec.ts b/browser_tests/tests/nodeHelp.spec.ts index 5260ade0d..7f0f1541f 100644 --- a/browser_tests/tests/nodeHelp.spec.ts +++ b/browser_tests/tests/nodeHelp.spec.ts @@ -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', () => { diff --git a/browser_tests/tests/propertiesPanel/propertiesPanelPosition.spec.ts b/browser_tests/tests/propertiesPanel/propertiesPanelPosition.spec.ts index eefbe6720..fd235ea0d 100644 --- a/browser_tests/tests/propertiesPanel/propertiesPanelPosition.spec.ts +++ b/browser_tests/tests/propertiesPanel/propertiesPanelPosition.spec.ts @@ -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() diff --git a/browser_tests/tests/remoteWidgets.spec.ts b/browser_tests/tests/remoteWidgets.spec.ts index e29f46ea2..a84dfd027 100644 --- a/browser_tests/tests/remoteWidgets.spec.ts +++ b/browser_tests/tests/remoteWidgets.spec.ts @@ -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)' diff --git a/browser_tests/tests/sidebar/nodeLibrary.spec.ts b/browser_tests/tests/sidebar/nodeLibrary.spec.ts index 6f18bcdee..c6304fe98 100644 --- a/browser_tests/tests/sidebar/nodeLibrary.spec.ts +++ b/browser_tests/tests/sidebar/nodeLibrary.spec.ts @@ -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', diff --git a/src/components/common/MarqueeLine.test.ts b/src/components/common/MarqueeLine.test.ts new file mode 100644 index 000000000..210f388c1 --- /dev/null +++ b/src/components/common/MarqueeLine.test.ts @@ -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') + }) +}) diff --git a/src/components/common/MarqueeLine.vue b/src/components/common/MarqueeLine.vue new file mode 100644 index 000000000..09894c195 --- /dev/null +++ b/src/components/common/MarqueeLine.vue @@ -0,0 +1,24 @@ + + + diff --git a/src/components/common/TextTickerMultiLine.test.ts b/src/components/common/TextTickerMultiLine.test.ts new file mode 100644 index 000000000..968047296 --- /dev/null +++ b/src/components/common/TextTickerMultiLine.test.ts @@ -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 + + 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') + }) +}) diff --git a/src/components/common/TextTickerMultiLine.vue b/src/components/common/TextTickerMultiLine.vue new file mode 100644 index 000000000..1c90a60cf --- /dev/null +++ b/src/components/common/TextTickerMultiLine.vue @@ -0,0 +1,66 @@ + + + diff --git a/src/components/common/TreeExplorerV2.vue b/src/components/common/TreeExplorerV2.vue index 6350f02b5..957da8d6d 100644 --- a/src/components/common/TreeExplorerV2.vue +++ b/src/components/common/TreeExplorerV2.vue @@ -1,37 +1,41 @@ diff --git a/src/composables/usePerTabState.test.ts b/src/composables/usePerTabState.test.ts new file mode 100644 index 000000000..947124adc --- /dev/null +++ b/src/composables/usePerTabState.test.ts @@ -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(initialTab) + const stateByTab = ref>({ + 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']) + }) +}) diff --git a/src/composables/usePerTabState.ts b/src/composables/usePerTabState.ts new file mode 100644 index 000000000..3019dba66 --- /dev/null +++ b/src/composables/usePerTabState.ts @@ -0,0 +1,14 @@ +import type { Ref } from 'vue' +import { computed } from 'vue' + +export function usePerTabState( + selectedTab: Ref, + stateByTab: Ref> +) { + return computed({ + get: () => stateByTab.value[selectedTab.value], + set: (value) => { + stateByTab.value[selectedTab.value] = value + } + }) +} diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 47aaa1336..3f780931f 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -804,7 +804,9 @@ "alphabeticalDesc": "Sort alphabetically within groups" }, "sections": { - "favorites": "Favorites" + "favorites": "Favorites", + "favoriteNode": "Favorite Node", + "unfavoriteNode": "Unfavorite Node" } }, "modelLibrary": "Model Library", diff --git a/src/platform/settings/constants/coreSettings.ts b/src/platform/settings/constants/coreSettings.ts index 55159eb70..8749ae600 100644 --- a/src/platform/settings/constants/coreSettings.ts +++ b/src/platform/settings/constants/coreSettings.ts @@ -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" diff --git a/src/utils/textTickerUtils.test.ts b/src/utils/textTickerUtils.test.ts new file mode 100644 index 000000000..3fa2592f9 --- /dev/null +++ b/src/utils/textTickerUtils.test.ts @@ -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', + '' + ]) + }) +}) diff --git a/src/utils/textTickerUtils.ts b/src/utils/textTickerUtils.ts new file mode 100644 index 000000000..484650766 --- /dev/null +++ b/src/utils/textTickerUtils.ts @@ -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)] +}