This commit is contained in:
huchenlei
2024-08-31 17:44:44 -04:00
committed by Chenlei Hu
parent 61342edba0
commit bc3ec65967
4 changed files with 361 additions and 0 deletions

View File

@@ -0,0 +1,170 @@
<template>
<Tree
class="tree-explorer"
:class="props.class"
v-model:expandedKeys="expandedKeys"
:value="renderedRoots"
:pt="{
nodeLabel: 'tree-explorer-node-label',
nodeContent: ({ props }) => ({
onClick: (e: MouseEvent) => onNodeContentClick(e, props.node),
onContextmenu: (e: MouseEvent) => handleContextMenu(props.node, e)
}),
nodeToggleButton: () => ({
onClick: (e: MouseEvent) => {
e.stopImmediatePropagation()
}
})
}"
>
<template #folder="{ node }">
<slot
name="folder"
v-bind="{
...nodeSlotProps,
node
}"
>
<TreeFolder :node="node" @itemDropped="handleItemDropped">
<template #folder-label="{ node }">
<EditableText
:modelValue="node.label"
:isEditing="renameEditingNode?.key === node.key"
@edit="(newName: string) => handleRename(node, newName)"
/>
</template>
</TreeFolder>
</slot>
</template>
<template #node="{ node }">
<slot
name="node"
v-bind="{
...nodeSlotProps,
node: node.data
}"
>
<TreeNode :node="node.data" />
</slot>
</template>
</Tree>
<ContextMenu ref="menu" :model="menuItems" />
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import Tree from 'primevue/tree'
import ContextMenu from 'primevue/contextmenu'
import EditableText from '@/components/common/EditableText.vue'
import TreeFolder from '@/components/common/treeExplorer/TreeFolder.vue'
import TreeNode from '@/components/common/treeExplorer/TreeNode.vue'
import type {
RenderedTreeExplorerNode,
TreeExplorerNode,
TreeExplorerNodeSlotProps
} from '@/types/treeExplorerTypes'
import type { MenuItem } from 'primevue/menuitem'
import { useTreeExpansion } from '@/hooks/treeHooks'
const props = defineProps<{
roots: TreeExplorerNode[]
class?: string
extraMenuItems?: MenuItem[]
}>()
const emit = defineEmits<{
(e: 'nodeClick', node: RenderedTreeExplorerNode): void
(e: 'nodeRename', node: RenderedTreeExplorerNode, newName: string): void
(e: 'contextMenu', node: RenderedTreeExplorerNode, event: MouseEvent): void
}>()
const { expandedKeys, toggleNodeOnEvent } = useTreeExpansion()
const renderedRoots = computed<RenderedTreeExplorerNode[]>(() => {
return props.roots.map(fillNodeInfo)
})
const getTreeNodeIcon = (node: TreeExplorerNode) => {
if (typeof node.icon === 'function') {
return node.icon(node)
} else if (typeof node.icon === 'string') {
return node.icon
}
// node.icon is undefined
if (node.leaf) {
return 'pi pi-file'
}
const isExpanded = expandedKeys.value[node.key]
return isExpanded ? 'pi pi-folder-open' : 'pi pi-folder'
}
const fillNodeInfo = (node: TreeExplorerNode): RenderedTreeExplorerNode => {
const children = node.children?.map(fillNodeInfo)
return {
...node,
icon: getTreeNodeIcon(node),
children,
type: node.leaf ? 'node' : 'folder',
totalLeaves: node.leaf
? 1
: children.reduce((acc, child) => acc + child.totalLeaves, 0)
}
}
const handleItemDropped = (node: TreeExplorerNode) => {
expandedKeys.value[node.key] = true
}
const onNodeContentClick = (e: MouseEvent, node: RenderedTreeExplorerNode) => {
if (!node.key) return
if (node.type === 'folder') {
toggleNodeOnEvent(e, node)
}
emit('nodeClick', node)
}
const menu = ref(null)
const menuTargetNode = ref<TreeExplorerNode | null>(null)
const renameEditingNode = ref<TreeExplorerNode | null>(null)
const menuItems = computed<MenuItem[]>(() => [
{
label: 'Rename',
icon: 'pi pi-file-edit',
command: () => {
renameEditingNode.value = menuTargetNode.value
}
},
...(props.extraMenuItems || [])
])
const handleContextMenu = (node: RenderedTreeExplorerNode, e: MouseEvent) => {
menuTargetNode.value = node
emit('contextMenu', node, e)
menu.value?.show(e)
}
const handleRename = (node: RenderedTreeExplorerNode, newName: string) => {
emit('nodeRename', node, newName)
renameEditingNode.value = null
}
const nodeSlotProps: TreeExplorerNodeSlotProps = {
node: {} as TreeExplorerNode, // This will be overwritten by v-bind in the template
handleItemDropped,
renameEditingNode,
handleRename
}
</script>
<style scoped>
.tree-explorer-node-label {
display: flex;
align-items: center;
margin-left: var(--p-tree-node-gap);
flex-grow: 1;
}
/*
* The following styles are necessary to avoid layout shift when dragging nodes over folders.
* By setting the position to relative on the parent and using an absolutely positioned pseudo-element,
* we can create a visual indicator for the drop target without affecting the layout of other elements.
*/
:deep(.p-tree-node-content:has(.tree-folder)) {
position: relative;
}
:deep(.p-tree-node-content:has(.tree-folder.can-drop))::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border: 1px solid var(--p-content-color);
pointer-events: none;
}
</style>

