import { type RefObject, useImperativeHandle, useRef, useState } from 'react';
import { Cropper, type CropperConfig, type CropValue } from '@oomfware/cropper';
import { clsx } from 'clsx';
import {
type ImageCrop,
type ImageSource,
type ImageTransformation,
manipulateImage,
} from '#/lib/media/composer-image';
import { getBlobUrl } from '#/lib/utils/blob-url';
import * as Dialog from '#/components/Dialog';
import { Spinner } from '#/components/Spinner';
import { Button, ButtonText } from '#/components/web/Button';
import { m } from '#/paraglide/messages';
import { type AspectRatio, AspectRatioSelect, CropToolbar } from './CropToolbar';
import type { EditImageDialogProps } from './EditImageDialog';
import * as styles from './EditImageDialogInner.css';
const CROPPER_CONFIG: Partial<CropperConfig> = {
insets: { top: 16, right: 16, bottom: 16, left: 16 },
};
export function EditImageDialogInner({
handle,
image,
onChange,
circularCrop,
aspectRatio,
}: EditImageDialogProps) {
const [pending, setPending] = useState(false);
const ref = useRef<{ save: () => Promise<void> }>(null);
const onPressSave = async () => {
setPending(true);
await ref.current?.save();
setPending(false);
};
return (
<>
<Dialog.Header.Outer>
<Dialog.Header.Slot>
<Button
color="primary"
disabled={pending}
label={m['common.action.cancel']()}
onClick={() => handle.close()}
size="small"
variant="ghost"
>
<ButtonText size="md">{m['common.action.cancel']()}</ButtonText>
</Button>
</Dialog.Header.Slot>
<Dialog.Header.Content>
<Dialog.Header.TitleText>{m['view.composer.gallery.action.edit']()}</Dialog.Header.TitleText>
</Dialog.Header.Content>
<Dialog.Header.Slot>
<Button
color="primary"
disabled={pending}
label={m['common.action.save']()}
onClick={() => void onPressSave()}
size="small"
variant="ghost"
>
<ButtonText size="md">{m['common.action.save']()}</ButtonText>
{pending && <Spinner color="default" label={m['common.status.saving']()} size="sm" />}
</Button>
</Dialog.Header.Slot>
</Dialog.Header.Outer>
{image && (
<EditImageInner
aspectRatio={aspectRatio}
circularCrop={circularCrop}
handle={handle}
image={image}
key={image.source.id}
onChange={onChange}
saveRef={ref}
/>
)}
</>
);
}
function EditImageInner({
image,
onChange,
saveRef,
handle,
circularCrop = false,
aspectRatio,
}: Required<Pick<EditImageDialogProps, 'image'>> &
Omit<EditImageDialogProps, 'image'> & {
saveRef: RefObject<{ save: () => Promise<void> } | null>;
}) {
const source = image.source;
const sourceUrl = getBlobUrl(source.blob);
const ratioIsFixed = aspectRatio !== undefined;
const initialCrop = getInitialCrop(image.manips);
const [ratio, setRatio] = useState<AspectRatio>(() => aspectRatio ?? image.manips?.ratio ?? null);
const cropRef = useRef(initialCrop);
const sourceDimensions =
source.width > 0 && source.height > 0
? {
height: source.height,
width: source.width,
}
: undefined;
useImperativeHandle(
saveRef,
() => ({
async save() {
const crop = cropRef.current && toImageCrop(cropRef.current, source);
const result = await manipulateImage(image, { crop, ratio });
onChange(result);
handle.close();
},
}),
[ratio, image, source, handle, onChange],
);
return (
<Cropper.Root
aspectRatio={ratio ?? undefined}
config={CROPPER_CONFIG}
defaultValue={initialCrop}
onValueChange={(value) => {
cropRef.current = value;
}}
>
<Dialog.Body className={styles.body}>
<Cropper.Viewport className={styles.viewport}>
<Cropper.Image alt="" src={sourceUrl} {...sourceDimensions} />
<Cropper.Window className={clsx(styles.cropWindow, circularCrop && styles.roundCropWindow)}>
<div className={styles.grid} />
</Cropper.Window>
</Cropper.Viewport>
</Dialog.Body>
<Dialog.Footer>
<CropToolbar>
{!ratioIsFixed && <AspectRatioSelect value={ratio} onValueChange={setRatio} />}
</CropToolbar>
</Dialog.Footer>
</Cropper.Root>
);
}
const getInitialCrop = (manips: ImageTransformation | undefined): CropValue | undefined => {
const crop = manips?.crop;
if (!crop) {
return undefined;
}
return {
rotation: crop.rotation,
x: crop.originX,
y: crop.originY,
width: crop.width,
height: crop.height,
};
};
const toImageCrop = (value: CropValue, source: ImageSource): ImageCrop | undefined => {
const originX = Math.round(value.x);
const originY = Math.round(value.y);
const width = Math.round(value.x + value.width) - originX;
const height = Math.round(value.y + value.height) - originY;
if (width === 0 || height === 0) {
return undefined;
}
if (value.rotation === 0 && width === source.width && height === source.height) {
return undefined;
}
return { rotation: value.rotation, height, originX, originY, width };
};