Foundations

Snapping

What is moved comes to rest in line with what is around it, or on a grid, and shows the line it found.

Markdown

When people move or resize something by hand, they are usually trying to put it somewhere exact: in line with its neighbour, centred under a heading, on the grid. Snapping finishes the gesture for them — and shows what it did.

Lines that hold

Drag a block near another's edge or middle: it is drawn into line, and the line is shown for as long as it holds. Zoom in, and the same few pixels reach a shorter way on the page.

Snapping
Keep within
100%
Autumn catalogue
Twelve pieces in stoneware, thrown and glazed by hand.
Price panel
Footer strip

Nothing selected

Code
"use client";

import { Radio, RadioGroup } from "@publictheta/ui/components/radio-group/radio-group.tsx";
import {
  Canvas,
  CanvasItem,
  applyItemChanges,
  invertItemChanges,
} from "@publictheta/ui/components/canvas/canvas.tsx";
import type {
  CanvasChangeKind,
  CanvasCopy,
  CanvasItemChange,
  CanvasItemData,
  Rect,
  Viewport,
} from "@publictheta/ui/components/canvas/canvas.tsx";
import { rectUnion } from "@publictheta/ui/algorithms/geometry.ts";
import { Button } from "@publictheta/ui/components/button/button.tsx";
import { isCommandKey } from "@publictheta/ui/lib/platform.ts";
import { createHistory } from "@publictheta/ui/primitives/history.ts";
import { IDENTITY, fit, zoomStop, zoomTo } from "@publictheta/ui/primitives/viewport.ts";
import { useEffect, useRef, useState } from "react";
import type { KeyboardEvent } from "react";
import { Controls, Readout } from "../../ui/controls.tsx";

interface Piece extends CanvasItemData {
  readonly kind: "page" | "panel" | "heading" | "text" | "image";
  readonly name: string;
  readonly text?: string;
}

const PAGE: Rect = { x: 0, y: 0, width: 640, height: 400 };

const PIECES: readonly Piece[] = [
  // The page itself: there to be aligned with and drawn over, not to be moved.
  { id: "page", kind: "page", name: "Page", rect: PAGE, locked: true },
  {
    id: "title",
    kind: "heading",
    name: "Title",
    text: "Autumn catalogue",
    rect: { x: 40, y: 36, width: 300, height: 44 },
  },
  {
    id: "lede",
    kind: "text",
    name: "Introduction",
    text: "Twelve pieces in stoneware, thrown and glazed by hand.",
    rect: { x: 40, y: 100, width: 232, height: 92 },
  },
  { id: "photo", kind: "image", name: "Photograph", rect: { x: 324, y: 100, width: 276, height: 180 } },
  { id: "price", kind: "panel", name: "Price panel", rect: { x: 40, y: 220, width: 232, height: 60 } },
  { id: "strip", kind: "panel", name: "Footer strip", rect: { x: 40, y: 316, width: 560, height: 48 } },
];

/** What undo puts back: places that changed, or the whole list where pieces came or went. */
type Entry =
  | { readonly kind: "places"; readonly changes: readonly CanvasItemChange[] }
  | { readonly kind: "pieces"; readonly before: readonly Piece[]; readonly after: readonly Piece[] };

const clamp = (value: number, least: number, most: number) => Math.min(Math.max(value, least), most);

/** A place held within the page: moved back inside, or — while being resized — cut off at its edge. */
function withinPage(rect: Rect, kind: CanvasChangeKind): Rect {
  if (kind === "move") {
    return {
      ...rect,
      x: clamp(rect.x, PAGE.x, PAGE.x + PAGE.width - rect.width),
      y: clamp(rect.y, PAGE.y, PAGE.y + PAGE.height - rect.height),
    };
  }
  const x = clamp(rect.x, PAGE.x, PAGE.x + PAGE.width);
  const y = clamp(rect.y, PAGE.y, PAGE.y + PAGE.height);
  return {
    x,
    y,
    width: clamp(rect.x + rect.width, x, PAGE.x + PAGE.width) - x,
    height: clamp(rect.y + rect.height, y, PAGE.y + PAGE.height) - y,
  };
}

