Skip to content

Commit f8b3eb6

Browse files
committed
feat: add canvas snap/alignment guidelines for frames
1 parent 6e072e5 commit f8b3eb6

File tree

7 files changed

+965
-607
lines changed

7 files changed

+965
-607
lines changed

apps/web/client/src/app/project/[id]/_components/canvas/frame/top-bar.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,17 +36,35 @@ export const TopBar = observer(
3636
const deltaX = (e.clientX - startX) / scale;
3737
const deltaY = (e.clientY - startY) / scale;
3838

39-
const newPosition = {
39+
let newPosition = {
4040
x: startPositionX + deltaX,
4141
y: startPositionY + deltaY,
4242
};
4343

44+
if (editorEngine.snap.config.enabled && !e.ctrlKey && !e.metaKey) {
45+
const snapTarget = editorEngine.snap.calculateSnapTarget(
46+
frame.id,
47+
newPosition,
48+
frame.dimension
49+
);
50+
51+
if (snapTarget) {
52+
newPosition = snapTarget.position;
53+
editorEngine.snap.showSnapLines(snapTarget.snapLines);
54+
} else {
55+
editorEngine.snap.hideSnapLines();
56+
}
57+
} else {
58+
editorEngine.snap.hideSnapLines();
59+
}
60+
4461
editorEngine.frames.updateAndSaveToStorage(frame.id, { position: newPosition });
4562
};
4663

4764
const endMove = (e: MouseEvent) => {
4865
e.preventDefault();
4966
e.stopPropagation();
67+
editorEngine.snap.hideSnapLines();
5068
window.removeEventListener('mousemove', handleMove);
5169
window.removeEventListener('mouseup', endMove);
5270
};
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
'use client';
2+
3+
import { useEditorEngine } from '@/components/store/editor';
4+
import { observer } from 'mobx-react-lite';
5+
6+
const SNAP_VISUAL_CONFIG = {
7+
TOP_BAR_HEIGHT: 28,
8+
TOP_BAR_MARGIN: 10,
9+
} as const;
10+
11+
export const SnapGuidelines = observer(() => {
12+
const editorEngine = useEditorEngine();
13+
const snapLines = editorEngine.snap.activeSnapLines;
14+
15+
if (snapLines.length === 0) {
16+
return null;
17+
}
18+
19+
const scale = editorEngine.canvas.scale;
20+
const canvasPosition = editorEngine.canvas.position;
21+
22+
return (
23+
<div
24+
className="absolute inset-0 pointer-events-none"
25+
style={{
26+
transform: `translate(${canvasPosition.x}px, ${canvasPosition.y}px) scale(${scale})`,
27+
transformOrigin: '0 0',
28+
}}
29+
>
30+
{snapLines.map((line) => {
31+
if (line.orientation === 'horizontal') {
32+
const visualOffset = (SNAP_VISUAL_CONFIG.TOP_BAR_HEIGHT + SNAP_VISUAL_CONFIG.TOP_BAR_MARGIN) / scale;
33+
34+
return (
35+
<div
36+
key={line.id}
37+
className="absolute bg-red-500"
38+
style={{
39+
left: `${line.start}px`,
40+
top: `${line.position + visualOffset}px`,
41+
width: `${line.end - line.start}px`,
42+
height: `${Math.max(1, 2 / scale)}px`,
43+
opacity: 0.9,
44+
boxShadow: '0 0 4px rgba(239, 68, 68, 0.6)',
45+
}}
46+
/>
47+
);
48+
} else {
49+
return (
50+
<div
51+
key={line.id}
52+
className="absolute bg-red-500"
53+
style={{
54+
left: `${line.position}px`,
55+
top: `${line.start}px`,
56+
width: `${Math.max(1, 2 / scale)}px`,
57+
height: `${line.end - line.start}px`,
58+
opacity: 0.9,
59+
boxShadow: '0 0 4px rgba(239, 68, 68, 0.6)',
60+
}}
61+
/>
62+
);
63+
}
64+
})}
65+
</div>
66+
);
67+
});

apps/web/client/src/app/project/[id]/_components/canvas/overlay/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { MeasurementOverlay } from './elements/measurement';
1010
import { ClickRect } from './elements/rect/click';
1111
import { HoverRect } from './elements/rect/hover';
1212
import { InsertRect } from './elements/rect/insert';
13+
import { SnapGuidelines } from './elements/snap-guidelines';
1314
import { TextEditor } from './elements/text';
1415

1516
export const Overlay = observer(() => {
@@ -73,6 +74,7 @@ export const Overlay = observer(() => {
7374
{overlayState.clickRects.length > 0 && (
7475
<OverlayButtons />
7576
)}
77+
<SnapGuidelines />
7678
</div>
7779
);
7880
});

apps/web/client/src/components/store/editor/engine.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { PagesManager } from './pages';
2222
import { PreloadScriptManager } from './preload';
2323
import { SandboxManager } from './sandbox';
2424
import { ScreenshotManager } from './screenshot';
25+
import { SnapManager } from './snap';
2526
import { StateManager } from './state';
2627
import { StyleManager } from './style';
2728
import { TextEditingManager } from './text';
@@ -59,6 +60,7 @@ export class EditorEngine {
5960
readonly frameEvent: FrameEventManager = new FrameEventManager(this);
6061
readonly preloadScript: PreloadScriptManager = new PreloadScriptManager(this);
6162
readonly screenshot: ScreenshotManager = new ScreenshotManager(this);
63+
readonly snap: SnapManager = new SnapManager(this);
6264

6365
constructor(projectId: string, posthog: PostHog) {
6466
this.projectId = projectId;
@@ -91,12 +93,14 @@ export class EditorEngine {
9193
this.sandbox.clear();
9294
this.frameEvent.clear();
9395
this.screenshot.clear();
96+
this.snap.hideSnapLines();
9497
}
9598

9699
clearUI() {
97100
this.overlay.clear();
98101
this.elements.clear();
99102
this.frames.deselectAll();
103+
this.snap.hideSnapLines();
100104
}
101105

102106
async refreshLayers() {
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
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

Comments
 (0)