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..c5a7c7d880 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 @@ -27,8 +27,20 @@ export const ResizeHandles = observer(( const startWidth = frame.dimension.width; const startHeight = frame.dimension.height; const aspectRatio = startWidth / startHeight; + let isResizeActive = false; const resize = (e: MouseEvent) => { + const dx = e.clientX - startX; + const dy = e.clientY - startY; + + // Check deadzone - only start resizing after 5px movement + if (!isResizeActive) { + if (dx * dx + dy * dy <= 25) { + return; // Still within deadzone + } + isResizeActive = true; + } + const scale = editorEngine.canvas.scale; let widthDelta = types.includes(HandleType.Right) ? (e.clientX - startX) / scale : 0; let heightDelta = types.includes(HandleType.Bottom) ? (e.clientY - startY) / scale : 0; @@ -65,7 +77,45 @@ export const ResizeHandles = observer(( newHeight = Math.max(newHeight, minHeight); } - editorEngine.frames.updateAndSaveToStorage(frame.id, { dimension: { width: Math.round(newWidth), height: Math.round(newHeight) } }); + // Apply dimension snapping if enabled + if (editorEngine.snap.config.enabled && !e.ctrlKey && !e.metaKey) { + const dimensionSnapTarget = editorEngine.snap.calculateDimensionSnapTarget( + frame.id, + { width: Math.round(newWidth), height: Math.round(newHeight) }, + frame.position, + { + width: types.includes(HandleType.Right), + height: types.includes(HandleType.Bottom), + }, + ); + + if (dimensionSnapTarget) { + // Apply snapped dimensions + const clamped = { + width: Math.max(Math.round(dimensionSnapTarget.dimension.width), minWidth), + height: Math.max(Math.round(dimensionSnapTarget.dimension.height), minHeight), + }; + const clampedChanged = + clamped.width !== dimensionSnapTarget.dimension.width || + clamped.height !== dimensionSnapTarget.dimension.height; + if (clampedChanged) { + editorEngine.snap.hideSnapLines(); + } else { + editorEngine.snap.showSnapLines(dimensionSnapTarget.snapLines); + } + editorEngine.frames.updateAndSaveToStorage(frame.id, { dimension: clamped }); + editorEngine.overlay.undebouncedRefresh(); + return; + } else { + editorEngine.snap.hideSnapLines(); + } + } + + // No snapping or snapping disabled + editorEngine.snap.hideSnapLines(); + editorEngine.frames.updateAndSaveToStorage(frame.id, { + dimension: { width: Math.round(newWidth), height: Math.round(newHeight) }, + }); editorEngine.overlay.undebouncedRefresh(); }; @@ -73,6 +123,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/components/store/editor/snap/index.ts b/apps/web/client/src/components/store/editor/snap/index.ts index f634283dd3..fd3f5a878e 100644 --- a/apps/web/client/src/components/store/editor/snap/index.ts +++ b/apps/web/client/src/components/store/editor/snap/index.ts @@ -22,6 +22,19 @@ export class SnapManager { makeAutoObservable(this); } + private isAlignedWithFrame(currentPosition: RectPosition, otherFrame: SnapFrame): boolean { + // Check if frames are horizontally aligned for width snapping + const yDifference = Math.abs(currentPosition.y - otherFrame.bounds.top); + const isHorizontallyAligned = yDifference <= this.config.threshold; + + // Check if frames are vertically aligned for height snapping + const xDifference = Math.abs(currentPosition.x - otherFrame.bounds.left); + const isVerticallyAligned = xDifference <= this.config.threshold; + + // Frame is aligned if it's either horizontally OR vertically aligned + return isHorizontallyAligned || isVerticallyAligned; + } + private createSnapBounds(position: RectPosition, dimension: RectDimension): SnapBounds { const left = position.x; const top = position.y; @@ -234,6 +247,106 @@ export class SnapManager { this.activeSnapLines = []; } + calculateDimensionSnapTarget( + frameId: string, + dimension: RectDimension, + position: RectPosition, + resizingDimensions?: { width: boolean; height: boolean }, + ): { dimension: RectDimension; snapLines: SnapLine[] } | null { + if (!this.config.enabled) { + return null; + } + + const allFrames = this.getSnapFrames(frameId); + + if (allFrames.length === 0) { + return null; + } + + // Filter frames that are spatially aligned with the current frame + const alignedFrames = allFrames.filter(frame => this.isAlignedWithFrame(position, frame)); + + if (alignedFrames.length === 0) { + return null; + } + + let snappedWidth = dimension.width; + let snappedHeight = dimension.height; + const snapLines: SnapLine[] = []; + + // Find width matches and prioritize closest + const widthMatches = alignedFrames + .map((frame) => ({ + frame, + difference: Math.abs(dimension.width - frame.bounds.width), + })) + .filter((match) => match.difference <= this.config.threshold) + .sort((a, b) => a.difference - b.difference); + + // Only check width if actively resizing width + if ((resizingDimensions?.width ?? true) && widthMatches.length > 0) { + const closestWidth = widthMatches[0]!; + snappedWidth = closestWidth.frame.bounds.width; + + + // Create snap bounds with snapped width for line calculation + const snappedBounds = this.createSnapBounds(position, { + width: snappedWidth, + height: dimension.height, + }); + + const widthLine = this.createSnapLine( + SnapLineType.EDGE_RIGHT, + 'vertical', + snappedBounds.right, + closestWidth.frame, + snappedBounds, + ); + snapLines.push(widthLine); + } + + // Find height matches and prioritize closest + const heightMatches = alignedFrames + .map((frame) => ({ + frame, + difference: Math.abs(dimension.height - frame.bounds.height), + })) + .filter((match) => match.difference <= this.config.threshold) + .sort((a, b) => a.difference - b.difference); + + // Only check height if actively resizing height + if ((resizingDimensions?.height ?? true) && heightMatches.length > 0) { + const closestHeight = heightMatches[0]!; + snappedHeight = closestHeight.frame.bounds.height; + + + // Create snap bounds with snapped height for line calculation + const snappedBounds = this.createSnapBounds(position, { + width: snappedWidth, + height: snappedHeight, + }); + + const heightLine = this.createSnapLine( + SnapLineType.EDGE_BOTTOM, + 'horizontal', + snappedBounds.bottom, + closestHeight.frame, + snappedBounds, + ); + snapLines.push(heightLine); + } + + // Return null if no snapping occurred + if (snapLines.length === 0) { + return null; + } + + return { + dimension: { width: snappedWidth, height: snappedHeight }, + snapLines, + }; + } + setConfig(config: Partial): void { Object.assign(this.config, config); }