function renderPiece(piece: Piece) {
  if (piece.kind === "page") {
    return (
      <CanvasItem aria-label={piece.name} className="rounded-sm border border-border-muted bg-surface" />
    );
  }
  if (piece.kind === "image") {
    return (
      <CanvasItem
        aria-label={piece.name}
        className="flex cursor-default items-center justify-center overflow-hidden bg-surface-strong text-fg-muted"
      >
        <svg aria-hidden viewBox="0 0 48 32" className="h-1/2 max-h-16" fill="none" stroke="currentColor">
          <path d="M2 28 16 12l9 10 6-6 15 12" strokeWidth="2" strokeLinejoin="round" />
          <circle cx="36" cy="8" r="4" strokeWidth="2" />
        </svg>
      </CanvasItem>
    );
  }
  if (piece.kind === "heading" || piece.kind === "text") {
    return (
      <CanvasItem
        aria-label={piece.name}
        className={
          piece.kind === "heading"
            ? "cursor-default overflow-hidden text-3xl leading-tight font-semibold text-fg-strong"
            : "cursor-default overflow-hidden text-sm leading-snug text-fg"
        }
      >
        {piece.text}
      </CanvasItem>
    );
  }
  return (
    <CanvasItem
      aria-label={piece.name}
      className="flex cursor-default items-center overflow-hidden rounded-md border border-border bg-surface-muted px-3 text-xs text-fg-muted"
    >
      {piece.name}
    </CanvasItem>
  );
}

const whole = (value: number) => Math.round(value * 10) / 10;

