数据可视化大屏设计器开发-多选拖拽

数据可视化大屏设计器开发-多选拖拽

开头

本文是数据可视化开始的开发细节第五章。关于画布中的元素的各种鼠标拖拽操作。

简单声明
本人只是一个菜鸡,以下方法仅个人思路,如有错误,轻喷🙏🏻 。

开头说明
下面所说的元素表示的是组或者组件的简称。

开始

大屏设计当中,不乏需要调整图表组件的位置尺寸
相较于网页低代码,图表大屏低代码可能需要更复杂的操作,比如嵌套成组多选单元素拖拽缩放多元素拖拽缩放
并且需要针对鼠标的动作做相应的区分,当中包含了相当的细节,这里就一一做相应的讲解。

涉及的依赖

  • react-rnd
    react-rnd是一个包含了拖拽和缩放两个功能的react组件,并且有非常丰富的配置项。
    内部是依赖了拖拽(react-draggable)和缩放(re-resizable)两个模块。
    奈何它并没有内置多元素的响应操作,本文就是针对它来实现对应的操作。

  • react-selecto
    react-selecto是一个简单的简单易用的多选元素组件。

  • eventemitter3
    eventemitter3是一个自定义事件模块,能够在任何地方触发和响应自定义的事件,非常的方便。

相关操作

多选

画布当中可以通过鼠标点击拖拽形成选区,选区内的元素即是被选中的状态。

这里即可以使用react-selecto来实现此功能。

从图上操作可以看到,在选区内的元素即被选中(会出现黑色边框)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import ReactSelecto from 'react-selecto';

const Selecto = () => {

return (
<ReactSelecto
// 会被选中元素的父容器 只有这个容器里的元素才会被选中
dragContainer={'#container'}
// 被选择的元素的query
selectableTargets={['.react-select-to']}
// 表示元素有被选中的百分比为多少时才能被选中
hitRate={10}
// 当已经存在选中项时,按住指定按键可进行继续选择
toggleContinueSelect={'shift'}
// 可以通过点击选择元素
selectByClick
// 是否从内部开始选择(?)
selectFromInside
// 拖拽的速率(不知道是不是这个意思)
ratio={0}
// 选择结束
onSelectEnd={handleSelectEnd}
></ReactSelecto>
);
};

这里有几个需要注意的地方。

  1. 操作互斥
    画布当中的多选和拖拽都是通过鼠标左键来完成的,所以当一个元素是被选中的时候,鼠标想从元素上开始拖拽选择组件是不被允许的,此时应该是拖拽元素,而不是多选元素。

而元素如果没有被选中时,上面的操作则变成了多选。

  1. 内部选中
    画布当中有的概念,它是一个组与组件无限嵌套的结构,并且可以单独选中组内的元素。
    当选中的是组内的元素时,即说明最外层的组是被选中的状态,同样需要考虑上面所说的互斥问题。

单元素拖拽缩放

单元素操作相对简单,只需要简单使用react-rnd提供的功能即可完成。

多元素拖拽缩放

这里就是本文的重点了,结合前面介绍的几个依赖,实现一个简单的多选拖拽缩放的功能。

具体思路

多个元素拖拽,说到底其实鼠标拖拽的还是一个元素,就是鼠标拖动的那一个元素。
其余被选中的元素,仅仅需要根据被拖动的元素的尺寸位置变动来做相应的加减处理即可。

相关问题
  • 信息计算
    联动元素的位置尺寸信息该如何计算。
  • 组件间通信
    因为每一个图表组件并非是单纯的同级关系,如果是通过层层props传递,免不了会有多余的刷新,造成性能问题。
    而通过全局的dva状态同样在更新的时候会让组件刷新。
  • 数据刷新
    图表数据是来自于dva全局的数据,现在频繁自刷新相当于是一直更新全局的数据,同样会造成性能问题。
  • 其他
    一些细节问题

解决方法

  • 信息计算
    关于位置的计算相对简单,只需要单纯的将操作的元素的位置和尺寸差值传递给联动组件即可。

  • 组件间通信
    根据上面问题的解析,可以使用eventemitter3来完成任意位置、层级的数据通信,并且它和react渲染无任何关系。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import { useCallback, useEffect } from 'react'
import EventEmitter from 'eventemitter3'

const eventemitter = new EventEmitter()

const SonA = () => {

console.log('刷新')

useEffect(() => {
const listener = (value) => {
console.log(value)
}
eventemitter.addListener('change', listener)
return () => {
eventemitter.removeListener('change', listener)
}
}, [])

return (
<span>son A</span>
)

}

