@flowgram-vue/free-snap-plugin 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/service.ts ADDED
@@ -0,0 +1,703 @@
1
+ /**
2
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
3
+ * SPDX-License-Identifier: MIT
4
+ */
5
+
6
+ import { inject, injectable } from 'inversify';
7
+ import { Disposable, Emitter, Rectangle } from '@flowgram-vue/utils';
8
+ import { IPoint } from '@flowgram-vue/utils';
9
+ import { WorkflowNodeEntity, WorkflowDocument } from '@flowgram-vue/free-layout-core';
10
+ import { WorkflowDragService } from '@flowgram-vue/free-layout-core';
11
+ import { FlowNodeTransformData } from '@flowgram-vue/document';
12
+ import { FlowNodeBaseType } from '@flowgram-vue/document';
13
+ import { EntityManager, PlaygroundConfigEntity, TransformData } from '@flowgram-vue/core';
14
+
15
+ import { isEqual, isGreaterThan, isLessThan, isLessThanOrEqual, isNumber } from './utils';
16
+ import type {
17
+ SnapEvent,
18
+ SnapHorizontalLine,
19
+ SnapLines,
20
+ SnapMidHorizontalLine,
21
+ SnapMidVerticalLine,
22
+ SnapVerticalLine,
23
+ WorkflowSnapServiceOptions,
24
+ AlignRects,
25
+ AlignRect,
26
+ AlignSpacing,
27
+ SnapNodeRect,
28
+ SnapEdgeLines,
29
+ } from './type';
30
+ import { SnapDefaultOptions } from './constant';
31
+
32
+ @injectable()
33
+ export class WorkflowSnapService {
34
+ @inject(WorkflowDocument) private readonly document: WorkflowDocument;
35
+
36
+ @inject(EntityManager) private readonly entityManager: EntityManager;
37
+
38
+ @inject(WorkflowDragService)
39
+ private readonly dragService: WorkflowDragService;
40
+
41
+ @inject(PlaygroundConfigEntity)
42
+ private readonly playgroundConfig: PlaygroundConfigEntity;
43
+
44
+ private disposers: Disposable[] = [];
45
+
46
+ private options: WorkflowSnapServiceOptions;
47
+
48
+ private snapEmitter = new Emitter<SnapEvent>();
49
+
50
+ public readonly onSnap = this.snapEmitter.event;
51
+
52
+ private _disabled = false;
53
+
54
+ public init(params: Partial<WorkflowSnapServiceOptions> = {}): void {
55
+ this.options = {
56
+ ...SnapDefaultOptions,
57
+ ...params,
58
+ };
59
+ this.mountListener();
60
+ }
61
+
62
+ public dispose(): void {
63
+ this.disposers.forEach((disposer) => disposer.dispose());
64
+ }
65
+
66
+ public get disabled(): boolean {
67
+ return this._disabled;
68
+ }
69
+
70
+ public disable(): void {
71
+ if (this._disabled) {
72
+ return;
73
+ }
74
+ this._disabled = true;
75
+ this.clear();
76
+ }
77
+
78
+ public enable(): void {
79
+ if (!this._disabled) {
80
+ return;
81
+ }
82
+ this._disabled = false;
83
+ this.clear();
84
+ }
85
+
86
+ private mountListener(): void {
87
+ const dragAdjusterDisposer = this.dragService.registerPosAdjuster((params) => {
88
+ const { selectedNodes: targetNodes, position } = params;
89
+ const isMultiSnapping = this.options.enableMultiSnapping ? false : targetNodes.length !== 1;
90
+ if (this._disabled || !this.options.enableEdgeSnapping || isMultiSnapping) {
91
+ return {
92
+ x: 0,
93
+ y: 0,
94
+ };
95
+ }
96
+ return this.snapping({
97
+ targetNodes,
98
+ position,
99
+ });
100
+ });
101
+ const dragEndDisposer = this.dragService.onNodesDrag((event) => {
102
+ if (event.type !== 'onDragEnd' || this._disabled) {
103
+ return;
104
+ }
105
+ if (this.options.enableGridSnapping) {
106
+ this.gridSnapping({
107
+ targetNodes: event.nodes,
108
+ gridSize: this.options.gridSize,
109
+ });
110
+ }
111
+ if (this.options.enableEdgeSnapping) {
112
+ this.clear();
113
+ }
114
+ });
115
+ this.disposers.push(dragAdjusterDisposer, dragEndDisposer);
116
+ }
117
+
118
+ private snapping(params: { targetNodes: WorkflowNodeEntity[]; position: IPoint }): IPoint {
119
+ const { targetNodes, position } = params;
120
+
121
+ const targetBounds = this.getBounds(targetNodes);
122
+
123
+ const targetRect = new Rectangle(
124
+ position.x,
125
+ position.y,
126
+ targetBounds.width,
127
+ targetBounds.height
128
+ );
129
+
130
+ const snapNodeRects = this.getSnapNodeRects({
131
+ targetNodes,
132
+ targetRect,
133
+ });
134
+
135
+ const { alignOffset, alignRects, alignSpacing } = this.calcAlignOffset({
136
+ targetRect,
137
+ alignThreshold: this.options.edgeThreshold,
138
+ snapNodeRects,
139
+ });
140
+
141
+ const { snapOffset, snapEdgeLines } = this.calcSnapOffset({
142
+ targetRect,
143
+ edgeThreshold: this.options.edgeThreshold,
144
+ snapNodeRects,
145
+ });
146
+
147
+ const offset: IPoint = {
148
+ x: snapOffset.x || alignOffset.x,
149
+ y: snapOffset.y || alignOffset.y,
150
+ };
151
+
152
+ const snapRect = new Rectangle(
153
+ position.x + offset.x,
154
+ position.y + offset.y,
155
+ targetRect.width,
156
+ targetRect.height
157
+ );
158
+
159
+ this.snapEmitter.fire({
160
+ snapRect,
161
+ snapEdgeLines,
162
+ alignRects,
163
+ alignSpacing,
164
+ });
165
+
166
+ return offset;
167
+ }
168
+
169
+ private calcSnapOffset(params: {
170
+ snapNodeRects: SnapNodeRect[];
171
+ targetRect: Rectangle;
172
+ edgeThreshold: number;
173
+ }): {
174
+ snapOffset: IPoint;
175
+ snapEdgeLines: SnapEdgeLines;
176
+ } {
177
+ const { snapNodeRects, edgeThreshold, targetRect } = params;
178
+
179
+ const snapLines = this.getSnapLines({
180
+ snapNodeRects,
181
+ });
182
+
183
+ // 找到最近的线条
184
+ const topYClosestLine = snapLines.horizontal.find((line) =>
185
+ isLessThanOrEqual(Math.abs(line.y - targetRect.top), edgeThreshold)
186
+ );
187
+ const bottomYClosestLine = snapLines.horizontal.find((line) =>
188
+ isLessThanOrEqual(Math.abs(line.y - targetRect.bottom), edgeThreshold)
189
+ );
190
+ const leftXClosestLine = snapLines.vertical.find((line) =>
191
+ isLessThanOrEqual(Math.abs(line.x - targetRect.left), edgeThreshold)
192
+ );
193
+ const rightXClosestLine = snapLines.vertical.find((line) =>
194
+ isLessThanOrEqual(Math.abs(line.x - targetRect.right), edgeThreshold)
195
+ );
196
+ const midYClosestLine = snapLines.midHorizontal.find((line) =>
197
+ isLessThanOrEqual(Math.abs(line.y - targetRect.center.y), edgeThreshold)
198
+ );
199
+ const midXClosestLine = snapLines.midVertical.find((line) =>
200
+ isLessThanOrEqual(Math.abs(line.x - targetRect.center.x), edgeThreshold)
201
+ );
202
+
203
+ // 计算最近坐标
204
+ const topYClosest = topYClosestLine?.y;
205
+ const bottomYClosest = isNumber(bottomYClosestLine?.y)
206
+ ? bottomYClosestLine!.y - targetRect.height
207
+ : undefined;
208
+ const leftXClosest = leftXClosestLine?.x;
209
+ const rightXClosest = isNumber(rightXClosestLine?.x)
210
+ ? rightXClosestLine!.x - targetRect.width
211
+ : undefined;
212
+ const midYClosest = isNumber(midYClosestLine?.y)
213
+ ? midYClosestLine!.y - targetRect.height / 2
214
+ : undefined;
215
+ const midXClosest = isNumber(midXClosestLine?.x)
216
+ ? midXClosestLine!.x - targetRect.width / 2
217
+ : undefined;
218
+
219
+ // 吸附后坐标,按优先级取值
220
+ const snappingPosition = {
221
+ x: midXClosest ?? leftXClosest ?? rightXClosest ?? targetRect.x,
222
+ y: midYClosest ?? topYClosest ?? bottomYClosest ?? targetRect.y,
223
+ };
224
+
225
+ // 吸附修正偏移量
226
+ const snapOffset: IPoint = {
227
+ x: snappingPosition.x - targetRect.x,
228
+ y: snappingPosition.y - targetRect.y,
229
+ };
230
+
231
+ // 生效的吸附线条
232
+ const snapEdgeLines: SnapEdgeLines = {
233
+ top: isEqual(topYClosest, snappingPosition.y) ? topYClosestLine : undefined,
234
+ bottom: isEqual(bottomYClosest, snappingPosition.y) ? bottomYClosestLine : undefined,
235
+ left: isEqual(leftXClosest, snappingPosition.x) ? leftXClosestLine : undefined,
236
+ right: isEqual(rightXClosest, snappingPosition.x) ? rightXClosestLine : undefined,
237
+ midVertical: isEqual(midXClosest, snappingPosition.x) ? midXClosestLine : undefined,
238
+ midHorizontal: isEqual(midYClosest, snappingPosition.y) ? midYClosestLine : undefined,
239
+ };
240
+
241
+ return { snapOffset, snapEdgeLines };
242
+ }
243
+
244
+ private gridSnapping(params: { gridSize: number; targetNodes: WorkflowNodeEntity[] }): void {
245
+ const { gridSize, targetNodes } = params;
246
+ const rect = this.getBounds(targetNodes);
247
+ const snap = (value: number) => Math.round(value / gridSize) * gridSize;
248
+ const snappedPosition: IPoint = {
249
+ x: snap(rect.x),
250
+ y: snap(rect.y),
251
+ };
252
+ const offset: IPoint = {
253
+ x: snappedPosition.x - rect.x,
254
+ y: snappedPosition.y - rect.y,
255
+ };
256
+ targetNodes.forEach((node) =>
257
+ this.updateNodePositionWithOffset({
258
+ node,
259
+ offset,
260
+ })
261
+ );
262
+ }
263
+
264
+ private clear() {
265
+ this.snapEmitter.fire({
266
+ snapEdgeLines: {},
267
+ snapRect: Rectangle.EMPTY,
268
+ alignRects: {
269
+ top: [],
270
+ bottom: [],
271
+ left: [],
272
+ right: [],
273
+ },
274
+ alignSpacing: {},
275
+ });
276
+ }
277
+
278
+ private getSnapLines(params: { snapNodeRects: SnapNodeRect[] }): SnapLines {
279
+ const { snapNodeRects } = params;
280
+ const horizontalLines: SnapHorizontalLine[] = [];
281
+ const verticalLines: SnapVerticalLine[] = [];
282
+ const midHorizontalLines: SnapMidHorizontalLine[] = [];
283
+ const midVerticalLines: SnapMidVerticalLine[] = [];
284
+
285
+ snapNodeRects.forEach((snapNodeRect) => {
286
+ const nodeBounds = snapNodeRect.rect;
287
+ const nodeCenter = nodeBounds.center;
288
+ // 边缘横线
289
+ const top: SnapHorizontalLine = {
290
+ y: nodeBounds.top,
291
+ sourceNodeId: snapNodeRect.id,
292
+ };
293
+ const bottom: SnapHorizontalLine = {
294
+ y: nodeBounds.bottom,
295
+ sourceNodeId: snapNodeRect.id,
296
+ };
297
+ // 边缘竖线
298
+ const left: SnapVerticalLine = {
299
+ x: nodeBounds.left,
300
+ sourceNodeId: snapNodeRect.id,
301
+ };
302
+ const right: SnapVerticalLine = {
303
+ x: nodeBounds.right,
304
+ sourceNodeId: snapNodeRect.id,
305
+ };
306
+ // 中间横线
307
+ const midHorizontal: SnapMidHorizontalLine = {
308
+ y: nodeCenter.y,
309
+ sourceNodeId: snapNodeRect.id,
310
+ };
311
+ // 中间竖线
312
+ const midVertical: SnapMidVerticalLine = {
313
+ x: nodeCenter.x,
314
+ sourceNodeId: snapNodeRect.id,
315
+ };
316
+ horizontalLines.push(top, bottom);
317
+ verticalLines.push(left, right);
318
+ midHorizontalLines.push(midHorizontal);
319
+ midVerticalLines.push(midVertical);
320
+ });
321
+
322
+ return {
323
+ horizontal: horizontalLines,
324
+ vertical: verticalLines,
325
+ midHorizontal: midHorizontalLines,
326
+ midVertical: midVerticalLines,
327
+ };
328
+ }
329
+
330
+ private getAvailableNodes(params: {
331
+ targetNodes: WorkflowNodeEntity[];
332
+ targetRect: Rectangle;
333
+ }): WorkflowNodeEntity[] {
334
+ const { targetNodes, targetRect } = params;
335
+
336
+ const targetCenter = targetRect.center;
337
+ const targetContainerId = targetNodes[0].parent?.id ?? this.document.root.id;
338
+
339
+ const disabledNodeIds = targetNodes.map((n) => n.id);
340
+ disabledNodeIds.push(FlowNodeBaseType.ROOT);
341
+ const availableNodes = this.nodes
342
+ .filter((n) => n.parent?.id === targetContainerId)
343
+ .filter((n) => !disabledNodeIds.includes(n.id))
344
+ .sort((nodeA, nodeB) => {
345
+ const nodeCenterA = nodeA.getData(FlowNodeTransformData)!.bounds.center;
346
+ const nodeCenterB = nodeB.getData(FlowNodeTransformData)!.bounds.center;
347
+ // 距离越近优先级越高
348
+ const distanceA =
349
+ Math.abs(nodeCenterA.x - targetCenter.x) + Math.abs(nodeCenterA.y - targetCenter.y);
350
+ const distanceB =
351
+ Math.abs(nodeCenterB.x - targetCenter.x) + Math.abs(nodeCenterB.y - targetCenter.y);
352
+ return distanceA - distanceB;
353
+ });
354
+ return availableNodes;
355
+ }
356
+
357
+ private viewRect(): Rectangle {
358
+ const { width, height, scrollX, scrollY, zoom } = this.playgroundConfig.config;
359
+ return new Rectangle(scrollX / zoom, scrollY / zoom, width / zoom, height / zoom);
360
+ }
361
+
362
+ private getSnapNodeRects(params: {
363
+ targetNodes: WorkflowNodeEntity[];
364
+ targetRect: Rectangle;
365
+ }): SnapNodeRect[] {
366
+ const availableNodes = this.getAvailableNodes(params);
367
+ const viewRect = this.viewRect();
368
+ return availableNodes
369
+ .map((node) => {
370
+ const snapNodeRect: SnapNodeRect = {
371
+ id: node.id,
372
+ rect: node.getData(FlowNodeTransformData).bounds,
373
+ entity: node,
374
+ };
375
+ if (
376
+ this.options.enableOnlyViewportSnapping &&
377
+ node.parent?.flowNodeType === FlowNodeBaseType.ROOT &&
378
+ !Rectangle.intersects(viewRect, snapNodeRect.rect)
379
+ ) {
380
+ // 最外层节点仅包含当前可见节点
381
+ return;
382
+ }
383
+ return snapNodeRect;
384
+ })
385
+ .filter(Boolean) as SnapNodeRect[];
386
+ }
387
+
388
+ private get nodes(): WorkflowNodeEntity[] {
389
+ return this.entityManager.getEntities<WorkflowNodeEntity>(WorkflowNodeEntity);
390
+ }
391
+
392
+ private getBounds(nodes: WorkflowNodeEntity[]): Rectangle {
393
+ if (nodes.length === 0) {
394
+ return Rectangle.EMPTY;
395
+ }
396
+ return Rectangle.enlarge(nodes.map((n) => n.getData(FlowNodeTransformData)!.bounds));
397
+ }
398
+
399
+ private updateNodePositionWithOffset(params: { node: WorkflowNodeEntity; offset: IPoint }): void {
400
+ const { node, offset } = params;
401
+ const transform = node.getData(TransformData);
402
+ const positionWithOffset: IPoint = {
403
+ x: transform.position.x + offset.x,
404
+ y: transform.position.y + offset.y,
405
+ };
406
+ transform.update({
407
+ position: positionWithOffset,
408
+ });
409
+ this.document.layout.updateAffectedTransform(node);
410
+ }
411
+
412
+ private calcAlignOffset(params: {
413
+ snapNodeRects: SnapNodeRect[];
414
+ targetRect: Rectangle;
415
+ alignThreshold: number;
416
+ }): {
417
+ alignOffset: IPoint;
418
+ alignRects: AlignRects;
419
+ alignSpacing: AlignSpacing;
420
+ } {
421
+ const { snapNodeRects, targetRect, alignThreshold } = params;
422
+
423
+ const alignRects = this.getAlignRects({
424
+ targetRect,
425
+ snapNodeRects,
426
+ });
427
+
428
+ const alignSpacing = this.calcAlignSpacing({
429
+ targetRect,
430
+ alignRects,
431
+ });
432
+
433
+ let topY: number | undefined;
434
+ let bottomY: number | undefined;
435
+ let leftX: number | undefined;
436
+ let rightX: number | undefined;
437
+ let midY: number | undefined;
438
+ let midX: number | undefined;
439
+
440
+ if (alignSpacing.top) {
441
+ const topAlignY = alignRects.top[0].rect.bottom + alignSpacing.top;
442
+ const isAlignTop = isLessThanOrEqual(Math.abs(targetRect.top - topAlignY), alignThreshold);
443
+ if (isAlignTop) {
444
+ // 生效
445
+ topY = topAlignY;
446
+ } else {
447
+ // 失效
448
+ alignSpacing.top = undefined;
449
+ }
450
+ }
451
+ if (alignSpacing.bottom) {
452
+ const bottomAlignY = alignRects.bottom[0].rect.top - alignSpacing.bottom;
453
+ const isAlignBottom = isLessThan(Math.abs(targetRect.bottom - bottomAlignY), alignThreshold);
454
+ if (isAlignBottom) {
455
+ bottomY = bottomAlignY - targetRect.height;
456
+ } else {
457
+ alignSpacing.bottom = undefined;
458
+ }
459
+ }
460
+ if (alignSpacing.left) {
461
+ const leftAlignX = alignRects.left[0].rect.right + alignSpacing.left;
462
+ const isAlignLeft = isLessThanOrEqual(Math.abs(targetRect.left - leftAlignX), alignThreshold);
463
+ if (isAlignLeft) {
464
+ leftX = leftAlignX;
465
+ } else {
466
+ alignSpacing.left = undefined;
467
+ }
468
+ }
469
+ if (alignSpacing.right) {
470
+ const rightAlignX = alignRects.right[0].rect.left - alignSpacing.right;
471
+ const isAlignRight = isLessThanOrEqual(
472
+ Math.abs(targetRect.right - rightAlignX),
473
+ alignThreshold
474
+ );
475
+ if (isAlignRight) {
476
+ rightX = rightAlignX - targetRect.width;
477
+ } else {
478
+ alignSpacing.right = undefined;
479
+ }
480
+ }
481
+ if (alignSpacing.midHorizontal) {
482
+ const leftAlignX = alignRects.left[0].rect.right + alignSpacing.midHorizontal;
483
+ const isAlignMidHorizontal = isLessThanOrEqual(
484
+ Math.abs(targetRect.left - leftAlignX),
485
+ alignThreshold
486
+ );
487
+ if (isAlignMidHorizontal) {
488
+ midX = leftAlignX;
489
+ } else {
490
+ alignSpacing.midHorizontal = undefined;
491
+ }
492
+ }
493
+ if (alignSpacing.midVertical) {
494
+ const topAlignY = alignRects.top[0].rect.bottom + alignSpacing.midVertical;
495
+ const isAlignMidVertical = isLessThanOrEqual(
496
+ Math.abs(targetRect.top - topAlignY),
497
+ alignThreshold
498
+ );
499
+ if (isAlignMidVertical) {
500
+ midY = topAlignY;
501
+ } else {
502
+ alignSpacing.midVertical = undefined;
503
+ }
504
+ }
505
+
506
+ const alignPosition: IPoint = {
507
+ x: midX ?? leftX ?? rightX ?? targetRect.x,
508
+ y: midY ?? topY ?? bottomY ?? targetRect.y,
509
+ };
510
+
511
+ const alignOffset: IPoint = {
512
+ x: alignPosition.x - targetRect.x,
513
+ y: alignPosition.y - targetRect.y,
514
+ };
515
+
516
+ return { alignOffset, alignRects, alignSpacing };
517
+ }
518
+
519
+ private calcAlignSpacing(params: {
520
+ targetRect: Rectangle;
521
+ alignRects: AlignRects;
522
+ }): AlignSpacing {
523
+ const { targetRect, alignRects } = params;
524
+
525
+ const topSpacing = this.getDirectionAlignSpacing({
526
+ rects: alignRects.top,
527
+ isHorizontal: false,
528
+ });
529
+ const bottomSpacing = this.getDirectionAlignSpacing({
530
+ rects: alignRects.bottom,
531
+ isHorizontal: false,
532
+ });
533
+ const leftSpacing = this.getDirectionAlignSpacing({
534
+ rects: alignRects.left,
535
+ isHorizontal: true,
536
+ });
537
+ const rightSpacing = this.getDirectionAlignSpacing({
538
+ rects: alignRects.right,
539
+ isHorizontal: true,
540
+ });
541
+ const midHorizontalSpacing = this.getMidAlignSpacing({
542
+ rectA: alignRects.left[0]?.rect,
543
+ rectB: alignRects.right[0]?.rect,
544
+ targetRect,
545
+ isHorizontal: true,
546
+ });
547
+ const midVerticalSpacing = this.getMidAlignSpacing({
548
+ rectA: alignRects.top[0]?.rect,
549
+ rectB: alignRects.bottom[0]?.rect,
550
+ targetRect,
551
+ isHorizontal: false,
552
+ });
553
+ return {
554
+ top: topSpacing,
555
+ bottom: bottomSpacing,
556
+ left: leftSpacing,
557
+ right: rightSpacing,
558
+ midHorizontal: midHorizontalSpacing,
559
+ midVertical: midVerticalSpacing,
560
+ };
561
+ }
562
+
563
+ private getAlignRects(params: {
564
+ targetRect: Rectangle;
565
+ snapNodeRects: SnapNodeRect[];
566
+ }): AlignRects {
567
+ const { targetRect, snapNodeRects } = params;
568
+
569
+ const topVerticalRects: AlignRect[] = [];
570
+ const bottomVerticalRects: AlignRect[] = [];
571
+ const leftHorizontalRects: AlignRect[] = [];
572
+ const rightHorizontalRects: AlignRect[] = [];
573
+
574
+ snapNodeRects.forEach((snapNodeRect) => {
575
+ const nodeRect = snapNodeRect.rect;
576
+ const { isVerticalIntersection, isHorizontalIntersection, isIntersection } =
577
+ this.intersection(nodeRect, targetRect);
578
+ if (isIntersection) {
579
+ // 忽略重叠的节点
580
+ return;
581
+ } else if (isVerticalIntersection) {
582
+ // 垂直重合
583
+ if (isGreaterThan(nodeRect.center.y, targetRect.center.y)) {
584
+ // 下方
585
+ bottomVerticalRects.push({
586
+ rect: nodeRect,
587
+ sourceNodeId: snapNodeRect.id,
588
+ });
589
+ } else {
590
+ // 上方
591
+ topVerticalRects.push({
592
+ rect: nodeRect,
593
+ sourceNodeId: snapNodeRect.id,
594
+ });
595
+ }
596
+ } else if (isHorizontalIntersection) {
597
+ // 水平重合
598
+ if (isGreaterThan(nodeRect.center.x, targetRect.center.x)) {
599
+ // 右方
600
+ rightHorizontalRects.push({
601
+ rect: nodeRect,
602
+ sourceNodeId: snapNodeRect.id,
603
+ });
604
+ } else {
605
+ // 左方
606
+ leftHorizontalRects.push({
607
+ rect: nodeRect,
608
+ sourceNodeId: snapNodeRect.id,
609
+ });
610
+ }
611
+ }
612
+ });
613
+
614
+ return {
615
+ top: topVerticalRects,
616
+ bottom: bottomVerticalRects,
617
+ left: leftHorizontalRects,
618
+ right: rightHorizontalRects,
619
+ };
620
+ }
621
+
622
+ private getMidAlignSpacing(params: {
623
+ rectA?: Rectangle;
624
+ rectB?: Rectangle;
625
+ targetRect: Rectangle;
626
+ isHorizontal: boolean;
627
+ }): number | undefined {
628
+ const { rectA, rectB, targetRect, isHorizontal } = params;
629
+ if (!rectA || !rectB) {
630
+ return;
631
+ }
632
+ const { isVerticalIntersection, isHorizontalIntersection, isIntersection } = this.intersection(
633
+ rectA,
634
+ rectB
635
+ );
636
+ if (isIntersection) {
637
+ return;
638
+ }
639
+ if (isHorizontal && isHorizontalIntersection && !isVerticalIntersection) {
640
+ const betweenSpacing = Math.min(
641
+ Math.abs(rectA.left - rectB.right),
642
+ Math.abs(rectA.right - rectB.left)
643
+ );
644
+ return (betweenSpacing - targetRect.width) / 2;
645
+ } else if (!isHorizontal && isVerticalIntersection && !isHorizontalIntersection) {
646
+ const betweenSpacing = Math.min(
647
+ Math.abs(rectA.top - rectB.bottom),
648
+ Math.abs(rectA.bottom - rectB.top)
649
+ );
650
+ return (betweenSpacing - targetRect.height) / 2;
651
+ }
652
+ }
653
+
654
+ private getDirectionAlignSpacing(params: {
655
+ rects: AlignRect[];
656
+ isHorizontal: boolean;
657
+ }): number | undefined {
658
+ const { rects, isHorizontal } = params;
659
+ if (rects.length < 2) {
660
+ // 非法情况
661
+ return;
662
+ }
663
+ const rectA = rects[0].rect;
664
+ const rectB = rects[1].rect;
665
+
666
+ const { isVerticalIntersection, isHorizontalIntersection, isIntersection } = this.intersection(
667
+ rectA,
668
+ rectB
669
+ );
670
+
671
+ if (isIntersection) {
672
+ // 非法情况:重叠
673
+ return;
674
+ }
675
+ if (isHorizontal && isHorizontalIntersection && !isVerticalIntersection) {
676
+ return Math.min(Math.abs(rectA.left - rectB.right), Math.abs(rectA.right - rectB.left));
677
+ } else if (!isHorizontal && isVerticalIntersection && !isHorizontalIntersection) {
678
+ return Math.min(Math.abs(rectA.top - rectB.bottom), Math.abs(rectA.bottom - rectB.top));
679
+ }
680
+ return;
681
+ }
682
+
683
+ private intersection(
684
+ rectA: Rectangle,
685
+ rectB: Rectangle
686
+ ): {
687
+ isHorizontalIntersection: boolean;
688
+ isVerticalIntersection: boolean;
689
+ isIntersection: boolean;
690
+ } {
691
+ const isVerticalIntersection =
692
+ isLessThan(rectA.left, rectB.right) && isGreaterThan(rectA.right, rectB.left);
693
+ const isHorizontalIntersection =
694
+ isLessThan(rectA.top, rectB.bottom) && isGreaterThan(rectA.bottom, rectB.top);
695
+ const isIntersection = isHorizontalIntersection && isVerticalIntersection;
696
+
697
+ return {
698
+ isHorizontalIntersection,
699
+ isVerticalIntersection,
700
+ isIntersection,
701
+ };
702
+ }
703
+ }