export default function CanvasLayout() {
  const [pieces, setPieces] = useState(PIECES);
  const [selected, setSelected] = useState<readonly string[]>([]);
  const [view, setView] = useState<Viewport>(IDENTITY);
  const [snapping, setSnapping] = useState<"on" | "off">("on");
  const [bounds, setBounds] = useState<"page" | "none">("page");
  const [history] = useState(() => createHistory<Entry>());
  const [, drawAgain] = useState(0);
  const canvas = useRef<HTMLDivElement>(null);
  const copies = useRef(0);

  const size = () => ({ width: canvas.current?.clientWidth ?? 0, height: canvas.current?.clientHeight ?? 0 });
  // Also run once the canvas exists, from the size it turns out to have.
  // oxlint-disable-next-line react/set-state-in-effect
  const fitPage = () => setView(fit(PAGE, size(), { padding: 20 }));
  // A phone's canvas is narrower than the page: begin with the whole page in view.
  // oxlint-disable-next-line react/exhaustive-effect-dependencies -- once
  useEffect(fitPage, []);

  function record(entry: Entry): void {
    history.push(entry);
    drawAgain((count) => count + 1);
  }

  function replace(after: readonly Piece[]): void {
    record({ kind: "pieces", before: pieces, after });
    setPieces(after);
  }

  function step(entry: Entry | undefined, back: boolean): void {
    if (entry === undefined) return;
    if (entry.kind === "pieces") setPieces(back ? entry.before : entry.after);
    else setPieces(applyItemChanges(pieces, back ? invertItemChanges(entry.changes) : entry.changes));
    drawAgain((count) => count + 1);
  }

  function duplicate(made: readonly CanvasCopy[]): void {
    const added = made.flatMap(({ source, rect }) => {
      const original = pieces.find((piece) => piece.id === source);
      copies.current += 1;
      return original === undefined
        ? []
        : [{ ...original, id: `${original.id}-${copies.current}`, name: `${original.name} copy`, rect }];
    });
    replace([...pieces, ...added]);
    setSelected(added.map((piece) => piece.id));
  }

  function onKeyDown(event: KeyboardEvent): void {
    const key = event.key.toLowerCase();
    if (isCommandKey(event) && key === "z") {
      event.preventDefault();
      step(event.shiftKey ? history.redo() : history.undo(), !event.shiftKey);
    } else if (key === "backspace" || key === "delete") {
      const going = new Set(selected);
      const kept = pieces.filter((piece) => piece.locked === true || !going.has(piece.id));
      if (kept.length === pieces.length) return;
      event.preventDefault();
      replace(kept);
      setSelected([]);
    }
  }

  const chosen = pieces.filter((piece) => selected.includes(piece.id));
  const around = rectUnion(chosen.map((piece) => piece.rect));
  const zoomBy = (direction: 1 | -1) => {
    const { width, height } = size();
    setView(zoomTo(view, zoomStop(view.zoom, direction), { x: width / 2, y: height / 2 }));
  };

  return (
    <div className="flex w-full flex-col">
      <Controls>
        <RadioGroup label="Snapping" size="small" value={snapping} onValueChange={setSnapping}>
          <Radio value="on">On</Radio>
          <Radio value="off">Off</Radio>
        </RadioGroup>
        <RadioGroup label="Keep within" size="small" value={bounds} onValueChange={setBounds}>
          <Radio value="page">The page</Radio>
          <Radio value="none">Nothing</Radio>
        </RadioGroup>
      </Controls>
      <div className="mb-2 flex flex-wrap items-center gap-2">
        <Button
          focusableWhenDisabled
          disabled={!history.canUndo()}
          onClick={() => step(history.undo(), true)}
        >
          Undo
        </Button>
        <Button
          focusableWhenDisabled
          disabled={!history.canRedo()}
          onClick={() => step(history.redo(), false)}
        >
          Redo
        </Button>
        <div className="ms-auto flex items-center gap-2">
          <Button size="icon" aria-label="Zoom out" onClick={() => zoomBy(-1)}>
            −
          </Button>
          <output className="w-12 text-center text-xs text-fg-muted tabular-nums">
            {Math.round(view.zoom * 100)}%
          </output>
          <Button size="icon" aria-label="Zoom in" onClick={() => zoomBy(1)}>
            +
          </Button>
          <Button onClick={fitPage}>Fit</Button>
        </div>
      </div>
      <Canvas<Piece>
        ref={canvas}
        aria-label="Catalogue page"
        items={pieces}
        onItemsChange={(changes) => {
          record({ kind: "places", changes });
          setPieces(applyItemChanges(pieces, changes));
        }}
        renderItem={renderPiece}
        value={selected}
        onValueChange={setSelected}
        viewport={view}
        onViewportChange={setView}
        snap={snapping === "on"}
        minSize={8}
        {...(bounds === "page" && {
          constrain: (_: string, rect: Rect, kind: CanvasChangeKind) => withinPage(rect, kind),
        })}
        onDuplicate={duplicate}
        onKeyDown={onKeyDown}
        className="h-[420px] w-full rounded-lg border border-border-muted bg-surface-muted max-sm:h-[340px]"
      />
      <Readout>
        {chosen.length === 0 || around === null
          ? "Nothing selected"
          : `${chosen.length === 1 ? chosen[0]!.name : `${chosen.length} selected`} · ${whole(around.x)}, ${whole(around.y)} · ${whole(around.width)} × ${whole(around.height)}`}
      </Readout>
    </div>
  );
}

Best practices

Snap to what is near and visible. The edges and the middle of the thing being moved are drawn to the edges and middles of the things around it. Where nothing is near, and there is a grid, they are drawn to the grid.

Take the nearest, and prefer an edge. Of several lines within reach, the nearest wins. Of two equally near, an edge wins over a middle: edges are what people align by eye.

Snap each direction by itself. Something can come into line horizontally with one neighbour and vertically with another.

Measure the reach on the screen. The distance within which a snap is taken is a distance under the hand — a few pixels — whatever the zoom. Zoomed in, people can place something a hair off a line; zoomed out, they cannot miss it.

Show the line. When something snaps, draw the line it snapped to, running across everything that lies on it, for as long as it holds. A snap that cannot be seen feels like the pointer sticking.

