Foundations

Resizing

Eight handles around a thing: which edges each moves, about the middle or in proportion, never inside out.

Markdown

Things on a plane โ€” a shape, a picture, a frame, a group of them โ€” are resized by handles: small marks at the corners and sides of whatever is selected, each of which moves the edges it sits on.

Handles

Select a block and drag a handle: the opposite edges stay. With Alt it resizes about its middle; with Shift it keeps its proportion. Select two with Shift-click: one frame, and each keeps its place within it. Command with the arrow keys resizes by one unit.

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

Offer eight handles. One at each corner, moving two edges, and one in the middle of each side, moving one. A side's handle changes one dimension however the pointer wanders.

Hold the opposite edges still. Dragging a handle moves its own edges and leaves the others where they are, so that people can set one edge against something without disturbing the rest.

Resize about the middle on request. With Alt (Option) held, the opposite edges move the other way by as much, and the middle stays where it is.

Keep the proportion on request. With Shift held, width and height change together. At a corner, whichever side has been asked to grow more leads, so the pointer never ends up outside the thing; at a side, the other dimension grows about its middle.

Never turn a thing inside out. An edge dragged past the one opposite stops at a smallest size. A thing that flips when a hand overshoots is rarely what was meant; flipping is its own command.

Resize several things as one. A selection of several shows one frame around them all. Resizing the frame places each thing within the new one as it lay in the old, and stops where the smallest of them would become too small.

Make handles a size on the screen. A handle looks the same, and is as easy to take, at any zoom. On something too small on the screen to carry all eight, leave out the sides' handles before the corners'.

Give a finger a larger target than it sees. A handle's target is at least 44 pixels across under a finger, while the mark stays small. Let the target reach no more than a quarter of the way into the thing and put the rest of it outside, so that a small thing can still be taken by its middle and moved.

Snap edges, and show it. An edge being moved comes to rest on the lines around it, as a thing being moved does, and shows the line; see Snapping.

Offer the keyboard an exact way. Command with the arrow keys moves the right and bottom edges by one unit, and by ten with Shift. Say the new size to people who cannot see it.

Conventions

IntentPointerKeyboardTouch
ResizeDrag a handleCommand with an arrow keyDrag a handle
About the middleDrag a handle with Altโ€”โ€”
Keep the proportionDrag a handle with Shiftโ€”โ€”
By a larger stepโ€”Command and Shift with an arrow keyโ€”

Command is โŒ˜ on Apple platforms and Ctrl elsewhere.

See also Dragging.

Behaviour

Handles

There are eight: one at each corner and one in the middle of each side
expect(RESIZE_HANDLES.map((each) => handlePoint(box, each))).toEqual([
  { x: 100, y: 50 },
  { x: 200, y: 50 },
  { x: 300, y: 50 },
  { x: 300, y: 100 },
  { x: 300, y: 150 },
  { x: 200, y: 150 },
  { x: 100, y: 150 },
  { x: 100, y: 100 },
]);
A corner's handle moves two edges, a side's moves one
expect(handleEdges("nw")).toEqual({ x: -1, y: -1 });
expect(handleEdges("e")).toEqual({ x: 1, y: 0 });
expect(handleEdges("s")).toEqual({ x: 0, y: 1 });

Dragging a handle

The edges the handle sits on follow the drag
expect(resizeRect(box, "se", 30, 20)).toEqual({ x: 100, y: 50, width: 230, height: 120 });
expect(resizeRect(box, "nw", 30, 20)).toEqual({ x: 130, y: 70, width: 170, height: 80 });
A side's handle changes one dimension, however the pointer wanders
expect(resizeRect(box, "e", 30, 999)).toEqual({ x: 100, y: 50, width: 230, height: 100 });
expect(resizeRect(box, "n", 999, -10)).toEqual({ x: 100, y: 40, width: 200, height: 110 });
The opposite edges stay where they are
fc.assert(
  fc.property(handle, distance, distance, (each, dx, dy) => {
    const next = resizeRect(box, each, dx, dy);
    const edges = handleEdges(each);
    return (
      (edges.x !== 1 || next.x === box.x) &&
      (edges.x !== -1 || next.x + next.width === box.x + box.width) &&
      (edges.x !== 0 || (next.x === box.x && next.width === box.width)) &&
      (edges.y !== 1 || next.y === box.y) &&
      (edges.y !== -1 || next.y + next.height === box.y + box.height) &&
      (edges.y !== 0 || (next.y === box.y && next.height === box.height))
    );
  }),
);
A drag of no distance changes nothing, and gives back the same rectangle
for (const each of RESIZE_HANDLES) expect(resizeRect(box, each, 0, 0)).toBe(box);

From the centre

The opposite edges move the other way by as much, and the middle stays still
expect(resizeRect(box, "se", 30, 20, { fromCentre: true })).toEqual({
  x: 70,
  y: 30,
  width: 260,
  height: 140,
});
fc.assert(
  fc.property(handle, distance, distance, (each, dx, dy) => {
    const next = resizeRect(box, each, dx, dy, { fromCentre: true });
    return next.x + next.width / 2 === 200 && next.y + next.height / 2 === 100;
  }),
);

Keeping the proportion

Width and height change by one factor
fc.assert(
  fc.property(handle, distance, distance, fc.boolean(), (each, dx, dy, fromCentre) => {
    const next = resizeRect(box, each, dx, dy, { keepRatio: true, fromCentre });
    return Math.abs(next.width - next.height * 2) < 1e-9;
  }),
);
At a corner, the side asked to grow more leads
// 20% wider, 10% taller: both become 20% larger.
expect(resizeRect(box, "se", 40, 10, { keepRatio: true })).toEqual({
  x: 100,
  y: 50,
  width: 240,
  height: 120,
});
expect(resizeRect(box, "nw", -10, -30, { keepRatio: true })).toEqual({
  x: 40,
  y: 20,
  width: 260,
  height: 130,
});
At a side, the other dimension grows about its middle
expect(resizeRect(box, "e", 100, 0, { keepRatio: true })).toEqual({
  x: 100,
  y: 25,
  width: 300,
  height: 150,
});
A rectangle with no width or no height has no proportion to keep, and resizes freely
const line = { x: 0, y: 0, width: 100, height: 0 };
expect(resizeRect(line, "se", 10, 20, { keepRatio: true })).toEqual({
  x: 0,
  y: 0,
  width: 110,
  height: 20,
});

The smallest size

An edge dragged past the one opposite stops at the minimum: the rectangle does not turn inside out
expect(resizeRect(box, "e", -900, 0, { minWidth: 10 })).toEqual({
  x: 100,
  y: 50,
  width: 10,
  height: 100,
});
expect(resizeRect(box, "nw", 900, 900, { minWidth: 10, minHeight: 8 })).toEqual({
  x: 290,
  y: 142,
  width: 10,
  height: 8,
});
Whatever minimum is given, or none, a size is never less than nothing
const minimum = fc.constantFrom(undefined, -50, 0);
fc.assert(
  fc.property(
    handle,
    distance,
    distance,
    fc.boolean(),
    fc.boolean(),
    minimum,
    (each, dx, dy, a, b, least) => {
      const next = resizeRect(box, each, dx, dy, {
        fromCentre: a,
        keepRatio: b,
        ...(least !== undefined && { minWidth: least, minHeight: least }),
      });
      return next.width >= 0 && next.height >= 0;
    },
  ),
);
Keeping the proportion, neither side goes below its minimum
expect(resizeRect(box, "se", -900, -900, { keepRatio: true, minWidth: 10, minHeight: 20 })).toEqual({
  x: 100,
  y: 50,
  width: 40,
  height: 20,
});

Several as one

