Skip to content

feat: tree节点拖拽功能 #184

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Feb 5, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 174 additions & 0 deletions packages/devui-vue/devui/tree/src/composables/use-draggable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { reactive, ref, watch } from 'vue'
import type { Ref } from 'vue'
import { TreeItem, IDropType, Nullable } from '../tree-types'
import { cloneDeep } from 'lodash-es'

const ACTIVE_NODE = 'devui-tree-node__content--value-wrapper'
interface DragState {
dropType?: 'prev' | 'next' | 'inner'
draggingNode?: Nullable<HTMLElement>
}

export default function useDraggable(
draggable: boolean,
dropType: IDropType,
node: Ref<Nullable<HTMLElement>>,
renderData: Ref<TreeItem[]>,
data: Ref<TreeItem[]>
): any {
const dragState = reactive<DragState>({
dropType: null,
draggingNode: null,
})
const treeIdMapValue = ref({})
watch(
() => renderData.value,
() => {
treeIdMapValue.value = renderData.value.reduce((acc, cur) => ({ ...acc, [cur.id]: cur }), {})
},
{ deep: true, immediate: true }
)

const removeDraggingStyle = (target: Nullable<HTMLElement>) => {
target
.querySelector(`.${ACTIVE_NODE}`)
?.classList.remove(...['prev', 'next', 'inner'].map((item) => `devui-drop-${item}`))
}

const checkIsParent = (childNodeId: number | string, parentNodeId: number | string) => {
const realParentId = treeIdMapValue.value[childNodeId].parentId
if (realParentId === parentNodeId) {
return true
} else if (realParentId !== undefined) {
return checkIsParent(realParentId, parentNodeId)
} else {
return false
}
}
const handlerDropData = (dragNodeId: string | number, dropNodeId: string | number, dropType?: string) => {
const cloneData = cloneDeep(data.value)
let nowDragNode
let nowDropNode
const findDragAndDropNode = (curr: TreeItem[]) => {
if (!Array.isArray(curr)) return
curr.every((item, index) => {
if (nowDragNode && nowDropNode) {
return false
}
if (item.id === dragNodeId) {
nowDragNode = { target: curr, index, item }
} else if (item.id === dropNodeId) {
nowDropNode = { target: curr, index, item }
}
if (!nowDragNode || !nowDropNode) {
findDragAndDropNode(item.children)
}
return true
})
}
findDragAndDropNode(cloneData)
if (nowDragNode && nowDropNode && dropType) {
const cloneDrapNode = cloneDeep(nowDragNode.target[nowDragNode.index])
if (dropType === 'prev') {
nowDropNode.target.splice(nowDropNode.index, 0, cloneDrapNode)
} else if (dropType === 'next') {
nowDropNode.target.splice(nowDropNode.index + 1, 0, cloneDrapNode)
} else if (dropType === 'inner') {
const children = nowDropNode.target[nowDropNode.index].children
if (Array.isArray(children)) {
children.unshift(cloneDrapNode)
} else {
nowDropNode.target[nowDropNode.index].children = [cloneDrapNode]
}
}
const targetIndex = nowDragNode.target.indexOf(nowDragNode.item)
if (targetIndex !== -1) {
nowDragNode.target.splice(targetIndex, 1)
}

}

return cloneData
}
const onDragstart = (event: DragEvent, treeNode: TreeItem) => {
dragState.draggingNode = <Nullable<HTMLElement>>event.target
const data = {
type: 'tree-node',
nodeId: treeNode.id
}
event.dataTransfer.setData('Text', JSON.stringify(data))
}
const onDragover = (event: DragEvent) => {
if (draggable) {
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
if (!node) {
return
}
const dropPrev = dropType.dropPrev
const dropNext = dropType.dropNext
const dropInner = dropType.dropInner

let innerDropType

const prevPercent = dropPrev ? (dropInner ? 0.25 : dropNext ? 0.45 : 1) : -1
const nextPercent = dropNext ? (dropInner ? 0.75 : dropPrev ? 0.55 : 0) : 1
const currentTarget = <Nullable<HTMLElement>>event.currentTarget
const targetPosition = currentTarget.getBoundingClientRect()
const distance = event.clientY - targetPosition.top

if (distance < targetPosition.height * prevPercent) {
innerDropType = 'prev'
} else if (distance > targetPosition.height * nextPercent) {
innerDropType = 'next'
} else if (dropInner) {
innerDropType = 'inner'
} else {
innerDropType = undefined
}
removeDraggingStyle(currentTarget)
if (innerDropType && innerDropType !== 'none') {
currentTarget.querySelector(`.${ACTIVE_NODE}`)?.classList.add(`devui-drop-${innerDropType}`)
}
dragState.dropType = innerDropType
}
}
const onDragleave = (event: DragEvent) => {
removeDraggingStyle(<Nullable<HTMLElement>>event.currentTarget)
}
const onDrop = (event: DragEvent, dropNode: TreeItem) => {
removeDraggingStyle(<Nullable<HTMLElement>>event.currentTarget)
if (!draggable) {
return
}
event.preventDefault()
const transferDataStr = event.dataTransfer.getData('Text')
if (transferDataStr) {
try {
const transferData = JSON.parse(transferDataStr)
if (typeof transferData === 'object' && transferData.type === 'tree-node') {
const dragNodeId = transferData.nodeId
const isParent = checkIsParent(dropNode.id, dragNodeId)
if (dragNodeId === dropNode.id || isParent) {
return
}
let result
if (dragState.dropType) {
result = handlerDropData(dragNodeId, dropNode.id, dragState.dropType)
}
data.value = result
}
} catch (e) {
console.error(e)
}
}
}

return {
onDragstart,
onDragover,
onDragleave,
onDrop,
dragState
}
}
29 changes: 15 additions & 14 deletions packages/devui-vue/devui/tree/src/composables/use-merge-node.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
import { ref } from 'vue'
import { Ref, ref, watch } from 'vue'
import { TreeItem } from '../tree-types'

