From 7347d9c79f09b7d789838b2b30474f5ece7fc0d6 Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Mon, 16 Mar 2026 09:21:53 +0300 Subject: [PATCH] feat(ui/tree): add universal tree component and migrate folders (#694) Add reusable tree component (src/renderer/components/ui/tree/) with: - Slot-based icon customization - DnD with position detection (before/after/center for containers) - Inline rename with outline, no layout shift - Multi-selection (single/range/toggle via consumer) - Focus/highlight visual states - External drop support - Context menu via event emission Migrate sidebar/folders to use UiTree as thin wrapper, removing old TreeNode.vue, composables, and injection keys (-741 lines). --- .../components/sidebar/folders/Tree.vue | 391 +++++++----- .../components/sidebar/folders/TreeNode.vue | 537 ----------------- .../sidebar/folders/composables/index.ts | 55 -- .../components/sidebar/folders/keys.ts | 13 - src/renderer/components/ui/tree/Tree.vue | 182 ++++++ src/renderer/components/ui/tree/TreeNode.vue | 562 ++++++++++++++++++ .../components/ui/tree/composables.ts | 66 ++ src/renderer/components/ui/tree/index.ts | 3 + src/renderer/components/ui/tree/keys.ts | 28 + src/renderer/components/ui/tree/types.ts | 14 + 10 files changed, 1110 insertions(+), 741 deletions(-) delete mode 100644 src/renderer/components/sidebar/folders/TreeNode.vue delete mode 100644 src/renderer/components/sidebar/folders/composables/index.ts delete mode 100644 src/renderer/components/sidebar/folders/keys.ts create mode 100644 src/renderer/components/ui/tree/Tree.vue create mode 100644 src/renderer/components/ui/tree/TreeNode.vue create mode 100644 src/renderer/components/ui/tree/composables.ts create mode 100644 src/renderer/components/ui/tree/index.ts create mode 100644 src/renderer/components/ui/tree/keys.ts create mode 100644 src/renderer/components/ui/tree/types.ts diff --git a/src/renderer/components/sidebar/folders/Tree.vue b/src/renderer/components/sidebar/folders/Tree.vue index 18a97da8..b90bfeb9 100644 --- a/src/renderer/components/sidebar/folders/Tree.vue +++ b/src/renderer/components/sidebar/folders/Tree.vue @@ -1,20 +1,18 @@ diff --git a/src/renderer/components/sidebar/folders/TreeNode.vue b/src/renderer/components/sidebar/folders/TreeNode.vue deleted file mode 100644 index 220b3210..00000000 --- a/src/renderer/components/sidebar/folders/TreeNode.vue +++ /dev/null @@ -1,537 +0,0 @@ - - - - - diff --git a/src/renderer/components/sidebar/folders/composables/index.ts b/src/renderer/components/sidebar/folders/composables/index.ts deleted file mode 100644 index 91e597b0..00000000 --- a/src/renderer/components/sidebar/folders/composables/index.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { Node, Store } from '../types' -import { computed, reactive } from 'vue' - -export const store = reactive({}) - -const draggedNodes = computed(() => { - if (store.dragNodes?.length) { - return store.dragNodes - } - - return store.dragNode ? [store.dragNode] : [] -}) - -export const dragNodeIds = computed<(string | number)[]>(() => - draggedNodes.value.map(node => node.id), -) - -export const dragNodeChildrenIds = computed(() => { - const ids: (string | number)[] = [] - - const findIds = (nodes: Node[]) => { - nodes.forEach((node) => { - ids.push(node.id) - - if (node.children?.length) { - findIds(node.children) - } - }) - } - - draggedNodes.value.forEach(node => findIds(node.children || [])) - - return ids -}) - -export const isAllowed = computed(() => { - if (!draggedNodes.value.length || !store.dragEnterNode) - return false - - const isSameNode = dragNodeIds.value.includes(store.dragEnterNode.id) - const isChildrenNode = dragNodeChildrenIds.value.includes( - store.dragEnterNode!.id, - ) - - return !isSameNode && !isChildrenNode -}) - -export function useFolders() { - return { - dragNodeIds, - dragNodeChildrenIds, - isAllowed, - store, - } -} diff --git a/src/renderer/components/sidebar/folders/keys.ts b/src/renderer/components/sidebar/folders/keys.ts deleted file mode 100644 index 276168c1..00000000 --- a/src/renderer/components/sidebar/folders/keys.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { InjectionKey, Ref } from 'vue' -import type { Node, Position } from './types' - -export interface TreeInjection { - clickNode: (id: number, event?: MouseEvent) => void - dragNode: (nodes: Node[], target: Node, position: Position) => void - focusHandler?: (isFocused: Ref) => void - isHoveredByIdDisabled: Ref - toggleNode: (node: Node) => void - contextMenu: (node: Node) => void -} - -export const treeKeys: InjectionKey = Symbol('tree') diff --git a/src/renderer/components/ui/tree/Tree.vue b/src/renderer/components/ui/tree/Tree.vue new file mode 100644 index 00000000..4f3022ec --- /dev/null +++ b/src/renderer/components/ui/tree/Tree.vue @@ -0,0 +1,182 @@ + + + diff --git a/src/renderer/components/ui/tree/TreeNode.vue b/src/renderer/components/ui/tree/TreeNode.vue new file mode 100644 index 00000000..8c78ea40 --- /dev/null +++ b/src/renderer/components/ui/tree/TreeNode.vue @@ -0,0 +1,562 @@ + + + + + diff --git a/src/renderer/components/ui/tree/composables.ts b/src/renderer/components/ui/tree/composables.ts new file mode 100644 index 00000000..a480e7db --- /dev/null +++ b/src/renderer/components/ui/tree/composables.ts @@ -0,0 +1,66 @@ +import type { DragStore, TreeNode } from './types' +import { computed, reactive } from 'vue' + +export const TREE_DND_TYPE = 'application/x-tree-node-ids' + +export const dragStore = reactive({}) + +const draggedNodes = computed(() => { + if (dragStore.dragNodes?.length) { + return dragStore.dragNodes + } + + return dragStore.dragNode ? [dragStore.dragNode] : [] +}) + +export const dragNodeIds = computed<(string | number)[]>(() => + draggedNodes.value.map(node => node.id), +) + +export const dragNodeChildrenIds = computed(() => { + const ids: (string | number)[] = [] + + const collectIds = (nodes: TreeNode[]) => { + nodes.forEach((node) => { + ids.push(node.id) + if (node.children?.length) { + collectIds(node.children) + } + }) + } + + draggedNodes.value.forEach(node => collectIds(node.children || [])) + + return ids +}) + +export const isDragAllowed = computed(() => { + if (!draggedNodes.value.length || !dragStore.dragEnterNode) + return false + + const isSameNode = dragNodeIds.value.includes(dragStore.dragEnterNode.id) + const isChildrenNode = dragNodeChildrenIds.value.includes( + dragStore.dragEnterNode.id, + ) + + return !isSameNode && !isChildrenNode +}) + +export function flattenTree(nodes: TreeNode[] | undefined): TreeNode[] { + if (!nodes) + return [] + + const result: TreeNode[] = [] + + const walk = (items: TreeNode[]) => { + items.forEach((node) => { + result.push(node) + if (node.children?.length) { + walk(node.children) + } + }) + } + + walk(nodes) + return result +} diff --git a/src/renderer/components/ui/tree/index.ts b/src/renderer/components/ui/tree/index.ts new file mode 100644 index 00000000..fd02bb75 --- /dev/null +++ b/src/renderer/components/ui/tree/index.ts @@ -0,0 +1,3 @@ +export { default as Tree } from './Tree.vue' +export { default as TreeNode } from './TreeNode.vue' +export type { DropPosition, TreeNode as TreeNodeData } from './types' diff --git a/src/renderer/components/ui/tree/keys.ts b/src/renderer/components/ui/tree/keys.ts new file mode 100644 index 00000000..48140c54 --- /dev/null +++ b/src/renderer/components/ui/tree/keys.ts @@ -0,0 +1,28 @@ +import type { InjectionKey, Ref } from 'vue' +import type { DropPosition, TreeNode } from './types' + +export interface TreeInjection { + clickNode: (id: string | number, event?: MouseEvent) => void + dblclickNode: (node: TreeNode) => void + dragNode: ( + nodes: TreeNode[], + target: TreeNode, + position: DropPosition, + ) => void + externalDrop: ( + data: DataTransfer, + target: TreeNode, + position: DropPosition, + ) => void + toggleNode: (node: TreeNode) => void + contextMenu: (node: TreeNode) => void + updateLabel: (node: TreeNode, value: string) => void + cancelEdit: (node: TreeNode) => void + isHoveredByIdDisabled: Ref + editableId: Ref + selectedIds: Ref<(string | number)[]> + focusedId: Ref + highlightedIds: Ref> +} + +export const treeInjectionKey: InjectionKey = Symbol('ui-tree') diff --git a/src/renderer/components/ui/tree/types.ts b/src/renderer/components/ui/tree/types.ts new file mode 100644 index 00000000..4ef1bbd2 --- /dev/null +++ b/src/renderer/components/ui/tree/types.ts @@ -0,0 +1,14 @@ +export interface TreeNode { + id: string | number + label: string + children?: TreeNode[] + isExpanded?: boolean +} + +export type DropPosition = 'after' | 'before' | 'center' + +export interface DragStore { + dragNode?: TreeNode + dragNodes?: TreeNode[] + dragEnterNode?: TreeNode +}