Skip to content

Conversation

@reddygtvs
Copy link
Contributor

@reddygtvs reddygtvs commented Aug 26, 2025

Description

Added snap/alignment guidelines for frame positioning. Shows red lines when frames align to edges or centers, with 12px snap threshold. Ctrl/Cmd disables snapping.

Related Issues

related to #2702

Type of Change

  • Bug fix
  • New feature
  • Documentation update
  • Release
  • Refactor
  • Other (please describe):

Testing

Screenshots (if applicable)

Screen.Recording.-.Aug.26.2025-VEED.mp4

Additional Notes

Open to making any changes related to styling (or otherwise). It was not defined in the feature request, so I ended up going with the standardised red/thin line/12 px snap offset based off what Figma/Adobe are doing for their own implementation.


Important

Adds snapping and alignment guidelines for frames with visual feedback and configurable settings in the canvas editor.

  • Behavior:
    • Adds snapping/alignment guidelines for frames in top-bar.tsx.
    • Red lines indicate alignment to edges or centers with a 12px snap threshold.
    • Snapping can be disabled with Ctrl/Cmd keys.
  • Components:
    • Introduces SnapGuidelines component in snap-guidelines.tsx for rendering snap lines.
    • Integrates SnapManager in engine.ts for managing snapping logic and configuration.
  • Configuration:
    • SnapManager handles snap configuration, including enabling/disabling and threshold settings.
    • Snap lines are shown/hidden based on alignment calculations.

This description was created by Ellipsis for f8b3eb6. You can customize this summary. It will automatically update as commits are pushed.

Summary by CodeRabbit

  • New Features
    • Snap-assisted dragging for frames to align with edges and centers of other elements.
    • Visual snap guidelines appear while dragging, adapt to canvas zoom/pan, and auto-hide when finished.
    • Hold Ctrl/Cmd to temporarily disable snapping for free movement.
    • Snapped positions are applied and saved on drop.
    • Snapping can be enabled/disabled via editor settings.

@vercel
Copy link

vercel bot commented Aug 26, 2025

@reddygtvs is attempting to deploy a commit to the Onlook Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link

coderabbitai bot commented Aug 26, 2025

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

Caution

Review failed

The pull request is closed.

Walkthrough

Adds a snapping system for frame dragging: new SnapManager and types, editorEngine.snap integration, snap-assisted top-bar drag handling that applies snapped positions and toggles guideline visibility, a SnapGuidelines overlay component to render lines, and hides guidelines on clears and drag end.

Changes