export default function useMergeNode(data: Array<any>): any {


const mergeObject = (
treeItem,
childName = 'children',
labelName = 'label'
) => {
export default function useMergeNode(data: Ref<TreeItem[]>): any {
const mergeObject = (treeItem, childName = 'children', labelName = 'label') => {
const { [childName]: children, [labelName]: label } = treeItem
if (
Array.isArray(children) &&
Expand All @@ -17,7 +12,7 @@ export default function useMergeNode(data: Array<any>): any {
) {
return mergeObject(
Object.assign({}, children[0], {
[labelName]: `${label} \\ ${children[0][labelName]}`,
[labelName]: `${label} \\ ${children[0][labelName]}`
})
)
}
Expand All @@ -41,14 +36,20 @@ export default function useMergeNode(data: Array<any>): any {
}
return Object.assign({}, currentObject, {
[childName]: mergeNode(currentObject[childName], level + 1, childName, labelName),
level: level + 1,
level: level + 1
})
})
}

const mergeData = ref(mergeNode(data))
const mergeData = ref(mergeNode(data.value))
watch(
() => data.value,
() => {
mergeData.value = mergeNode(data.value)
},
{ deep: true }
)

return {
mergeData,
mergeData
}
}
17 changes: 16 additions & 1 deletion packages/devui-vue/devui/tree/src/tree-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ export interface TreeItem {
children?: TreeData
[key: string]: any
}
export interface IDropType {
dropPrev?: boolean
dropNext?: boolean
dropInner?: boolean
}
export interface SelectType {
[key: string]: 'none' | 'half' | 'select'
}
Expand All @@ -36,14 +41,24 @@ export const treeProps = {
type: Boolean,
default: false
},
draggable: {
type: Boolean,
default: false
},
checkableRelation: {
type: String as () => CheckableRelationType,
default: 'none',
}
},
dropType: {
type: Object as PropType<IDropType>,
default: () => ({}),
},
} as const

export type TreeProps = ExtractPropTypes<typeof treeProps>

export type Nullable<T> = null | T

export interface TreeRootType {
ctx: SetupContext<any>
props: TreeProps
Expand Down
31 changes: 27 additions & 4 deletions packages/devui-vue/devui/tree/src/tree.scss
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,33 @@ $keyframe-blue: #5e7ce0;
white-space: nowrap;
}

.devui-tree-indicator {
height: 1px;
background-color: $devui-brand;
position: absolute;
}
.devui-tree-node {
color: $devui-text-weak;
line-height: 1.5;
white-space: nowrap;
position: relative;

.devui-drop {
&-draggable {
border-top: 1px solid transparent;
border-bottom: 1px solid transparent;
}
&-inner {
color: $devui-brand;
}
&-prev {
border-top: 1px solid $devui-brand;
}
&-next {
border-bottom: 1px solid $devui-brand;
}
}

.devui-tree-node__content {
display: inline-flex;
align-items: center;
Expand Down Expand Up @@ -149,6 +170,12 @@ $keyframe-blue: #5e7ce0;
color: #f2b806;
}
}
&__indent {
display: inline-block;
width: 16px;
height: 16px;
margin-left: 8px;
}

