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:
Abdulmuizz Hamzat
2025-12-23 11:25:38 +01:00
committed by GitHub
co-authored by Rotimi Best
parent bc9e3b41ae
commit fdadc7bccf
16 changed files with 293 additions and 259 deletions
@@ -79,7 +79,7 @@
</script> </script>
<section <section
class="m-auto w-full p-3 lg:w-11/12 lg:px-4" class="mx-auto w-full p-3 lg:w-11/12 lg:px-4"
use:dndzone={{ use:dndzone={{
items: $lessons, items: $lessons,
flipDurationMs, flipDurationMs,
@@ -178,7 +178,7 @@
<DeleteLessonConfirmation bind:openDeleteModal {deleteLesson} /> <DeleteLessonConfirmation bind:openDeleteModal {deleteLesson} />
<section <section
class="m-auto w-full p-3 lg:w-11/12 lg:px-4" class="mx-auto w-full p-3 lg:w-11/12 lg:px-4"
use:dndzone={{ use:dndzone={{
items: $lessonSections, items: $lessonSections,
flipDurationMs, flipDurationMs,
@@ -2,12 +2,11 @@
import { Skeleton } from '@cio/ui/base/skeleton'; import { Skeleton } from '@cio/ui/base/skeleton';
</script> </script>
<div class="mb-7 flex max-w-3xl flex-col gap-5 rounded-md border border-gray-300 p-4"> <div class="mb-7 flex w-full max-w-3xl flex-col gap-3 rounded-md border border-gray-300 p-3">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Skeleton class="h-8 w-8 overflow-hidden rounded-full" /> <Skeleton class="h-8 w-8 overflow-hidden rounded-full" />
<Skeleton class="h-4 w-[30%]" /> <Skeleton class="h-4 w-[30%]" />
</div> </div>
<Skeleton class="h-4 w-full" /> <Skeleton class="h-4 w-full" />
<Skeleton class="h-4 w-full" /> <Skeleton class="h-4 w-full" />
<Skeleton class="h-4 w-full" />
</div> </div>
@@ -8,15 +8,14 @@
import MessageSquareMoreIcon from '@lucide/svelte/icons/message-square-more'; import MessageSquareMoreIcon from '@lucide/svelte/icons/message-square-more';
import MessageCirclePlusIcon from '@lucide/svelte/icons/message-circle-plus'; import MessageCirclePlusIcon from '@lucide/svelte/icons/message-circle-plus';
import { Vote } from '$features/ui';
import { profile } from '$lib/utils/store/user'; import { profile } from '$lib/utils/store/user';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import { communityApi } from '../api/community.svelte';
import { calDateDiff } from '$lib/utils/functions/date'; import { calDateDiff } from '$lib/utils/functions/date';
import { currentOrg, currentOrgPath } from '$lib/utils/store/org'; import { currentOrg, currentOrgPath } from '$lib/utils/store/org';
import { AskCommunityButton } from '../components'; import { CommunityListLoader, AskCommunityButton } from '../components';
import { CommunityListLoader } from '../components'; import { communityApi } from '../api/community.svelte';
import { Vote } from '$features/ui';
import type { CommunityQuestionData } from '../utils/types'; import type { CommunityQuestionData } from '../utils/types';
interface Props { interface Props {
@@ -32,9 +31,7 @@
if (!orgId || !profileId) return; if (!orgId || !profileId) return;
untrack(async () => { untrack(async () => {
// Fetch courses - API class handles this
await communityApi.fetchCoursesForOrg(profileId, orgId); await communityApi.fetchCoursesForOrg(profileId, orgId);
// Call API - API class handles loading, errors, navigation
await communityApi.fetchCommunityQuestions({ orgId, isLMS }); await communityApi.fetchCommunityQuestions({ orgId, isLMS });
}); });
} }
@@ -46,8 +43,8 @@
let filteredQuestions = $derived( let filteredQuestions = $derived(
communityApi.questions.filter( communityApi.questions.filter(
(question: CommunityQuestionData[number]) => (question: CommunityQuestionData[number]) =>
question.title.toLowerCase().includes(searchValue.toLowerCase()) && question?.title?.toLowerCase?.()?.includes(searchValue.toLowerCase()) &&
(!selectedId || question.courseId === selectedId) (!selectedId || question?.courseId === selectedId)
) )
); );
</script> </script>
@@ -90,11 +87,11 @@
{question.title} {question.title}
</Item.Title> </Item.Title>
<Item.Description> <Item.Description>
{question.authorFullname} asked {calDateDiff(question.createdAt)} {question?.authorFullname} asked {calDateDiff(question?.createdAt)}
</Item.Description> </Item.Description>
<a class="m-0" href="/courses/{question.courseId}" onclick={(e) => e.stopPropagation()}> <a class="m-0" href="/courses/{question.courseId}" onclick={(e) => e.stopPropagation()}>
<span class="text-muted-foreground p-0 text-xs"> <span class="text-muted-foreground p-0 text-xs">
#{question.courseTitle} #{question?.courseTitle}
</span> </span>
</a> </a>
</Item.Content> </Item.Content>
@@ -250,7 +250,7 @@
/> />
</RoleBasedSecurity> </RoleBasedSecurity>
{#if isLoading} {#if isLoading}
<div> <div class="flex w-full flex-col items-center">
<NewsFeedLoader /> <NewsFeedLoader />
<NewsFeedLoader /> <NewsFeedLoader />
<NewsFeedLoader /> <NewsFeedLoader />
@@ -260,8 +260,7 @@
title={$t('course.navItem.news_feed.body_header')} title={$t('course.navItem.news_feed.body_header')}
description={$t('course.navItem.news_feed.body_content')} description={$t('course.navItem.news_feed.body_content')}
icon={BookIcon} icon={BookIcon}
class="h-full" />
></Empty>
{:else} {:else}
{#each $newsFeed as feed} {#each $newsFeed as feed}
{#if feed.isPinned} {#if feed.isPinned}
@@ -286,4 +285,3 @@
{/each} {/each}
{/if} {/if}
</RoleBasedSecurity> </RoleBasedSecurity>
@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import { Progress } from '@cio/ui/base/progress'; import { Progress } from '@cio/ui/base/progress';
import * as ImageCropper from '@cio/ui/custom/image-cropper';
import CameraIcon from '@lucide/svelte/icons/camera'; import CameraIcon from '@lucide/svelte/icons/camera';
import PencilIcon from '@lucide/svelte/icons/pencil';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
@@ -19,7 +21,7 @@
let { let {
avatar = $bindable(), avatar = $bindable(),
src, src = $bindable(),
widthHeight = '', widthHeight = '',
shape = 'rounded-full', shape = 'rounded-full',
errorMessage = $bindable(null), errorMessage = $bindable(null),
@@ -31,100 +33,99 @@
}: Props = $props(); }: Props = $props();
const defaultImg = 'https://cdn4.iconfinder.com/data/icons/small-n-flat/24/user-alt-512.png'; const defaultImg = 'https://cdn4.iconfinder.com/data/icons/small-n-flat/24/user-alt-512.png';
let fileinput: HTMLInputElement | undefined = $state();
let imgRef: HTMLImageElement | undefined = $state();
const onFileSelected = ( // Local image source for the cropper (never null)
e: Event & { let cropperSrc = $derived(src || '');
currentTarget: EventTarget & HTMLInputElement;
}
) => {
const image = e.currentTarget?.files?.[0];
if (!image) return;
const maxFileSize = maxFileSizeInMb * 1024 * 1024; const onCropped = async (croppedUrl: string) => {
if (image.size > maxFileSize) { // Convert the cropped data URL to a File object
errorMessage = `${$t('settings.profile.profile_picture.validation_error')} ${maxFileSize / (1024 * 1024)} MB`; const response = await fetch(croppedUrl);
const blob = await response.blob();
// getCroppedImg outputs PNG format, so use blob.type (which will be 'image/png') and matching filename
const file = new File([blob], 'cropped-image.png', { type: blob.type });
// Update avatar with the cropped file
avatar = file;
// Update the src preview
src = croppedUrl;
cropperSrc = croppedUrl;
// trigger onchange to parent
change?.();
// clear error
errorMessage = null;
};
const onUnsupportedFile = (file: File) => {
const maxFileSizeInBytes = maxFileSizeInMb * 1024 * 1024;
// Check if error is due to file size
if (file.size > maxFileSizeInBytes) {
errorMessage = `${$t('settings.profile.profile_picture.validation_error')} File size exceeds ${maxFileSizeInMb}MB limit`;
return; return;
} }
const reader = new FileReader(); // Otherwise, it's an unsupported file type
reader.addEventListener('load', function () { errorMessage = `${$t('settings.profile.profile_picture.validation_error')} Unsupported file type: ${file.type}`;
if (!imgRef || !reader.result) return;
// pass image as avatar to parent component
avatar = image;
// update the image src on the DOM
imgRef.setAttribute('src', reader.result.toString());
// trigger onchange to parent
change?.();
// clear error
errorMessage = null;
});
reader.readAsDataURL(image);
}; };
</script> </script>
<section class="width-fit flex p-3 {flexDirection} items-center justify-between gap-5"> <section class="flex w-fit p-3 {flexDirection} items-center justify-between gap-2">
<div <ImageCropper.Root
class="avatar-container {widthHeight || bind:src={cropperSrc}
'setwidthheight'} pointer relative border-2 border-gray-200 dark:border-neutral-600 {shape}" {onCropped}
> {onUnsupportedFile}
<img bind:this={imgRef} class="h-full w-full {shape}" src={src || defaultImg} alt="" /> maxFileSize={maxFileSizeInMb * 1024 * 1024}
</div>
<div class="flex flex-col items-center">
<button
class="width-fit ui:text-primary flex flex-col items-center text-sm {isDisabled || isUploading
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer'}"
onclick={() => {
if (!isDisabled || isUploading) {
fileinput?.click();
}
}}
disabled={isDisabled || isUploading}
>
{#if isUploading}
<Progress />
{:else}
<CameraIcon size={16} />
{/if}
<span class="ml-2">{$t('settings.profile.profile_picture.upload_image')}</span>
</button>
<p class="text-center text-xs text-gray-500">
{$t('settings.profile.profile_picture.file_size')}
{maxFileSizeInMb}MB<br />
{$t('settings.profile.profile_picture.accepted')}: jpeg, jpg, png, webp
</p>
{#if errorMessage}
<p class="text-sm text-red-500">{errorMessage}</p>
{/if}
</div>
<input
style="display:none"
type="file"
accept=".jpg, .jpeg, .png, .webp" accept=".jpg, .jpeg, .png, .webp"
onchange={(e) => onFileSelected(e)} disabled={isDisabled || isUploading}
bind:this={fileinput} >
/> <ImageCropper.UploadTrigger aria-disabled={isDisabled || isUploading}>
<div
class="avatar-container {widthHeight ||
'h-[128px] w-[128px]'} pointer border-2 border-gray-200 dark:border-neutral-600 {shape}"
>
<ImageCropper.Preview>
{#snippet child({ src: imageSrc })}
<div class="group relative h-full w-full">
<img class="h-full w-full {shape}" src={imageSrc || defaultImg} alt="" />
<div
class="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity duration-200 group-hover:opacity-100 {shape}"
>
<PencilIcon class="stroke-white" size={16} />
</div>
</div>
{/snippet}
</ImageCropper.Preview>
</div>
<div class="mt-1 flex flex-col items-center">
{#if isUploading}
<Progress />
{:else}
<CameraIcon size={16} />
{/if}
<span class="text-sm">{$t('settings.profile.profile_picture.upload_image')}</span>
</div>
</ImageCropper.UploadTrigger>
<div class="flex flex-col items-center">
<p class="text-center text-xs text-gray-500">
{$t('settings.profile.profile_picture.file_size')}
{maxFileSizeInMb}MB<br />
{$t('settings.profile.profile_picture.accepted')}: jpeg, jpg, png, webp
</p>
{#if errorMessage}
<p class="text-sm text-red-500">{errorMessage}</p>
{/if}
</div>
<ImageCropper.Dialog>
<ImageCropper.Cropper cropShape={shape === 'rounded-full' ? 'round' : 'rect'} />
<ImageCropper.Controls>
<ImageCropper.Cancel />
<ImageCropper.Crop />
</ImageCropper.Controls>
</ImageCropper.Dialog>
</ImageCropper.Root>
</section> </section>
<style>
.width-fit {
width: fit-content;
}
.avatar-container.setwidthheight {
height: 128px;
width: 128px;
}
.upload-icon {
display: none;
}
</style>
@@ -123,12 +123,13 @@
"email_change_verification_note": "A verification email has been sent to your current email address. The new email will not be in effect until you accept the verification email.", "email_change_verification_note": "A verification email has been sent to your current email address. The new email will not be in effect until you accept the verification email.",
"email_change_verification_sent": "A verification email has been sent to your new email address. Please check your inbox to verify your new email address." "email_change_verification_sent": "A verification email has been sent to your new email address. Please check your inbox to verify your new email address."
}, },
"heading": "Profile",
"update_profile": "Update Profile", "update_profile": "Update Profile",
"logout": "Log out" "logout": "Log out"
}, },
"organization": { "organization": {
"organization_profile": { "organization_profile": {
"heading": "Organization Profile", "heading": "Organization",
"organization_name": "Organization Name", "organization_name": "Organization Name",
"upload_image": "Upload Image", "upload_image": "Upload Image",
"update_organization": "Update Organization", "update_organization": "Update Organization",
@@ -24,7 +24,7 @@
<Page.Header> <Page.Header>
<Page.HeaderContent> <Page.HeaderContent>
<Page.Title>{$t('settings.heading')}</Page.Title> <Page.Title>{$t('settings.profile.heading')}</Page.Title>
</Page.HeaderContent> </Page.HeaderContent>
<Page.Action> <Page.Action>
<Button variant="secondary" loading={isLoading} onclick={handleUpdate}> <Button variant="secondary" loading={isLoading} onclick={handleUpdate}>
@@ -24,7 +24,7 @@
<Page.Header> <Page.Header>
<Page.HeaderContent> <Page.HeaderContent>
<Page.Title>{$t('settings.heading')}</Page.Title> <Page.Title>{$t('settings.profile.heading')}</Page.Title>
</Page.HeaderContent> </Page.HeaderContent>
<Page.Action> <Page.Action>
<Button variant="secondary" loading={isLoading} onclick={handleUpdate}> <Button variant="secondary" loading={isLoading} onclick={handleUpdate}>
@@ -14,7 +14,7 @@
bind:this={ref} bind:this={ref}
data-slot="sidebar-inset" data-slot="sidebar-inset"
class={cn( 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', '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 className
)} )}
+2 -2
View File
@@ -7,11 +7,11 @@
import { cn } from '../../tools'; import { cn } from '../../tools';
const emptyVariants = tv({ const emptyVariants = tv({
base: 'ui:w-full', base: 'ui:w-full ui:max-h-90',
variants: { variants: {
variant: { variant: {
default: '', 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"> <script lang="ts">
import { useImageCropperTrigger } from './image-cropper.svelte.js'; import { useImageCropperTrigger } from './image-cropper.svelte.js';
import type { ImageCropperUploadTriggerProps } from './types'; import type { ImageCropperUploadTriggerProps } from './types';
import { cn } from '../../tools';
let { ref = $bindable(null), children, ...rest }: ImageCropperUploadTriggerProps = $props(); let { ref = $bindable(null), children, ...rest }: ImageCropperUploadTriggerProps = $props();
const triggerState = useImageCropperTrigger(); const triggerState = useImageCropperTrigger();
const isDisabled = $derived(triggerState.rootState.disabled);
function handleClick(e: MouseEvent) {
if (isDisabled) {
e.preventDefault();
e.stopPropagation();
return false;
}
}
</script> </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?.()} {@render children?.()}
</label> </label>
@@ -1,43 +1,47 @@
<script lang="ts"> <script lang="ts">
import { box } from 'svelte-toolbelt'; import { box } from 'svelte-toolbelt';
import { useImageCropperRoot } from './image-cropper.svelte.js'; import { useImageCropperRoot } from './image-cropper.svelte.js';
import type { ImageCropperRootProps } from './types'; import type { ImageCropperRootProps } from './types';
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import { useId } from 'bits-ui'; import { useId } from 'bits-ui';
let { let {
id = useId(), id = useId(),
src = $bindable(''), src = $bindable(''),
onCropped = () => {}, onCropped = () => {},
onUnsupportedFile = () => {}, onUnsupportedFile = () => {},
children, maxFileSize,
...rest disabled = false,
}: ImageCropperRootProps = $props(); children,
...rest
}: ImageCropperRootProps = $props();
const rootState = useImageCropperRoot({ const rootState = useImageCropperRoot({
id: box.with(() => id), id: box.with(() => id),
src: box.with( src: box.with(
() => src, () => src,
(v) => (src = v) (v) => (src = v)
), ),
onCropped: box.with(() => onCropped), onCropped: box.with(() => onCropped),
onUnsupportedFile: box.with(() => onUnsupportedFile) onUnsupportedFile: box.with(() => onUnsupportedFile),
}); maxFileSize: box.with(() => maxFileSize),
disabled: box.with(() => disabled ?? undefined)
});
onDestroy(() => rootState.dispose()); onDestroy(() => rootState.dispose());
</script> </script>
{@render children?.()} {@render children?.()}
<input <input
{...rest} {...rest}
onchange={(e) => { onchange={(e) => {
const file = e.currentTarget.files?.[0]; const file = e.currentTarget.files?.[0];
if (!file) return; if (!file) return;
rootState.onUpload(file); rootState.onUpload(file);
// reset so that we can reupload the same file // reset so that we can reupload the same file
(e.target! as HTMLInputElement).value = ''; (e.target! as HTMLInputElement).value = '';
}} }}
type="file" type="file"
{id} {id}
style="display: none;" 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 { CropArea, DispatchEvents } from 'svelte-easy-crop';
import type { ReadableBoxedValues, WritableBoxedValues } from 'svelte-toolbelt';
import { Context } from 'runed';
import { getCroppedImg } from './utils'; import { getCroppedImg } from './utils';
// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/img#supported_image_formats // https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/img#supported_image_formats
export const VALID_IMAGE_TYPES = [ export const VALID_IMAGE_TYPES = [
'image/apng', 'image/apng',
'image/avif', 'image/avif',
'image/gif', 'image/gif',
'image/jpeg', 'image/jpeg',
'image/png', 'image/png',
'image/svg+xml', 'image/svg+xml',
'image/webp' 'image/webp'
]; ];
export type ImageCropperRootProps = WritableBoxedValues<{ export type ImageCropperRootProps = WritableBoxedValues<{
src: string; src: string;
}> & }> &
ReadableBoxedValues<{ ReadableBoxedValues<{
id: string; id: string;
onCropped: (url: string) => void; onCropped: (url: string) => void;
onUnsupportedFile: (file: File) => void; onUnsupportedFile: (file: File) => void;
}>; maxFileSize?: number; // Maximum file size in bytes
disabled?: boolean;
}>;
class ImageCropperRootState { class ImageCropperRootState {
#createdUrls = $state<string[]>([]); #createdUrls = $state<string[]>([]);
open = $state(false); open = $state(false);
tempUrl = $state<string>(); tempUrl = $state<string>();
pixelCrop = $state<CropArea>(); pixelCrop = $state<CropArea>();
constructor(readonly opts: ImageCropperRootProps) { constructor(readonly opts: ImageCropperRootProps) {
this.onUpload = this.onUpload.bind(this); this.onUpload = this.onUpload.bind(this);
this.onCancel = this.onCancel.bind(this); this.onCancel = this.onCancel.bind(this);
this.onCrop = this.onCrop.bind(this); this.onCrop = this.onCrop.bind(this);
this.dispose = this.dispose.bind(this); this.dispose = this.dispose.bind(this);
} }
onUpload(file: File) { get disabled() {
if (!VALID_IMAGE_TYPES.includes(file.type)) { return this.opts.disabled?.current ?? false;
this.opts.onUnsupportedFile.current(file); }
return;
}
this.tempUrl = URL.createObjectURL(file); onUpload(file: File) {
this.#createdUrls.push(this.tempUrl); // Don't upload if disabled
this.open = true; if (this.disabled) {
} return;
}
onCancel() { // Check file size first if maxFileSize is specified
this.tempUrl = undefined; if (this.opts.maxFileSize?.current !== undefined) {
this.open = false; const maxFileSize = this.opts.maxFileSize.current;
this.pixelCrop = undefined; if (file.size > maxFileSize) {
} this.opts.onUnsupportedFile.current(file);
return;
}
}
async onCrop() { // Check file type
if (!this.pixelCrop || !this.tempUrl) return; 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() { this.opts.src.current = await getCroppedImg(this.tempUrl, this.pixelCrop);
return this.opts.src.current;
}
get id() { this.open = false;
return this.opts.id.current;
}
dispose() { this.opts.onCropped.current(this.opts.src.current);
for (const url of this.#createdUrls) { }
URL.revokeObjectURL(url);
} 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<{ export type ImageCropperTriggerProps = ReadableBoxedValues<{
id?: string; id?: string;
}>; }>;
class ImageCropperTriggerState { class ImageCropperTriggerState {
constructor(readonly rootState: ImageCropperRootState) {} constructor(readonly rootState: ImageCropperRootState) {}
} }
class ImageCropperPreviewState { class ImageCropperPreviewState {
constructor(readonly rootState: ImageCropperRootState) {} constructor(readonly rootState: ImageCropperRootState) {}
} }
class ImageCropperDialogState { class ImageCropperDialogState {
constructor(readonly rootState: ImageCropperRootState) {} constructor(readonly rootState: ImageCropperRootState) {}
} }
class ImageCropperCropperState { class ImageCropperCropperState {
constructor(readonly rootState: ImageCropperRootState) { constructor(readonly rootState: ImageCropperRootState) {
this.onCropComplete = this.onCropComplete.bind(this); this.onCropComplete = this.onCropComplete.bind(this);
} }
onCropComplete(e: DispatchEvents['cropcomplete']) { onCropComplete(e: DispatchEvents['cropcomplete']) {
this.rootState.pixelCrop = e.pixels; this.rootState.pixelCrop = e.pixels;
} }
} }
class ImageCropperCropState { class ImageCropperCropState {
constructor(readonly rootState: ImageCropperRootState) { constructor(readonly rootState: ImageCropperRootState) {
this.onclick = this.onclick.bind(this); this.onclick = this.onclick.bind(this);
} }
onclick() { onclick() {
this.rootState.onCrop(); this.rootState.onCrop();
} }
} }
class ImageCropperCancelState { class ImageCropperCancelState {
constructor(readonly rootState: ImageCropperRootState) { constructor(readonly rootState: ImageCropperRootState) {
this.onclick = this.onclick.bind(this); this.onclick = this.onclick.bind(this);
} }
onclick() { onclick() {
this.rootState.onCancel(); this.rootState.onCancel();
} }
} }
const ImageCropperRootContext = new Context<ImageCropperRootState>('ImageCropper.Root'); const ImageCropperRootContext = new Context<ImageCropperRootState>('ImageCropper.Root');
export const useImageCropperRoot = (props: ImageCropperRootProps) => { export const useImageCropperRoot = (props: ImageCropperRootProps) => {
return ImageCropperRootContext.set(new ImageCropperRootState(props)); return ImageCropperRootContext.set(new ImageCropperRootState(props));
}; };
export const useImageCropperTrigger = () => { export const useImageCropperTrigger = () => {
const rootState = ImageCropperRootContext.get(); const rootState = ImageCropperRootContext.get();
return new ImageCropperTriggerState(rootState); return new ImageCropperTriggerState(rootState);
}; };
export const useImageCropperPreview = () => { export const useImageCropperPreview = () => {
const rootState = ImageCropperRootContext.get(); const rootState = ImageCropperRootContext.get();
return new ImageCropperPreviewState(rootState); return new ImageCropperPreviewState(rootState);
}; };
export const useImageCropperDialog = () => { export const useImageCropperDialog = () => {
const rootState = ImageCropperRootContext.get(); const rootState = ImageCropperRootContext.get();
return new ImageCropperDialogState(rootState); return new ImageCropperDialogState(rootState);
}; };
export const useImageCropperCropper = () => { export const useImageCropperCropper = () => {
const rootState = ImageCropperRootContext.get(); const rootState = ImageCropperRootContext.get();
return new ImageCropperCropperState(rootState); return new ImageCropperCropperState(rootState);
}; };
export const useImageCropperCrop = () => { export const useImageCropperCrop = () => {
const rootState = ImageCropperRootContext.get(); const rootState = ImageCropperRootContext.get();
return new ImageCropperCropState(rootState); return new ImageCropperCropState(rootState);
}; };
export const useImageCropperCancel = () => { export const useImageCropperCancel = () => {
const rootState = ImageCropperRootContext.get(); const rootState = ImageCropperRootContext.get();
return new ImageCropperCancelState(rootState); return new ImageCropperCancelState(rootState);
}; };
+15 -20
View File
@@ -1,19 +1,15 @@
import type { import type { AvatarRootProps, DialogContentProps, WithChildren, WithoutChild, WithoutChildren } from 'bits-ui';
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'; import type { HTMLAttributes, HTMLInputAttributes } from 'svelte/elements';
import type { CropperProps } from 'svelte-easy-crop';
import type { Snippet } from 'svelte';
export type ImageCropperRootPropsWithoutHTML = WithChildren<{ export type ImageCropperRootPropsWithoutHTML = WithChildren<{
id?: string; id?: string;
src?: string; src?: string;
onCropped?: (url: string) => void; onCropped?: (url: string) => void;
onUnsupportedFile?: (file: File) => void; onUnsupportedFile?: (file: File) => void;
maxFileSize?: number; // Maximum file size in bytes
}>; }>;
export type ImageCropperRootProps = ImageCropperRootPropsWithoutHTML & HTMLInputAttributes; export type ImageCropperRootProps = ImageCropperRootPropsWithoutHTML & HTMLInputAttributes;
@@ -23,22 +19,21 @@ export type ImageCropperDialogProps = DialogContentProps;
export type ImageCropperCropperProps = Omit<Partial<CropperProps>, 'oncropcomplete' | 'image'>; export type ImageCropperCropperProps = Omit<Partial<CropperProps>, 'oncropcomplete' | 'image'>;
export type ImageCropperControlsWithoutHTML = WithChildren<{ export type ImageCropperControlsWithoutHTML = WithChildren<{
ref?: HTMLDivElement | null; ref?: HTMLDivElement | null;
}>; }>;
export type ImageCropperControlsProps = ImageCropperControlsWithoutHTML & export type ImageCropperControlsProps = ImageCropperControlsWithoutHTML &
WithoutChildren<HTMLAttributes<HTMLDivElement>>; WithoutChildren<HTMLAttributes<HTMLDivElement>>;
export type ImageCropperPreviewPropsWithoutHTML = { export type ImageCropperPreviewPropsWithoutHTML = {
child?: Snippet<[{ src: string }]>; child?: Snippet<[{ src: string }]>;
}; };
export type ImageCropperPreviewProps = ImageCropperPreviewPropsWithoutHTML & export type ImageCropperPreviewProps = ImageCropperPreviewPropsWithoutHTML & WithoutChild<AvatarRootProps>;
WithoutChild<AvatarRootProps>;
export type ImageCropperUploadTriggerPropsWithoutHTML = WithChildren<{ export type ImageCropperUploadTriggerPropsWithoutHTML = WithChildren<{
ref?: HTMLLabelElement | null; ref?: HTMLLabelElement | null;
}>; }>;
export type ImageCropperUploadTriggerProps = ImageCropperUploadTriggerPropsWithoutHTML & export type ImageCropperUploadTriggerProps = ImageCropperUploadTriggerPropsWithoutHTML &
WithoutChildren<HTMLAttributes<HTMLLabelElement>>; WithoutChildren<HTMLAttributes<HTMLLabelElement>>;
File diff suppressed because one or more lines are too long