web(avatar-crop): fix narrow-image centering and add circular tg-like mask
All checks were successful
CI / test (push) Successful in 39s

This commit is contained in:
2026-03-08 19:33:24 +03:00
parent 958a85be91
commit 702679c99d

View File

@@ -12,6 +12,7 @@ const VIEWPORT_SIZE = 320;
const OUTPUT_SIZE = 500; const OUTPUT_SIZE = 500;
const MIN_ZOOM = 1; const MIN_ZOOM = 1;
const MAX_ZOOM = 4; const MAX_ZOOM = 4;
const MASK_RATIO = 0.88;
export function AvatarCropModal({ open, file, onCancel, onApply }: AvatarCropModalProps) { export function AvatarCropModal({ open, file, onCancel, onApply }: AvatarCropModalProps) {
const [imageUrl, setImageUrl] = useState<string | null>(null); const [imageUrl, setImageUrl] = useState<string | null>(null);
@@ -162,13 +163,13 @@ export function AvatarCropModal({ open, file, onCancel, onApply }: AvatarCropMod
<div className="absolute inset-0 flex items-center justify-center p-4" onClick={(event) => event.stopPropagation()}> <div className="absolute inset-0 flex items-center justify-center p-4" onClick={(event) => event.stopPropagation()}>
<div className="w-full max-w-lg rounded-2xl border border-slate-700/80 bg-slate-900/95 p-4 shadow-2xl"> <div className="w-full max-w-lg rounded-2xl border border-slate-700/80 bg-slate-900/95 p-4 shadow-2xl">
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<p className="text-sm font-semibold text-slate-100">Crop avatar</p> <p className="text-sm font-semibold text-slate-100">Drag to reposition</p>
<button className="rounded bg-slate-700 px-2 py-1 text-xs text-slate-100 disabled:opacity-60" disabled={processing} onClick={onCancel} type="button"> <button className="rounded bg-slate-700 px-2 py-1 text-xs text-slate-100 disabled:opacity-60" disabled={processing} onClick={onCancel} type="button">
Cancel Cancel
</button> </button>
</div> </div>
<div className="mx-auto mb-3 h-[320px] w-[320px] overflow-hidden rounded-xl border border-slate-700 bg-slate-950"> <div className="mx-auto mb-3 h-[320px] w-[320px] overflow-hidden rounded-2xl border border-slate-700 bg-slate-950">
<div <div
className="relative h-full w-full cursor-grab select-none active:cursor-grabbing" className="relative h-full w-full cursor-grab select-none active:cursor-grabbing"
onPointerDown={handlePointerDown} onPointerDown={handlePointerDown}
@@ -193,6 +194,12 @@ export function AvatarCropModal({ open, file, onCancel, onApply }: AvatarCropMod
}} }}
/> />
) : null} ) : null}
<div className="pointer-events-none absolute inset-0">
<div
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full border border-slate-200/70 shadow-[0_0_0_9999px_rgba(15,23,42,0.58)]"
style={{ width: `${Math.floor(VIEWPORT_SIZE * MASK_RATIO)}px`, height: `${Math.floor(VIEWPORT_SIZE * MASK_RATIO)}px` }}
/>
</div>
</div> </div>
</div> </div>
@@ -229,10 +236,16 @@ export function AvatarCropModal({ open, file, onCancel, onApply }: AvatarCropMod
} }
function clampPosition(pos: { x: number; y: number }, width: number, height: number) { function clampPosition(pos: { x: number; y: number }, width: number, height: number) {
const minX = Math.min(0, VIEWPORT_SIZE - width); if (width <= VIEWPORT_SIZE && height <= VIEWPORT_SIZE) {
const maxX = 0; return {
const minY = Math.min(0, VIEWPORT_SIZE - height); x: (VIEWPORT_SIZE - width) / 2,
const maxY = 0; y: (VIEWPORT_SIZE - height) / 2,
};
}
const minX = width <= VIEWPORT_SIZE ? (VIEWPORT_SIZE - width) / 2 : VIEWPORT_SIZE - width;
const maxX = width <= VIEWPORT_SIZE ? (VIEWPORT_SIZE - width) / 2 : 0;
const minY = height <= VIEWPORT_SIZE ? (VIEWPORT_SIZE - height) / 2 : VIEWPORT_SIZE - height;
const maxY = height <= VIEWPORT_SIZE ? (VIEWPORT_SIZE - height) / 2 : 0;
return { return {
x: Math.max(minX, Math.min(maxX, pos.x)), x: Math.max(minX, Math.min(maxX, pos.x)),
y: Math.max(minY, Math.min(maxY, pos.y)), y: Math.max(minY, Math.min(maxY, pos.y)),