From 3240e7802f33c0a5ca3b04a0b24e52852114256d Mon Sep 17 00:00:00 2001 From: Chenlei Hu <hcl@comfy.org> Date: Mon, 17 Mar 2025 12:48:06 -0400 Subject: [PATCH 1/4] Add support for create folder in TreeExplorer --- src/components/common/TreeExplorer.vue | 20 ++++- .../tree/useTreeFolderOperations.ts | 76 +++++++++++++++++++ src/types/treeExplorerTypes.ts | 6 +- src/utils/treeUtil.ts | 43 ++++++++++- 4 files changed, 138 insertions(+), 7 deletions(-) create mode 100644 src/composables/tree/useTreeFolderOperations.ts diff --git a/src/components/common/TreeExplorer.vue b/src/components/common/TreeExplorer.vue index 1b85843c9..1581f6ee4 100644 --- a/src/components/common/TreeExplorer.vue +++ b/src/components/common/TreeExplorer.vue @@ -43,12 +43,15 @@ import { computed, provide, ref } from 'vue' import { useI18n } from 'vue-i18n' import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue' +import { useTreeFolderOperations } from '@/composables/tree/useTreeFolderOperations' import { useErrorHandling } from '@/composables/useErrorHandling' +import { useTreeExpansion } from '@/composables/useTreeExpansion' import { InjectKeyHandleEditLabelFunction, type RenderedTreeExplorerNode, type TreeExplorerNode } from '@/types/treeExplorerTypes' +import { combineTrees } from '@/utils/treeUtil' const expandedKeys = defineModel<Record<string, boolean>>('expandedKeys') const selectionKeys = defineModel<Record<string, boolean>>('selectionKeys') @@ -64,8 +67,16 @@ const emit = defineEmits<{ (e: 'nodeDelete', node: RenderedTreeExplorerNode): void (e: 'contextMenu', node: RenderedTreeExplorerNode, event: MouseEvent): void }>() + +const { expandNode } = useTreeExpansion(expandedKeys) +const { newFolderNode, getAddFolderMenuItem, handleFolderCreation } = + useTreeFolderOperations(expandNode) + const renderedRoot = computed<RenderedTreeExplorerNode>(() => { - return fillNodeInfo(props.root) + const renderedRoot = fillNodeInfo(props.root) + return newFolderNode.value + ? combineTrees(renderedRoot, newFolderNode.value) + : renderedRoot }) const getTreeNodeIcon = (node: TreeExplorerNode) => { if (node.getIcon) { @@ -127,7 +138,11 @@ const handleNodeLabelEdit = async ( ) => { await errorHandling.wrapWithErrorHandlingAsync( async () => { - await node.handleRename(newName) + if (node.key === newFolderNode.value?.key) { + await handleFolderCreation(newName) + } else { + await node.handleRename(newName) + } }, node.handleError, () => { @@ -147,6 +162,7 @@ const deleteCommand = async (node: RenderedTreeExplorerNode) => { } const menuItems = computed<MenuItem[]>(() => [ + getAddFolderMenuItem(menuTargetNode.value), { label: t('g.rename'), icon: 'pi pi-file-edit', diff --git a/src/composables/tree/useTreeFolderOperations.ts b/src/composables/tree/useTreeFolderOperations.ts new file mode 100644 index 000000000..5f0a127b9 --- /dev/null +++ b/src/composables/tree/useTreeFolderOperations.ts @@ -0,0 +1,76 @@ +import { ref } from 'vue' +import { useI18n } from 'vue-i18n' + +import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes' + +/** + * Use this to handle folder operations in a tree. + * @param expandNode - The function to expand a node. + */ +export function useTreeFolderOperations( + expandNode: (node: RenderedTreeExplorerNode) => void +) { + const { t } = useI18n() + const newFolderNode = ref<RenderedTreeExplorerNode | null>(null) + const addFolderTargetNode = ref<RenderedTreeExplorerNode | null>(null) + + // Generate a unique temporary key for the new folder + const generateTempKey = (parentKey: string) => { + return `${parentKey}new_folder_${Date.now()}` + } + + // Handle folder creation after name is confirmed + const handleFolderCreation = async (newName: string) => { + if (!newFolderNode.value || !addFolderTargetNode.value) return + + try { + // Call the handleAddFolder method with the new folder name + await addFolderTargetNode.value?.handleAddFolder?.(newName) + } finally { + newFolderNode.value = null + addFolderTargetNode.value = null + } + } + + /** + * The command to add a folder to a node via the context menu + * @param targetNode - The node where the folder will be added under + */ + const addFolderCommand = (targetNode: RenderedTreeExplorerNode) => { + expandNode(targetNode) + newFolderNode.value = { + key: generateTempKey(targetNode.key), + label: '', + leaf: false, + children: [], + icon: 'pi pi-folder', + type: 'folder', + totalLeaves: 0, + badgeText: '', + isEditingLabel: true + } + addFolderTargetNode.value = targetNode + } + + // Generate the "Add Folder" menu item + const getAddFolderMenuItem = ( + targetNode: RenderedTreeExplorerNode | null + ) => { + return { + label: t('g.addFolder'), + icon: 'pi pi-folder-plus', + command: () => { + if (targetNode) addFolderCommand(targetNode) + }, + visible: targetNode && !targetNode.leaf && !!targetNode.handleAddFolder, + isAsync: false + } + } + + return { + newFolderNode, + addFolderCommand, + getAddFolderMenuItem, + handleFolderCreation + } +} diff --git a/src/types/treeExplorerTypes.ts b/src/types/treeExplorerTypes.ts index cbbe2acdc..c3d63af48 100644 --- a/src/types/treeExplorerTypes.ts +++ b/src/types/treeExplorerTypes.ts @@ -19,10 +19,10 @@ export interface TreeExplorerNode<T = any> { ) => void | Promise<void> /** Function to handle deleting the node */ handleDelete?: (this: TreeExplorerNode<T>) => void | Promise<void> - /** Function to handle adding a child node */ - handleAddChild?: ( + /** Function to handle adding a folder */ + handleAddFolder?: ( this: TreeExplorerNode<T>, - child: TreeExplorerNode<T> + folderName: string ) => void | Promise<void> /** Whether the node is draggable */ draggable?: boolean diff --git a/src/utils/treeUtil.ts b/src/utils/treeUtil.ts index a8763be53..6190cd24a 100644 --- a/src/utils/treeUtil.ts +++ b/src/utils/treeUtil.ts @@ -105,7 +105,10 @@ export function sortedTree( return newNode } -export const findNodeByKey = (root: TreeNode, key: string): TreeNode | null => { +export const findNodeByKey = <T extends TreeNode>( + root: T, + key: string +): T | null => { if (root.key === key) { return root } @@ -113,10 +116,46 @@ export const findNodeByKey = (root: TreeNode, key: string): TreeNode | null => { return null } for (const child of root.children) { - const result = findNodeByKey(child, key) + const result = findNodeByKey(child as T, key) if (result) { return result } } return null } + +/** + * Deep clone a tree node and its children. + * @param node - The node to clone. + * @returns A deep clone of the node. + */ +export function cloneTree<T extends TreeNode>(node: T): T { + const clone: T = { ...node } as T + + // Clone children recursively + if (node.children && node.children.length > 0) { + clone.children = node.children.map((child) => cloneTree(child as T)) + } + + return clone +} + +/** + * Merge a subtree into the tree. + * @param root - The root of the tree. + * @param subtree - The subtree to merge. + * @returns A new tree with the subtree merged. + */ +export const combineTrees = <T extends TreeNode>(root: T, subtree: T): T => { + const newRoot = cloneTree(root) + + const parentKey = subtree.key.slice(0, subtree.key.lastIndexOf('/')) + const parent = findNodeByKey(newRoot, parentKey) + + if (parent) { + parent.children ??= [] + parent.children.push(cloneTree(subtree)) + } + + return newRoot +} From 27da88fd85d508cd7eeac25a6dbe70e0b7fc50b7 Mon Sep 17 00:00:00 2001 From: Chenlei Hu <hcl@comfy.org> Date: Mon, 17 Mar 2025 13:17:28 -0400 Subject: [PATCH 2/4] Migrate node library bookmark tree to use new folder feature --- src/components/common/TreeExplorer.vue | 19 ++++++++--- .../nodeLibrary/NodeBookmarkTreeExplorer.vue | 33 ++++--------------- .../tabs/nodeLibrary/NodeTreeFolder.vue | 7 ++-- .../tree/useTreeFolderOperations.ts | 4 +-- src/stores/nodeBookmarkStore.ts | 12 +++---- 5 files changed, 33 insertions(+), 42 deletions(-) diff --git a/src/components/common/TreeExplorer.vue b/src/components/common/TreeExplorer.vue index 1581f6ee4..d9c074325 100644 --- a/src/components/common/TreeExplorer.vue +++ b/src/components/common/TreeExplorer.vue @@ -51,7 +51,7 @@ import { type RenderedTreeExplorerNode, type TreeExplorerNode } from '@/types/treeExplorerTypes' -import { combineTrees } from '@/utils/treeUtil' +import { combineTrees, findNodeByKey } from '@/utils/treeUtil' const expandedKeys = defineModel<Record<string, boolean>>('expandedKeys') const selectionKeys = defineModel<Record<string, boolean>>('selectionKeys') @@ -69,8 +69,12 @@ const emit = defineEmits<{ }>() const { expandNode } = useTreeExpansion(expandedKeys) -const { newFolderNode, getAddFolderMenuItem, handleFolderCreation } = - useTreeFolderOperations(expandNode) +const { + newFolderNode, + getAddFolderMenuItem, + handleFolderCreation, + addFolderCommand +} = useTreeFolderOperations(expandNode) const renderedRoot = computed<RenderedTreeExplorerNode>(() => { const renderedRoot = fillNodeInfo(props.root) @@ -210,7 +214,14 @@ const wrapCommandWithErrorHandler = ( defineExpose({ renameCommand, - deleteCommand + deleteCommand, + /** + * The command to add a folder to a node via the context menu + * @param targetNodeKey - The key of the node where the folder will be added under + */ + addFolderCommand: (targetNodeKey: string) => { + addFolderCommand(findNodeByKey(renderedRoot.value, targetNodeKey)) + } }) </script> diff --git a/src/components/sidebar/tabs/nodeLibrary/NodeBookmarkTreeExplorer.vue b/src/components/sidebar/tabs/nodeLibrary/NodeBookmarkTreeExplorer.vue index e986bd810..3ef5c50eb 100644 --- a/src/components/sidebar/tabs/nodeLibrary/NodeBookmarkTreeExplorer.vue +++ b/src/components/sidebar/tabs/nodeLibrary/NodeBookmarkTreeExplorer.vue @@ -40,7 +40,6 @@ import type { TreeExplorerDragAndDropData, TreeExplorerNode } from '@/types/treeExplorerTypes' -import { findNodeByKey } from '@/utils/treeUtil' const props = defineProps<{ filteredNodeDefs: ComfyNodeDefImpl[] @@ -94,14 +93,6 @@ const { t } = useI18n() const extraMenuItems = ( menuTargetNode: RenderedTreeExplorerNode<ComfyNodeDefImpl> ) => [ - { - label: t('g.newFolder'), - icon: 'pi pi-folder-plus', - command: () => { - addNewBookmarkFolder(menuTargetNode) - }, - visible: !menuTargetNode?.leaf - }, { label: t('g.customize'), icon: 'pi pi-palette', @@ -152,6 +143,11 @@ const renderedBookmarkedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>( }, children: sortedChildren, draggable: node.leaf, + handleAddFolder(newName: string) { + if (newName !== '') { + nodeBookmarkStore.addNewBookmarkFolder(this.data, newName) + } + }, renderDragPreview(container) { const vnode = h(NodePreview, { nodeDef: node.data }) render(vnode, container) @@ -197,25 +193,8 @@ const renderedBookmarkedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>( ) const treeExplorerRef = ref<InstanceType<typeof TreeExplorer> | null>(null) -const addNewBookmarkFolder = ( - parent?: RenderedTreeExplorerNode<ComfyNodeDefImpl> -) => { - const newFolderKey = - 'root/' + nodeBookmarkStore.addNewBookmarkFolder(parent?.data).slice(0, -1) - nextTick(() => { - treeExplorerRef.value?.renameCommand( - findNodeByKey( - renderedBookmarkedRoot.value, - newFolderKey - ) as RenderedTreeExplorerNode - ) - if (parent) { - expandedKeys.value[parent.key] = true - } - }) -} defineExpose({ - addNewBookmarkFolder + addNewBookmarkFolder: () => treeExplorerRef.value?.addFolderCommand('root') }) const showCustomizationDialog = ref(false) diff --git a/src/components/sidebar/tabs/nodeLibrary/NodeTreeFolder.vue b/src/components/sidebar/tabs/nodeLibrary/NodeTreeFolder.vue index 74b43f1d4..9370a1f44 100644 --- a/src/components/sidebar/tabs/nodeLibrary/NodeTreeFolder.vue +++ b/src/components/sidebar/tabs/nodeLibrary/NodeTreeFolder.vue @@ -16,13 +16,16 @@ import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore' import { ComfyNodeDefImpl } from '@/stores/nodeDefStore' import { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes' -const props = defineProps<{ +const { node } = defineProps<{ node: RenderedTreeExplorerNode<ComfyNodeDefImpl> }>() const nodeBookmarkStore = useNodeBookmarkStore() const customization = computed<BookmarkCustomization | undefined>(() => { - return nodeBookmarkStore.bookmarksCustomization[props.node.data.nodePath] + const nodeDef = node.data + return nodeDef + ? nodeBookmarkStore.bookmarksCustomization[nodeDef.nodePath] + : undefined }) const treeNodeElement = ref<HTMLElement | null>(null) diff --git a/src/composables/tree/useTreeFolderOperations.ts b/src/composables/tree/useTreeFolderOperations.ts index 5f0a127b9..8e8bf29c4 100644 --- a/src/composables/tree/useTreeFolderOperations.ts +++ b/src/composables/tree/useTreeFolderOperations.ts @@ -16,7 +16,7 @@ export function useTreeFolderOperations( // Generate a unique temporary key for the new folder const generateTempKey = (parentKey: string) => { - return `${parentKey}new_folder_${Date.now()}` + return `${parentKey}/new_folder_${Date.now()}` } // Handle folder creation after name is confirmed @@ -57,7 +57,7 @@ export function useTreeFolderOperations( targetNode: RenderedTreeExplorerNode | null ) => { return { - label: t('g.addFolder'), + label: t('g.newFolder'), icon: 'pi pi-folder-plus', command: () => { if (targetNode) addFolderCommand(targetNode) diff --git a/src/stores/nodeBookmarkStore.ts b/src/stores/nodeBookmarkStore.ts index 14a7469b9..b54341c9a 100644 --- a/src/stores/nodeBookmarkStore.ts +++ b/src/stores/nodeBookmarkStore.ts @@ -73,14 +73,12 @@ export const useNodeBookmarkStore = defineStore('nodeBookmark', () => { ) } - const addNewBookmarkFolder = (parent?: ComfyNodeDefImpl) => { + const addNewBookmarkFolder = ( + parent: ComfyNodeDefImpl | undefined, + folderName: string + ) => { const parentPath = parent ? parent.nodePath : '' - let newFolderPath = parentPath + 'New Folder/' - let suffix = 1 - while (bookmarks.value.some((b: string) => b.startsWith(newFolderPath))) { - newFolderPath = parentPath + `New Folder ${suffix}/` - suffix++ - } + const newFolderPath = parentPath + folderName + '/' addBookmark(newFolderPath) return newFolderPath } From 07622a4111f81dab6ca040f695e04b3f27d71f5a Mon Sep 17 00:00:00 2001 From: Chenlei Hu <hcl@comfy.org> Date: Mon, 17 Mar 2025 13:53:14 -0400 Subject: [PATCH 3/4] Fix playwright test --- browser_tests/menu.spec.ts | 11 ++++++++--- src/components/common/TreeExplorer.vue | 2 ++ .../tabs/nodeLibrary/NodeTreeFolder.vue | 9 ++++++--- src/composables/useTreeExpansion.ts | 18 ++++-------------- src/types/treeExplorerTypes.ts | 5 ++++- 5 files changed, 24 insertions(+), 21 deletions(-) diff --git a/browser_tests/menu.spec.ts b/browser_tests/menu.spec.ts index 4af56ede6..72197aa38 100644 --- a/browser_tests/menu.spec.ts +++ b/browser_tests/menu.spec.ts @@ -119,7 +119,10 @@ test.describe('Menu', () => { test('Can add new bookmark folder', async ({ comfyPage }) => { const tab = comfyPage.menu.nodeLibraryTab await tab.newFolderButton.click() - await comfyPage.page.keyboard.press('Enter') + const textInput = comfyPage.page.locator('.editable-text input') + await textInput.waitFor({ state: 'visible' }) + await textInput.fill('New Folder') + await textInput.press('Enter') expect(await tab.getFolder('New Folder').count()).toBe(1) expect( await comfyPage.getSetting('Comfy.NodeLibrary.Bookmarks.V2') @@ -132,8 +135,10 @@ test.describe('Menu', () => { await tab.getFolder('foo').click({ button: 'right' }) await comfyPage.page.getByLabel('New Folder').click() - await comfyPage.page.keyboard.type('bar') - await comfyPage.page.keyboard.press('Enter') + const textInput = comfyPage.page.locator('.editable-text input') + await textInput.waitFor({ state: 'visible' }) + await textInput.fill('bar') + await textInput.press('Enter') expect(await tab.getFolder('bar').count()).toBe(1) expect( diff --git a/src/components/common/TreeExplorer.vue b/src/components/common/TreeExplorer.vue index d9c074325..372e2a411 100644 --- a/src/components/common/TreeExplorer.vue +++ b/src/components/common/TreeExplorer.vue @@ -47,6 +47,7 @@ import { useTreeFolderOperations } from '@/composables/tree/useTreeFolderOperati import { useErrorHandling } from '@/composables/useErrorHandling' import { useTreeExpansion } from '@/composables/useTreeExpansion' import { + InjectKeyExpandedKeys, InjectKeyHandleEditLabelFunction, type RenderedTreeExplorerNode, type TreeExplorerNode @@ -54,6 +55,7 @@ import { import { combineTrees, findNodeByKey } from '@/utils/treeUtil' const expandedKeys = defineModel<Record<string, boolean>>('expandedKeys') +provide(InjectKeyExpandedKeys, expandedKeys) const selectionKeys = defineModel<Record<string, boolean>>('selectionKeys') // Tracks whether the caller has set the selectionKeys model. const storeSelectionKeys = selectionKeys.value !== undefined diff --git a/src/components/sidebar/tabs/nodeLibrary/NodeTreeFolder.vue b/src/components/sidebar/tabs/nodeLibrary/NodeTreeFolder.vue index 9370a1f44..74e28963f 100644 --- a/src/components/sidebar/tabs/nodeLibrary/NodeTreeFolder.vue +++ b/src/components/sidebar/tabs/nodeLibrary/NodeTreeFolder.vue @@ -8,13 +8,16 @@ </template> <script setup lang="ts"> -import { Ref, computed, inject, onMounted, onUnmounted, ref, watch } from 'vue' +import { computed, inject, onMounted, onUnmounted, ref, watch } from 'vue' import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue' import type { BookmarkCustomization } from '@/schemas/apiSchema' import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore' import { ComfyNodeDefImpl } from '@/stores/nodeDefStore' -import { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes' +import { + InjectKeyExpandedKeys, + type RenderedTreeExplorerNode +} from '@/types/treeExplorerTypes' const { node } = defineProps<{ node: RenderedTreeExplorerNode<ComfyNodeDefImpl> @@ -59,7 +62,7 @@ onUnmounted(() => { } }) -const expandedKeys = inject<Ref<Record<string, boolean>>>('expandedKeys') +const expandedKeys = inject(InjectKeyExpandedKeys) const handleItemDrop = (node: RenderedTreeExplorerNode) => { expandedKeys.value[node.key] = true } diff --git a/src/composables/useTreeExpansion.ts b/src/composables/useTreeExpansion.ts index 580b3bb00..9ede6654c 100644 --- a/src/composables/useTreeExpansion.ts +++ b/src/composables/useTreeExpansion.ts @@ -23,30 +23,20 @@ export function useTreeExpansion(expandedKeys: Ref<Record<string, boolean>>) { } const expandNode = (node: TreeNode) => { - if ( - node.key && - typeof node.key === 'string' && - node.children && - node.children.length - ) { + if (node.key && typeof node.key === 'string' && !node.leaf) { expandedKeys.value[node.key] = true - for (const child of node.children) { + for (const child of node.children ?? []) { expandNode(child) } } } const collapseNode = (node: TreeNode) => { - if ( - node.key && - typeof node.key === 'string' && - node.children && - node.children.length - ) { + if (node.key && typeof node.key === 'string' && !node.leaf) { delete expandedKeys.value[node.key] - for (const child of node.children) { + for (const child of node.children ?? []) { collapseNode(child) } } diff --git a/src/types/treeExplorerTypes.ts b/src/types/treeExplorerTypes.ts index c3d63af48..360f8e2d7 100644 --- a/src/types/treeExplorerTypes.ts +++ b/src/types/treeExplorerTypes.ts @@ -1,5 +1,5 @@ import type { MenuItem } from 'primevue/menuitem' -import type { InjectionKey } from 'vue' +import type { InjectionKey, Ref } from 'vue' export interface TreeExplorerNode<T = any> { key: string @@ -71,3 +71,6 @@ export type TreeExplorerDragAndDropData<T = any> = { export const InjectKeyHandleEditLabelFunction: InjectionKey< (node: RenderedTreeExplorerNode, newName: string) => void > = Symbol() + +export const InjectKeyExpandedKeys: InjectionKey<Ref<Record<string, boolean>>> = + Symbol() From b02016411fbfe83d59e5e6572e5864fc259b5d2d Mon Sep 17 00:00:00 2001 From: Chenlei Hu <hcl@comfy.org> Date: Mon, 17 Mar 2025 13:54:58 -0400 Subject: [PATCH 4/4] nit --- src/components/common/TreeExplorer.vue | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/common/TreeExplorer.vue b/src/components/common/TreeExplorer.vue index 372e2a411..8e34a05c6 100644 --- a/src/components/common/TreeExplorer.vue +++ b/src/components/common/TreeExplorer.vue @@ -45,7 +45,6 @@ import { useI18n } from 'vue-i18n' import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue' import { useTreeFolderOperations } from '@/composables/tree/useTreeFolderOperations' import { useErrorHandling } from '@/composables/useErrorHandling' -import { useTreeExpansion } from '@/composables/useTreeExpansion' import { InjectKeyExpandedKeys, InjectKeyHandleEditLabelFunction, @@ -70,13 +69,16 @@ const emit = defineEmits<{ (e: 'contextMenu', node: RenderedTreeExplorerNode, event: MouseEvent): void }>() -const { expandNode } = useTreeExpansion(expandedKeys) const { newFolderNode, getAddFolderMenuItem, handleFolderCreation, addFolderCommand -} = useTreeFolderOperations(expandNode) +} = useTreeFolderOperations( + /* expandNode */ (node: TreeExplorerNode) => { + expandedKeys.value[node.key] = true + } +) const renderedRoot = computed<RenderedTreeExplorerNode>(() => { const renderedRoot = fillNodeInfo(props.root)