From 6ad894d5af53cad2c05f0bee6cb5f83e4e90d4cf Mon Sep 17 00:00:00 2001 From: Johnpaul Date: Tue, 9 Sep 2025 19:09:11 +0100 Subject: [PATCH] feat: enhance selection toolbox with BypassButton and DeleteButton updates; add refresh functionality --- .../selectionToolbox/BypassButton.spec.ts | 120 ++++++++++++++++ .../graph/selectionToolbox/BypassButton.vue | 23 ++- .../graph/selectionToolbox/DeleteButton.vue | 10 +- .../RefreshSelectionButton.vue | 11 +- src/composables/graph/useSelectionState.ts | 132 ++++++++++++++++++ src/locales/en/main.json | 1 + 6 files changed, 283 insertions(+), 14 deletions(-) create mode 100644 src/components/graph/selectionToolbox/BypassButton.spec.ts create mode 100644 src/composables/graph/useSelectionState.ts diff --git a/src/components/graph/selectionToolbox/BypassButton.spec.ts b/src/components/graph/selectionToolbox/BypassButton.spec.ts new file mode 100644 index 000000000..e61146b2a --- /dev/null +++ b/src/components/graph/selectionToolbox/BypassButton.spec.ts @@ -0,0 +1,120 @@ +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import PrimeVue from 'primevue/config' +import Tooltip from 'primevue/tooltip' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createI18n } from 'vue-i18n' + +import BypassButton from '@/components/graph/selectionToolbox/BypassButton.vue' +import { LGraphEventMode } from '@/lib/litegraph/src/litegraph' +import { useCommandStore } from '@/stores/commandStore' +import { useCanvasStore } from '@/stores/graphStore' + +const mockLGraphNode = { + type: 'TestNode', + title: 'Test Node', + mode: LGraphEventMode.ALWAYS +} + +vi.mock('@/utils/litegraphUtil', () => ({ + isLGraphNode: vi.fn(() => true) +})) + +describe('BypassButton', () => { + let canvasStore: ReturnType + let commandStore: ReturnType + + const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + selectionToolbox: { + bypassButton: { + tooltip: 'Toggle bypass mode' + } + } + } + } + }) + + beforeEach(() => { + setActivePinia(createPinia()) + canvasStore = useCanvasStore() + commandStore = useCommandStore() + + vi.clearAllMocks() + }) + + const mountComponent = () => { + return mount(BypassButton, { + global: { + plugins: [i18n, PrimeVue], + directives: { tooltip: Tooltip }, + stubs: { + 'i-lucide:ban': true + } + } + }) + } + + it('should render bypass button', () => { + canvasStore.selectedItems = [mockLGraphNode] as any + const wrapper = mountComponent() + const button = wrapper.find('button') + expect(button.exists()).toBe(true) + }) + + it('should have correct test id', () => { + canvasStore.selectedItems = [mockLGraphNode] as any + const wrapper = mountComponent() + const button = wrapper.find('[data-testid="bypass-button"]') + expect(button.exists()).toBe(true) + }) + + it('should execute bypass command when clicked', async () => { + canvasStore.selectedItems = [mockLGraphNode] as any + const executeSpy = vi.spyOn(commandStore, 'execute').mockResolvedValue() + + const wrapper = mountComponent() + await wrapper.find('button').trigger('click') + + expect(executeSpy).toHaveBeenCalledWith( + 'Comfy.Canvas.ToggleSelectedNodes.Bypass' + ) + }) + + it('should show normal styling when node is not bypassed', () => { + const normalNode = { ...mockLGraphNode, mode: LGraphEventMode.ALWAYS } + canvasStore.selectedItems = [normalNode] as any + + const wrapper = mountComponent() + const button = wrapper.find('button') + + expect(button.classes()).not.toContain( + 'dark-theme:[&:not(:active)]:!bg-[#262729]' + ) + }) + + it('should show bypassed styling when node is bypassed', async () => { + const bypassedNode = { ...mockLGraphNode, mode: LGraphEventMode.BYPASS } + canvasStore.selectedItems = [bypassedNode] as any + vi.spyOn(commandStore, 'execute').mockResolvedValue() + const wrapper = mountComponent() + + // Click to trigger the reactivity update + await wrapper.find('button').trigger('click') + await wrapper.vm.$nextTick() + + const button = wrapper.find('button') + expect(button.exists()).toBe(true) + }) + + it('should handle multiple selected items', () => { + vi.spyOn(commandStore, 'execute').mockResolvedValue() + canvasStore.selectedItems = [mockLGraphNode, mockLGraphNode] as any + const wrapper = mountComponent() + const button = wrapper.find('button') + expect(button.exists()).toBe(true) + }) +}) diff --git a/src/components/graph/selectionToolbox/BypassButton.vue b/src/components/graph/selectionToolbox/BypassButton.vue index 7ae84f8e6..ce51717d2 100644 --- a/src/components/graph/selectionToolbox/BypassButton.vue +++ b/src/components/graph/selectionToolbox/BypassButton.vue @@ -1,6 +1,6 @@ diff --git a/src/components/graph/selectionToolbox/DeleteButton.vue b/src/components/graph/selectionToolbox/DeleteButton.vue index 7c341614e..9f95ff515 100644 --- a/src/components/graph/selectionToolbox/DeleteButton.vue +++ b/src/components/graph/selectionToolbox/DeleteButton.vue @@ -5,9 +5,11 @@ value: t('commands.Comfy_Canvas_DeleteSelectedItems.label'), showDelay: 1000 }" - severity="danger" + severity="secondary" text + icon-class="w-4 h-4" icon="pi pi-trash" + data-testid="delete-button" @click="() => commandStore.execute('Comfy.Canvas.DeleteSelectedItems')" /> @@ -17,14 +19,14 @@ import Button from 'primevue/button' import { computed } from 'vue' import { useI18n } from 'vue-i18n' +import { useSelectionState } from '@/composables/graph/useSelectionState' import { useCommandStore } from '@/stores/commandStore' -import { useCanvasStore } from '@/stores/graphStore' const { t } = useI18n() const commandStore = useCommandStore() -const canvasStore = useCanvasStore() +const { selectedItems } = useSelectionState() const isDeletable = computed(() => - canvasStore.selectedItems.some((x) => x.removable !== false) + selectedItems.value.some((x: any) => x.removable !== false) ) diff --git a/src/components/graph/selectionToolbox/RefreshSelectionButton.vue b/src/components/graph/selectionToolbox/RefreshSelectionButton.vue index 786fe511f..0da7364a0 100644 --- a/src/components/graph/selectionToolbox/RefreshSelectionButton.vue +++ b/src/components/graph/selectionToolbox/RefreshSelectionButton.vue @@ -1,17 +1,22 @@ diff --git a/src/composables/graph/useSelectionState.ts b/src/composables/graph/useSelectionState.ts new file mode 100644 index 000000000..173ba0b53 --- /dev/null +++ b/src/composables/graph/useSelectionState.ts @@ -0,0 +1,132 @@ +import { computed } from 'vue' + +import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab' +import { + LGraphEventMode, + LGraphNode, + SubgraphNode +} from '@/lib/litegraph/src/litegraph' +import { useCanvasStore } from '@/stores/graphStore' +import { useNodeDefStore } from '@/stores/nodeDefStore' +import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore' +import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore' +import { isImageNode, isLGraphNode } from '@/utils/litegraphUtil' +import { filterOutputNodes } from '@/utils/nodeFilterUtil' + +export interface NodeSelectionState { + collapsed: boolean + pinned: boolean + bypassed: boolean +} + +/** + * Centralized computed selection state + shared helper actions to avoid duplication + * between selection toolbox, context menus, and other UI affordances. + */ +export function useSelectionState() { + const canvasStore = useCanvasStore() + const nodeDefStore = useNodeDefStore() + const sidebarTabStore = useSidebarTabStore() + const nodeHelpStore = useNodeHelpStore() + const { id: nodeLibraryTabId } = useNodeLibrarySidebarTab() + + const selectedItems = computed(() => canvasStore.selectedItems) + + const selectedNodes = computed(() => { + return selectedItems.value.filter((i) => isLGraphNode(i)) as LGraphNode[] + }) + + const nodeDef = computed(() => { + if (selectedNodes.value.length !== 1) return null + return nodeDefStore.fromLGraphNode(selectedNodes.value[0]) + }) + + const hasAnySelection = computed(() => selectedItems.value.length > 0) + const hasSingleSelection = computed(() => selectedItems.value.length === 1) + const hasMultipleSelection = computed(() => selectedItems.value.length > 1) + + const isSingleNode = computed( + () => hasSingleSelection.value && isLGraphNode(selectedItems.value[0]) + ) + const isSingleSubgraph = computed( + () => + isSingleNode.value && + (selectedItems.value[0] as LGraphNode)?.isSubgraphNode?.() + ) + const isSingleImageNode = computed( + () => + isSingleNode.value && isImageNode(selectedItems.value[0] as LGraphNode) + ) + + const hasSubgraphs = computed(() => + selectedItems.value.some((i) => i instanceof SubgraphNode) + ) + + const hasImageNode = computed(() => isSingleImageNode.value) + const hasOutputNodesSelected = computed( + () => filterOutputNodes(selectedNodes.value).length > 0 + ) + + // Helper function to compute selection flags (reused by both computed and function) + const computeSelectionStatesFromNodes = ( + nodes: LGraphNode[] + ): NodeSelectionState => { + if (!nodes.length) + return { collapsed: false, pinned: false, bypassed: false } + return { + collapsed: nodes.some((n) => n.flags?.collapsed), + pinned: nodes.some((n) => n.pinned), + bypassed: nodes.some((n) => n.mode === LGraphEventMode.BYPASS) + } + } + + const selectedNodesStates = computed(() => + computeSelectionStatesFromNodes(selectedNodes.value) + ) + + // On-demand computation (non-reactive) so callers can fetch fresh flags + const computeSelectionFlags = (): NodeSelectionState => + computeSelectionStatesFromNodes(selectedNodes.value) + + /** Toggle node help sidebar/panel for the single selected node (if any). */ + const showNodeHelp = () => { + const def = nodeDef.value + if (!def) return + + const isSidebarActive = + sidebarTabStore.activeSidebarTabId === nodeLibraryTabId + const currentHelpNode: any = nodeHelpStore.currentHelpNode + const isSameNodeHelpOpen = + isSidebarActive && + nodeHelpStore.isHelpOpen && + currentHelpNode && + currentHelpNode.nodePath === def.nodePath + + if (isSameNodeHelpOpen) { + nodeHelpStore.closeHelp() + sidebarTabStore.toggleSidebarTab(nodeLibraryTabId) + return + } + + if (!isSidebarActive) sidebarTabStore.toggleSidebarTab(nodeLibraryTabId) + nodeHelpStore.openHelp(def) + } + + return { + selectedItems, + selectedNodes, + nodeDef, + showNodeHelp, + hasAnySelection, + hasSingleSelection, + hasMultipleSelection, + isSingleNode, + isSingleSubgraph, + isSingleImageNode, + hasSubgraphs, + hasImageNode, + hasOutputNodesSelected, + selectedNodesStates, + computeSelectionFlags + } +} diff --git a/src/locales/en/main.json b/src/locales/en/main.json index cbb84b5f1..86186892a 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -9,6 +9,7 @@ "import": "Import", "loadAllFolders": "Load All Folders", "refresh": "Refresh", + "refreshNode": "Refresh Node", "terminal": "Terminal", "logs": "Logs", "videoFailedToLoad": "Video failed to load",