Always offer a way out. A modifier held during the drag — Control on most platforms, Command on some — moves freely. Snapping that cannot be turned off makes some positions unreachable.

Leave the keyboard exact. Arrow keys move by a fixed step, and by a larger one with Shift. They do not snap.

Stay immediate among thousands. Gather the lines once, when the drag begins, and search them in order: a drag among ten thousand things should cost what one among ten does.

See also Pan and zoom.

Behaviour

Lines

Each thing offers its edges and its middle, along both axes, in order
expect([...lines.xs]).toEqual([0, 50, 100, 300, 330, 360]);
expect([...lines.ys]).toEqual([0, 25, 50, 200, 230, 260]);
The nearest line to a value is the nearest of all of them
fc.assert(
  fc.property(
    fc.array(fc.integer({ min: -1000, max: 1000 }), { minLength: 1, maxLength: 40 }),
    fc.double({ min: -1200, max: 1200, noNaN: true }),
    (values, value) => {
      const sorted = Float64Array.from(values).toSorted();
      const least = Math.min(...values.map((line) => Math.abs(line - value)));
      return Math.abs(nearestLine(sorted, value) - value) === least;
    },
  ),
);
Among no lines there is none
expect(nearestLine(new Float64Array(0), 5)).toBeNaN();
A value rests on a line when it is at one, and not when it is merely near
expect(onLine(lines.xs, 100)).toBe(true);
expect(onLine(lines.xs, 100.001)).toBe(false);
expect(onLine(lines.xs, 100.001, 0.01)).toBe(true);
expect(onLine(new Float64Array(0), 100)).toBe(false);
Finding the nearest among a million lines reads a few dozen of them
const many = counted(Float64Array.from({ length: 1_000_000 }, (_, index) => index * 3));
nearestLine(many.array, 1_234_567.8);
expect(many.accesses()).toBeLessThanOrEqual(2 * Math.ceil(Math.log2(1_000_000)) + 2);

Moving

An edge within the threshold of a line comes to rest on it, and the line is reported
// Left edge at 103: three from the right edge of the first box.
expect(snapMove(box(103, 400), lines, 5)).toEqual({ dx: -3, dy: 0, x: 100, y: null });
An edge beyond the threshold stays where it is
const snap = snapMove(box(110, 400), lines, 5);
expect([snap.dx, snap.x]).toEqual([0, null]);
The far edge and the middle snap as the near edge does
// Right edge at 298, two from 300.
expect(snapMove(box(198, 400), lines, 5).dx).toBe(2);
// Middle at 331, one from 330; neither edge is near a line.
expect(snapMove(box(281, 400), lines, 5)).toMatchObject({ dx: -1, x: 330 });
Of several lines within reach the nearest wins, and of two as near, an edge over a middle
const pair = { xs: Float64Array.of(0, 52), ys: new Float64Array(0) };
// Left edge 2 from a line at 0... and middle exactly 2 from a line at 52: the edge.
expect(snapMove(box(2, 100, 104, 10), pair, 5)).toMatchObject({ dx: -2, x: 0 });
// The middle is nearer: it wins.
expect(snapMove(box(4, 100, 98, 10), pair, 5)).toMatchObject({ dx: -1, x: 52 });
The two axes snap independently
expect(snapMove(box(103, 198), lines, 5)).toEqual({ dx: -3, dy: 2, x: 100, y: 200 });
After a snap, the thing moved has a line of its own exactly on the line reported
fc.assert(
  fc.property(
    fc.array(fc.tuple(fc.integer({ min: 0, max: 500 }), fc.integer({ min: 0, max: 500 })), {
      minLength: 1,
      maxLength: 8,
    }),
    fc.integer({ min: 0, max: 500 }),
    fc.integer({ min: 0, max: 500 }),
    fc.integer({ min: 1, max: 12 }),
    (corners, x, y, threshold) => {
      const found = snapLines(corners.map(([cx, cy]) => box(cx, cy, 40, 30)));
      const moving = box(x, y, 64, 20);
      const snap = snapMove(moving, found, threshold);
      const within = Math.abs(snap.dx) <= threshold && Math.abs(snap.dy) <= threshold;
      const onX =
        snap.x === null ||
        [0, 0.5, 1].some((part) => moving.x + snap.dx + moving.width * part === snap.x);
      const onY =
        snap.y === null ||
        [0, 0.5, 1].some((part) => moving.y + snap.dy + moving.height * part === snap.y);
      return within && onX && onY;
    },
  ),
);
With nothing near, a corner comes to rest on the grid; a line within reach is preferred to the grid
expect(snapMove(box(613, 407), lines, 5, 8)).toEqual({ dx: 3, dy: 1, x: null, y: null });
expect(snapMove(box(103, 407), lines, 5, 8)).toMatchObject({ dx: -3, x: 100, dy: 1 });
With nothing near and no grid, nothing changes
expect(snapMove(box(613, 407), lines, 5)).toEqual({ dx: 0, dy: 0, x: null, y: null });
A single value — an edge being resized — snaps to a line, else to the grid, else stays
expect(snapValue(98, lines.xs, 5)).toBe(100);
expect(snapValue(613, lines.xs, 5, 8)).toBe(616);
expect(snapValue(613, lines.xs, 5)).toBe(613);

Guides

A guide runs from the first to the last thing that has a line on it
const resting = box(100, 400);
expect(guideExtent([...others, resting], "x", 100)).toEqual([0, 450]);
A line nothing lies on has no guide
expect(guideExtent(others, "y", 999)).toBeNull();
Along the other axis, a guide spans the things across
expect(guideExtent([box(0, 0), box(400, 0)], "y", 25)).toEqual([0, 500]);

React

Install

npx shadcn@latest add https://ui.publictheta.com/r/snap.json
Source

src/primitives/snap.ts

/**
 * Snapping: what is being moved comes to rest in line with what is around it.
 * Its edges and its middle are drawn to the edges and middles of the things
 * near it, and — where nothing is near — to a grid.
 *
 * The lines to snap to are gathered once, when a drag begins, into two sorted
 * arrays. Each move then asks, for three lines of the thing moved along each
 * axis, which gathered line is nearest: a binary search, so a drag among ten
 * thousand things costs what one among ten does.
 *
 * - A snap is taken only within a threshold, given in the plane's units: a
 *   distance on the screen divided by the zoom, so that it feels the same at
 *   any scale.
 * - Of several lines within reach, the nearest wins; of two as near, an edge
 *   wins over a middle.
 * - The two axes snap independently.
 * - Holding a key to move freely is the caller's affair: it does not ask.
 */
import type { Rect } from "../algorithms/geometry.ts";

/** The lines things offer to snap to, along each axis, in order. */
export interface SnapLines {
  /** The x of each vertical line: left edges, centres and right edges. */
  readonly xs: Float64Array;
  /** The y of each horizontal line: top edges, middles and bottom edges. */
  readonly ys: Float64Array;
}

/** The lines of some rectangles: each one's edges and middle, along both axes. */
export function snapLines(rects: readonly Rect[]): SnapLines {
  const xs = new Float64Array(rects.length * 3);
  const ys = new Float64Array(rects.length * 3);
  for (let index = 0; index < rects.length; index += 1) {
    const { x, y, width, height } = rects[index]!;
    xs[index * 3] = x;
    xs[index * 3 + 1] = x + width / 2;
    xs[index * 3 + 2] = x + width;
    ys[index * 3] = y;
    ys[index * 3 + 1] = y + height / 2;
    ys[index * 3 + 2] = y + height;
  }
  // In place: the arrays are this function's own, and a copy of each would be thrown away.
  // oxlint-disable-next-line unicorn/no-array-sort
  return { xs: xs.sort(), ys: ys.sort() };
}

/** The line nearest a value among sorted lines, or `NaN` where there are none. */
export function nearestLine(lines: ArrayLike<number>, value: number): number {
  const count = lines.length;
  if (count === 0) return Number.NaN;
  let low = 0;
  let high = count - 1;
  // The first line at or after the value.
  while (low < high) {
    const middle = (low + high) >>> 1;
    if (lines[middle]! < value) low = middle + 1;
    else high = middle;
  }
  const after = lines[low]!;
  if (low === 0) return after;
  const before = lines[low - 1]!;
  return value - before <= after - value ? before : after;
}

/** Whether a value rests on one of the lines: what decides that a guide is shown for it. */
export function onLine(lines: ArrayLike<number>, value: number, epsilon = 1e-6): boolean {
  return Math.abs(nearestLine(lines, value) - value) <= epsilon;
}

export interface Snap {
  /** How far to move what is being moved, beyond where it was asked to go. */
  readonly dx: number;
  readonly dy: number;
  /** The vertical and horizontal lines it came to rest on, to be shown; `null` where it rests on none. */
  readonly x: number | null;
  readonly y: number | null;
}

const NONE: Snap = Object.freeze({ dx: 0, dy: 0, x: null, y: null });

/** The correction along one axis: the offset to add, and the line reached, or `NaN`s for none. */
function along(
  lines: ArrayLike<number>,
  start: number,
  extent: number,
  threshold: number,
): [offset: number, line: number] {
  let best = Number.NaN;
  let line = Number.NaN;
  // Edges first: a middle wins only by being strictly nearer.
  for (const part of [0, 1, 0.5]) {
    const at = start + extent * part;
    const nearest = nearestLine(lines, at);
    const offset = nearest - at;
    if (Math.abs(offset) <= threshold && !(Math.abs(offset) >= Math.abs(best))) {
      best = offset;
      line = nearest;
    }
  }
  return [best, line];
}

/**
 * Where a rectangle that has been moved to `moving` should come to rest: on the
 * nearest line within `threshold` along each axis, else — with a `grid` — with
 * its corner on the grid, else where it is.
 */
export function snapMove(moving: Rect, lines: SnapLines, threshold: number, grid = 0): Snap {
  const [dx, x] = along(lines.xs, moving.x, moving.width, threshold);
  const [dy, y] = along(lines.ys, moving.y, moving.height, threshold);
  const onGrid = (value: number) => (grid > 0 ? Math.round(value / grid) * grid - value : 0);
  const snappedX = !Number.isNaN(dx);
  const snappedY = !Number.isNaN(dy);
  const result = {
    dx: snappedX ? dx : onGrid(moving.x),
    dy: snappedY ? dy : onGrid(moving.y),
    x: snappedX ? x : null,
    y: snappedY ? y : null,
  };
  return result.dx === 0 && result.dy === 0 && result.x === null && result.y === null ? NONE : result;
}

/** A single value brought to the nearest line within the threshold, else to the grid, else left: an edge being resized. */
export function snapValue(value: number, lines: ArrayLike<number>, threshold: number, grid = 0): number {
  const nearest = nearestLine(lines, value);
  if (Math.abs(nearest - value) <= threshold) return nearest;
  return grid > 0 ? Math.round(value / grid) * grid : value;
}

/**
 * How far a guide along a line runs: from the first to the last of the given
 * rectangles that have an edge or a middle on it, along the other axis — `null`
 * when none has. The rectangle being moved is given among them, at rest.
 */
export function guideExtent(
  rects: readonly Rect[],
  axis: "x" | "y",
  at: number,
  epsilon = 1e-6,
): readonly [from: number, to: number] | null {
  let from = Infinity;
  let to = -Infinity;
  for (const rect of rects) {
    const start = axis === "x" ? rect.x : rect.y;
    const extent = axis === "x" ? rect.width : rect.height;
    const on =
      Math.abs(start - at) <= epsilon ||
      Math.abs(start + extent / 2 - at) <= epsilon ||
      Math.abs(start + extent - at) <= epsilon;
    if (!on) continue;
    const across = axis === "x" ? rect.y : rect.x;
    from = Math.min(from, across);
    to = Math.max(to, across + (axis === "x" ? rect.height : rect.width));
  }
  return to < from ? null : [from, to];
}