Skip to content

Commit 1ccf8e5

Browse files
authored
feat: outline tree support dragging (#1050)
1 parent 11f9244 commit 1ccf8e5

File tree

3 files changed

+499
-207
lines changed

3 files changed

+499
-207
lines changed

packages/canvas/container/src/container.js

+2
Original file line numberDiff line numberDiff line change
@@ -874,6 +874,8 @@ export const canvasApi = {
874874
setDesignMode,
875875
getDocument,
876876
canvasDispatch,
877+
getConfigure,
878+
allowInsert,
877879
Builtin,
878880
removeBlockCompsCache: (...args) => {
879881
return canvasState.renderer.removeBlockCompsCache(...args)
+387
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,387 @@
1+
<template>
2+
<!-- TODO 后续抽取公共逻辑,迁移至公共组件 -->
3+
<div class="draggable-tree" @dragleave="handleDragLeaveContainer">
4+
<div
5+
v-for="row in rows"
6+
:key="row.id"
7+
v-show="!row.collapsed"
8+
:class="[
9+
'tree-row',
10+
'flex-center',
11+
{
12+
active: active === row.id,
13+
dragging: draggingState.hovering?.id === row.id,
14+
'border-all': draggingState.hovering?.id === row.id && draggingState.position === 'center',
15+
forbid: draggingState.forbidInsert
16+
}
17+
]"
18+
:draggable="draggable ? 'true' : undefined"
19+
@click="handleClickRow(row)"
20+
@mouseenter="handleMouseEnterRow(row)"
21+
@dragstart="handleDragStart($event, row)"
22+
@dragover="handleDragOver($event, row)"
23+
@dragenter="handleDragOver($event, row)"
24+
@drop="handleDrop"
25+
@dragend="handleDragEnd"
26+
>
27+
<div class="content flex-center" :style="{ paddingLeft: `${12 * row.level}px` }">
28+
<span v-if="!row.hasChildren" class="expand-icon"></span>
29+
<svg-icon
30+
v-if="row.hasChildren"
31+
name="dropdown"
32+
:class="['expand-icon', { rotate: collapseMap[row.id] }]"
33+
@click.stop="switchCollapse(row.id)"
34+
></svg-icon>
35+
<div
36+
:class="[
37+
'slot-content',
38+
'flex-center',
39+
{
40+
[draggingState.borderClass]: draggingState.hovering?.id === row.id && draggingState.position !== 'center',
41+
forbid: draggingState.forbidInsert
42+
}
43+
]"
44+
>
45+
<slot name="content" v-bind="row"></slot>
46+
</div>
47+
</div>
48+
</div>
49+
</div>
50+
</template>
51+
52+
<script setup>
53+
import { computed, defineEmits, defineProps, reactive, ref } from 'vue'
54+
55+
const props = defineProps({
56+
data: {
57+
type: Array,
58+
default: () => []
59+
},
60+
active: {
61+
type: String
62+
},
63+
idKey: {
64+
type: String,
65+
default: 'id'
66+
},
67+
labelKey: {
68+
type: String,
69+
default: 'label'
70+
},
71+
childrenKey: {
72+
type: String,
73+
default: 'children'
74+
},
75+
draggable: {
76+
type: Boolean,
77+
default: false
78+
},
79+
disallowDrop: {
80+
type: Function,
81+
default: () => false
82+
}
83+
})
84+
85+
/**
86+
* @typedef {Object} Node
87+
* @property {string} id
88+
* @property {string} label
89+
* @property {Node[]} [children]
90+
* @property {any} rawData
91+
*/
92+
93+
/**
94+
*
95+
* @param dataItem
96+
* @returns {Node}
97+
*/
98+
const normalizeDataItem = (dataItem) => {
99+
const { idKey, labelKey, childrenKey } = props
100+
101+
const id = dataItem[idKey]
102+
const label = dataItem[labelKey]
103+
const children = dataItem[childrenKey]
104+
105+
const result = { id, label, rawData: dataItem }
106+
107+
if (Array.isArray(children)) {
108+
result.children = children.map((child) => normalizeDataItem(child))
109+
}
110+
111+
return result
112+
}
113+
114+
const normalizeData = (data) => {
115+
if (!Array.isArray(data)) {
116+
return []
117+
}
118+
119+
return data.map((item) => normalizeDataItem(item))
120+
}
121+
122+
const normalizedData = computed(() => normalizeData(props.data))
123+
124+
const useCollapseMap = () => {
125+
const collapseMap = ref({})
126+
127+
const setCollapse = (id, value) => {
128+
collapseMap.value[id] = value
129+
}
130+
131+
const switchCollapse = (id) => {
132+
collapseMap.value[id] = !collapseMap.value[id]
133+
}
134+
135+
return { collapseMap, setCollapse, switchCollapse }
136+
}
137+
138+
const { collapseMap, setCollapse, switchCollapse } = useCollapseMap()
139+
140+
/**
141+
* @typedef {Object} RowItem
142+
* @property {string} id
143+
* @property {string} label
144+
* @property {number} level level 为 0 表示顶层节点
145+
* @property {string} [parentId]
146+
* @property {RowItem} parent
147+
* @property {boolean} hasChildren
148+
* @property {boolean} collapsed
149+
* @property {any} rawData
150+
*/
151+
152+
/**
153+
*
154+
* @param {Node} node
155+
* @param parentId
156+
* @param level
157+
* @param collapsed
158+
* @returns {RowItem[]}
159+
*/
160+
const flattenNode = (node, parentId, level = 0, collapsed = false) => {
161+
const { children, ...rest } = node
162+
163+
const descendantNodes = (children || [])
164+
.map((child) => flattenNode(child, node.id, level + 1, collapsed || collapseMap.value[node.id]))
165+
.flat()
166+
167+
const rowItem = {
168+
...rest,
169+
parentId,
170+
level,
171+
hasChildren: children?.length > 0,
172+
collapsed
173+
}
174+
175+
descendantNodes.forEach((node) => {
176+
if (!node.parent) {
177+
node.parent = rowItem
178+
}
179+
})
180+
181+
return [rowItem].concat(descendantNodes)
182+
}
183+
184+
/**
185+
*
186+
* @param {Node[]} nodes
187+
* @returns {RowItem[]}
188+
*/
189+
const flattenNodes = (nodes) => {
190+
const dummyNode = { children: nodes }
191+
return flattenNode(dummyNode, null, -1).slice(1)
192+
}
193+
194+
const rows = computed(() => flattenNodes(normalizedData.value))
195+
196+
const emit = defineEmits(['click', 'mouseenter', 'drop'])
197+
198+
const handleClickRow = (row) => {
199+
emit('click', row)
200+
}
201+
202+
const handleMouseEnterRow = (row) => {
203+
emit('mouseenter', row)
204+
}
205+
206+
const useDraggingState = () => {
207+
/**
208+
* @type {{dragged?: RowItem, hovering?: RowItem, position: string, borderClass: string, forbidInsert: boolean}}
209+
*/
210+
const initialState = { dragged: null, hovering: null, position: '', borderClass: '', forbidInsert: false }
211+
const draggingState = reactive({ ...initialState })
212+
const resetDraggingState = () => {
213+
Object.assign(draggingState, initialState)
214+
}
215+
return { draggingState, resetDraggingState }
216+
}
217+
218+
const { draggingState, resetDraggingState } = useDraggingState()
219+
220+
const handleDragStart = (event, row) => {
221+
if (!props.draggable) {
222+
return
223+
}
224+
225+
// 去掉ghost image
226+
event.dataTransfer.setDragImage(new Image(), 0, 0)
227+
228+
draggingState.dragged = row
229+
230+
// 收起有子节点的节点
231+
if (row.hasChildren) {
232+
setCollapse(row.id, true)
233+
}
234+
}
235+
236+
const getPositionData = (event) => {
237+
const rect = event.currentTarget.getBoundingClientRect()
238+
const offsetY = event.clientY - rect.top
239+
240+
// 判断鼠标的位置并设置边框样式
241+
const threshold = 8
242+
if (offsetY <= threshold) {
243+
// 顶部边框
244+
return { position: 'top', borderClass: 'border-top' }
245+
}
246+
247+
if (offsetY >= rect.height - threshold) {
248+
// 底部边框
249+
return { position: 'bottom', borderClass: 'border-bottom' }
250+
}
251+
252+
return { position: 'center', borderClass: 'border-all' }
253+
}
254+
255+
const handleDragOver = (event, row) => {
256+
if (!props.draggable) {
257+
return
258+
}
259+
260+
const data = getPositionData(event)
261+
262+
// 无法将拖拽节点设置为根节点的兄弟节点
263+
if (row.id === rows.value[0].id && data.position !== 'center') {
264+
event.preventDefault()
265+
return
266+
}
267+
268+
if (props.disallowDrop({ dragged: draggingState.dragged, target: row, position: data.position })) {
269+
Object.assign(draggingState, { ...data, hovering: row, forbidInsert: true })
270+
return
271+
}
272+
273+
event.preventDefault()
274+
275+
Object.assign(draggingState, { ...data, hovering: row, forbidInsert: false })
276+
}
277+
278+
const handleDrop = () => {
279+
if (!props.draggable || draggingState.forbidInsert) {
280+
return
281+
}
282+
283+
const { dragged, hovering, position } = draggingState
284+
285+
emit('drop', { dragged, target: hovering, position })
286+
}
287+
288+
const handleDragEnd = () => {
289+
resetDraggingState()
290+
}
291+
292+
const handleDragLeaveContainer = (event) => {
293+
if (!props.draggable) {
294+
return
295+
}
296+
297+
const rect = event.currentTarget.getBoundingClientRect()
298+
const threshold = 4
299+
// 如果拖拽时,拖拽到其他元素上,可能触发dragleave事件,所以再加个坐标判断
300+
if (
301+
event.clientX <= rect.left + threshold ||
302+
event.clientX >= rect.right - threshold ||
303+
event.clientY <= rect.top + threshold ||
304+
event.clientY >= rect.bottom - threshold
305+
) {
306+
Object.assign(draggingState, { hovering: null })
307+
}
308+
}
309+
</script>
310+
311+
<style lang="less" scoped>
312+
.draggable-tree {
313+
.tree-row {
314+
height: 24px;
315+
width: fit-content;
316+
min-width: 100%;
317+
padding: 0 8px;
318+
319+
&,
320+
* {
321+
cursor: pointer;
322+
}
323+
&:hover,
324+
&.active {
325+
background-color: var(--te-common-bg-container);
326+
}
327+
&.dragging {
328+
background-color: var(--te-common-bg-info);
329+
&.forbid {
330+
background-color: var(--te-common-bg-error);
331+
}
332+
}
333+
334+
& > * {
335+
flex-shrink: 0;
336+
}
337+
}
338+
.content {
339+
flex: 1;
340+
height: 100%;
341+
}
342+
343+
.rotate {
344+
transform: rotate(-90deg);
345+
}
346+
.expand-icon {
347+
font-size: 16px;
348+
width: 16px;
349+
margin-right: 4px;
350+
}
351+
.slot-content {
352+
flex: 1;
353+
height: 100%;
354+
padding: 0 4px;
355+
}
356+
357+
.border-top {
358+
box-shadow: inset 0 2px 0 0 var(--te-common-text-checked);
359+
&.forbid {
360+
box-shadow: inset 0 2px 0 0 var(--te-common-color-error);
361+
}
362+
}
363+
.border-bottom {
364+
box-shadow: inset 0 -2px 0 0 var(--te-common-text-checked);
365+
&.forbid {
366+
box-shadow: inset 0 -2px 0 0 var(--te-common-color-error);
367+
}
368+
}
369+
.border-all {
370+
outline: 1px solid var(--te-common-text-checked);
371+
outline-offset: -1px;
372+
&.forbid {
373+
outline: 1px solid var(--te-common-color-error);
374+
}
375+
}
376+
}
377+
svg {
378+
color: var(--te-common-icon-secondary);
379+
&:hover {
380+
color: var(--te-common-icon-hover);
381+
}
382+
}
383+
.flex-center {
384+
display: flex;
385+
align-items: center;
386+
}
387+
</style>

0 commit comments

Comments
 (0)