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.
Free crop #
Crop the photo%22%2F%3E%3Ccircle%20cx%3D%22610%22%20cy%3D%22150%22%20r%3D%2270%22%20fill%3D%22hsl(45%2095%25%2065%25)%22%2F%3E%3Cpath%20d%3D%22M0%20460%20L230%20250%20L420%20430%20L560%20320%20L800%20500%20V600%20H0%20Z%22%20fill%3D%22hsl(150%2030%25%2045%25)%22%2F%3E%3C%2Fsvg%3E)
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%22%2F%3E%3Ccircle%20cx%3D%22610%22%20cy%3D%22150%22%20r%3D%2270%22%20fill%3D%22hsl(45%2095%25%2065%25)%22%2F%3E%3Cpath%20d%3D%22M0%20460%20L230%20250%20L420%20430%20L560%20320%20L800%20500%20V600%20H0%20Z%22%20fill%3D%22hsl(150%2030%25%2045%25)%22%2F%3E%3C%2Fsvg%3E)
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.