1
+ import type { RectDimension , RectPosition } from '@onlook/models' ;
2
+ import { makeAutoObservable } from 'mobx' ;
3
+ import type { EditorEngine } from '../engine' ;
4
+ import type { SnapBounds , SnapConfig , SnapFrame , SnapLine , SnapTarget } from './types' ;
5
+ import { SnapLineType } from './types' ;
6
+
7
+ const SNAP_CONFIG = {
8
+ DEFAULT_THRESHOLD : 12 ,
9
+ LINE_EXTENSION : 160 ,
10
+ } as const ;
11
+
12
+ export class SnapManager {
13
+ config : SnapConfig = {
14
+ threshold : SNAP_CONFIG . DEFAULT_THRESHOLD ,
15
+ enabled : true ,
16
+ showGuidelines : true ,
17
+ } ;
18
+
19
+ activeSnapLines : SnapLine [ ] = [ ] ;
20
+
21
+ constructor ( private editorEngine : EditorEngine ) {
22
+ makeAutoObservable ( this ) ;
23
+ }
24
+
25
+ private createSnapBounds ( position : RectPosition , dimension : RectDimension ) : SnapBounds {
26
+ const left = position . x ;
27
+ const top = position . y ;
28
+ const right = position . x + dimension . width ;
29
+ const bottom = position . y + dimension . height ;
30
+ const centerX = position . x + dimension . width / 2 ;
31
+ const centerY = position . y + dimension . height / 2 ;
32
+
33
+ return {
34
+ left,
35
+ top,
36
+ right,
37
+ bottom,
38
+ centerX,
39
+ centerY,
40
+ width : dimension . width ,
41
+ height : dimension . height ,
42
+ } ;
43
+ }
44
+
45
+ private getSnapFrames ( excludeFrameId ?: string ) : SnapFrame [ ] {
46
+ return this . editorEngine . frames . getAll ( )
47
+ . filter ( frameData => frameData . frame . id !== excludeFrameId )
48
+ . map ( frameData => {
49
+ const frame = frameData . frame ;
50
+ return {
51
+ id : frame . id ,
52
+ position : frame . position ,
53
+ dimension : frame . dimension ,
54
+ bounds : this . createSnapBounds ( frame . position , frame . dimension ) ,
55
+ } ;
56
+ } ) ;
57
+ }
58
+
59
+ calculateSnapTarget (
60
+ dragFrameId : string ,
61
+ currentPosition : RectPosition ,
62
+ dimension : RectDimension ,
63
+ ) : SnapTarget | null {
64
+ if ( ! this . config . enabled ) {
65
+ return null ;
66
+ }
67
+
68
+ const dragBounds = this . createSnapBounds ( currentPosition , dimension ) ;
69
+ const otherFrames = this . getSnapFrames ( dragFrameId ) ;
70
+
71
+ if ( otherFrames . length === 0 ) {
72
+ return null ;
73
+ }
74
+
75
+ const snapCandidates : Array < { position : RectPosition ; lines : SnapLine [ ] ; distance : number } > = [ ] ;
76
+
77
+ for ( const otherFrame of otherFrames ) {
78
+ const candidates = this . calculateSnapCandidates ( dragBounds , otherFrame ) ;
79
+ snapCandidates . push ( ...candidates ) ;
80
+ }
81
+
82
+ if ( snapCandidates . length === 0 ) {
83
+ return null ;
84
+ }
85
+
86
+ snapCandidates . sort ( ( a , b ) => a . distance - b . distance ) ;
87
+ const bestCandidate = snapCandidates [ 0 ] ;
88
+
89
+ if ( ! bestCandidate || bestCandidate . distance > this . config . threshold ) {
90
+ return null ;
91
+ }
92
+
93
+ const firstLine = bestCandidate . lines [ 0 ] ;
94
+ if ( ! firstLine ) {
95
+ return null ;
96
+ }
97
+
98
+ return {
99
+ position : bestCandidate . position ,
100
+ snapLines : [ firstLine ] ,
101
+ distance : bestCandidate . distance ,
102
+ } ;
103
+ }
104
+
105
+ private calculateSnapCandidates (
106
+ dragBounds : SnapBounds ,
107
+ otherFrame : SnapFrame ,
108
+ ) : Array < { position : RectPosition ; lines : SnapLine [ ] ; distance : number } > {
109
+ const candidates : Array < { position : RectPosition ; lines : SnapLine [ ] ; distance : number } > = [ ] ;
110
+
111
+ const edgeAlignments = [
112
+ {
113
+ type : SnapLineType . EDGE_LEFT ,
114
+ dragOffset : dragBounds . left ,
115
+ targetValue : otherFrame . bounds . left ,
116
+ orientation : 'vertical' as const ,
117
+ } ,
118
+ {
119
+ type : SnapLineType . EDGE_LEFT ,
120
+ dragOffset : dragBounds . right ,
121
+ targetValue : otherFrame . bounds . left ,
122
+ orientation : 'vertical' as const ,
123
+ } ,
124
+ {
125
+ type : SnapLineType . EDGE_RIGHT ,
126
+ dragOffset : dragBounds . left ,
127
+ targetValue : otherFrame . bounds . right ,
128
+ orientation : 'vertical' as const ,
129
+ } ,
130
+ {
131
+ type : SnapLineType . EDGE_RIGHT ,
132
+ dragOffset : dragBounds . right ,
133
+ targetValue : otherFrame . bounds . right ,
134
+ orientation : 'vertical' as const ,
135
+ } ,
136
+ {
137
+ type : SnapLineType . EDGE_TOP ,
138
+ dragOffset : dragBounds . top ,
139
+ targetValue : otherFrame . bounds . top ,
140
+ orientation : 'horizontal' as const ,
141
+ } ,
142
+ {
143
+ type : SnapLineType . EDGE_TOP ,
144
+ dragOffset : dragBounds . bottom ,
145
+ targetValue : otherFrame . bounds . top ,
146
+ orientation : 'horizontal' as const ,
147
+ } ,
148
+ {
149
+ type : SnapLineType . EDGE_BOTTOM ,
150
+ dragOffset : dragBounds . top ,
151
+ targetValue : otherFrame . bounds . bottom ,
152
+ orientation : 'horizontal' as const ,
153
+ } ,
154
+ {
155
+ type : SnapLineType . EDGE_BOTTOM ,
156
+ dragOffset : dragBounds . bottom ,
157
+ targetValue : otherFrame . bounds . bottom ,
158
+ orientation : 'horizontal' as const ,
159
+ } ,
160
+ {
161
+ type : SnapLineType . CENTER_HORIZONTAL ,
162
+ dragOffset : dragBounds . centerY ,
163
+ targetValue : otherFrame . bounds . centerY ,
164
+ orientation : 'horizontal' as const ,
165
+ } ,
166
+ {
167
+ type : SnapLineType . CENTER_VERTICAL ,
168
+ dragOffset : dragBounds . centerX ,
169
+ targetValue : otherFrame . bounds . centerX ,
170
+ orientation : 'vertical' as const ,
171
+ } ,
172
+ ] ;
173
+
174
+ for ( const alignment of edgeAlignments ) {
175
+ const distance = Math . abs ( alignment . dragOffset - alignment . targetValue ) ;
176
+
177
+ if ( distance <= this . config . threshold ) {
178
+ const offset = alignment . targetValue - alignment . dragOffset ;
179
+ const newPosition = alignment . orientation === 'horizontal'
180
+ ? { x : dragBounds . left , y : dragBounds . top + offset }
181
+ : { x : dragBounds . left + offset , y : dragBounds . top } ;
182
+
183
+ const snapLine = this . createSnapLine ( alignment . type , alignment . orientation , alignment . targetValue , otherFrame , dragBounds ) ;
184
+
185
+
186
+ candidates . push ( {
187
+ position : newPosition ,
188
+ lines : [ snapLine ] ,
189
+ distance,
190
+ } ) ;
191
+ }
192
+ }
193
+
194
+ return candidates ;
195
+ }
196
+
197
+ private createSnapLine (
198
+ type : SnapLineType ,
199
+ orientation : 'horizontal' | 'vertical' ,
200
+ position : number ,
201
+ otherFrame : SnapFrame ,
202
+ dragBounds : SnapBounds ,
203
+ ) : SnapLine {
204
+ let start : number ;
205
+ let end : number ;
206
+
207
+ if ( orientation === 'horizontal' ) {
208
+ start = Math . min ( dragBounds . left , otherFrame . bounds . left ) - SNAP_CONFIG . LINE_EXTENSION ;
209
+ end = Math . max ( dragBounds . right , otherFrame . bounds . right ) + SNAP_CONFIG . LINE_EXTENSION ;
210
+ } else {
211
+ start = Math . min ( dragBounds . top , otherFrame . bounds . top ) - SNAP_CONFIG . LINE_EXTENSION ;
212
+ end = Math . max ( dragBounds . bottom , otherFrame . bounds . bottom ) + SNAP_CONFIG . LINE_EXTENSION ;
213
+ }
214
+
215
+ return {
216
+ id : `${ type } -${ otherFrame . id } -${ Date . now ( ) } ` ,
217
+ type,
218
+ orientation,
219
+ position,
220
+ start,
221
+ end,
222
+ frameIds : [ otherFrame . id ] ,
223
+ } ;
224
+ }
225
+
226
+ showSnapLines ( lines : SnapLine [ ] ) : void {
227
+ if ( ! this . config . showGuidelines ) {
228
+ return ;
229
+ }
230
+ this . activeSnapLines = lines ;
231
+ }
232
+
233
+ hideSnapLines ( ) : void {
234
+ this . activeSnapLines = [ ] ;
235
+ }
236
+
237
+ setConfig ( config : Partial < SnapConfig > ) : void {
238
+ Object . assign ( this . config , config ) ;
239
+ }
240
+ }
0 commit comments