feat: include image cropper (#617)
* fix: include image cropper * clean styles * ui improvements * bug bot feedbacks * fix: update image upload format to PNG for consistency with cropping output --------- Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
co-authored by
Rotimi Best
parent
bc9e3b41ae
commit
fdadc7bccf
@@ -14,7 +14,7 @@
|
||||
bind:this={ref}
|
||||
data-slot="sidebar-inset"
|
||||
class={cn(
|
||||
'ui:bg-background ui:relative ui:flex ui:w-full ui:flex-1 ui:flex-col',
|
||||
'ui:bg-background ui:relative ui:flex ui:w-full ui:flex-1 ui:flex-col ui:h-full',
|
||||
'ui:md:peer-data-[variant=inset]:m-2 ui:md:peer-data-[variant=inset]:ml-0 ui:md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2 ui:md:peer-data-[variant=inset]:rounded-xl ui:md:peer-data-[variant=inset]:shadow-sm',
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
import { cn } from '../../tools';
|
||||
|
||||
const emptyVariants = tv({
|
||||
base: 'ui:w-full',
|
||||
base: 'ui:w-full ui:max-h-90',
|
||||
variants: {
|
||||
variant: {
|
||||
default: '',
|
||||
page: 'ui:h-full ui:border ui:border-dashed ui:max-w-3xl ui:max-h-2/4 ui:mx-auto'
|
||||
page: 'ui:h-full ui:border ui:border-dashed ui:max-w-3xl ui:mx-auto'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { useImageCropperTrigger } from './image-cropper.svelte.js';
|
||||
import type { ImageCropperUploadTriggerProps } from './types';
|
||||
import { cn } from '../../tools';
|
||||
|
||||
let { ref = $bindable(null), children, ...rest }: ImageCropperUploadTriggerProps = $props();
|
||||
|
||||
const triggerState = useImageCropperTrigger();
|
||||
const isDisabled = $derived(triggerState.rootState.disabled);
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (isDisabled) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<label {...rest} bind:this={ref} for={triggerState.rootState.id} class="ui:hover:cursor-pointer">
|
||||
<label
|
||||
{...rest}
|
||||
bind:this={ref}
|
||||
for={isDisabled ? undefined : triggerState.rootState.id}
|
||||
onclick={handleClick}
|
||||
class={cn('ui:cursor-pointer', isDisabled ? 'ui:cursor-not-allowed ui:opacity-50 ui:pointer-events-none' : '')}
|
||||
aria-disabled={isDisabled}
|
||||
>
|
||||
{@render children?.()}
|
||||
</label>
|
||||
|
||||
@@ -1,43 +1,47 @@
|
||||
<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';
|
||||
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();
|
||||
let {
|
||||
id = useId(),
|
||||
src = $bindable(''),
|
||||
onCropped = () => {},
|
||||
onUnsupportedFile = () => {},
|
||||
maxFileSize,
|
||||
disabled = false,
|
||||
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)
|
||||
});
|
||||
const rootState = useImageCropperRoot({
|
||||
id: box.with(() => id),
|
||||
src: box.with(
|
||||
() => src,
|
||||
(v) => (src = v)
|
||||
),
|
||||
onCropped: box.with(() => onCropped),
|
||||
onUnsupportedFile: box.with(() => onUnsupportedFile),
|
||||
maxFileSize: box.with(() => maxFileSize),
|
||||
disabled: box.with(() => disabled ?? undefined)
|
||||
});
|
||||
|
||||
onDestroy(() => rootState.dispose());
|
||||
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;"
|
||||
{...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;"
|
||||
/>
|
||||
|
||||
@@ -1,167 +1,189 @@
|
||||
import type { ReadableBoxedValues, WritableBoxedValues } from 'svelte-toolbelt';
|
||||
import { Context } from 'runed';
|
||||
import type { CropArea, DispatchEvents } from 'svelte-easy-crop';
|
||||
import type { ReadableBoxedValues, WritableBoxedValues } from 'svelte-toolbelt';
|
||||
|
||||
import { Context } from 'runed';
|
||||
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'
|
||||
'image/apng',
|
||||
'image/avif',
|
||||
'image/gif',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/svg+xml',
|
||||
'image/webp'
|
||||
];
|
||||
|
||||
export type ImageCropperRootProps = WritableBoxedValues<{
|
||||
src: string;
|
||||
src: string;
|
||||
}> &
|
||||
ReadableBoxedValues<{
|
||||
id: string;
|
||||
onCropped: (url: string) => void;
|
||||
onUnsupportedFile: (file: File) => void;
|
||||
}>;
|
||||
ReadableBoxedValues<{
|
||||
id: string;
|
||||
onCropped: (url: string) => void;
|
||||
onUnsupportedFile: (file: File) => void;
|
||||
maxFileSize?: number; // Maximum file size in bytes
|
||||
disabled?: boolean;
|
||||
}>;
|
||||
|
||||
class ImageCropperRootState {
|
||||
#createdUrls = $state<string[]>([]);
|
||||
open = $state(false);
|
||||
tempUrl = $state<string>();
|
||||
pixelCrop = $state<CropArea>();
|
||||
#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);
|
||||
}
|
||||
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;
|
||||
}
|
||||
get disabled() {
|
||||
return this.opts.disabled?.current ?? false;
|
||||
}
|
||||
|
||||
this.tempUrl = URL.createObjectURL(file);
|
||||
this.#createdUrls.push(this.tempUrl);
|
||||
this.open = true;
|
||||
}
|
||||
onUpload(file: File) {
|
||||
// Don't upload if disabled
|
||||
if (this.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
onCancel() {
|
||||
this.tempUrl = undefined;
|
||||
this.open = false;
|
||||
this.pixelCrop = undefined;
|
||||
}
|
||||
// Check file size first if maxFileSize is specified
|
||||
if (this.opts.maxFileSize?.current !== undefined) {
|
||||
const maxFileSize = this.opts.maxFileSize.current;
|
||||
if (file.size > maxFileSize) {
|
||||
this.opts.onUnsupportedFile.current(file);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async onCrop() {
|
||||
if (!this.pixelCrop || !this.tempUrl) return;
|
||||
// Check file type
|
||||
if (!VALID_IMAGE_TYPES.includes(file.type)) {
|
||||
this.opts.onUnsupportedFile.current(file);
|
||||
return;
|
||||
}
|
||||
|
||||
this.opts.src.current = await getCroppedImg(this.tempUrl, this.pixelCrop);
|
||||
this.tempUrl = URL.createObjectURL(file);
|
||||
this.#createdUrls.push(this.tempUrl);
|
||||
this.open = true;
|
||||
}
|
||||
|
||||
this.open = false;
|
||||
onCancel() {
|
||||
this.tempUrl = undefined;
|
||||
this.open = false;
|
||||
this.pixelCrop = undefined;
|
||||
}
|
||||
|
||||
this.opts.onCropped.current(this.opts.src.current);
|
||||
}
|
||||
async onCrop() {
|
||||
if (!this.pixelCrop || !this.tempUrl) return;
|
||||
|
||||
get src() {
|
||||
return this.opts.src.current;
|
||||
}
|
||||
this.opts.src.current = await getCroppedImg(this.tempUrl, this.pixelCrop);
|
||||
|
||||
get id() {
|
||||
return this.opts.id.current;
|
||||
}
|
||||
this.open = false;
|
||||
|
||||
dispose() {
|
||||
for (const url of this.#createdUrls) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
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;
|
||||
id?: string;
|
||||
}>;
|
||||
|
||||
class ImageCropperTriggerState {
|
||||
constructor(readonly rootState: ImageCropperRootState) {}
|
||||
constructor(readonly rootState: ImageCropperRootState) {}
|
||||
}
|
||||
|
||||
class ImageCropperPreviewState {
|
||||
constructor(readonly rootState: ImageCropperRootState) {}
|
||||
constructor(readonly rootState: ImageCropperRootState) {}
|
||||
}
|
||||
|
||||
class ImageCropperDialogState {
|
||||
constructor(readonly rootState: ImageCropperRootState) {}
|
||||
constructor(readonly rootState: ImageCropperRootState) {}
|
||||
}
|
||||
|
||||
class ImageCropperCropperState {
|
||||
constructor(readonly rootState: ImageCropperRootState) {
|
||||
this.onCropComplete = this.onCropComplete.bind(this);
|
||||
}
|
||||
constructor(readonly rootState: ImageCropperRootState) {
|
||||
this.onCropComplete = this.onCropComplete.bind(this);
|
||||
}
|
||||
|
||||
onCropComplete(e: DispatchEvents['cropcomplete']) {
|
||||
this.rootState.pixelCrop = e.pixels;
|
||||
}
|
||||
onCropComplete(e: DispatchEvents['cropcomplete']) {
|
||||
this.rootState.pixelCrop = e.pixels;
|
||||
}
|
||||
}
|
||||
|
||||
class ImageCropperCropState {
|
||||
constructor(readonly rootState: ImageCropperRootState) {
|
||||
this.onclick = this.onclick.bind(this);
|
||||
}
|
||||
constructor(readonly rootState: ImageCropperRootState) {
|
||||
this.onclick = this.onclick.bind(this);
|
||||
}
|
||||
|
||||
onclick() {
|
||||
this.rootState.onCrop();
|
||||
}
|
||||
onclick() {
|
||||
this.rootState.onCrop();
|
||||
}
|
||||
}
|
||||
|
||||
class ImageCropperCancelState {
|
||||
constructor(readonly rootState: ImageCropperRootState) {
|
||||
this.onclick = this.onclick.bind(this);
|
||||
}
|
||||
constructor(readonly rootState: ImageCropperRootState) {
|
||||
this.onclick = this.onclick.bind(this);
|
||||
}
|
||||
|
||||
onclick() {
|
||||
this.rootState.onCancel();
|
||||
}
|
||||
onclick() {
|
||||
this.rootState.onCancel();
|
||||
}
|
||||
}
|
||||
|
||||
const ImageCropperRootContext = new Context<ImageCropperRootState>('ImageCropper.Root');
|
||||
|
||||
export const useImageCropperRoot = (props: ImageCropperRootProps) => {
|
||||
return ImageCropperRootContext.set(new ImageCropperRootState(props));
|
||||
return ImageCropperRootContext.set(new ImageCropperRootState(props));
|
||||
};
|
||||
|
||||
export const useImageCropperTrigger = () => {
|
||||
const rootState = ImageCropperRootContext.get();
|
||||
const rootState = ImageCropperRootContext.get();
|
||||
|
||||
return new ImageCropperTriggerState(rootState);
|
||||
return new ImageCropperTriggerState(rootState);
|
||||
};
|
||||
|
||||
export const useImageCropperPreview = () => {
|
||||
const rootState = ImageCropperRootContext.get();
|
||||
const rootState = ImageCropperRootContext.get();
|
||||
|
||||
return new ImageCropperPreviewState(rootState);
|
||||
return new ImageCropperPreviewState(rootState);
|
||||
};
|
||||
|
||||
export const useImageCropperDialog = () => {
|
||||
const rootState = ImageCropperRootContext.get();
|
||||
const rootState = ImageCropperRootContext.get();
|
||||
|
||||
return new ImageCropperDialogState(rootState);
|
||||
return new ImageCropperDialogState(rootState);
|
||||
};
|
||||
|
||||
export const useImageCropperCropper = () => {
|
||||
const rootState = ImageCropperRootContext.get();
|
||||
const rootState = ImageCropperRootContext.get();
|
||||
|
||||
return new ImageCropperCropperState(rootState);
|
||||
return new ImageCropperCropperState(rootState);
|
||||
};
|
||||
|
||||
export const useImageCropperCrop = () => {
|
||||
const rootState = ImageCropperRootContext.get();
|
||||
const rootState = ImageCropperRootContext.get();
|
||||
|
||||
return new ImageCropperCropState(rootState);
|
||||
return new ImageCropperCropState(rootState);
|
||||
};
|
||||
|
||||
export const useImageCropperCancel = () => {
|
||||
const rootState = ImageCropperRootContext.get();
|
||||
const rootState = ImageCropperRootContext.get();
|
||||
|
||||
return new ImageCropperCancelState(rootState);
|
||||
return new ImageCropperCancelState(rootState);
|
||||
};
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import type {
|
||||
AvatarRootProps,
|
||||
DialogContentProps,
|
||||
WithChildren,
|
||||
WithoutChild,
|
||||
WithoutChildren
|
||||
} from 'bits-ui';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { CropperProps } from 'svelte-easy-crop';
|
||||
import type { AvatarRootProps, DialogContentProps, WithChildren, WithoutChild, WithoutChildren } from 'bits-ui';
|
||||
import type { HTMLAttributes, HTMLInputAttributes } from 'svelte/elements';
|
||||
|
||||
import type { CropperProps } from 'svelte-easy-crop';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
export type ImageCropperRootPropsWithoutHTML = WithChildren<{
|
||||
id?: string;
|
||||
src?: string;
|
||||
onCropped?: (url: string) => void;
|
||||
onUnsupportedFile?: (file: File) => void;
|
||||
id?: string;
|
||||
src?: string;
|
||||
onCropped?: (url: string) => void;
|
||||
onUnsupportedFile?: (file: File) => void;
|
||||
maxFileSize?: number; // Maximum file size in bytes
|
||||
}>;
|
||||
|
||||
export type ImageCropperRootProps = ImageCropperRootPropsWithoutHTML & HTMLInputAttributes;
|
||||
@@ -23,22 +19,21 @@ export type ImageCropperDialogProps = DialogContentProps;
|
||||
export type ImageCropperCropperProps = Omit<Partial<CropperProps>, 'oncropcomplete' | 'image'>;
|
||||
|
||||
export type ImageCropperControlsWithoutHTML = WithChildren<{
|
||||
ref?: HTMLDivElement | null;
|
||||
ref?: HTMLDivElement | null;
|
||||
}>;
|
||||
|
||||
export type ImageCropperControlsProps = ImageCropperControlsWithoutHTML &
|
||||
WithoutChildren<HTMLAttributes<HTMLDivElement>>;
|
||||
WithoutChildren<HTMLAttributes<HTMLDivElement>>;
|
||||
|
||||
export type ImageCropperPreviewPropsWithoutHTML = {
|
||||
child?: Snippet<[{ src: string }]>;
|
||||
child?: Snippet<[{ src: string }]>;
|
||||
};
|
||||
|
||||
export type ImageCropperPreviewProps = ImageCropperPreviewPropsWithoutHTML &
|
||||
WithoutChild<AvatarRootProps>;
|
||||
export type ImageCropperPreviewProps = ImageCropperPreviewPropsWithoutHTML & WithoutChild<AvatarRootProps>;
|
||||
|
||||
export type ImageCropperUploadTriggerPropsWithoutHTML = WithChildren<{
|
||||
ref?: HTMLLabelElement | null;
|
||||
ref?: HTMLLabelElement | null;
|
||||
}>;
|
||||
|
||||
export type ImageCropperUploadTriggerProps = ImageCropperUploadTriggerPropsWithoutHTML &
|
||||
WithoutChildren<HTMLAttributes<HTMLLabelElement>>;
|
||||
WithoutChildren<HTMLAttributes<HTMLLabelElement>>;
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user