mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
fix: add delete/bookmark actions for blueprints in V2 node library sidebar (#10827)
## Summary Add missing delete and bookmark actions for user blueprints in the V2 node library sidebar, fixing parity with the V1 sidebar. ## Changes - **What**: - Add delete button (inline + context menu) for user blueprints in `TreeExplorerV2Node` and `TreeExplorerV2` - Extract `isUserBlueprint()` helper in `subgraphStore` for DRY usage across V1/V2 sidebars  ## Review Focus - `isUserBlueprint` consolidates logic previously duplicated between `NodeTreeLeaf` and the new V2 components - Context menu guard `contextMenuNode?.data` prevents showing empty menus - Folder `@contextmenu` handler clears stale `contextMenuNode` to prevent wrong actions ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-10827-fix-add-delete-bookmark-actions-for-blueprints-in-V2-node-library-sidebar-3366d73d36508111afd2c2c7d8ff0220) by [Unito](https://www.unito.io)
This commit is contained in:
@@ -16,7 +16,7 @@
|
||||
@plugin "./lucideStrokePlugin.js";
|
||||
|
||||
/* Safelist dynamic comfy icons for node library folders */
|
||||
@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver-ai}]");
|
||||
@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver}]");
|
||||
|
||||
/* Safelist dynamic comfy icons for essential nodes (kebab-case of node names) */
|
||||
@source inline("icon-[comfy--{save-image,load-video,save-video,load-3-d,save-glb,image-batch,batch-images-node,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,lora-loader-model-only,primitive-string-multiline,get-video-components,video-slice,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,preview-image,image-and-mask-preview,layer-mask-mask-preview,mask-preview,image-preview-from-latent,i-tools-preview-image,i-tools-compare-image,canny-to-image,image-edit,text-to-image,pose-to-image,depth-to-video,image-to-image,canny-to-video,depth-to-image,image-to-video,pose-to-video,text-to-video,image-inpainting,image-outpainting}]");
|
||||
|
||||
|
Before Width: | Height: | Size: 534 B After Width: | Height: | Size: 534 B |
@@ -37,13 +37,13 @@
|
||||
</TreeRoot>
|
||||
</ContextMenuTrigger>
|
||||
|
||||
<ContextMenuPortal v-if="showContextMenu">
|
||||
<ContextMenuPortal v-if="showContextMenu && contextMenuNode?.data">
|
||||
<ContextMenuContent
|
||||
class="z-9999 min-w-32 overflow-hidden rounded-md border border-border-default bg-comfy-menu-bg p-1 shadow-md"
|
||||
>
|
||||
<ContextMenuItem
|
||||
class="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none hover:bg-highlight focus:bg-highlight"
|
||||
@select="handleAddToFavorites"
|
||||
@select="handleToggleBookmark"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
@@ -59,6 +59,14 @@
|
||||
: $t('sideToolbar.nodeLibraryTab.sections.favoriteNode')
|
||||
}}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
v-if="isCurrentNodeUserBlueprint"
|
||||
class="text-destructive flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none hover:bg-highlight focus:bg-highlight"
|
||||
@select="handleDeleteBlueprint"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
{{ $t('g.delete') }}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenuPortal>
|
||||
</ContextMenuRoot>
|
||||
@@ -79,6 +87,7 @@ import { computed, provide, ref } from 'vue'
|
||||
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
import { InjectKeyContextMenuNode } from '@/types/treeExplorerTypes'
|
||||
|
||||
@@ -98,7 +107,6 @@ const emit = defineEmits<{
|
||||
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>,
|
||||
event: MouseEvent
|
||||
]
|
||||
addToFavorites: [node: RenderedTreeExplorerNode<ComfyNodeDefImpl>]
|
||||
}>()
|
||||
|
||||
const contextMenuNode = ref<RenderedTreeExplorerNode<ComfyNodeDefImpl> | null>(
|
||||
@@ -107,6 +115,7 @@ const contextMenuNode = ref<RenderedTreeExplorerNode<ComfyNodeDefImpl> | null>(
|
||||
provide(InjectKeyContextMenuNode, contextMenuNode)
|
||||
|
||||
const nodeBookmarkStore = useNodeBookmarkStore()
|
||||
const subgraphStore = useSubgraphStore()
|
||||
|
||||
const isCurrentNodeBookmarked = computed(() => {
|
||||
const node = contextMenuNode.value
|
||||
@@ -114,9 +123,21 @@ const isCurrentNodeBookmarked = computed(() => {
|
||||
return nodeBookmarkStore.isBookmarked(node.data)
|
||||
})
|
||||
|
||||
function handleAddToFavorites() {
|
||||
if (contextMenuNode.value) {
|
||||
emit('addToFavorites', contextMenuNode.value)
|
||||
const isCurrentNodeUserBlueprint = computed(() =>
|
||||
subgraphStore.isUserBlueprint(contextMenuNode.value?.data?.name)
|
||||
)
|
||||
|
||||
function handleToggleBookmark() {
|
||||
const node = contextMenuNode.value
|
||||
if (node?.data) {
|
||||
nodeBookmarkStore.toggleBookmark(node.data)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteBlueprint() {
|
||||
const name = contextMenuNode.value?.data?.name
|
||||
if (name) {
|
||||
void subgraphStore.deleteBlueprint(name)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -13,7 +13,7 @@ import TreeExplorerV2Node from './TreeExplorerV2Node.vue'
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} }
|
||||
messages: { en: { g: { delete: 'Delete' } } }
|
||||
})
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
@@ -29,6 +29,17 @@ vi.mock('@/stores/nodeBookmarkStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
const mockDeleteBlueprint = vi.fn()
|
||||
const mockIsUserBlueprint = vi.fn().mockReturnValue(false)
|
||||
|
||||
vi.mock('@/stores/subgraphStore', () => ({
|
||||
useSubgraphStore: () => ({
|
||||
isUserBlueprint: mockIsUserBlueprint,
|
||||
deleteBlueprint: mockDeleteBlueprint,
|
||||
typePrefix: 'SubgraphBlueprint.'
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/components/node/NodePreviewCard.vue', () => ({
|
||||
default: { template: '<div />' }
|
||||
}))
|
||||
@@ -175,8 +186,12 @@ describe('TreeExplorerV2Node', () => {
|
||||
expect(contextMenuNode.value).toEqual(nodeItem.value)
|
||||
})
|
||||
|
||||
it('does not set contextMenuNode for folder items', async () => {
|
||||
const contextMenuNode = ref<RenderedTreeExplorerNode | null>(null)
|
||||
it('clears contextMenuNode when right-clicking a folder', async () => {
|
||||
const contextMenuNode = ref<RenderedTreeExplorerNode | null>({
|
||||
key: 'stale',
|
||||
type: 'node',
|
||||
label: 'Stale'
|
||||
} as RenderedTreeExplorerNode)
|
||||
|
||||
const { wrapper } = mountComponent(
|
||||
{ item: createMockItem('folder') },
|
||||
@@ -194,6 +209,59 @@ describe('TreeExplorerV2Node', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('blueprint actions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('shows delete button for user blueprints', () => {
|
||||
mockIsUserBlueprint.mockReturnValue(true)
|
||||
const { wrapper } = mountComponent({
|
||||
item: createMockItem('node', {
|
||||
data: { name: 'SubgraphBlueprint.test' }
|
||||
})
|
||||
})
|
||||
|
||||
expect(wrapper.find('[aria-label="Delete"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('hides delete button for non-blueprint nodes', () => {
|
||||
mockIsUserBlueprint.mockReturnValue(false)
|
||||
const { wrapper } = mountComponent({
|
||||
item: createMockItem('node', {
|
||||
data: { name: 'KSampler' }
|
||||
})
|
||||
})
|
||||
|
||||
expect(wrapper.find('[aria-label="Delete"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('always shows bookmark button', () => {
|
||||
mockIsUserBlueprint.mockReturnValue(true)
|
||||
const { wrapper } = mountComponent({
|
||||
item: createMockItem('node', {
|
||||
data: { name: 'SubgraphBlueprint.test' }
|
||||
})
|
||||
})
|
||||
|
||||
expect(wrapper.find('[aria-label="icon.bookmark"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('calls deleteBlueprint when delete button is clicked', async () => {
|
||||
mockIsUserBlueprint.mockReturnValue(true)
|
||||
const nodeName = 'SubgraphBlueprint.test'
|
||||
const { wrapper } = mountComponent({
|
||||
item: createMockItem('node', {
|
||||
data: { name: nodeName }
|
||||
})
|
||||
})
|
||||
|
||||
await wrapper.find('[aria-label="Delete"]').trigger('click')
|
||||
|
||||
expect(mockDeleteBlueprint).toHaveBeenCalledWith(nodeName)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders node icon for node type', () => {
|
||||
const { wrapper } = mountComponent({
|
||||
|
||||
@@ -25,25 +25,30 @@
|
||||
{{ item.value.label }}
|
||||
</slot>
|
||||
</span>
|
||||
<button
|
||||
:class="
|
||||
cn(
|
||||
'hover:text-foreground flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-sm border-none bg-transparent text-muted-foreground',
|
||||
'opacity-0 group-hover/tree-node:opacity-100'
|
||||
)
|
||||
"
|
||||
:aria-label="$t('icon.bookmark')"
|
||||
@click.stop="toggleBookmark"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
isBookmarked ? 'pi pi-bookmark-fill' : 'pi pi-bookmark',
|
||||
'text-xs'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
<div class="flex shrink-0 items-center gap-0.5">
|
||||
<button
|
||||
v-if="isUserBlueprint"
|
||||
:class="cn(ACTION_BTN_CLASS, 'text-destructive')"
|
||||
:aria-label="$t('g.delete')"
|
||||
@click.stop="deleteBlueprint"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] text-xs" />
|
||||
</button>
|
||||
<button
|
||||
:class="cn(ACTION_BTN_CLASS, 'text-muted-foreground')"
|
||||
:aria-label="$t('icon.bookmark')"
|
||||
@click.stop="toggleBookmark"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
isBookmarked ? 'pi pi-bookmark-fill' : 'pi pi-bookmark',
|
||||
'text-xs'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Folder -->
|
||||
@@ -53,6 +58,7 @@
|
||||
:class="cn(ROW_CLASS, isSelected && 'bg-comfy-input')"
|
||||
:style="rowStyle"
|
||||
@click.stop="handleClick($event, handleToggle, handleSelect)"
|
||||
@contextmenu="clearContextMenuNode"
|
||||
>
|
||||
<i
|
||||
v-if="item.hasChildren"
|
||||
@@ -96,6 +102,7 @@ import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
|
||||
import { useNodePreviewAndDrag } from '@/composables/node/useNodePreviewAndDrag'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
import { InjectKeyContextMenuNode } from '@/types/treeExplorerTypes'
|
||||
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
@@ -107,6 +114,9 @@ defineOptions({
|
||||
const ROW_CLASS =
|
||||
'group/tree-node flex w-full min-w-0 cursor-pointer select-none items-center gap-3 overflow-hidden py-2 outline-none hover:bg-comfy-input rounded'
|
||||
|
||||
const ACTION_BTN_CLASS =
|
||||
'flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-sm border-none bg-transparent opacity-0 group-hover/tree-node:opacity-100 hover:text-foreground'
|
||||
|
||||
const { item } = defineProps<{
|
||||
item: FlattenedItem<RenderedTreeExplorerNode<ComfyNodeDefImpl>>
|
||||
}>()
|
||||
@@ -120,6 +130,7 @@ const emit = defineEmits<{
|
||||
|
||||
const contextMenuNode = inject(InjectKeyContextMenuNode)
|
||||
const nodeBookmarkStore = useNodeBookmarkStore()
|
||||
const subgraphStore = useSubgraphStore()
|
||||
|
||||
const nodeDef = computed(() => item.value.data)
|
||||
|
||||
@@ -128,12 +139,22 @@ const isBookmarked = computed(() => {
|
||||
return nodeBookmarkStore.isBookmarked(nodeDef.value)
|
||||
})
|
||||
|
||||
const isUserBlueprint = computed(() =>
|
||||
subgraphStore.isUserBlueprint(nodeDef.value?.name)
|
||||
)
|
||||
|
||||
function toggleBookmark() {
|
||||
if (nodeDef.value) {
|
||||
nodeBookmarkStore.toggleBookmark(nodeDef.value)
|
||||
}
|
||||
}
|
||||
|
||||
function deleteBlueprint() {
|
||||
if (nodeDef.value) {
|
||||
void subgraphStore.deleteBlueprint(nodeDef.value.name)
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
previewRef,
|
||||
showPreview,
|
||||
@@ -166,6 +187,12 @@ function handleContextMenu() {
|
||||
}
|
||||
}
|
||||
|
||||
function clearContextMenuNode() {
|
||||
if (contextMenuNode) {
|
||||
contextMenuNode.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseEnter(e: MouseEvent) {
|
||||
if (item.value.type !== 'node') return
|
||||
baseHandleMouseEnter(e)
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
:root="favoritesRoot"
|
||||
show-context-menu
|
||||
@node-click="(node) => emit('nodeClick', node)"
|
||||
@add-to-favorites="handleAddToFavorites"
|
||||
/>
|
||||
<div v-else class="px-6 py-2 text-xs text-muted-background">
|
||||
{{ $t('sideToolbar.nodeLibraryTab.noBookmarkedNodes') }}
|
||||
@@ -31,7 +30,6 @@
|
||||
:root="section.root"
|
||||
show-context-menu
|
||||
@node-click="(node) => emit('nodeClick', node)"
|
||||
@add-to-favorites="handleAddToFavorites"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -71,12 +69,4 @@ const hasFavorites = computed(
|
||||
const favoritesRoot = computed(() =>
|
||||
fillNodeInfo(nodeBookmarkStore.bookmarkedRoot)
|
||||
)
|
||||
|
||||
function handleAddToFavorites(
|
||||
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>
|
||||
) {
|
||||
if (node.data) {
|
||||
nodeBookmarkStore.toggleBookmark(node.data)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -187,6 +187,35 @@ describe('useSubgraphStore', () => {
|
||||
expect(store.isGlobalBlueprint('nonexistent')).toBe(false)
|
||||
})
|
||||
|
||||
describe('isUserBlueprint', () => {
|
||||
it('should return true for user blueprints', async () => {
|
||||
await mockFetch({ 'test.json': mockGraph })
|
||||
expect(store.isUserBlueprint('SubgraphBlueprint.test')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for global blueprints', async () => {
|
||||
await mockFetch(
|
||||
{},
|
||||
{
|
||||
global_bp: {
|
||||
name: 'Global Blueprint',
|
||||
info: { node_pack: 'comfy_essentials' },
|
||||
data: JSON.stringify(mockGraph)
|
||||
}
|
||||
}
|
||||
)
|
||||
expect(store.isUserBlueprint('SubgraphBlueprint.global_bp')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for non-blueprint node types', () => {
|
||||
expect(store.isUserBlueprint('KSampler')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for undefined', () => {
|
||||
expect(store.isUserBlueprint(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('blueprint badge display', () => {
|
||||
it('should set isGlobal flag on global blueprints', async () => {
|
||||
await mockFetch(
|
||||
|
||||
@@ -432,6 +432,12 @@ export const useSubgraphStore = defineStore('subgraph', () => {
|
||||
return nodeDef !== undefined && nodeDef.isGlobal === true
|
||||
}
|
||||
|
||||
function isUserBlueprint(nodeType?: string): boolean {
|
||||
if (!nodeType?.startsWith(typePrefix)) return false
|
||||
const name = nodeType.slice(typePrefix.length)
|
||||
return name in subgraphCache && !isGlobalBlueprint(name)
|
||||
}
|
||||
|
||||
return {
|
||||
deleteBlueprint,
|
||||
editBlueprint,
|
||||
@@ -439,6 +445,7 @@ export const useSubgraphStore = defineStore('subgraph', () => {
|
||||
getBlueprint,
|
||||
isGlobalBlueprint,
|
||||
isSubgraphBlueprint,
|
||||
isUserBlueprint,
|
||||
publishSubgraph,
|
||||
subgraphBlueprints,
|
||||
typePrefix
|
||||
|
||||
@@ -73,7 +73,7 @@ const PROVIDER_COLORS: Record<string, string | [string, string]> = {
|
||||
'moonvalley-marey': '#DAD9C5',
|
||||
openai: '#B6B6B6',
|
||||
pixverse: ['#B465E6', '#E8632A'],
|
||||
'quiver-ai': '#B6B6B6',
|
||||
quiver: '#B6B6B6',
|
||||
recraft: '#B6B6B6',
|
||||
reve: '#B6B6B6',
|
||||
rodin: '#F7F7F7',
|
||||
|
||||
Reference in New Issue
Block a user