diff --git a/packages/devui-vue/devui/tree/src/composables/use-draggable.ts b/packages/devui-vue/devui/tree/src/composables/use-draggable.ts new file mode 100644 index 0000000000..f653358294 --- /dev/null +++ b/packages/devui-vue/devui/tree/src/composables/use-draggable.ts @@ -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 +} + +export default function useDraggable( + draggable: boolean, + dropType: IDropType, + node: Ref>, + renderData: Ref, + data: Ref +): any { + const dragState = reactive({ + 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) => { + 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 = >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 = >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(>event.currentTarget) + } + const onDrop = (event: DragEvent, dropNode: TreeItem) => { + removeDraggingStyle(>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 + } +} diff --git a/packages/devui-vue/devui/tree/src/composables/use-merge-node.ts b/packages/devui-vue/devui/tree/src/composables/use-merge-node.ts index 705cf07809..ff7a18c7e0 100644 --- a/packages/devui-vue/devui/tree/src/composables/use-merge-node.ts +++ b/packages/devui-vue/devui/tree/src/composables/use-merge-node.ts @@ -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 { - - - const mergeObject = ( - treeItem, - childName = 'children', - labelName = 'label' - ) => { +export default function useMergeNode(data: Ref): any { + const mergeObject = (treeItem, childName = 'children', labelName = 'label') => { const { [childName]: children, [labelName]: label } = treeItem if ( Array.isArray(children) && @@ -17,7 +12,7 @@ export default function useMergeNode(data: Array): any { ) { return mergeObject( Object.assign({}, children[0], { - [labelName]: `${label} \\ ${children[0][labelName]}`, + [labelName]: `${label} \\ ${children[0][labelName]}` }) ) } @@ -41,14 +36,20 @@ export default function useMergeNode(data: Array): 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 } } diff --git a/packages/devui-vue/devui/tree/src/tree-types.ts b/packages/devui-vue/devui/tree/src/tree-types.ts index 58d6d70d93..e8951ba8cf 100644 --- a/packages/devui-vue/devui/tree/src/tree-types.ts +++ b/packages/devui-vue/devui/tree/src/tree-types.ts @@ -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' } @@ -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, + default: () => ({}), + }, } as const export type TreeProps = ExtractPropTypes +export type Nullable = null | T + export interface TreeRootType { ctx: SetupContext props: TreeProps diff --git a/packages/devui-vue/devui/tree/src/tree.scss b/packages/devui-vue/devui/tree/src/tree.scss index 2ffc16243d..2924182987 100644 --- a/packages/devui-vue/devui/tree/src/tree.scss +++ b/packages/devui-vue/devui/tree/src/tree.scss @@ -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; @@ -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; @@ -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; -} diff --git a/packages/devui-vue/devui/tree/src/tree.tsx b/packages/devui-vue/devui/tree/src/tree.tsx index c75410d5aa..41924cd8f4 100644 --- a/packages/devui-vue/devui/tree/src/tree.tsx +++ b/packages/devui-vue/devui/tree/src/tree.tsx @@ -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' @@ -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' @@ -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>(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('treeRoot', { ctx, props }) - const Indent = () => { - return - } + 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, { @@ -108,7 +108,7 @@ export default defineComponent({ ? open ? : - : + : } @@ -119,13 +119,18 @@ export default defineComponent({
onDragstart(event, item)} + onDragover={(event: DragEvent) => onDragover(event, item)} + onDragleave={(event: DragEvent) => onDragleave(event)} + onDrop={(event: DragEvent) => onDrop(event, item)} >
handleClickOnNode(id)} > -
- { renderFoldIcon(item) } + { renderFoldIcon(item) } +
{ checkable.value && onNodeClick(item)} disabled={disabled} {...checkState} /> } { operateIconReflect.value.find(({ id: d }) => id === d).renderIcon(item) } diff --git a/packages/devui-vue/devui/tree/src/util.ts b/packages/devui-vue/devui/tree/src/util.ts index 9b0917e3a3..c984e8167a 100644 --- a/packages/devui-vue/devui/tree/src/util.ts +++ b/packages/devui-vue/devui/tree/src/util.ts @@ -20,7 +20,6 @@ export const flatten = (tree: Array, key = 'children'): Array => { const getRandomId = (): string => (Math.random() * 10 ** 9).toString().slice(0,8) const preCheckNodeId = (d: TreeItem, postfixId?: string): TreeItem => { const randomStr = getRandomId() - console.info('randomStr: ', randomStr) return { ...d, id: postfixId ? `${postfixId}_${randomStr}` : randomStr } } export const getId = (id: string): string => { @@ -34,6 +33,9 @@ export const getId = (id: string): string => { export const preCheckTree = (ds: TreeData, postfixId?: string): TreeData => { return ds.map(d => { const dd = preCheckNodeId(d, postfixId) + if (!dd.parentId && postfixId) { + dd.parentId = postfixId + } return d.children ? { ...dd, children: preCheckTree(d.children, dd.id) diff --git a/packages/devui-vue/docs/components/tree/index.md b/packages/devui-vue/docs/components/tree/index.md index a3ab18fab8..69458a1808 100644 --- a/packages/devui-vue/docs/components/tree/index.md +++ b/packages/devui-vue/docs/components/tree/index.md @@ -930,3 +930,101 @@ export default defineComponent({ ``` ::: + + +### 可拖拽树 + +:::demo 通过OperableTree的 draggable 属性配置节点的拖拽功能,并支持外部元素拖拽入树。 + +```vue + + + +``` +:::