PhoenixKit.Users.AvatarCrop (phoenix_kit v2.22.0)

Copy Markdown View Source

Non-destructive avatar cropping, Apple Photos style.

A crop is data, never pixels: custom_fields["avatar_crop"] holds a focal point, a zoom, and the image's aspect ratio, while the original file — and every generated variant of it — stays exactly as uploaded. Adjusting the crop rewrites four numbers; resetting it drops the key; nothing is ever re-encoded, so there is no quality loss and no way to crop yourself into a corner.

Rendering applies the stored geometry as an inline style on the <img> inside the avatar's (square, overflow-hidden) frame. Because the storage module's image variants are aspect-preserving scales of the original, the same normalized numbers are valid against any of them — a page full of avatars keeps loading the small variants it always loaded, and the crop costs a few floats that were already in the user record. No extra request, no imaging job, no per-crop file.

The stored map

%{"x" => 0.5, "y" => 0.35, "zoom" => 2.0, "ar" => 1.5}
  • x, y — the focal point (what the person centered), as fractions of the image's width and height. 0.5/0.5 is the image center.
  • zoom — magnification relative to the cover fit. 1.0 shows exactly what object-fit: cover would show; the ceiling is 8.0.
  • ar — the image's width/height ratio, captured when the crop was made so rendering never needs to look dimensions up (avatars appear dozens to a page; a per-avatar dimension query would be the N+1 this design exists to avoid).

Geometry

Inside a square frame taken as 100%: the cover fit makes the image's short side match the frame, so a landscape image is 100·zoom tall and 100·zoom·ar wide (portrait mirrored). The image is then offset so the focal point sits at the frame's center, clamped so the frame never shows past an edge — the same clamp the editor applies, so what was saved is what renders.

Summary

Functions

Nil for a crop that shows exactly what no crop would show.

The user's stored crop, or nil when there is none (or it is invalid).

The inline style that seats a cropped image in its frame.

The image's placement inside its square frame, as percentages of the frame: %{width:, height:, left:, top:}.

The zoom ceiling, shared with the editor UI.

Clamp raw crop params into a storable map, or nil if they do not describe a crop at all.

The cheapest stored variant that still renders sharply: a zoomed crop shows 1/zoom of the source, so a box of box_px CSS pixels needs box_px · 2 (retina) · zoom source pixels along the frame edge.

Functions

drop_identity(crop)

Nil for a crop that shows exactly what no crop would show.

Centered at cover fit is the identity: storing it as numbers would make every future render do geometry for nothing, and (worse) pin the avatar to the aspect ratio recorded at save time. Tolerances absorb the float noise a drag leaves behind.

from_user(arg1)

The user's stored crop, or nil when there is none (or it is invalid).

img_style(crop)

The inline style that seats a cropped image in its frame.

The frame supplies position: relative; overflow: hidden (the avatar component already has both); max-width: none undoes Tailwind's global img { max-width: 100% }, without which every percentage below is re-clamped and the crop silently collapses to a stretch.

layout(map)

The image's placement inside its square frame, as percentages of the frame: %{width:, height:, left:, top:}.

Mirrored by avatarCropLayout in priv/static/assets/phoenix_kit.js — the editor previews with the same math that later renders, so the saved crop cannot drift from the preview. Change one, change both.

max_zoom()

The zoom ceiling, shared with the editor UI.

normalize(params)

Clamp raw crop params into a storable map, or nil if they do not describe a crop at all.

Accepts string or atom keys and numbers or numeric strings — the values arrive from a JS hook. Out-of-range values are clamped rather than refused: a drag that overshoots an edge is intent to reach the edge.

variant_for(box_px, zoom \\ 1.0, ar \\ 1.0)

The cheapest stored variant that still renders sharply: a zoomed crop shows 1/zoom of the source, so a box of box_px CSS pixels needs box_px · 2 (retina) · zoom source pixels along the frame edge.

The frame edge is covered by the image's SHORT side, while variants are scaled by width — so a landscape image needs its width to be ar times the frame requirement before the short side suffices. Portrait images' width is their short side, so their factor is 1.

Without a crop, zoom is 1.0 and this degrades to plain size-based selection. Capped at "large": past it only the original upload could help, and serving an unbounded file into an avatar circle buys marginal sharpness at arbitrary transfer cost.