Cohort / File(s) Summary
Drag snapping integration
apps/web/client/src/app/project/[id]/_components/canvas/frame/top-bar.tsx
Adds snap-assisted dragging: clears UI on move, computes calculateSnapTarget(...), applies snapped newPosition when available, shows/hides snap lines during move, and hides lines on drag end; persists position via updateAndSaveToStorage.
Overlay guidelines UI
apps/web/client/src/app/project/[id]/_components/canvas/overlay/elements/snap-guidelines.tsx, apps/web/client/src/app/project/[id]/_components/canvas/overlay/index.tsx
New SnapGuidelines observer component renders active snap lines transformed by canvas scale/position; imported and rendered by Overlay unconditionally.
Editor engine wiring
apps/web/client/src/components/store/editor/engine.ts
Adds readonly snap: SnapManager to EditorEngine, initializes it, and calls snap.hideSnapLines() from clear() and clearUI().
Snapping core logic
apps/web/client/src/components/store/editor/snap/index.ts
New SnapManager implementing config, candidate generation (edges/centers/spacing), calculateSnapTarget, and show/hide of activeSnapLines; selects best candidate within threshold.
Snap types
apps/web/client/src/components/store/editor/snap/types.ts
Adds exported types/interfaces: SnapBounds, SnapTarget, SnapLine, SnapFrame, SnapConfig, and enum SnapLineType.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant U as User
  participant TopBar as TopBar Drag Handler
  participant Engine as EditorEngine
  participant Snap as SnapManager
  participant Storage as Frames Store
  participant Overlay as SnapGuidelines

  U->>TopBar: drag move (dx, dy, modifiers)
  TopBar->>Engine: clearElements()
  TopBar->>Snap: calculateSnapTarget(frameId, position, dimension)
  alt snapping enabled and threshold matched and no Ctrl/Meta
    Snap-->>TopBar: SnapTarget(position', snapLines)
    TopBar->>Snap: showSnapLines(snapLines)
    TopBar->>Storage: updateAndSaveToStorage(frameId, { position: position' })
  else no snap
    Snap-->>TopBar: null
    TopBar->>Snap: hideSnapLines()
    TopBar->>Storage: updateAndSaveToStorage(frameId, { position })
  end
  note over Overlay,Snap: Overlay observes Snap.activeSnapLines and renders guidelines

  U-->>TopBar: drag end
  TopBar->>Snap: hideSnapLines()
Loading
sequenceDiagram
  participant App as App
  participant Engine as EditorEngine
  participant Snap as SnapManager
  participant Overlay as SnapGuidelines

  App->>Engine: clear()/clearUI()
  Engine->>Snap: hideSnapLines()
  Overlay-->>Overlay: activeSnapLines empty → render nothing
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

I hop across the canvas bright,
Red lines shimmer in my sight,
A gentle snap, a tidy nudge,
Frames align with tiny judge.
I twitch my nose and dance in lines—hooray! 🐇✨


📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between f8b3eb6 and 1639171.

📒 Files selected for processing (1)
  • apps/web/client/src/app/project/[id]/_components/canvas/frame/top-bar.tsx (1 hunks)
✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore or @coderabbit ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (13)
apps/web/client/src/app/project/[id]/_components/canvas/overlay/index.tsx (1)

77-77: Consider z-order explicitly for guides (optional).

Guidelines render after OverlayButtons and may visually stack above them. With pointer-events: none this won’t break interaction, but if you want buttons to visually dominate, add a lower z-index to the guides container or higher z-index to buttons.

Example:

-            <SnapGuidelines />
+            <SnapGuidelines className="z-10" />

(Then accept an optional className prop in SnapGuidelines.)

apps/web/client/src/app/project/[id]/_components/canvas/frame/top-bar.tsx (1)

44-60: Snapping logic and Ctrl/Cmd override are implemented correctly; one UX caveat.

Behavior aligns with the PR description. However, actual snap sensitivity should feel constant in screen pixels regardless of zoom. That is handled inside SnapManager; ensure it uses a scale-adjusted threshold (see my comment in SnapManager).

If needed, you can also give users visual feedback that snapping is disabled while Ctrl/Cmd is held (e.g., temporarily dim guides or show a small “snap off” badge).

apps/web/client/src/app/project/[id]/_components/canvas/overlay/elements/snap-guidelines.tsx (2)

6-10: Avoid duplicating top bar metrics; move to a shared constant.

TOP_BAR_HEIGHT and TOP_BAR_MARGIN are also encoded in TopBar. Drift will misplace lines if either changes. Centralize these values (e.g., a shared visual-config) and import them here and in TopBar.

Add a small shared module (example):

// apps/web/client/src/components/store/editor/snap/visual-config.ts
export const SNAP_VISUAL_CONFIG = {
  TOP_BAR_HEIGHT: 28,
  TOP_BAR_MARGIN: 10,
} as const;

Then import it here and use it in TopBar.


22-67: Recheck the horizontal “visualOffset” logic across scales and alignment types.

Adding (TOP_BAR_HEIGHT + TOP_BAR_MARGIN) / scale to all horizontal guides assumes every horizontal line should be pushed down by the top-bar stack. If the line’s position already reflects frame bounds (content), this may double-offset; if it reflects the frame’s outer box, it may be correct. Please verify at scales 0.5x, 1x, 2x for:

  • top-edge alignment,
  • bottom-edge alignment,
  • center-horizontal alignment.

If misaligned, consider deriving an explicit “frameOuterToContentYOffset” from the same place that positions TopBar and applying it only when the snapped edge is the top edge. Alternatively, render guidelines relative to the exact rects used to compute snapping to avoid ad-hoc offsets.

apps/web/client/src/components/store/editor/snap/index.ts (3)

75-88: Avoid sorting all candidates on every mousemove; track the best in linear time.

This reduces allocations and CPU when many frames exist.

Example change (illustrative, same behavior):

-        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];
+        let bestCandidate: { position: RectPosition; lines: SnapLine[]; distance: number } | null = null;
+        for (const otherFrame of otherFrames) {
+            const candidates = this.calculateSnapCandidates(dragBounds, otherFrame);
+            for (const c of candidates) {
+                if (!bestCandidate || c.distance < bestCandidate.distance) {
+                    bestCandidate = c;
+                    if (bestCandidate.distance === 0) break; // early exit exact match
+                }
+            }
+        }
+        if (!bestCandidate) {
+            return null;
+        }

98-103: Support showing both axes when both snap within threshold (optional).

Tools typically snap independently on X and Y; if both axes qualify, render both guidelines. Current code returns only lines[0].

One approach:

  • Track best horizontal and best vertical candidates separately.
  • Combine them if both within threshold before returning.

If you prefer minimal changes, keep the current single-line behavior for now.

Also applies to: 226-231


215-223: Stabilize guideline keys to reduce DOM churn (optional).

Using Date.now() changes keys every frame; React will recreate nodes unnecessarily. Encode identity from deterministic parts (type, target frame, rounded position).

Apply this diff:

-        return {
-            id: `${type}-${otherFrame.id}-${Date.now()}`,
+        return {
+            id: `${type}-${otherFrame.id}-${Math.round(position)}`,
             type,
             orientation,
             position,
             start,
             end,
             frameIds: [otherFrame.id],
         };
apps/web/client/src/components/store/editor/snap/types.ts (6)

3-12: SnapBounds duplicates derivable geometry; consider immutability and documenting invariants

left/top/right/bottom and centerX/centerY/width/height can drift if any are computed separately. Either document invariants (single source of truth) or expose only one representation and derive the rest at use sites. Also, marking this shape as immutable helps avoid accidental mutation during drag.

Apply immutability via readonly:

-export interface SnapBounds {
-    left: number;
-    top: number;
-    right: number;
-    bottom: number;
-    centerX: number;
-    centerY: number;
-    width: number;
-    height: number;
-}
+export interface SnapBounds {
+    readonly left: number;
+    readonly top: number;
+    readonly right: number;
+    readonly bottom: number;
+    readonly centerX: number;
+    readonly centerY: number;
+    readonly width: number;
+    readonly height: number;
+}

If you already compute bounds from RectPosition/RectDimension in SnapManager, consider removing either width/height or centerX/centerY here to enforce one source of truth.


14-18: “distance” is ambiguous; capture snap delta per axis and make fields readonly

A scalar distance doesn’t convey whether we snapped on X, Y, or both. Explicit dx/dy improves intent and avoids sign ambiguity. Also, make this immutable and the lines array readonly.

-export interface SnapTarget {
-    position: RectPosition;
-    snapLines: SnapLine[];
-    distance: number;
-}
+export interface SnapTarget {
+    readonly position: RectPosition;
+    readonly snapLines: readonly SnapLine[];
+    /** Total scalar distance used to rank candidates (retain for compatibility). */
+    readonly distance: number;
+    /** Pixel delta to apply to reach the snapped position. */
+    readonly offset: { dx: number; dy: number };
+}

Confirm the drag code in top-bar uses both axes; if it only snaps along one axis per interaction, we can make offset one-dimensional conditionally.


20-28: Avoid type/orientation inconsistencies; add immutability and optional spacing value

orientation is derivable from type (e.g., EDGE_TOP implies horizontal). Keeping both risks mismatch. If you keep orientation, add a runtime assert or derive it in a helper. Also, make fields readonly. For spacing guidelines, consider carrying the spacing amount.

-export interface SnapLine {
-    id: string;
-    type: SnapLineType;
-    orientation: 'horizontal' | 'vertical';
-    position: number;
-    start: number;
-    end: number;
-    frameIds: string[];
-}
+export interface SnapLine {
+    readonly id: string;
+    readonly type: SnapLineType;
+    readonly orientation: 'horizontal' | 'vertical';
+    /** Primary axis coordinate of the guide line. */
+    readonly position: number;
+    /** Extents along the secondary axis. */
+    readonly start: number;
+    readonly end: number;
+    /** For SPACING lines, the spacing amount in pixels. */
+    readonly spacing?: number;
+    readonly frameIds: readonly string[];
+}

If you prefer to eliminate the mismatch entirely, consider a discriminated union keyed by orientation:

type Orientation = 'horizontal' | 'vertical';

type HorizontalSnapLine = Readonly<{
  id: string;
  type: Extract<SnapLineType, 'edge-top' | 'edge-bottom' | 'center-horizontal' | 'spacing'>;
  orientation: 'horizontal';
  position: number; // y
  start: number;    // x1
  end: number;      // x2
  spacing?: number;
  frameIds: readonly string[];
}>;

type VerticalSnapLine = Readonly<{
  id: string;
  type: Extract<SnapLineType, 'edge-left' | 'edge-right' | 'center-vertical' | 'spacing'>;
  orientation: 'vertical';
  position: number; // x
  start: number;    // y1
  end: number;      // y2
  spacing?: number;
  frameIds: readonly string[];
}>;

export type SnapLine = HorizontalSnapLine | VerticalSnapLine;

30-38: Prefer string-literal union over runtime enum for shared libs

Using a TypeScript enum emits runtime code; a union keeps this file type-only and tree-shakeable.

-export enum SnapLineType {
-    EDGE_LEFT = 'edge-left',
-    EDGE_RIGHT = 'edge-right',
-    EDGE_TOP = 'edge-top',
-    EDGE_BOTTOM = 'edge-bottom',
-    CENTER_HORIZONTAL = 'center-horizontal',
-    CENTER_VERTICAL = 'center-vertical',
-    SPACING = 'spacing',
-}
+export type SnapLineType =
+    | 'edge-left'
+    | 'edge-right'
+    | 'edge-top'
+    | 'edge-bottom'
+    | 'center-horizontal'
+    | 'center-vertical'
+    | 'spacing';

Optional helper (outside this file) to derive orientation from type:

export const orientationFromType = (t: SnapLineType): 'horizontal' | 'vertical' =>
  t === 'edge-top' || t === 'edge-bottom' || t === 'center-horizontal' ? 'horizontal' : 'vertical';

40-45: SnapFrame doubles state with bounds + position/dimension; make immutable or document derivation

Carrying both position/dimension and bounds introduces drift risk on updates. If bounds are cached for perf, document that they’re derived and refresh together; otherwise consider deriving on read. Also, mark fields readonly.

-export interface SnapFrame {
-    id: string;
-    position: RectPosition;
-    dimension: RectDimension;
-    bounds: SnapBounds;
-}
+export interface SnapFrame {
+    readonly id: string;
+    readonly position: RectPosition;
+    readonly dimension: RectDimension;
+    readonly bounds: SnapBounds;
+}

47-51: Config is missing the “Ctrl/Cmd disables snapping” behavior; add typed modifiers and defaults

PR summary states Ctrl/Cmd disables snapping. Encode that here to avoid scattering UI-specific checks. Also, readonly fields help treat config as value objects.

-export interface SnapConfig {
-    threshold: number;
-    enabled: boolean;
-    showGuidelines: boolean;
-}
+export interface SnapConfig {
+    /** Snap activation threshold in pixels. Default: 12. */
+    readonly threshold: number;
+    readonly enabled: boolean;
+    readonly showGuidelines: boolean;
+    /** Modifier keys that temporarily disable snapping (KeyboardEvent.key values). */
+    readonly disableWithModifiers?: ReadonlyArray<'Meta' | 'Control'>;
+}

Optionally centralize defaults in a constants module:

// apps/web/client/src/components/store/editor/snap/constants.ts
export const DEFAULT_SNAP_THRESHOLD = 12;
export const DEFAULT_DISABLE_WITH_MODIFIERS: ReadonlyArray<'Meta' | 'Control'> = ['Meta', 'Control'];

I can follow through by updating SnapManager initialization to use these defaults and wire the modifier check to this config if you want.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 6e072e5 and f8b3eb6.

📒 Files selected for processing (6)
  • apps/web/client/src/app/project/[id]/_components/canvas/frame/top-bar.tsx (1 hunks)
  • apps/web/client/src/app/project/[id]/_components/canvas/overlay/elements/snap-guidelines.tsx (1 hunks)
  • apps/web/client/src/app/project/[id]/_components/canvas/overlay/index.tsx (2 hunks)
  • apps/web/client/src/components/store/editor/engine.ts (3 hunks)
  • apps/web/client/src/components/store/editor/snap/index.ts (1 hunks)
  • apps/web/client/src/components/store/editor/snap/types.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (4)
apps/web/client/src/app/project/[id]/_components/canvas/overlay/index.tsx (1)
apps/web/client/src/app/project/[id]/_components/canvas/overlay/elements/snap-guidelines.tsx (1)
  • SnapGuidelines (11-67)
apps/web/client/src/app/project/[id]/_components/canvas/overlay/elements/snap-guidelines.tsx (1)
apps/web/client/src/components/store/editor/index.tsx (1)
  • useEditorEngine (9-13)
apps/web/client/src/components/store/editor/snap/index.ts (2)
apps/web/client/src/components/store/editor/snap/types.ts (5)
  • SnapConfig (47-51)
  • SnapLine (20-28)
  • SnapBounds (3-12)
  • SnapFrame (40-45)
  • SnapTarget (14-18)
apps/web/client/src/components/store/editor/engine.ts (1)
  • EditorEngine (32-115)
apps/web/client/src/components/store/editor/engine.ts (1)
apps/web/client/src/components/store/editor/snap/index.ts (1)
  • SnapManager (12-240)
🔇 Additional comments (7)
apps/web/client/src/components/store/editor/engine.ts (3)

25-25: Good integration: SnapManager is part of the EditorEngine public surface.

Import and initialization pattern are consistent with other managers. Positioning snap after frames ensures dependencies are available during construction.

Also applies to: 63-63


96-97: Hide guidelines on full clear is correct.

Clearing snap lines during clear() prevents stale guides when projects switch or engine resets.


103-104: UI clear also hides guidelines — consistent with UX expectations.

This avoids leftover guides after selection/UI resets.

apps/web/client/src/app/project/[id]/_components/canvas/overlay/index.tsx (1)

13-13: Overlay now renders SnapGuidelines — import looks correct.

No side effects on existing overlay logic.

apps/web/client/src/app/project/[id]/_components/canvas/frame/top-bar.tsx (2)

39-43: Mutable newPosition to accommodate snapping is appropriate.

This sets up the subsequent snap adjustment correctly.


67-67: Hiding guides on drag end is correct.

Prevents ghost lines after interaction.

apps/web/client/src/components/store/editor/snap/types.ts (1)

1-1: Good use of import type

Using import type avoids bundling runtime code and keeps this module purely declarative.

Copy link
Contributor

@Kitenite Kitenite left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome work @reddygtvs !

@Kitenite Kitenite merged commit fdfccf1 into onlook-dev:main Sep 1, 2025
0 of 3 checks passed
@coderabbitai coderabbitai bot mentioned this pull request Sep 5, 2025
6 tasks
@coderabbitai coderabbitai bot mentioned this pull request Oct 21, 2025
5 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants