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