Components

Image cropper

A crop of an image dragged and resized by handles and keys, zoomed, turned, locked to an aspect or round, and exported from a canvas.

guides
handles
viewport
scheme
direction

Free crop #

Crop the photo
Zoom
export const ImageCropperStyle = forSlots(ImageCropperSlots)(Recipes.ImageCropper({}))
// The zoom is a Slider; its label is the cropper's to say.
const ZoomStyle = forSlots(SliderSlots)(Recipes.Slider({ label: 'hidden' }))

/** An 800 by 600 landscape, standing in for an upload. */
export const LANDSCAPE = `data:image/svg+xml,${encodeURIComponent(
  `<svg xmlns="http://www.w3.org/2000/svg" width="800" height="600" viewBox="0 0 800 600"><rect width="800" height="600" fill="hsl(205 70% 82%)"/><circle cx="610" cy="150" r="70" fill="hsl(45 95% 65%)"/><path d="M0 460 L230 250 L420 430 L560 320 L800 500 V600 H0 Z" fill="hsl(150 30% 45%)"/></svg>`,
)}`

/** The crop, zoom and turn are the cropper's Model, folded with `update`; the image is the page's. */
export const imageCropper = <Message>(
  style: NamedStyle<typeof ImageCropperSlots>,
  model: ImageCropper.Model,
  onMessage: (message: ImageCropper.Message) => Message,
  h: HtmlBuilder<Message>,
): Html =>
  h.div(
    [h.Style({ inlineSize: 'min(32rem, 100%)' })],
    [
      ImageCropper.view(
        { model, onMessage, label: 'Crop the photo', src: LANDSCAPE, style, slider: ZoomStyle },
        h,
      ),
    ],
  )

Round, square, exported #

Profile picture
Zoom
// A round crop at an aspect of 1, in a square well: its Model from
// `ImageCropper.init({ id, shape: 'round', aspect: 1 })`.
export const AvatarCropStyle = forSlots(ImageCropperSlots)(
  Recipes.ImageCropper({ viewport: 'square', handles: 'corners' }),
)

/**
 * `RequestedExport` answers with `Exported`, a PNG of the crop as a data URL,
 * which the page keeps to show (`url`).
 */
export const avatarCropper = <Message>(
  style: NamedStyle<typeof ImageCropperSlots>,
  model: ImageCropper.Model,
  url: Option.Option<string>,
  onMessage: (message: ImageCropper.Message) => Message,
  h: HtmlBuilder<Message>,
): Html =>
  h.div(
    [h.Style({ display: 'grid', gap: '0.75rem', inlineSize: 'min(20rem, 100%)' })],
    [
      ImageCropper.view(
        {
          model,
          onMessage,
          label: 'Profile picture',
          src: LANDSCAPE,
          controls: { rotate: false },
          style,
          slider: ZoomStyle,
        },
        h,
      ),
      h.div(
        [h.Style({ display: 'flex', gap: '0.75rem', alignItems: 'center' })],
        [
          Button.view(
            {
              label: 'Use this crop',
              onClick: onMessage(
                ImageCropper.Message.RequestedExport({ type: 'image/png', quality: 1 }),
              ),
              style: ButtonStyle,
            },
            h,
          ),
          ...Option.match(url, {
            onNone: () => [],
            onSome: src => [
              h.img([
                h.Src(src),
                h.Alt('The cropped picture'),
                h.Style({ inlineSize: '3rem', blockSize: '3rem', borderRadius: '50%' }),
              ]),
            ],
          }),
        ],
      ),
    ],
  )

Usage

Each example above names the function that drew it, over the theme module Create a theme writes. The preview passes it the style picked above instead of its default.

The recipe ↗This code ↗