From f13792b0effa71ed38653582123c6cd60ad3a4a3 Mon Sep 17 00:00:00 2001 From: Daniel Kumlin Date: Mon, 20 Oct 2025 21:29:08 -0400 Subject: [PATCH 1/6] feat: add dimension alignment and snapping functionality to SnapManager --- .../src/components/store/editor/snap/index.ts | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) 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..31f94f4ede 100644 --- a/apps/web/client/src/components/store/editor/snap/index.ts +++ b/apps/web/client/src/components/store/editor/snap/index.ts @@ -234,6 +234,146 @@ export class SnapManager { this.activeSnapLines = []; } + detectDimensionAlignment( + frameId: string, + dimension: RectDimension, + position: RectPosition, + ): SnapLine[] { + if (!this.config.enabled) { + return []; + } + + const dragBounds = this.createSnapBounds(position, dimension); + const otherFrames = this.getSnapFrames(frameId); + + if (otherFrames.length === 0) { + return []; + } + + const snapLines: SnapLine[] = []; + + for (const otherFrame of otherFrames) { + const widthDifference = Math.abs(dimension.width - otherFrame.bounds.width); + if (widthDifference <= this.config.threshold) { + const widthLine = this.createSnapLine( + SnapLineType.EDGE_RIGHT, + 'vertical', + dragBounds.right, + otherFrame, + dragBounds, + ); + snapLines.push(widthLine); + } + + const heightDifference = Math.abs(dimension.height - otherFrame.bounds.height); + if (heightDifference <= this.config.threshold) { + const heightLine = this.createSnapLine( + SnapLineType.EDGE_BOTTOM, + 'horizontal', + dragBounds.bottom, + otherFrame, + dragBounds, + ); + snapLines.push(heightLine); + } + } + + return snapLines; + } + + calculateDimensionSnapTarget( + frameId: string, + dimension: RectDimension, + position: RectPosition, + resizingDimensions?: { width: boolean; height: boolean }, + ): { dimension: RectDimension; snapLines: SnapLine[] } | null { + if (!this.config.enabled) { + return null; + } + + const otherFrames = this.getSnapFrames(frameId); + + if (otherFrames.length === 0) { + return null; + } + + let snappedWidth = dimension.width; + let snappedHeight = dimension.height; + const snapLines: SnapLine[] = []; + + // Find width matches and prioritize closest + const widthMatches = otherFrames + .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 = otherFrames + .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); } From ac587cbdd053a0935239a99f1ebbb51ec54c6343 Mon Sep 17 00:00:00 2001 From: Daniel Kumlin Date: Mon, 20 Oct 2025 21:29:17 -0400 Subject: [PATCH 2/6] feat: implement deadzone for resizing and enhance dimension snapping in ResizeHandles --- .../canvas/frame/resize-handles.tsx | 46 ++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) 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..a41a953c11 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,38 @@ 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 + editorEngine.snap.showSnapLines(dimensionSnapTarget.snapLines); + editorEngine.frames.updateAndSaveToStorage(frame.id, { + dimension: dimensionSnapTarget.dimension, + }); + editorEngine.overlay.undebouncedRefresh(); + return; + } else { + editorEngine.snap.hideSnapLines(); + } + } 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 +116,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); }; From 106b8d87feefe48b5eec7f48c124a240ec5f10ef Mon Sep 17 00:00:00 2001 From: Daniel Kumlin Date: Tue, 21 Oct 2025 15:02:35 -0400 Subject: [PATCH 3/6] refactor: remove unused dimension alignment detection from SnapManager --- .../src/components/store/editor/snap/index.ts | 47 ------------------- 1 file changed, 47 deletions(-) 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 31f94f4ede..d756faf0cb 100644 --- a/apps/web/client/src/components/store/editor/snap/index.ts +++ b/apps/web/client/src/components/store/editor/snap/index.ts @@ -234,53 +234,6 @@ export class SnapManager { this.activeSnapLines = []; } - detectDimensionAlignment( - frameId: string, - dimension: RectDimension, - position: RectPosition, - ): SnapLine[] { - if (!this.config.enabled) { - return []; - } - - const dragBounds = this.createSnapBounds(position, dimension); - const otherFrames = this.getSnapFrames(frameId); - - if (otherFrames.length === 0) { - return []; - } - - const snapLines: SnapLine[] = []; - - for (const otherFrame of otherFrames) { - const widthDifference = Math.abs(dimension.width - otherFrame.bounds.width); - if (widthDifference <= this.config.threshold) { - const widthLine = this.createSnapLine( - SnapLineType.EDGE_RIGHT, - 'vertical', - dragBounds.right, - otherFrame, - dragBounds, - ); - snapLines.push(widthLine); - } - - const heightDifference = Math.abs(dimension.height - otherFrame.bounds.height); - if (heightDifference <= this.config.threshold) { - const heightLine = this.createSnapLine( - SnapLineType.EDGE_BOTTOM, - 'horizontal', - dragBounds.bottom, - otherFrame, - dragBounds, - ); - snapLines.push(heightLine); - } - } - - return snapLines; - } - calculateDimensionSnapTarget( frameId: string, dimension: RectDimension, From 1e8db6352e8c2bf855568e4df88d2dc6d15ffc31 Mon Sep 17 00:00:00 2001 From: Daniel Kumlin Date: Tue, 21 Oct 2025 16:17:49 -0400 Subject: [PATCH 4/6] refactor: simplify hideSnapLines call frequency --- .../project/[id]/_components/canvas/frame/resize-handles.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 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 a41a953c11..9fa442cee9 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 @@ -100,9 +100,7 @@ export const ResizeHandles = observer(( } else { editorEngine.snap.hideSnapLines(); } - } else { - editorEngine.snap.hideSnapLines(); - } + } // No snapping or snapping disabled editorEngine.snap.hideSnapLines(); From aa52826d5915833eec83a30bfb5480e2bc24722a Mon Sep 17 00:00:00 2001 From: Daniel Kumlin Date: Tue, 21 Oct 2025 16:29:03 -0400 Subject: [PATCH 5/6] fix: clamp dimensions bug in ResizeHandles --- .../_components/canvas/frame/resize-handles.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 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 9fa442cee9..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 @@ -91,10 +91,19 @@ export const ResizeHandles = observer(( if (dimensionSnapTarget) { // Apply snapped dimensions - editorEngine.snap.showSnapLines(dimensionSnapTarget.snapLines); - editorEngine.frames.updateAndSaveToStorage(frame.id, { - dimension: dimensionSnapTarget.dimension, - }); + 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 { From 34a97ba742f7db483a752b07d8c0eb81fb065875 Mon Sep 17 00:00:00 2001 From: Daniel Kumlin Date: Tue, 21 Oct 2025 16:29:35 -0400 Subject: [PATCH 6/6] fix: snapping only occurs when frames are spatially aligned --- .../src/components/store/editor/snap/index.ts | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) 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 d756faf0cb..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; @@ -244,9 +257,16 @@ export class SnapManager { return null; } - const otherFrames = this.getSnapFrames(frameId); + const allFrames = this.getSnapFrames(frameId); - if (otherFrames.length === 0) { + 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; } @@ -255,7 +275,7 @@ export class SnapManager { const snapLines: SnapLine[] = []; // Find width matches and prioritize closest - const widthMatches = otherFrames + const widthMatches = alignedFrames .map((frame) => ({ frame, difference: Math.abs(dimension.width - frame.bounds.width), @@ -286,7 +306,7 @@ export class SnapManager { } // Find height matches and prioritize closest - const heightMatches = otherFrames + const heightMatches = alignedFrames .map((frame) => ({ frame, difference: Math.abs(dimension.height - frame.bounds.height),