Each keeps its place within the rectangle around them, in proportion
const twice = { x: 50, y: 0, width: 200, height: 100 };
expect(resizeWithin({ x: 0, y: 0, width: 20, height: 20 }, around, twice)).toEqual({
  x: 50,
  y: 0,
  width: 40,
  height: 20,
});
expect(resizeWithin({ x: 80, y: 60, width: 20, height: 40 }, around, twice)).toEqual({
  x: 210,
  y: 60,
  width: 40,
  height: 40,
});
Resized there and back, each returns to where it was
fc.assert(
  fc.property(handle, distance, distance, (each, dx, dy) => {
    const inner = { x: 10, y: 30, width: 40, height: 20 };
    const to = resizeRect(around, each, dx, dy, { minWidth: 1, minHeight: 1 });
    const back = resizeWithin(resizeWithin(inner, around, to), to, around);
    return (
      Math.abs(back.x - inner.x) < 1e-6 &&
      Math.abs(back.y - inner.y) < 1e-6 &&
      Math.abs(back.width - inner.width) < 1e-6 &&
      Math.abs(back.height - inner.height) < 1e-6
    );
  }),
);
A rectangle around them with no width leaves widths as they are
const flat = { x: 0, y: 0, width: 0, height: 100 };
expect(
  resizeWithin({ x: 0, y: 10, width: 0, height: 10 }, flat, { x: 5, y: 0, width: 50, height: 200 }),
).toEqual({ x: 5, y: 20, width: 0, height: 20 });

Hit targets

On a large rectangle a target is centred on its handle
expect(handleTargetOffset(-1, 400, 44)).toBe(-22);
expect(handleTargetOffset(1, 400, 44)).toBe(-22);
expect(handleTargetOffset(0, 400, 44)).toBe(-22);
A target never reaches more than a quarter of the way into the rectangle: the rest lies outside
// A side of 40: ten pixels in, thirty-four out.
expect(handleTargetOffset(-1, 40, 44)).toBe(-34);
expect(handleTargetOffset(1, 40, 44)).toBe(-10);
fc.assert(
  fc.property(fc.integer({ min: 0, max: 600 }), fc.integer({ min: 1, max: 60 }), (side, target) => {
    const start = handleTargetOffset(-1, side, target) + target;
    const end = side + handleTargetOffset(1, side, target);
    // The middle half of the side is left free.
    return start <= side / 4 && end >= side - side / 4;
  }),
);
A side carries a handle in its middle only when it is two targets long
expect(sideHandleFits(88, 44)).toBe(true);
expect(sideHandleFits(87, 44)).toBe(false);

React

Install

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

src/primitives/resize.ts

/**
 * Resizing by handles: eight of them around a rectangle, one at each corner and
 * one in the middle of each side. Dragging a handle moves the edges it sits on
 * and leaves the opposite ones where they are. All of it is arithmetic on
 * rectangles; nothing here knows about elements or events.
 *
 * - From the centre: the opposite edges move the other way by as much, so the
 *   middle stays where it is.
 * - Keeping the proportion: width and height change by one factor. At a corner
 *   the side asked to grow more leads; at a side, the other dimension grows
 *   about its middle.
 * - A rectangle is never smaller than a minimum, and never turns inside out: an
 *   edge dragged past the one opposite stops at the minimum size.
 * - Several things are resized as one by resizing the rectangle around them
 *   and placing each in the new one as it lay in the old.
 * - A handle's hit target is a size on the screen. It never reaches more than a
 *   quarter of the way into the rectangle, so that a small rectangle can still
 *   be taken by its middle; what does not fit inside lies outside.
 */
import type { Point, Rect } from "../algorithms/geometry.ts";

export type ResizeHandle = "nw" | "n" | "ne" | "e" | "se" | "s" | "sw" | "w";

/** The handles in order around the rectangle, from the top-left corner. */
export const RESIZE_HANDLES: readonly ResizeHandle[] = ["nw", "n", "ne", "e", "se", "s", "sw", "w"];

type Edge = -1 | 0 | 1;

/** Which edges a handle moves along each axis: -1 the left or top, 1 the right or bottom, 0 neither. */
export function handleEdges(handle: ResizeHandle): { readonly x: Edge; readonly y: Edge } {
  return {
    x: handle.includes("w") ? -1 : handle.includes("e") ? 1 : 0,
    y: handle.includes("n") ? -1 : handle.includes("s") ? 1 : 0,
  };
}

/** Where a handle sits on a rectangle. */
export function handlePoint(rect: Rect, handle: ResizeHandle): Point {
  const { x, y } = handleEdges(handle);
  return { x: rect.x + (rect.width * (x + 1)) / 2, y: rect.y + (rect.height * (y + 1)) / 2 };
}

export interface ResizeOptions {
  /** Resize about the middle: the opposite edges move too. */
  readonly fromCentre?: boolean;
  /** Keep width and height in the proportion they have. */
  readonly keepRatio?: boolean;
  readonly minWidth?: number;
  readonly minHeight?: number;
}

/** One axis: where it starts and how long it is once its extent is `extent`, by which point stays still. */
function place(start: number, was: number, extent: number, edge: Edge, fromCentre: boolean): number {
  if (edge === 0 || fromCentre) return start + (was - extent) / 2;
  return edge === 1 ? start : start + was - extent;
}

/**
 * A rectangle after one of its handles has been dragged by a distance. The
 * edges opposite stay where they are โ€” or, from the centre, mirror the drag.
 */
export function resizeRect(
  rect: Rect,
  handle: ResizeHandle,
  dx: number,
  dy: number,
  { fromCentre = false, keepRatio = false, minWidth = 0, minHeight = 0 }: ResizeOptions = {},
): Rect {
  const edges = handleEdges(handle);
  const reach = fromCentre ? 2 : 1;
  let width = rect.width + edges.x * dx * reach;
  let height = rect.height + edges.y * dy * reach;

  if (keepRatio && rect.width > 0 && rect.height > 0) {
    const across = width / rect.width;
    const down = height / rect.height;
    // At a corner the side asked to grow more leads, so the pointer stays within the rectangle.
    const asked = edges.x === 0 ? down : edges.y === 0 ? across : Math.max(across, down);
    const factor = Math.max(asked, minWidth / rect.width, minHeight / rect.height, 0);
    width = rect.width * factor;
    height = rect.height * factor;
  } else {
    width = Math.max(width, minWidth, 0);
    height = Math.max(height, minHeight, 0);
  }
  if (width === rect.width && height === rect.height) return rect;
  return {
    x: place(rect.x, rect.width, width, edges.x, fromCentre),
    y: place(rect.y, rect.height, height, edges.y, fromCentre),
    width,
    height,
  };
}

/**
 * Where a rectangle lies once the rectangle around it has gone from `from` to
 * `to`: in the same place within it, in proportion. This is how several things
 * are resized as one.
 */
export function resizeWithin(rect: Rect, from: Rect, to: Rect): Rect {
  if (from === to) return rect;
  const across = from.width > 0 ? to.width / from.width : 1;
  const down = from.height > 0 ? to.height / from.height : 1;
  return {
    x: to.x + (rect.x - from.x) * across,
    y: to.y + (rect.y - from.y) * down,
    width: rect.width * across,
    height: rect.height * down,
  };
}

/**
 * Where a handle's hit target begins along one axis, from the handle's own
 * point: a target `target` long on a side `side` long, both on the screen. In
 * the middle of a side it is centred. At an edge it reaches inward by half its
 * length, but by no more than a quarter of the side.
 */
export function handleTargetOffset(edge: Edge, side: number, target: number): number {
  if (edge === 0) return -target / 2;
  const inward = Math.min(target / 2, Math.max(side, 0) / 4);
  return edge === -1 ? inward - target : -inward;
}

/** Whether a side is long enough to carry a handle in its middle, clear of the corners' targets. */
export function sideHandleFits(side: number, target: number): boolean {
  return side >= target * 2;
}