From 3374cf4ed5d319ce2cea968f91f744e4e644aa66 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 25 Sep 2025 05:51:33 +0000
Subject: [PATCH 1/2] Initial plan
From 798bd4de1d4dfff06c6a8db3820c87c25c5665f3 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 25 Sep 2025 05:58:40 +0000
Subject: [PATCH 2/2] Add width/height snap guides feature - core
implementation
Co-authored-by: Kitenite <31864905+Kitenite@users.noreply.github.com>
---
.../canvas/frame/resize-handles.tsx | 16 +++
.../overlay/elements/snap-guidelines.tsx | 32 ++++-
.../src/components/store/editor/snap/index.ts | 128 +++++++++++++++++-
.../src/components/store/editor/snap/types.ts | 8 ++
4 files changed, 179 insertions(+), 5 deletions(-)
diff --git a/apps/web/client/src/app/project/[id]/_components/canvas/frame/resize-handles.tsx b/apps/web/client/src/app/project/[id]/_components/canvas/frame/resize-handles.tsx
index e7043980b5..d37244260c 100644
--- a/apps/web/client/src/app/project/[id]/_components/canvas/frame/resize-handles.tsx
+++ b/apps/web/client/src/app/project/[id]/_components/canvas/frame/resize-handles.tsx
@@ -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();
};
@@ -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);
};
diff --git a/apps/web/client/src/app/project/[id]/_components/canvas/overlay/elements/snap-guidelines.tsx b/apps/web/client/src/app/project/[id]/_components/canvas/overlay/elements/snap-guidelines.tsx
index 9cc5f089a0..f18b1395c7 100644
--- a/apps/web/client/src/app/project/[id]/_components/canvas/overlay/elements/snap-guidelines.tsx
+++ b/apps/web/client/src/app/project/[id]/_components/canvas/overlay/elements/snap-guidelines.tsx
@@ -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 = {
@@ -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 (
{
}}
>
{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 (
);
@@ -49,14 +73,14 @@ export const SnapGuidelines = observer(() => {
return (
);
diff --git a/apps/web/client/src/components/store/editor/snap/index.ts b/apps/web/client/src/components/store/editor/snap/index.ts
index f634283dd3..b6d6aa1845 100644
--- a/apps/web/client/src/components/store/editor/snap/index.ts
+++ b/apps/web/client/src/components/store/editor/snap/index.ts
@@ -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 = {
@@ -237,4 +237,130 @@ export class SnapManager {
setConfig(config: Partial
): 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],
+ };
+ }
}
\ No newline at end of file
diff --git a/apps/web/client/src/components/store/editor/snap/types.ts b/apps/web/client/src/components/store/editor/snap/types.ts
index 9ccd6185f2..b032ac4147 100644
--- a/apps/web/client/src/components/store/editor/snap/types.ts
+++ b/apps/web/client/src/components/store/editor/snap/types.ts
@@ -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;
@@ -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 {