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 <best@classroomio.com>
This commit is contained in:
co-authored by
Rotimi Best
parent
9a5aee1ee0
commit
ce216b1ade
@@ -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>
|
||||
@@ -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');
|
||||
});
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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
Generated
+12
@@ -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(svelte@5.43.2)
|
||||
svelte-sonner:
|
||||
specifier: ^1.0.5
|
||||
version: 1.0.6(svelte@5.43.2)
|
||||
@@ -10286,6 +10289,11 @@ packages:
|
||||
peerDependencies:
|
||||
svelte: '>=3.23.0 || ^5.0.0-next.0'
|
||||
|
||||
svelte-easy-crop@5.0.0:
|
||||
resolution: {integrity: sha512-niWtner+qYVas6ypIOQ277NKNACVz9qz6wfpEW7t+x5Lhv6nOjpv3S1WhEPO+SnDaW3VZv+0bmm6jVDHK2GMAg==}
|
||||
peerDependencies:
|
||||
svelte: ^5.0.0
|
||||
|
||||
svelte-eslint-parser@1.4.0:
|
||||
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
|
||||
|
||||
svelte-easy-crop@5.0.0(svelte@5.43.2):
|
||||
dependencies:
|
||||
svelte: 5.43.2
|
||||
|
||||
svelte-eslint-parser@1.4.0(svelte@5.43.2):
|
||||
dependencies:
|
||||
eslint-scope: 8.4.0
|
||||
|
||||
Reference in New Issue
Block a user