Skip to content
Draft
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 @@ -65,6 +65,21 @@ export const ResizeHandles = observer((
newHeight = Math.max(newHeight, minHeight);
}

// Check for dimension snapping
const snapTarget = editorEngine.snap.calculateResizeSnapTarget(
frame.id,
frame.position,
{ width: newWidth, height: newHeight }
);

if (snapTarget) {
newWidth = snapTarget.dimension.width;
newHeight = snapTarget.dimension.height;
editorEngine.snap.showSnapLines(snapTarget.snapLines);
} else {
editorEngine.snap.hideSnapLines();
}

editorEngine.frames.updateAndSaveToStorage(frame.id, { dimension: { width: Math.round(newWidth), height: Math.round(newHeight) } });
editorEngine.overlay.undebouncedRefresh();
};
Expand All @@ -73,6 +88,7 @@ export const ResizeHandles = observer((
e.preventDefault();
e.stopPropagation();
setIsResizing(false);
editorEngine.snap.hideSnapLines();
window.removeEventListener('mousemove', resize as unknown as EventListener);
window.removeEventListener('mouseup', stopResize as unknown as EventListener);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

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

const SNAP_VISUAL_CONFIG = {
Expand All @@ -19,6 +20,26 @@ export const SnapGuidelines = observer(() => {
const scale = editorEngine.canvas.scale;
const canvasPosition = editorEngine.canvas.position;

const getLineColor = (lineType: SnapLineType) => {
switch (lineType) {
case SnapLineType.WIDTH_MATCH:
case SnapLineType.HEIGHT_MATCH:
return 'bg-blue-500'; // Different color for dimension guides
default:
return 'bg-red-500'; // Default color for alignment guides
}
};

const getBoxShadow = (lineType: SnapLineType) => {
switch (lineType) {
case SnapLineType.WIDTH_MATCH:
case SnapLineType.HEIGHT_MATCH:
return '0 0 4px rgba(59, 130, 246, 0.6)'; // Blue shadow for dimension guides
default:
return '0 0 4px rgba(239, 68, 68, 0.6)'; // Red shadow for alignment guides
}
};

return (
<div
className="absolute inset-0 pointer-events-none"
Expand All @@ -28,35 +49,38 @@ export const SnapGuidelines = observer(() => {
}}
>
{snapLines.map((line) => {
const lineColor = getLineColor(line.type);
const boxShadow = getBoxShadow(line.type);

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"
className={`absolute ${lineColor}`}
style={{
left: `${line.start}px`,
top: `${line.position + visualOffset}px`,
width: `${line.end - line.start}px`,
height: `${Math.max(1, 1 / scale)}px`,
opacity: 0.9,
boxShadow: '0 0 4px rgba(239, 68, 68, 0.6)',
boxShadow,
}}
/>
);
} else {
return (
<div
key={line.id}
className="absolute bg-red-500"
className={`absolute ${lineColor}`}
style={{
left: `${line.position}px`,
top: `${line.start}px`,
width: `${Math.max(1, 1 / scale)}px`,
height: `${line.end - line.start}px`,
opacity: 0.9,
boxShadow: '0 0 4px rgba(239, 68, 68, 0.6)',
boxShadow,
}}
/>
);
Expand Down
128 changes: 127 additions & 1 deletion apps/web/client/src/components/store/editor/snap/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
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 type { SnapBounds, SnapConfig, SnapFrame, SnapLine, SnapTarget, ResizeSnapTarget } from './types';
import { SnapLineType } from './types';

const SNAP_CONFIG = {
Expand Down Expand Up @@ -237,4 +237,130 @@ export class SnapManager {
setConfig(config: Partial<SnapConfig>): void {
Object.assign(this.config, config);
}

calculateResizeSnapTarget(
resizeFrameId: string,
currentPosition: RectPosition,
currentDimension: RectDimension,
): ResizeSnapTarget | null {
if (!this.config.enabled) {
return null;
}

const otherFrames = this.getSnapFrames(resizeFrameId);

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

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

for (const otherFrame of otherFrames) {
// Check for width matching
const widthDistance = Math.abs(currentDimension.width - otherFrame.dimension.width);
if (widthDistance <= this.config.threshold) {
const snapLine = this.createDimensionSnapLine(
SnapLineType.WIDTH_MATCH,
'vertical',
otherFrame,
currentPosition,
currentDimension
);

snapCandidates.push({
dimension: {
width: otherFrame.dimension.width,
height: currentDimension.height
},
lines: [snapLine],
distance: widthDistance,
});
}

// Check for height matching
const heightDistance = Math.abs(currentDimension.height - otherFrame.dimension.height);
if (heightDistance <= this.config.threshold) {
const snapLine = this.createDimensionSnapLine(
SnapLineType.HEIGHT_MATCH,
'horizontal',
otherFrame,
currentPosition,
currentDimension
);

snapCandidates.push({
dimension: {
width: currentDimension.width,
height: otherFrame.dimension.height
},
lines: [snapLine],
distance: heightDistance,
});
}
}

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

// Find the best candidate (closest match)
snapCandidates.sort((a, b) => a.distance - b.distance);
const bestCandidate = snapCandidates[0];

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

return {
dimension: bestCandidate.dimension,
snapLines: bestCandidate.lines,
distance: bestCandidate.distance,
};
}

private createDimensionSnapLine(
type: SnapLineType,
orientation: 'horizontal' | 'vertical',
otherFrame: SnapFrame,
currentPosition: RectPosition,
currentDimension: RectDimension,
): SnapLine {
let position: number;
let start: number;
let end: number;

if (type === SnapLineType.WIDTH_MATCH) {
// Show vertical line at the right edge to indicate width matching
position = Math.max(
currentPosition.x + otherFrame.dimension.width,
otherFrame.position.x + otherFrame.dimension.width
);
start = Math.min(currentPosition.y, otherFrame.position.y) - SNAP_CONFIG.LINE_EXTENSION;
end = Math.max(
currentPosition.y + currentDimension.height,
otherFrame.position.y + otherFrame.dimension.height
) + SNAP_CONFIG.LINE_EXTENSION;
} else {
// HEIGHT_MATCH: Show horizontal line at the bottom edge
position = Math.max(
currentPosition.y + otherFrame.dimension.height,
otherFrame.position.y + otherFrame.dimension.height
);
start = Math.min(currentPosition.x, otherFrame.position.x) - SNAP_CONFIG.LINE_EXTENSION;
end = Math.max(
currentPosition.x + currentDimension.width,
otherFrame.position.x + otherFrame.dimension.width
) + SNAP_CONFIG.LINE_EXTENSION;
}

return {
id: `${type}-${otherFrame.id}-${Date.now()}`,
type,
orientation,
position,
start,
end,
frameIds: [otherFrame.id],
};
}
}
8 changes: 8 additions & 0 deletions apps/web/client/src/components/store/editor/snap/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ export interface SnapTarget {
distance: number;
}

export interface ResizeSnapTarget {
dimension: RectDimension;
snapLines: SnapLine[];
distance: number;
}

export interface SnapLine {
id: string;
type: SnapLineType;
Expand All @@ -35,6 +41,8 @@ export enum SnapLineType {
CENTER_HORIZONTAL = 'center-horizontal',
CENTER_VERTICAL = 'center-vertical',
SPACING = 'spacing',
WIDTH_MATCH = 'width-match',
HEIGHT_MATCH = 'height-match',
}

export interface SnapFrame {
Expand Down