.devui-loading-children {
display: inline-block;
Expand Down Expand Up @@ -308,7 +335,3 @@ $keyframe-blue: #5e7ce0;
cursor: not-allowed !important;
background-color: transparent !important;
}

.devui-tree-node__content {
transition: color $devui-animation-duration-fast $devui-animation-ease-in-smooth, background-color $devui-animation-duration-fast $devui-animation-ease-in-smooth;
}
29 changes: 17 additions & 12 deletions packages/devui-vue/devui/tree/src/tree.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { defineComponent, reactive, toRefs, provide } from 'vue'
import { defineComponent, reactive, ref, toRefs, provide, unref } from 'vue'
import type { SetupContext } from 'vue'
import { treeProps, TreeProps, TreeItem, TreeRootType } from './tree-types'
import { treeProps, TreeProps, TreeItem, TreeRootType, Nullable } from './tree-types'
import { CHECK_CONFIG } from './config'
import { preCheckTree, deleteNode, getId } from './util'
import Loading from '../../loading/src/service'
Expand All @@ -11,6 +11,7 @@ import useHighlightNode from './composables/use-highlight'
import useChecked from './composables/use-checked'
import useLazy from './composables/use-lazy'
import useOperate from './composables/use-operate'
import useDraggable from './composables/use-draggable'
import IconOpen from './assets/open.svg'
import IconClose from './assets/close.svg'
import NodeContent from './tree-node-content'
Expand All @@ -21,20 +22,19 @@ export default defineComponent({
props: treeProps,
emits: ['nodeSelected'],
setup(props: TreeProps, ctx: SetupContext) {
const { data, checkable, checkableRelation: cbr } = toRefs(reactive({ ...props, data: preCheckTree(props.data) }))
const { mergeData } = useMergeNode(data.value)
const { data, checkable, draggable, dropType, checkableRelation: cbr } = toRefs(reactive({ ...props, data: preCheckTree(props.data) }))
const node = ref<Nullable<HTMLElement>>(null)
const { mergeData } = useMergeNode(data)
const { openedData, toggle } = useToggle(mergeData)
const { nodeClassNameReflect, handleInitNodeClassNameReflect, handleClickOnNode } = useHighlightNode()
const { lazyNodesReflect, handleInitLazyNodeReflect, getLazyData } = useLazy()
const { selected, onNodeClick } = useChecked(cbr, ctx, data.value)
const { editStatusReflect, operateIconReflect, handleReflectIdToIcon } = useOperate(data)

const { onDragstart, onDragover, onDragleave, onDrop } = useDraggable(draggable.value, dropType.value, node, openedData, data);
provide<TreeRootType>('treeRoot', { ctx, props })
const Indent = () => {
return <span style="display: inline-block; width: 16px; height: 16px; margin-left: 8px;"></span>
}

const renderNode = (item: TreeItem) => {
const { id = '', label, disabled, open, isParent, level, children, addable, editable, deletable } = item
const { id = '', disabled, open, isParent, level, children, addable, editable, deletable } = item
handleReflectIdToIcon(
id,
{
Expand Down Expand Up @@ -108,7 +108,7 @@ export default defineComponent({
? open
? <IconOpen class="mr-xs" />
: <IconClose class="mr-xs" />
: <Indent />
: <span class="devui-tree-node__indent" />
}
</div>

Expand All @@ -119,13 +119,18 @@ export default defineComponent({
<div
class={['devui-tree-node', open && 'devui-tree-node__open']}
style={{ paddingLeft: `${24 * (level - 1)}px` }}
draggable={draggable.value}
onDragstart={(event: DragEvent) => onDragstart(event, item)}
onDragover={(event: DragEvent) => onDragover(event, item)}
onDragleave={(event: DragEvent) => onDragleave(event)}
onDrop={(event: DragEvent) => onDrop(event, item)}
>
<div
class={`devui-tree-node__content ${nodeClassNameReflect.value[id]}`}
onClick={() => handleClickOnNode(id)}
>
<div class="devui-tree-node__content--value-wrapper">
{ renderFoldIcon(item) }
{ renderFoldIcon(item) }
<div class={['devui-tree-node__content--value-wrapper', draggable && 'devui-drop-draggable']}>
{ checkable.value && <Checkbox key={id} onClick={() => onNodeClick(item)} disabled={disabled} {...checkState} /> }
<NodeContent node={item} editStatusReflect={editStatusReflect.value} />
{ operateIconReflect.value.find(({ id: d }) => id === d).renderIcon(item) }
Expand Down
Loading