View File

@@ -0,0 +1,94 @@
<template>
<div :class="['tree-folder', { 'can-drop': canDrop }]" ref="container">
<span class="folder-label">
<slot name="folder-label" :node="props.node">
{{ props.node.label }}
</slot>
</span>
<Badge
v-if="props.node.totalLeaves"
:value="props.node.totalLeaves"
severity="secondary"
class="leaf-count-badge"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import Badge from 'primevue/badge'
import type {
TreeExplorerDragAndDropData,
RenderedTreeExplorerNode
} from '@/types/treeExplorerTypes'
import {
dropTargetForElements,
draggable
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
const props = defineProps<{
node: RenderedTreeExplorerNode
}>()
const emit = defineEmits<{
(
e: 'itemDropped',
node: RenderedTreeExplorerNode,
data: RenderedTreeExplorerNode
): void
(e: 'dragStart', node: RenderedTreeExplorerNode): void
(e: 'dragEnd', node: RenderedTreeExplorerNode): void
}>()
const container = ref<HTMLElement | null>(null)
const canDrop = ref(false)
const treeNodeElement = ref<HTMLElement | null>(null)
let dropTargetCleanup = () => {}
let draggableCleanup = () => {}
onMounted(() => {
treeNodeElement.value = container.value?.closest(
'.p-tree-node-content'
) as HTMLElement
dropTargetCleanup = dropTargetForElements({
element: treeNodeElement.value,
onDrop: (event) => {
const dndData = event.source.data as TreeExplorerDragAndDropData
if (dndData.type === 'tree-explorer-node') {
canDrop.value = false
emit('itemDropped', props.node, dndData.data)
}
},
onDragEnter: (event) => {
const dndData = event.source.data as TreeExplorerDragAndDropData
if (dndData.type === 'tree-explorer-node') {
canDrop.value = true
}
},
onDragLeave: () => {
canDrop.value = false
}
})
draggableCleanup = draggable({
element: treeNodeElement.value,
getInitialData() {
return {
type: 'tree-explorer-node',
data: props.node
}
},
onDragStart: () => emit('dragStart', props.node),
onDrop: () => emit('dragEnd', props.node)
})
})
onUnmounted(() => {
dropTargetCleanup()
draggableCleanup()
})
</script>
<style scoped>
.tree-folder {
display: flex;
align-items: center;
}
.leaf-count-badge {
margin-left: 0.5rem;
}
</style>

View File

@@ -0,0 +1,67 @@
<template>
<div class="tree-leaf" ref="container">
<div class="leaf-content">
<span class="leaf-label">
<slot name="label" :node="node">
{{ props.node.label }}
</slot>
</span>
</div>
<slot name="actions" :node="node">
<!-- Default slot content for actions -->
</slot>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
import { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
const props = defineProps<{
node: RenderedTreeExplorerNode
}>()
const emit = defineEmits<{
(e: 'dragStart', node: RenderedTreeExplorerNode): void
(e: 'dragEnd', node: RenderedTreeExplorerNode): void
}>()
const container = ref<HTMLElement | null>(null)
let draggableCleanup: () => void
onMounted(() => {
const treeNodeElement = container.value?.closest(
'.p-tree-node'
) as HTMLElement
draggableCleanup = draggable({
element: treeNodeElement,
getInitialData() {
return {
type: 'tree-explorer-node',
data: props.node
}
},
onDragStart: () => emit('dragStart', props.node),
onDrop: () => emit('dragEnd', props.node)
})
})
onUnmounted(() => {
if (draggableCleanup) {
draggableCleanup()
}
})
</script>
<style scoped>
.tree-leaf {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
}
.leaf-content {
display: flex;
align-items: center;
flex-grow: 1;
}
.leaf-label {
margin-left: 0.5rem;
}
</style>

View File

@@ -0,0 +1,30 @@
import { Ref } from 'vue'
export interface TreeExplorerNode<T = any> {
key: string
label: string
data: T
leaf: boolean
children?: TreeExplorerNode<T>[]
icon?: string | ((node: TreeExplorerNode<T>) => string)
}
export interface RenderedTreeExplorerNode<T = any> extends TreeExplorerNode<T> {
children?: RenderedTreeExplorerNode<T>[]
icon: string
type: 'folder' | 'node'
// Total number of leaves in the subtree
totalLeaves: number
}
export type TreeExplorerDragAndDropData<T = any> = {
type: 'tree-explorer-node'
data: RenderedTreeExplorerNode<T>
}
export interface TreeExplorerNodeSlotProps {
node: RenderedTreeExplorerNode
handleItemDropped: (node: RenderedTreeExplorerNode) => void
renameEditingNode: Ref<RenderedTreeExplorerNode | null>
handleRename: (node: RenderedTreeExplorerNode, newName: string) => void
}