Skip to content

Lesson 27 - Snap and align

Snapping is a common feature in graphics editor applications. The core idea is to automatically align element boundaries or anchor points to the nearest pixel grid lines or other graphics when moving, drawing, or scaling elements. In this lesson, we'll introduce their implementation.

Snap to pixel grid

In [Lesson 5 - Drawing grids], we introduced how to efficiently draw straight-line grids. In some drag interactions such as moving and drawing, snapping to the minimum unit of the grid ensures that the position or geometric information of graphics are integers. This feature is called "Snap to pixel grid" in Figma and can be enabled in "User Preferences".

source: Snap to grid in Excalidraw
ts
export interface AppState {
    snapToPixelGridEnabled: boolean; 
    snapToPixelGridSize: number; 
}

To implement this feature, we first need to calculate the coordinates of points in the world coordinate system. When users drag, scale, or draw graphics, we get the current coordinates (such as x, y), then round these coordinates to the nearest integer (pixel point) or the specified grid spacing:

ts
// Snap to custom grid (e.g. 10px)
function snapToGrid(value, gridSize = 10) {
    return Math.round(value / gridSize) * gridSize;
}

Then apply this processing function in all Systems that need to calculate coordinates of points in the world coordinate system (such as Select, DrawRect):

ts
let { x: sx, y: sy } = api.viewport2Canvas({
    x: prevX,
    y: prevY,
});
let { x: ex, y: ey } = api.viewport2Canvas({
    x,
    y,
});
const { snapToPixelGridEnabled, snapToPixelGridSize } = api.getAppState(); 
if (snapToPixelGridEnabled) {
    sx = snapToGrid(sx, snapToPixelGridSize);
    sy = snapToGrid(sy, snapToPixelGridSize);
    ex = snapToGrid(ex, snapToPixelGridSize);
    ey = snapToGrid(ey, snapToPixelGridSize);
}

In the example below, we set snapToPixelGridSize to 10. You can experience the effect by dragging, moving, and drawing:

Object-level snapping

The implementation of snapping functionality in Excalidraw is divided into the following key steps:

Below, we will implement this by following the steps outlined above.

Is snapping enabled

We have added the following configuration options to the application settings, which can also be enabled in the “Preferences Menu”:

ts
export interface AppState {
    snapToObjectsEnabled: boolean; 
}

Triggered when dragging and moving or drawing shapes:

source: https://github.com/excalidraw/excalidraw/issues/263#issuecomment-577605528

Get point snaps

Snap points are divided into two categories: selected shapes and other shapes. For one or more selected shapes, common snap points include the four corners and the center of the bounding box:

ts
const { minX, minY, maxX, maxY } = api.getBounds(
    selected.map((id) => api.getNodeById(id)),
);
const boundsWidth = maxX - minX;
const boundsHeight = maxY - minY;
const selectionSnapPoints = [
    new Point(minX, minY), // 4 corners
    new Point(maxX, minY),
    new Point(minX, maxY),
    new Point(maxX, maxY),
    new Point(minX + boundsWidth / 2, minY + boundsHeight / 2), // center
];

Considering performance, we should minimize the number of times we detect the attachment points of the selected shape relative to all other shapes. We've already covered similar issues in Lesson 8 - Using spatial indexing, where we only retrieve shapes within the viewport.

ts
const unculledAndUnselected = api
    .getNodes()
    .map((node) => api.getEntity(node))
    .filter((entity) => !entity.has(Culled) && !entity.has(Selected));

Similarly, calculate the reference points for these shapes:

ts
const referenceSnapPoints: [number, number][] = unculledAndUnselected
    .map((entity) => getElementsCorners(api, [api.getNodeByEntity(entity).id]))
    .flat();

Get gap snaps

Beyond the currently selected shape on the canvas, other shapes may form pairs with gaps between them. The diagram in the Excalidraw code illustrates this well—take horizontalGap as an example:

ts
// https://github.com/excalidraw/excalidraw/blob/f55ecb96cc8db9a2417d48cd8077833c3822d64e/packages/excalidraw/snapping.ts#L65C1-L81C3
export type Gap = {
    //  start side ↓     length
    // ┌───────────┐◄───────────────►
    // │           │-----------------┌───────────┐
    // │  start    │       ↑         │           │
    // │  element  │    overlap      │  end      │
    // │           │       ↓         │  element  │
    // └───────────┘-----------------│           │
    //                               └───────────┘
    //                               ↑ end side
    startBounds: Bounds;
    endBounds: Bounds;
    startSide: [GlobalPoint, GlobalPoint];
    endSide: [GlobalPoint, GlobalPoint];
    overlap: InclusiveRange;
    length: number;
};

If the bounding box of the selected shape does not overlap with the gap, skip the detection.

ts
for (const gap of horizontalGaps) {
    if (!rangesOverlap([minY, maxY], gap.overlap)) {
        continue;
    }
}

Detect the center point, right edge, and left edge in sequence:

ts
// center
if (gapIsLargerThanSelection && Math.abs(centerOffset) <= minOffset[0]) {
}
// side right
if (Math.abs(sideOffsetRight) <= minOffset[0]) {
}
// side left
if (Math.abs(sideOffsetLeft) <= minOffset[0]) {
}

Render snap lines

Extended reading

Released under the MIT License.