feat: Add Image Cropper to ui & storybook (#607)

* feat: add image cropper

* installation

* chore: add max width for dialogue container

---------

Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
Adeyemi Adetunji
2025-12-04 01:51:58 +01:00
committed by GitHub
co-authored by Rotimi Best
parent 9a5aee1ee0
commit ce216b1ade
19 changed files with 681 additions and 1 deletions
@@ -0,0 +1 @@
export const FIELDS = ['src', 'onCropped', 'onUnsupportedFile'];
@@ -0,0 +1,149 @@
<script module>
import { Button } from '@cio/ui';
import { defineMeta } from '@storybook/addon-svelte-csf';
import * as ImageCropper from '@cio/ui/custom/image-cropper';
import { FIELDS } from './fields';
const { Story } = defineMeta({
title: 'Molecules/ImageCropper',
component: ImageCropper.Root,
parameters: {
layout: 'centered',
controls: {
include: FIELDS
}
},
argTypes: {
src: {
control: 'text',
description: 'The source URL of the current image',
table: { defaultValue: { summary: '""' } }
},
onCropped: {
control: false,
description: 'Callback function called when image is cropped',
table: {
type: { summary: '(url: string) => void' },
defaultValue: { summary: 'undefined' }
}
},
onUnsupportedFile: {
control: false,
description: 'Callback function called when unsupported file is uploaded',
table: {
type: { summary: '(file: File) => void' },
defaultValue: { summary: 'undefined' }
}
}
},
tags: ['autodocs']
});
</script>
<Story name="Default">
{#snippet template(args)}
<div class="ui:flex ui:flex-col ui:items-center ui:gap-4">
<ImageCropper.Root
{...args}
accept="image/*"
src="https://github.com/shadcn.png"
onUnsupportedFile={(file) => {
console.error(`Unsupported file type: ${file.type}`);
}}
>
<ImageCropper.UploadTrigger>
<ImageCropper.Preview />
</ImageCropper.UploadTrigger>
<ImageCropper.Dialog>
<ImageCropper.Cropper />
<ImageCropper.Controls>
<ImageCropper.Cancel />
<ImageCropper.Crop />
</ImageCropper.Controls>
</ImageCropper.Dialog>
</ImageCropper.Root>
</div>
{/snippet}
</Story>
<Story name="Square Preview">
{#snippet template(args)}
<div class="ui:flex ui:flex-col ui:items-center ui:gap-4">
<ImageCropper.Root
{...args}
accept="image/*"
src="https://github.com/shadcn.png"
onUnsupportedFile={(file) => {
console.error(`Unsupported file type: ${file.type}`);
}}
>
<ImageCropper.UploadTrigger>
<ImageCropper.Preview class="ui:rounded-md" />
</ImageCropper.UploadTrigger>
<ImageCropper.Dialog>
<ImageCropper.Cropper cropShape="rect" />
<ImageCropper.Controls>
<ImageCropper.Cancel />
<ImageCropper.Crop />
</ImageCropper.Controls>
</ImageCropper.Dialog>
</ImageCropper.Root>
</div>
{/snippet}
</Story>
<Story name="No Default Image">
{#snippet template(args)}
<div class="flex flex-col items-center gap-4">
<ImageCropper.Root
{...args}
accept="image/*"
onCropped={(url) => {
console.log('Cropped image URL:', url);
}}
onUnsupportedFile={(file) => {
console.error('Unsupported file:', file.name);
}}
>
<ImageCropper.UploadTrigger>
<ImageCropper.Preview />
</ImageCropper.UploadTrigger>
<ImageCropper.Dialog>
<ImageCropper.Cropper />
<ImageCropper.Controls>
<ImageCropper.Cancel />
<ImageCropper.Crop />
</ImageCropper.Controls>
</ImageCropper.Dialog>
</ImageCropper.Root>
</div>
{/snippet}
</Story>
<Story name="Custom Preview">
{#snippet template(args)}
<div class="ui:flex ui:flex-col ui:items-center ui:gap-4">
<ImageCropper.Root
src="https://github.com/shadcn.png"
onUnsupportedFile={(file) => {
toast.error(`Unsupported file type: ${file.type}`);
}}
>
<ImageCropper.UploadTrigger>
<ImageCropper.Preview>
{#snippet child({ src })}
<img {src} alt="your avatar" class="ui:size-32 ui:border-2 ui:border-blue-500" />
{/snippet}
</ImageCropper.Preview>
</ImageCropper.UploadTrigger>
<ImageCropper.Dialog>
<ImageCropper.Cropper cropShape="rect" />
<ImageCropper.Controls>
<ImageCropper.Cancel />
<ImageCropper.Crop />
</ImageCropper.Controls>
</ImageCropper.Dialog>
</ImageCropper.Root>
</div>
{/snippet}
</Story>
+1
View File
@@ -26,6 +26,7 @@
"mode-watcher": "^1.1.0",
"runed": "^0.36.0",
"svelte": "^5.39.11",
"svelte-easy-crop": "^5.0.0",
"svelte-sonner": "^1.0.5",
"svelte-toolbelt": "^0.10.6",
"tailwind-merge": "^3.4.0",
@@ -0,0 +1,28 @@
<script lang="ts">
import Button, { type ButtonElementProps } from '../../base/button/button.svelte';
import { useImageCropperCancel } from './image-cropper.svelte.js';
import Trash2Icon from '@lucide/svelte/icons/trash-2';
let { ref = $bindable(null), variant = 'outline', size = 'sm', onclick, ...rest }: ButtonElementProps = $props();
const cancelState = useImageCropperCancel();
</script>
<Button
{...rest}
bind:ref
{size}
{variant}
onclick={(
e: MouseEvent & {
currentTarget: EventTarget & HTMLButtonElement;
}
) => {
onclick?.(e);
cancelState.onclick();
}}
>
<Trash2Icon />
<span>Cancel</span>
</Button>
@@ -0,0 +1,14 @@
<script lang="ts">
import { cn } from '../../tools';
import type { ImageCropperControlsProps } from './types';
let { ref = $bindable(null), class: className, children, ...rest }: ImageCropperControlsProps = $props();
</script>
<div
{...rest}
bind:this={ref}
class={cn('ui:flex ui:w-full ui:place-items-center ui:justify-center ui:gap-2', className)}
>
{@render children?.()}
</div>
@@ -0,0 +1,28 @@
<script lang="ts">
import Button, { type ButtonElementProps } from '../../base/button/button.svelte';
import { useImageCropperCrop } from './image-cropper.svelte.js';
import CropIcon from '@lucide/svelte/icons/crop';
let { ref = $bindable(null), variant = 'default', size = 'sm', onclick, ...rest }: ButtonElementProps = $props();
const cropState = useImageCropperCrop();
</script>
<Button
{...rest}
bind:ref
{size}
{variant}
onclick={(
e: MouseEvent & {
currentTarget: EventTarget & HTMLButtonElement;
}
) => {
onclick?.(e);
cropState.onclick();
}}
>
<CropIcon />
<span>Crop</span>
</Button>
@@ -0,0 +1,21 @@
<script lang="ts">
import Cropper from 'svelte-easy-crop';
import { useImageCropperCropper } from './image-cropper.svelte.js';
import type { ImageCropperCropperProps } from './types.js';
let { cropShape = 'round', aspect = 1, showGrid = false, ...rest }: ImageCropperCropperProps = $props();
const cropperState = useImageCropperCropper();
</script>
<!-- This needs to be relative https://github.com/ValentinH/svelte-easy-crop#basic-usage -->
<div class="ui:relative ui:h-full ui:w-full">
<Cropper
{...rest}
{cropShape}
{aspect}
{showGrid}
image={cropperState.rootState.tempUrl}
oncropcomplete={cropperState.onCropComplete}
/>
</div>
@@ -0,0 +1,25 @@
<script lang="ts">
import * as Dialog from '../../base/dialog';
import { cn } from '../../tools';
import { useImageCropperDialog } from './image-cropper.svelte.js';
import type { ImageCropperDialogProps } from './types';
let { children, class: className, ...rest }: ImageCropperDialogProps = $props();
const dialogState = useImageCropperDialog();
</script>
<Dialog.Root bind:open={dialogState.rootState.open}>
<Dialog.Content
{...rest}
showCloseButton
class={cn(
'ui:min-h-96 ui:max-w-[60%] ui:rounded-none ui:border-x-0 sm:ui:max-w-lg sm:ui:rounded-lg sm:ui:border-x',
className
)}
>
<div class="ui:flex ui:flex-col ui:gap-4">
{@render children?.()}
</div>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,23 @@
<script lang="ts">
import * as Avatar from '../../base/avatar';
import type { ImageCropperPreviewProps } from './types';
import { useImageCropperPreview } from './image-cropper.svelte.js';
import UploadIcon from '@lucide/svelte/icons/upload';
import { cn } from '../../tools';
let { child, class: className }: ImageCropperPreviewProps = $props();
const previewState = useImageCropperPreview();
</script>
{#if child}
{@render child({ src: previewState.rootState.src })}
{:else}
<Avatar.Root class={cn('ui:ring-accent ui:ring-offset-background ui:size-20 ui:ring-2 ui:ring-offset-2', className)}>
<Avatar.Image src={previewState.rootState.src} />
<Avatar.Fallback>
<UploadIcon class="ui:size-4" />
<span class="ui:sr-only">Upload image</span>
</Avatar.Fallback>
</Avatar.Root>
{/if}
@@ -0,0 +1,12 @@
<script lang="ts">
import { useImageCropperTrigger } from './image-cropper.svelte.js';
import type { ImageCropperUploadTriggerProps } from './types';
let { ref = $bindable(null), children, ...rest }: ImageCropperUploadTriggerProps = $props();
const triggerState = useImageCropperTrigger();
</script>
<label {...rest} bind:this={ref} for={triggerState.rootState.id} class="ui:hover:cursor-pointer">
{@render children?.()}
</label>
@@ -0,0 +1,43 @@
<script lang="ts">
import { box } from 'svelte-toolbelt';
import { useImageCropperRoot } from './image-cropper.svelte.js';
import type { ImageCropperRootProps } from './types';
import { onDestroy } from 'svelte';
import { useId } from 'bits-ui';
let {
id = useId(),
src = $bindable(''),
onCropped = () => {},
onUnsupportedFile = () => {},
children,
...rest
}: ImageCropperRootProps = $props();
const rootState = useImageCropperRoot({
id: box.with(() => id),
src: box.with(
() => src,
(v) => (src = v)
),
onCropped: box.with(() => onCropped),
onUnsupportedFile: box.with(() => onUnsupportedFile)
});
onDestroy(() => rootState.dispose());
</script>
{@render children?.()}
<input
{...rest}
onchange={(e) => {
const file = e.currentTarget.files?.[0];
if (!file) return;
rootState.onUpload(file);
// reset so that we can reupload the same file
(e.target! as HTMLInputElement).value = '';
}}
type="file"
{id}
style="display: none;"
/>
@@ -0,0 +1,167 @@
import type { ReadableBoxedValues, WritableBoxedValues } from 'svelte-toolbelt';
import { Context } from 'runed';
import type { CropArea, DispatchEvents } from 'svelte-easy-crop';
import { getCroppedImg } from './utils';
// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/img#supported_image_formats
export const VALID_IMAGE_TYPES = [
'image/apng',
'image/avif',
'image/gif',
'image/jpeg',
'image/png',
'image/svg+xml',
'image/webp'
];
export type ImageCropperRootProps = WritableBoxedValues<{
src: string;
}> &
ReadableBoxedValues<{
id: string;
onCropped: (url: string) => void;
onUnsupportedFile: (file: File) => void;
}>;
class ImageCropperRootState {
#createdUrls = $state<string[]>([]);
open = $state(false);
tempUrl = $state<string>();
pixelCrop = $state<CropArea>();
constructor(readonly opts: ImageCropperRootProps) {
this.onUpload = this.onUpload.bind(this);
this.onCancel = this.onCancel.bind(this);
this.onCrop = this.onCrop.bind(this);
this.dispose = this.dispose.bind(this);
}
onUpload(file: File) {
if (!VALID_IMAGE_TYPES.includes(file.type)) {
this.opts.onUnsupportedFile.current(file);
return;
}
this.tempUrl = URL.createObjectURL(file);
this.#createdUrls.push(this.tempUrl);
this.open = true;
}
onCancel() {
this.tempUrl = undefined;
this.open = false;
this.pixelCrop = undefined;
}
async onCrop() {
if (!this.pixelCrop || !this.tempUrl) return;
this.opts.src.current = await getCroppedImg(this.tempUrl, this.pixelCrop);
this.open = false;
this.opts.onCropped.current(this.opts.src.current);
}
get src() {
return this.opts.src.current;
}
get id() {
return this.opts.id.current;
}
dispose() {
for (const url of this.#createdUrls) {
URL.revokeObjectURL(url);
}
}
}
export type ImageCropperTriggerProps = ReadableBoxedValues<{
id?: string;
}>;
class ImageCropperTriggerState {
constructor(readonly rootState: ImageCropperRootState) {}
}
class ImageCropperPreviewState {
constructor(readonly rootState: ImageCropperRootState) {}
}
class ImageCropperDialogState {
constructor(readonly rootState: ImageCropperRootState) {}
}
class ImageCropperCropperState {
constructor(readonly rootState: ImageCropperRootState) {
this.onCropComplete = this.onCropComplete.bind(this);
}
onCropComplete(e: DispatchEvents['cropcomplete']) {
this.rootState.pixelCrop = e.pixels;
}
}
class ImageCropperCropState {
constructor(readonly rootState: ImageCropperRootState) {
this.onclick = this.onclick.bind(this);
}
onclick() {
this.rootState.onCrop();
}
}
class ImageCropperCancelState {
constructor(readonly rootState: ImageCropperRootState) {
this.onclick = this.onclick.bind(this);
}
onclick() {
this.rootState.onCancel();
}
}
const ImageCropperRootContext = new Context<ImageCropperRootState>('ImageCropper.Root');
export const useImageCropperRoot = (props: ImageCropperRootProps) => {
return ImageCropperRootContext.set(new ImageCropperRootState(props));
};
export const useImageCropperTrigger = () => {
const rootState = ImageCropperRootContext.get();
return new ImageCropperTriggerState(rootState);
};
export const useImageCropperPreview = () => {
const rootState = ImageCropperRootContext.get();
return new ImageCropperPreviewState(rootState);
};
export const useImageCropperDialog = () => {
const rootState = ImageCropperRootContext.get();
return new ImageCropperDialogState(rootState);
};
export const useImageCropperCropper = () => {
const rootState = ImageCropperRootContext.get();
return new ImageCropperCropperState(rootState);
};
export const useImageCropperCrop = () => {
const rootState = ImageCropperRootContext.get();
return new ImageCropperCropState(rootState);
};
export const useImageCropperCancel = () => {
const rootState = ImageCropperRootContext.get();
return new ImageCropperCancelState(rootState);
};
@@ -0,0 +1,13 @@
import Root from './image-cropper.svelte';
import UploadTrigger from './image-cropper-upload-trigger.svelte';
import Preview from './image-cropper-preview.svelte';
import Dialog from './image-cropper-dialog.svelte';
import Cropper from './image-cropper-cropper.svelte';
import Controls from './image-cropper-controls.svelte';
import Crop from './image-cropper-crop.svelte';
import Cancel from './image-cropper-cancel.svelte';
import { getFileFromUrl } from './utils';
export { Root, UploadTrigger, Preview, Dialog, Cropper, Controls, Crop, Cancel, getFileFromUrl };
export type * from './types';
@@ -0,0 +1,44 @@
import type {
AvatarRootProps,
DialogContentProps,
WithChildren,
WithoutChild,
WithoutChildren
} from 'bits-ui';
import type { Snippet } from 'svelte';
import type { CropperProps } from 'svelte-easy-crop';
import type { HTMLAttributes, HTMLInputAttributes } from 'svelte/elements';
export type ImageCropperRootPropsWithoutHTML = WithChildren<{
id?: string;
src?: string;
onCropped?: (url: string) => void;
onUnsupportedFile?: (file: File) => void;
}>;
export type ImageCropperRootProps = ImageCropperRootPropsWithoutHTML & HTMLInputAttributes;
export type ImageCropperDialogProps = DialogContentProps;
export type ImageCropperCropperProps = Omit<Partial<CropperProps>, 'oncropcomplete' | 'image'>;
export type ImageCropperControlsWithoutHTML = WithChildren<{
ref?: HTMLDivElement | null;
}>;
export type ImageCropperControlsProps = ImageCropperControlsWithoutHTML &
WithoutChildren<HTMLAttributes<HTMLDivElement>>;
export type ImageCropperPreviewPropsWithoutHTML = {
child?: Snippet<[{ src: string }]>;
};
export type ImageCropperPreviewProps = ImageCropperPreviewPropsWithoutHTML &
WithoutChild<AvatarRootProps>;
export type ImageCropperUploadTriggerPropsWithoutHTML = WithChildren<{
ref?: HTMLLabelElement | null;
}>;
export type ImageCropperUploadTriggerProps = ImageCropperUploadTriggerPropsWithoutHTML &
WithoutChildren<HTMLAttributes<HTMLLabelElement>>;
@@ -0,0 +1,85 @@
import type { CropArea } from 'svelte-easy-crop';
export const getFileFromUrl = async (url: string, fileName = 'cropped.png'): Promise<File> => {
// Fetch the file data from the URL
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch resource: ${response.status} ${response.statusText}`);
}
// Convert the response into a Blob
const blob = await response.blob();
// Create and return a File. You can set a custom type if needed.
return new File([blob], fileName, { type: blob.type });
};
const createImage = (url: string): Promise<HTMLImageElement> => {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.addEventListener('load', () => resolve(image));
image.addEventListener('error', (error) => reject(error));
image.setAttribute('crossOrigin', 'anonymous'); // needed to avoid cross-origin issues on CodeSandbox
image.src = url;
});
};
const getRadianAngle = (degreeValue: number) => {
return (degreeValue * Math.PI) / 180;
};
/** Gets the cropped image from the src using the cropped area
*
* @param imageSrc
* @param pixelCrop
* @param rotation
* @returns
*/
export const getCroppedImg = async (
imageSrc: string,
pixelCrop: CropArea,
rotation = 0
): Promise<string> => {
const image = await createImage(imageSrc);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Error getting 2d rendering context');
}
const maxSize = Math.max(image.width, image.height);
const safeArea = 2 * ((maxSize / 2) * Math.sqrt(2));
// set each dimensions to double largest dimension to allow for a safe area for the
// image to rotate in without being clipped by canvas context
canvas.width = safeArea;
canvas.height = safeArea;
// translate canvas context to a central location on image to allow rotating around the center.
ctx.translate(safeArea / 2, safeArea / 2);
ctx.rotate(getRadianAngle(rotation));
ctx.translate(-safeArea / 2, -safeArea / 2);
// draw rotated image and store data.
ctx.drawImage(image, safeArea / 2 - image.width * 0.5, safeArea / 2 - image.height * 0.5);
const data = ctx.getImageData(0, 0, safeArea, safeArea);
// set canvas width to final desired crop size - this will clear existing context
canvas.width = pixelCrop.width;
canvas.height = pixelCrop.height;
// paste generated rotate image with correct offsets for x,y crop values.
ctx.putImageData(
data,
Math.round(0 - safeArea / 2 + image.width * 0.5 - pixelCrop.x),
Math.round(0 - safeArea / 2 + image.height * 0.5 - pixelCrop.y)
);
return new Promise((resolve) => {
canvas.toBlob((file) => {
resolve(URL.createObjectURL(file!));
}, 'image/png');
});
};
+1
View File
@@ -50,3 +50,4 @@ export { BarChart, LineChart, AreaChart, PieChart, scaleBand } from './base/char
export * from './tools';
export * from './custom/editor/ui';
export * as ImageCropper from './custom/image-cropper';
+13
View File
@@ -0,0 +1,13 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, 'child'> : T;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, 'children'> : T;
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };
File diff suppressed because one or more lines are too long
+12
View File
@@ -1042,6 +1042,9 @@ importers:
svelte:
specifier: ^5.39.11
version: 5.43.2
svelte-easy-crop:
specifier: ^5.0.0
version: 5.0.0([email protected])
svelte-sonner:
specifier: ^1.0.5
version: 1.0.6([email protected])
@@ -10286,6 +10289,11 @@ packages:
peerDependencies:
svelte: '>=3.23.0 || ^5.0.0-next.0'
[email protected]:
resolution: {integrity: sha512-niWtner+qYVas6ypIOQ277NKNACVz9qz6wfpEW7t+x5Lhv6nOjpv3S1WhEPO+SnDaW3VZv+0bmm6jVDHK2GMAg==}
peerDependencies:
svelte: ^5.0.0
[email protected]:
resolution: {integrity: sha512-fjPzOfipR5S7gQ/JvI9r2H8y9gMGXO3JtmrylHLLyahEMquXI0lrebcjT+9/hNgDej0H7abTyox5HpHmW1PSWA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0, pnpm: 10.18.3}
@@ -22511,6 +22519,10 @@ snapshots:
dependencies:
svelte: 5.43.2
[email protected]([email protected]):
dependencies:
svelte: 5.43.2
[email protected]([email protected]):
dependencies:
eslint-scope: 8.4.0