const SonB = () => {

const handleClick = useCallback(() => {
eventemitter.emit('change', 'son B')
}, [])

return (
<span>
<button onClick={handleClick}>son B</button>
</span>
)

}

const Parent = () => {

return (
<div>
<SonA />
<br />
<SonB />
</div>
)

}

运行上面的例子可以发现,点击SonB组件的按钮,可以让SonA接收到来自其的数据,并且并没有触发SonA的刷新。
需要接收数据的组件只需要监听(addListener)指定的事件即可,比如上面的change事件。
而需要发送数据的组件则直接发布(emit)事件即可。
这样就避免了一些不必要的刷新。

  • 数据刷新
    频繁刷新全局数据,会导致所有依赖其数据的组件都会刷新,所以考虑为需要刷新数据的组件在内部单独维护一份状态。
    开始操作时,记录下状态,标识开始使用内部状态表示图表的信息,结束操作时处理下内部数据状态,将数据更新到全局中去。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import { useMemo, useEffect, useState, useRef } from 'react'
import EventEmitter from 'eventemitter3'

const eventemitter = new EventEmitter()

const Component = (props: {
position: {left: number, top: number}
}) => {

const [ position, setPosition ] = useState({
left: 0,
top: 0
})

const isDrag = useRef(false)

const dragStart = () => {
isDrag.current = true
setPosition(props.position)
}

const drag = (position) => {
setPosition(position)
}

const dragEnd = () => {
isDrag.current = false
// TODO
// 更新数据到全局
}

useEffect(() => {
eventemitter.addListener('dragStart', dragStart)
eventemitter.addListener('drag', drag)
eventemitter.addListener('dragEnd', dragEnd)
return () => {
eventemitter.removeListener('dragStart', dragStart)
eventemitter.removeListener('drag', drag)
eventemitter.removeListener('dragEnd', dragEnd)
}
}, [])

return (
<span
style={{
left: (isDrag.current ? position : props.position).left,
top: (isDrag.current ? position : props.position).top
}}
>图表组件</span>
)

}


上面的数据更新还可以更加优化,对于短时间多次更新操作,可以控制一下更新频率,将多次更新合并为一次。

  • 其他
    • 控制刷新
      这里的控制刷新指的是上述的内部刷新,不需要每次都响应react-rnd发出的相关事件,可以做对应的节流(throttle)操作,减少事件触发频率。
    • 通信冲突问题
      因为所有的组件都需要监听拖拽的事件,包括当前被拖拽的组件,所以在传递信息时,需要把自身的id类似值传递,防止冲突。
    • 组件的缩放属性
      这里是关于前文说到的成组的逻辑相关,因为组存在scaleXscaleY两个属性,所以在调整大小的时候,也要兼顾此属性(本文先暂时不考虑这个问题)。
    • 单元素选中情况
      自定义事件的监听是无差别的,当只选中了一个元素进行拖拽缩放操作时,无须触发相应的事件。

最后的DEMO

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
import {
useState,
useRef,
useCallback,
useMemo,
useEffect
} from 'react'
import { Rnd } from 'react-rnd'
import type { RndResizeCallback, RndDragCallback, RndResizeStartCallback } from 'react-rnd'
import EventEmitter from 'eventemitter3';
import ReactSelecto from 'react-selecto'
import { throttle } from 'lodash'

// 大屏组件的数据类型
type ComponentList = {
id: string
width: number
height: number
left: number
top: number
background: string
}

// 创建事件
const eventEmitter = new EventEmitter()
const EVENT_MAP = {
DRAG_START: "DRAG_START",
DRAG: "DRAG",
DRAG_STOP: "DRAG_STOP",
RESIZE_START: "RESIZE_START",
RESIZE: "RESIZE",
RESIZE_STOP: "RESIZE_STOP"
}

const getComponentStyle = (position: any, size: any) => {
const newWidth = parseInt(size.style.width) || 20;
const newHeight = parseInt(size.style.height) || 20;
return {
width: newWidth,
height: newHeight,
left: position.x,
top: position.y,
};
};

// 这里用到的 dataset 是html原生data-的属性
// 用于标识组件
const Selecto = (props: {
setSelect: (value: string[]) => void;
select: string[]
}) => {
const { setSelect, select } = props;

// 这里内部控制一个状态
// 是因为不想频繁刷新选中的状态
// 其实也没有必要
// 最后拖拽完成再更改选中状态就可以了
const currentSelect = useRef<string[]>([]);

// 拖拽完成更改状态
const handleSelectEnd = useCallback(() => {
setSelect(currentSelect.current);
}, [setSelect]);

// 拖拽中
const handleSelect = useCallback((e: any) => {
const { added, removed } = e;

const toAddList = added.reduce((acc: any, element: any) => {
const select = element.dataset.id;
acc.push(select)
return acc;
}, []);
const toRemoveList = removed.map((element: any) => element.dataset.id);

const newSelect = [
...currentSelect.current.filter((item) => !toRemoveList.includes(item)),
...toAddList,
];
currentSelect.current = newSelect;
}, []);

const handleDragStart = useCallback(
(e: any) => {
try {
// 组件id
const componentId = e.inputEvent.target.dataset?.id;
// 组件缩放的边框
// 不排除他的话无法缩放
const componentBorder =
e.inputEvent.target.className.includes('react-select-to-border');
// 可以在这里扩展更多的判断
// 因为根据前面介绍可能会从组内元素 或者 外层不同的元素开始拖拽
// 可能会存在冲突

if (!select.includes(componentId) && !componentBorder) {
setSelect?.([]);
} else {
e.stop();
}
} catch (err) {
e.stop();
}
},
[setSelect, select],
);

return (
<ReactSelecto
dragContainer={'#container'}
selectableTargets={['.react-select-to']}
hitRate={10}
toggleContinueSelect={'shift'}
selectByClick
selectFromInside
ratio={0}
onDragStart={handleDragStart}
onSelectEnd={handleSelectEnd}
onSelect={handleSelect}
></ReactSelecto>
);
};

const Component = (props: ComponentList & {
isSelect: boolean
isMultiSelect: boolean
onChange: (value: Partial<ComponentList> & { id: string }) => void
}) => {

const {
isSelect,
background,
left,
top,
width,
height,
id,
onChange,
isMultiSelect
} = props;

// 内部的位置信息
const [statePosition, setStatePosition] = useState({
x: left,
y: top
});
// 内部的尺寸信息
const [stateSize, setStateSize] = useState({
width,
height
})
// 是否处于拖拽中
const [isDealing, setIsDealing] = useState<boolean>(false)

// 拖拽中的组件的信息
// 使用ref是为了刷新
const dragInfo = useRef({
left: statePosition?.x || 0,
top: statePosition?.y || 0,
drag: false,
});
// 缩放的组件信息
const resizeInfo = useRef({
left: statePosition?.x || 0,
top: statePosition?.y || 0,
width: stateSize?.width || 0,
height: stateSize?.height || 0,
resize: false,
});

// 根据是否操作的状态使用不同的状态信息
// 位置
const position = useMemo(() => {
if (isDealing) {
return statePosition;
}
return {
x: left,
y: top
};
}, [left, top, statePosition, isDealing]);

// 根据是否操作的状态使用不同的状态信息
// 尺寸
const size = useMemo(() => {
if (isDealing) return stateSize;
return {
width,
height
};
}, [width, height, stateSize, isDealing]);

// 调整大小方法
const resizeMethod: any = (
e: any,
direction: any,
ref: any,
delta: any,
position: any,
isSelf: boolean,
value: ComponentList,
outerResizeInfo: any,
) => {
// 获取到之前的位置信息
const resizePositionX = (outerResizeInfo || resizeInfo.current).left;
const resizePositionY = (outerResizeInfo || resizeInfo.current).top;

let newWidth = 0;
let newHeight = 0;
// delta
let realDeltaX =
typeof resizePositionX === 'number' ? position.x - resizePositionX : 0;
let realDeltaY =
typeof resizePositionY === 'number' ? position.y - resizePositionY : 0;

// 获取当前的状态信息
const newStyle = getComponentStyle(position, ref);
let defaultChangeConfig: Partial<ComponentList> = {};

// 鼠标操作组件
if (isSelf) {
const { width, height } = newStyle;
newWidth = width;
newHeight = height;
defaultChangeConfig = {
...newStyle
};
}
// 不是鼠标操作组件
else {
const { width, height, left, top } = value;
let realDeltaWidth = outerResizeInfo
? newStyle.width - outerResizeInfo.width
: 0;
let realDeltaHeight = outerResizeInfo
? newStyle.height - outerResizeInfo.height
: 0;

newWidth = width + realDeltaWidth;
newHeight = height + realDeltaHeight;
defaultChangeConfig = {
width: newWidth,
height: newHeight,
left: left + realDeltaX,
top: top + realDeltaY,
};
}

// 不考虑嵌套组
if (true) return defaultChangeConfig
// TODO
// 可以继续在下面处理操作组的逻辑
};

// 拖拽方法
const dragMethod: any = (
event: any,
data: any,
isSelf: boolean,
value: any,
outerDragInfo: any,
) => {
const { x, y, deltaX, deltaY } = data;

const left = x;
const top = y;

// 鼠标操作组件
if (isSelf) {
return {
left,
top,
};
}

// 不是鼠标操作组件则使用delta来进行计算
return {
left: value.left + deltaX,
top: value.top + deltaY,
};
};

// 多组件复合调整大小
const multiOnResize: RndResizeCallback = (
e,
direction,
ref,
delta,
position,
) => {
const newStyle = getComponentStyle(position, ref);

resizeInfo.current.resize = true;

// 触发事件
eventEmitter.emit(
EVENT_MAP.RESIZE,
id,
e,
direction,
ref,
delta,
position,
resizeInfo.current,
);

// 更新自身的状态
resizeInfo.current = {
...resizeInfo.current,
...newStyle,
};
};

// 复合拖拽
const multiOnDrag: RndDragCallback = (event, data) => {
const { x, y } = data;

dragInfo.current.drag = true;

// 计算delta给其他联动组件使用
const deltaX = x - dragInfo.current.left;
const deltaY = y - dragInfo.current.top;

// 触发事件
eventEmitter.emit(
EVENT_MAP.DRAG,
id,
event,
{
...data,
deltaX,
deltaY,
},
dragInfo.current,
);

// 更新自身状态
dragInfo.current = {
...dragInfo.current,
left: x,
top: y,
};
};

// realtion开头的都是监听
// 鼠标操作的目标组件发出的自定义事件
const onRelationDragStart = (targetId: string) => {
if (!isSelect || id === targetId) return;
setIsDealing(true);
};

const onRelationDrag = (
targetId: string,
event: any,
data: any,
outerDragInfo: any,
) => {
if (!isSelect || id === targetId) return;
const nextPosition = dragMethod(
event,
data,
false,
{
left: dragInfo.current.left || 0,
top: dragInfo.current.top || 0,
},
outerDragInfo,
);
const nextState = {
x: nextPosition.left || 0,
y: nextPosition.top || 0,
};
dragInfo.current = {
...dragInfo.current,
...nextPosition,
};
setStatePosition((prev) => ({ ...nextState }));
};

const onRelationDragStop = (
targetId: string,
event: any,
data: any,
outerDragInfo: any,
) => {
if (!isSelect || id === targetId) return;
setIsDealing(false);
const nextPosition = dragMethod(
event,
data,
false,
{
left: dragInfo.current.left || 0,
top: dragInfo.current.top || 0,
},
outerDragInfo,
);
dragInfo.current = {
...dragInfo.current,
...nextPosition,
};

const { left, top } = dragInfo.current;
onChange({
id,
left,
top
})
};

const onRelationResizeStart = (targetId: string, direction: any) => {
if (!isSelect || id === targetId) return;
setIsDealing(true);
};

const onRelationResize = (
targetId: string,
e: any,
direction: any,
ref: any,
delta: any,
position: any,
outerResizeInfo: any,
) => {
if (!isSelect || id === targetId) return;

const nextConfig = resizeMethod(
e,
direction,
ref,
delta,
position,
false,
{
left: resizeInfo.current.left || 0,
top: resizeInfo.current.top || 0,
width: resizeInfo.current.width || 0,
height: resizeInfo.current.height || 0,
},
outerResizeInfo,
);

const { left, top, width, height } = nextConfig;
resizeInfo.current = {
...resizeInfo.current,
left,
top,
width,
height
};

setStatePosition((prev) => ({
x: resizeInfo.current.left,
y: resizeInfo.current.top
}));
setStateSize((prev) => ({
width: resizeInfo.current.width,
height: resizeInfo.current.height
}));
};

const onRelationResizeStop = (
targetId: string,
e: any,
direction: any,
ref: any,
delta: any,
position: any,
outerResizeInfo: any,
) => {
if (!isSelect || id === targetId) return;

setIsDealing(false);
const nextConfig = resizeMethod(
e,
direction,
ref,
delta,
position,
false,
{
left: resizeInfo.current.left || 0,
top: resizeInfo.current.top || 0,
width: resizeInfo.current.width || 0,
height: resizeInfo.current.height || 0,
},
outerResizeInfo,
);

const { left, top, width, height } = nextConfig;
resizeInfo.current = {
...resizeInfo.current,
left,
top,
width,
height
};

const { left: x, top: y, width: currentWidth, height: currentHeight } = resizeInfo.current;

onChange({
id,
left: x || 0,
top: y || 0,
width: (currentWidth as number) || 20,
height: (currentHeight as number) || 20,
})
};

const multiResizeStart: RndResizeStartCallback = (_, direction) => {
eventEmitter.emit(
EVENT_MAP.RESIZE_START,
id,
direction,
);
resizeInfo.current.resize = true;
}

const _onDrag: RndDragCallback = (event: any, data: any) => {
// * 复合移动
if (isMultiSelect) {
multiOnDrag(event, data);
}
};

// 节流帮助减少频繁的更新
// 因为每一次都更新时没有必要的
const multiDrag = throttle(_onDrag, 100);

const _onResize: RndResizeCallback = (...args) => {
// * 复合尺寸修改
if (isMultiSelect) {
multiOnResize(...args);
}
};

const multiResize = throttle(_onResize, 100);

const multiDragStop: RndDragCallback = (event, data) => {
const { x, y } = data;
if (isMultiSelect) {
const deltaX = x - dragInfo.current.left;
const deltaY = y - dragInfo.current.top;

eventEmitter.emit(
EVENT_MAP.DRAG_STOP,
id,
event,
{
...data,
deltaX,
deltaY,
},
dragInfo.current,
);

dragInfo.current = {
...dragInfo.current,
left: x,
top: y,
};
}
dragInfo.current.drag = false;
};

const multiResizeStop: RndResizeCallback = (
e,
direction,
ref,
delta,
position,
) => {
const newStyle = getComponentStyle(position, ref);

resizeInfo.current.resize = true;

eventEmitter.emit(
EVENT_MAP.RESIZE_STOP,
id,
e,
direction,
ref,
delta,
position,
resizeInfo.current,
);

resizeInfo.current.resize = false;

resizeInfo.current = {
...resizeInfo.current,
...newStyle,
};
};

const multiDragStart: RndDragCallback = () => {
eventEmitter.emit(
EVENT_MAP.DRAG_START,
id,
);
dragInfo.current.drag = true;
}

const onDragStart: RndDragCallback = (...args) => {
// * 未选中不触发事件
if (!isSelect) return;
multiDragStart(...args)
}

const onDrag: RndDragCallback = (...args) => {
// * 未选中不触发事件
if (!isSelect) return;
multiDrag(...args)
};

const onResize: RndResizeCallback = (...args) => {
// * 未选中不触发事件
if (!isSelect) return;
multiResize(...args)
}

const onDragStop: RndDragCallback = (event, data) => {
// * 未选中不触发事件
if (!isSelect) return;
onChange({
...dragMethod(event, data, true),
id
})

multiDragStop(event, data);
};

const onResizeStart: RndResizeStartCallback = (...args) => {
// * 未选中不触发事件
if (!isSelect) return;
multiResizeStart(...args)
}

const onResizeStop: RndResizeCallback = (
e,
direction,
ref,
delta,
position,
) => {
// * 未选中不触发事件
if (!isSelect) return;

onChange({
...resizeMethod(e, direction, ref, delta, position, true),
id
})

multiResizeStop(e, direction, ref, delta, position)
};

// 绑定拖拽和缩放的事件
// 用于多选组件间通信
useEffect(() => {
if(isSelect) {
eventEmitter.addListener(
EVENT_MAP.DRAG_START,
onRelationDragStart,
);
eventEmitter.addListener(
EVENT_MAP.DRAG,
onRelationDrag,
);
eventEmitter.addListener(
EVENT_MAP.DRAG_STOP,
onRelationDragStop,
);
eventEmitter.addListener(
EVENT_MAP.RESIZE_START,
onRelationResizeStart,
);
eventEmitter.addListener(
EVENT_MAP.RESIZE,
onRelationResize,
);
eventEmitter.addListener(
EVENT_MAP.RESIZE_STOP,
onRelationResizeStop,
);
}
return () => {
eventEmitter.removeListener(
EVENT_MAP.DRAG_START,
onRelationDragStart,
);
eventEmitter.removeListener(
EVENT_MAP.DRAG,
onRelationDrag,
);
eventEmitter.removeListener(
EVENT_MAP.DRAG_STOP,
onRelationDragStop,
);
eventEmitter.removeListener(
EVENT_MAP.RESIZE_START,
onRelationResizeStart,
);
eventEmitter.removeListener(
EVENT_MAP.RESIZE,
onRelationResize,
);
eventEmitter.removeListener(
EVENT_MAP.RESIZE_STOP,
onRelationResizeStop,
);
};
}, [isSelect]);

// 全局组件的尺寸信息状态更新时
// 同步到组件内部
useEffect(() => {
setStatePosition({
x: left,
y: top
});
resizeInfo.current = {
...resizeInfo.current,
left: left ?? resizeInfo.current.left,
top: top ?? resizeInfo.current.top,
};
dragInfo.current = {
...dragInfo.current,
left: left ?? dragInfo.current.left,
top: top ?? dragInfo.current.top,
};
}, [left, top]);

// 全局组件的宽高信息状态更新时
// 同步到组件内部
useEffect(() => {
setStateSize({
width,
height
});
resizeInfo.current = {
...resizeInfo.current,
width: width ?? resizeInfo.current.width,
height: height ?? resizeInfo.current.height,
};
}, [width, height]);

return (
<Rnd
style={{
border: `1px solid ${isSelect ? 'black' : 'transparent'}`
}}
enableResizing={isSelect}
disableDragging={!isSelect}
default={{
x: 0,
y: 0,
width: 320,
height: 200,
}}
onDragStart={onDragStart}
onDrag={onDrag}
onDragStop={onDragStop}
onResizeStart={onResizeStart}
onResize={onResize}
onResizeStop={onResizeStop}
minWidth={20}
minHeight={20}
position={position}
size={size}
// 设置缩放边框的class
// 不设置的话会导致与缩放冲突
resizeHandleClasses={[
'left',
'top',
'right',
'bottom',
'topLeft',
'topRight',
'bottomLeft',
'bottomRight',
].reduce<any>((acc, cur) => {
acc[cur] = 'react-select-to-border';
return acc;
}, {})}
>
<div
style={{
backgroundColor: background,
width: '100%',
height: '100%'
}}
// 用于Selecto组件中的组件标识
data-id={id}
// 和Selecto组件中的selectableTargets对应
className="react-select-to"
>

</div>
</Rnd>
)

}

