Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -32,21 +32,40 @@ export const TopBar = observer(
const startPositionY = frame.position.y;

const handleMove = async (e: MouseEvent) => {
clearElements();
const scale = editorEngine.canvas.scale;
const deltaX = (e.clientX - startX) / scale;
const deltaY = (e.clientY - startY) / scale;

const newPosition = {
let newPosition = {
x: startPositionX + deltaX,
y: startPositionY + deltaY,
};

if (editorEngine.snap.config.enabled && !e.ctrlKey && !e.metaKey) {
const snapTarget = editorEngine.snap.calculateSnapTarget(
frame.id,
newPosition,
frame.dimension
);

if (snapTarget) {
newPosition = snapTarget.position;
editorEngine.snap.showSnapLines(snapTarget.snapLines);
} else {
editorEngine.snap.hideSnapLines();
}
} else {
editorEngine.snap.hideSnapLines();
}

editorEngine.frames.updateAndSaveToStorage(frame.id, { position: newPosition });
};

const endMove = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
editorEngine.snap.hideSnapLines();
window.removeEventListener('mousemove', handleMove);
window.removeEventListener('mouseup', endMove);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
'use client';

import { useEditorEngine } from '@/components/store/editor';
import { observer } from 'mobx-react-lite';

const SNAP_VISUAL_CONFIG = {
TOP_BAR_HEIGHT: 28,
TOP_BAR_MARGIN: 10,
} as const;

export const SnapGuidelines = observer(() => {
const editorEngine = useEditorEngine();
const snapLines = editorEngine.snap.activeSnapLines;

if (snapLines.length === 0) {
return null;
}

const scale = editorEngine.canvas.scale;
const canvasPosition = editorEngine.canvas.position;

return (
<div
className="absolute inset-0 pointer-events-none"
style={{
transform: `translate(${canvasPosition.x}px, ${canvasPosition.y}px) scale(${scale})`,
transformOrigin: '0 0',
}}
>
{snapLines.map((line) => {
if (line.orientation === 'horizontal') {
const visualOffset = (SNAP_VISUAL_CONFIG.TOP_BAR_HEIGHT + SNAP_VISUAL_CONFIG.TOP_BAR_MARGIN) / scale;

return (
<div
key={line.id}
className="absolute bg-red-500"
style={{
left: `${line.start}px`,
top: `${line.position + visualOffset}px`,
width: `${line.end - line.start}px`,
height: `${Math.max(1, 2 / scale)}px`,
opacity: 0.9,
boxShadow: '0 0 4px rgba(239, 68, 68, 0.6)',
}}
/>
);
} else {
return (
<div
key={line.id}
className="absolute bg-red-500"
style={{
left: `${line.position}px`,
top: `${line.start}px`,
width: `${Math.max(1, 2 / scale)}px`,
height: `${line.end - line.start}px`,
opacity: 0.9,
boxShadow: '0 0 4px rgba(239, 68, 68, 0.6)',
}}
/>
);
}
})}
</div>
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { MeasurementOverlay } from './elements/measurement';
import { ClickRect } from './elements/rect/click';
import { HoverRect } from './elements/rect/hover';
import { InsertRect } from './elements/rect/insert';
import { SnapGuidelines } from './elements/snap-guidelines';
import { TextEditor } from './elements/text';

export const Overlay = observer(() => {
Expand Down Expand Up @@ -73,6 +74,7 @@ export const Overlay = observer(() => {
{overlayState.clickRects.length > 0 && (
<OverlayButtons />
)}
<SnapGuidelines />
</div>
);
});
4 changes: 4 additions & 0 deletions apps/web/client/src/components/store/editor/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { PagesManager } from './pages';
import { PreloadScriptManager } from './preload';
import { SandboxManager } from './sandbox';
import { ScreenshotManager } from './screenshot';
import { SnapManager } from './snap';
import { StateManager } from './state';
import { StyleManager } from './style';
import { TextEditingManager } from './text';
Expand Down Expand Up @@ -59,6 +60,7 @@ export class EditorEngine {
readonly frameEvent: FrameEventManager = new FrameEventManager(this);
readonly preloadScript: PreloadScriptManager = new PreloadScriptManager(this);
readonly screenshot: ScreenshotManager = new ScreenshotManager(this);
readonly snap: SnapManager = new SnapManager(this);

constructor(projectId: string, posthog: PostHog) {
this.projectId = projectId;
Expand Down Expand Up @@ -91,12 +93,14 @@ export class EditorEngine {
this.sandbox.clear();
this.frameEvent.clear();
this.screenshot.clear();
this.snap.hideSnapLines();
}

clearUI() {
this.overlay.clear();
this.elements.clear();
this.frames.deselectAll();
this.snap.hideSnapLines();
}

async refreshLayers() {
Expand Down
240 changes: 240 additions & 0 deletions apps/web/client/src/components/store/editor/snap/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
import type { RectDimension, RectPosition } from '@onlook/models';
import { makeAutoObservable } from 'mobx';
import type { EditorEngine } from '../engine';
import type { SnapBounds, SnapConfig, SnapFrame, SnapLine, SnapTarget } from './types';
import { SnapLineType } from './types';

const SNAP_CONFIG = {
DEFAULT_THRESHOLD: 12,
LINE_EXTENSION: 160,
} as const;

export class SnapManager {
config: SnapConfig = {
threshold: SNAP_CONFIG.DEFAULT_THRESHOLD,
enabled: true,
showGuidelines: true,
};

activeSnapLines: SnapLine[] = [];

constructor(private editorEngine: EditorEngine) {
makeAutoObservable(this);
}

private createSnapBounds(position: RectPosition, dimension: RectDimension): SnapBounds {
const left = position.x;
const top = position.y;
const right = position.x + dimension.width;
const bottom = position.y + dimension.height;
const centerX = position.x + dimension.width / 2;
const centerY = position.y + dimension.height / 2;

return {
left,
top,
right,
bottom,
centerX,
centerY,
width: dimension.width,
height: dimension.height,
};
}

private getSnapFrames(excludeFrameId?: string): SnapFrame[] {
return this.editorEngine.frames.getAll()
.filter(frameData => frameData.frame.id !== excludeFrameId)
.map(frameData => {
const frame = frameData.frame;
return {
id: frame.id,
position: frame.position,
dimension: frame.dimension,
bounds: this.createSnapBounds(frame.position, frame.dimension),
};
});
}

calculateSnapTarget(
dragFrameId: string,
currentPosition: RectPosition,
dimension: RectDimension,
): SnapTarget | null {
if (!this.config.enabled) {
return null;
}

const dragBounds = this.createSnapBounds(currentPosition, dimension);
const otherFrames = this.getSnapFrames(dragFrameId);

if (otherFrames.length === 0) {
return null;
}

const snapCandidates: Array<{ position: RectPosition; lines: SnapLine[]; distance: number }> = [];

for (const otherFrame of otherFrames) {
const candidates = this.calculateSnapCandidates(dragBounds, otherFrame);
snapCandidates.push(...candidates);
}

if (snapCandidates.length === 0) {
return null;
}

snapCandidates.sort((a, b) => a.distance - b.distance);
const bestCandidate = snapCandidates[0];

if (!bestCandidate || bestCandidate.distance > this.config.threshold) {
return null;
}

const firstLine = bestCandidate.lines[0];
if (!firstLine) {
return null;
}

return {
position: bestCandidate.position,
snapLines: [firstLine],
distance: bestCandidate.distance,
};
}

private calculateSnapCandidates(
dragBounds: SnapBounds,
otherFrame: SnapFrame,
): Array<{ position: RectPosition; lines: SnapLine[]; distance: number }> {
const candidates: Array<{ position: RectPosition; lines: SnapLine[]; distance: number }> = [];

const edgeAlignments = [
{
type: SnapLineType.EDGE_LEFT,
dragOffset: dragBounds.left,
targetValue: otherFrame.bounds.left,
orientation: 'vertical' as const,
},
{
type: SnapLineType.EDGE_LEFT,
dragOffset: dragBounds.right,
targetValue: otherFrame.bounds.left,
orientation: 'vertical' as const,
},
{
type: SnapLineType.EDGE_RIGHT,
dragOffset: dragBounds.left,
targetValue: otherFrame.bounds.right,
orientation: 'vertical' as const,
},
{
type: SnapLineType.EDGE_RIGHT,
dragOffset: dragBounds.right,
targetValue: otherFrame.bounds.right,
orientation: 'vertical' as const,
},
{
type: SnapLineType.EDGE_TOP,
dragOffset: dragBounds.top,
targetValue: otherFrame.bounds.top,
orientation: 'horizontal' as const,
},
{
type: SnapLineType.EDGE_TOP,
dragOffset: dragBounds.bottom,
targetValue: otherFrame.bounds.top,
orientation: 'horizontal' as const,
},
{
type: SnapLineType.EDGE_BOTTOM,
dragOffset: dragBounds.top,
targetValue: otherFrame.bounds.bottom,
orientation: 'horizontal' as const,
},
{
type: SnapLineType.EDGE_BOTTOM,
dragOffset: dragBounds.bottom,
targetValue: otherFrame.bounds.bottom,
orientation: 'horizontal' as const,
},
{
type: SnapLineType.CENTER_HORIZONTAL,
dragOffset: dragBounds.centerY,
targetValue: otherFrame.bounds.centerY,
orientation: 'horizontal' as const,
},
{
type: SnapLineType.CENTER_VERTICAL,
dragOffset: dragBounds.centerX,
targetValue: otherFrame.bounds.centerX,
orientation: 'vertical' as const,
},
];

for (const alignment of edgeAlignments) {
const distance = Math.abs(alignment.dragOffset - alignment.targetValue);

if (distance <= this.config.threshold) {
const offset = alignment.targetValue - alignment.dragOffset;
const newPosition = alignment.orientation === 'horizontal'
? { x: dragBounds.left, y: dragBounds.top + offset }
: { x: dragBounds.left + offset, y: dragBounds.top };

const snapLine = this.createSnapLine(alignment.type, alignment.orientation, alignment.targetValue, otherFrame, dragBounds);


candidates.push({
position: newPosition,
lines: [snapLine],
distance,
});
}
}

return candidates;
}

private createSnapLine(
type: SnapLineType,
orientation: 'horizontal' | 'vertical',
position: number,
otherFrame: SnapFrame,
dragBounds: SnapBounds,
): SnapLine {
let start: number;
let end: number;

if (orientation === 'horizontal') {
start = Math.min(dragBounds.left, otherFrame.bounds.left) - SNAP_CONFIG.LINE_EXTENSION;
end = Math.max(dragBounds.right, otherFrame.bounds.right) + SNAP_CONFIG.LINE_EXTENSION;
} else {
start = Math.min(dragBounds.top, otherFrame.bounds.top) - SNAP_CONFIG.LINE_EXTENSION;
end = Math.max(dragBounds.bottom, otherFrame.bounds.bottom) + SNAP_CONFIG.LINE_EXTENSION;
}

return {
id: `${type}-${otherFrame.id}-${Date.now()}`,
type,
orientation,
position,
start,
end,
frameIds: [otherFrame.id],
};
}

showSnapLines(lines: SnapLine[]): void {
if (!this.config.showGuidelines) {
return;
}
this.activeSnapLines = lines;
}

hideSnapLines(): void {
this.activeSnapLines = [];
}

setConfig(config: Partial<SnapConfig>): void {
Object.assign(this.config, config);
}
}
Loading