|
| 1 | +<script setup lang="ts"> |
| 2 | +import type { DropPosition, TreeNode } from './types' |
| 3 | +import { treeInjectionKey } from './keys' |
| 4 | +import TreeNodeComponent from './TreeNode.vue' |
| 5 | +
|
| 6 | +interface Props { |
| 7 | + modelValue: TreeNode[] |
| 8 | + selectedIds?: (string | number)[] |
| 9 | + editableId?: string | number | null |
| 10 | + focusedId?: string | number | undefined |
| 11 | + highlightedIds?: Set<string | number> |
| 12 | + indent?: number |
| 13 | +} |
| 14 | +
|
| 15 | +interface Emits { |
| 16 | + (e: 'update:modelValue', value: TreeNode[]): void |
| 17 | + (e: 'update:selectedIds', value: (string | number)[]): void |
| 18 | + (e: 'update:editableId', value: string | number | null): void |
| 19 | + (e: 'update:focusedId', value: string | number | undefined): void |
| 20 | + (e: 'update:highlightedIds', value: Set<string | number>): void |
| 21 | + (e: 'clickNode', value: { node: TreeNode, event?: MouseEvent }): void |
| 22 | + (e: 'dblclickNode', value: TreeNode): void |
| 23 | + (e: 'toggleNode', value: TreeNode): void |
| 24 | + ( |
| 25 | + e: 'dragNode', |
| 26 | + value: { nodes: TreeNode[], target: TreeNode, position: DropPosition }, |
| 27 | + ): void |
| 28 | + ( |
| 29 | + e: 'externalDrop', |
| 30 | + value: { data: DataTransfer, target: TreeNode, position: DropPosition }, |
| 31 | + ): void |
| 32 | + (e: 'updateLabel', value: { node: TreeNode, value: string }): void |
| 33 | + (e: 'cancelEdit', value: TreeNode): void |
| 34 | + ( |
| 35 | + e: 'contextMenu', |
| 36 | + value: { node: TreeNode, selectedNodes: TreeNode[] }, |
| 37 | + ): void |
| 38 | +} |
| 39 | +
|
| 40 | +const props = withDefaults(defineProps<Props>(), { |
| 41 | + selectedIds: () => [], |
| 42 | + editableId: null, |
| 43 | + focusedId: undefined, |
| 44 | + highlightedIds: () => new Set(), |
| 45 | + indent: 10, |
| 46 | +}) |
| 47 | +
|
| 48 | +const emit = defineEmits<Emits>() |
| 49 | +
|
| 50 | +const hoveredNodeId = ref('') |
| 51 | +const isHoveredByIdDisabled = ref(false) |
| 52 | +
|
| 53 | +const internalEditableId = computed({ |
| 54 | + get: () => props.editableId, |
| 55 | + set: val => emit('update:editableId', val), |
| 56 | +}) |
| 57 | +
|
| 58 | +const internalSelectedIds = computed({ |
| 59 | + get: () => props.selectedIds, |
| 60 | + set: val => emit('update:selectedIds', val), |
| 61 | +}) |
| 62 | +
|
| 63 | +const internalFocusedId = computed({ |
| 64 | + get: () => props.focusedId, |
| 65 | + set: val => emit('update:focusedId', val), |
| 66 | +}) |
| 67 | +
|
| 68 | +const internalHighlightedIds = computed({ |
| 69 | + get: () => props.highlightedIds, |
| 70 | + set: val => emit('update:highlightedIds', val), |
| 71 | +}) |
| 72 | +
|
| 73 | +function findNodeById(id: string | number): TreeNode | undefined { |
| 74 | + const walk = (nodes: TreeNode[]): TreeNode | undefined => { |
| 75 | + for (const node of nodes) { |
| 76 | + if (node.id === id) |
| 77 | + return node |
| 78 | + if (node.children?.length) { |
| 79 | + const found = walk(node.children) |
| 80 | + if (found) |
| 81 | + return found |
| 82 | + } |
| 83 | + } |
| 84 | + } |
| 85 | + return walk(props.modelValue) |
| 86 | +} |
| 87 | +
|
| 88 | +function clickNode(id: string | number, event?: MouseEvent) { |
| 89 | + const node = findNodeById(id) |
| 90 | + if (!node) |
| 91 | + return |
| 92 | +
|
| 93 | + emit('clickNode', { node, event }) |
| 94 | +} |
| 95 | +
|
| 96 | +function dblclickNode(node: TreeNode) { |
| 97 | + emit('dblclickNode', node) |
| 98 | +} |
| 99 | +
|
| 100 | +function dragNodeHandler( |
| 101 | + nodes: TreeNode[], |
| 102 | + target: TreeNode, |
| 103 | + position: DropPosition, |
| 104 | +) { |
| 105 | + emit('dragNode', { nodes, target, position }) |
| 106 | +} |
| 107 | +
|
| 108 | +function externalDropHandler( |
| 109 | + data: DataTransfer, |
| 110 | + target: TreeNode, |
| 111 | + position: DropPosition, |
| 112 | +) { |
| 113 | + emit('externalDrop', { data, target, position }) |
| 114 | +} |
| 115 | +
|
| 116 | +function toggleNode(node: TreeNode) { |
| 117 | + emit('toggleNode', node) |
| 118 | +} |
| 119 | +
|
| 120 | +function contextMenu(node: TreeNode) { |
| 121 | + const selectedNodes = internalSelectedIds.value |
| 122 | + .map(id => findNodeById(id)) |
| 123 | + .filter((n): n is TreeNode => Boolean(n)) |
| 124 | +
|
| 125 | + emit('contextMenu', { node, selectedNodes }) |
| 126 | +} |
| 127 | +
|
| 128 | +function updateLabelHandler(node: TreeNode, value: string) { |
| 129 | + emit('updateLabel', { node, value }) |
| 130 | +} |
| 131 | +
|
| 132 | +function cancelEditHandler(node: TreeNode) { |
| 133 | + emit('cancelEdit', node) |
| 134 | +} |
| 135 | +
|
| 136 | +provide(treeInjectionKey, { |
| 137 | + clickNode, |
| 138 | + dblclickNode, |
| 139 | + dragNode: dragNodeHandler, |
| 140 | + externalDrop: externalDropHandler, |
| 141 | + toggleNode, |
| 142 | + contextMenu, |
| 143 | + updateLabel: updateLabelHandler, |
| 144 | + cancelEdit: cancelEditHandler, |
| 145 | + isHoveredByIdDisabled, |
| 146 | + editableId: internalEditableId, |
| 147 | + selectedIds: internalSelectedIds, |
| 148 | + focusedId: internalFocusedId, |
| 149 | + highlightedIds: internalHighlightedIds, |
| 150 | +}) |
| 151 | +</script> |
| 152 | + |
| 153 | +<template> |
| 154 | + <div |
| 155 | + v-if="modelValue.length" |
| 156 | + class="h-full min-h-0" |
| 157 | + > |
| 158 | + <div class="scrollbar h-full min-h-0 overflow-x-hidden overflow-y-auto"> |
| 159 | + <div data-tree> |
| 160 | + <TreeNodeComponent |
| 161 | + v-for="(node, index) in modelValue" |
| 162 | + :key="node.id" |
| 163 | + :node="node" |
| 164 | + :nodes="modelValue" |
| 165 | + :index="index" |
| 166 | + :indent="indent" |
| 167 | + :hovered-node-id="hoveredNodeId" |
| 168 | + > |
| 169 | + <template |
| 170 | + v-if="$slots.icon" |
| 171 | + #icon="iconProps" |
| 172 | + > |
| 173 | + <slot |
| 174 | + name="icon" |
| 175 | + v-bind="iconProps" |
| 176 | + /> |
| 177 | + </template> |
| 178 | + </TreeNodeComponent> |
| 179 | + </div> |
| 180 | + </div> |
| 181 | + </div> |
| 182 | +</template> |
0 commit comments