const Parent = () => {

const [componentList, setComponentList] = useState<ComponentList[]>([
{
id: '1',
background: 'red',
left: 20,
top: 30,
width: 200,
height: 100
},
{
id: '2',
background: 'green',
left: 200,
top: 300,
width: 100,
height: 100
},
{
id: '3',
background: 'gray',
left: 250,
top: 50,
width: 50,
height: 50
},
{
id: '4',
background: 'blue',
left: 250,
top: 100,
width: 100,
height: 100
},
{
id: '5',
background: 'pink',
left: 400,
top: 150,
width: 100,
height: 100
},
{
id: '6',
background: 'yellow',
left: 400,
top: 300,
width: 100,
height: 100
}
])
const [select, setSelect] = useState<string[]>([])

// 更新组件信息
const onComponentChange = useCallback((value: Partial<ComponentList> & { id: string }) => {
const { id } = value
setComponentList(prev => {
return prev.map(item => {
if (item.id !== id) return item
return {
...item,
...value
}
})
})
}, [])

return (
// 和Selecto组件中的container对应
<div
id="container"
style={{
width: '100vw',
height: '100vh'
}}
>
<Selecto
select={select}
setSelect={setSelect}
/>
{
componentList.map(component => {
return (
<Component
key={component.id}
{...component}
isSelect={select.includes(component.id)}
onChange={onComponentChange}
isMultiSelect={select.length > 1}
/>
)
})
}
</div>
)

}

export default Parent

成品

其实在之前就已经发现其实react-selecto的作者也有研发其他的可视化操作模块,包括本文所说的多选拖拽的操作,但是奈何无法满足本项目的需求,故自己实现了功能。
如果有兴趣可以去看一下这个成品moveable

总结

通过上面的思路,即可完成一个简单的多元素拖拽缩放的功能,其核心其实就是eventemitter3的自定义事件功能,它的用途在平常的业务中非常广泛。
比如我们完全可以在以上例子的基础上,加上元素拖拽时吸附的功能。

结束

结束🔚。

顺便在下面附上相关的链接。

试用地址
试用账号
静态版试用地址
操作文档
代码地址


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!