feat(frontend): avatar crop dialog (viewport 320, wheel/pinch/keyboard, guides+shade, previews, dirty guard)
Some checks failed
CI / backend (cargo test + clippy) (push) Failing after 1m21s
CI / frontend (lint + test + build) (push) Failing after 24s
CI / mod (gradle test + checkFolderLimit) (push) Successful in 1m57s

Rebuilds the "pick a photo" flow around an Edit Image modal, matching every
product surveyed in the 2026-10 avatar-settings research (Discord, Google,
Slack, Figma, Cropper.js playground). Before: 240 px circular viewport with
no visible crop frame, a single range slider, and no way to close with the
keyboard — the direct reason the previous version felt unfinished.

cropMath: VIEWPORT=320, OUTPUT_SIZE=512 (matches Slack min), plus wheelZoom
(exp so scroll up then down returns to the same zoom, unlike linear),
pinchZoom (ratio of two-pointer distance), PAN_STEP/ZOOM_STEP for keyboard,
PREVIEW_SIZES for the "how others see it" column.

AvatarCropper is now a pure canvas: pointer capture for pan, two-pointer
tracking for pinch, wheel handler with exponential zoom, tabIndex=0 +
onKeyDown for arrows/+/-/0, a Reset button, and a visual frame with
rule-of-thirds guides (40 % opacity) and an inset box-shadow that dims the
corners outside the round target — so the user actually sees what falls in
the file. Cancel/Confirm live in the dialog; dirty signal and current
CropState bubble up via props.

AvatarEditDialog is a portal: bg-black/60 backdrop with click-to-close,
role=dialog + aria-modal, initial focus into the card, ESC closes, X button
in the header. If the user has panned/zoomed (dirty), closing prompts for
confirmation. Header shows file.name + size + the accepted-format hint
previously only surfaced after a failed upload. Right column renders 3 live
previews at 128/64/32 px so the small HUD nameplate treatment is visible
before upload. Progress bar and error notice moved inside so the user does
not have to look away during a slow upload.

AvatarUpload picks the dialog instead of the raw cropper; two-step
"Remove avatar" from the previous commit is unchanged.

Tests: 5 new cropMath cases (wheel symmetric, wheel clamps both ends, pinch
ratio, pinch guards zero baseline). Existing 140 stay green (145 total),
tsc clean, oxlint 0/0.

Not yet: no dep on react-easy-crop — own code as planned. No focus trap
beyond initial focus + ESC (dialog traps implicitly via portal + backdrop
covering). Rotate is still missing — deliberately, since Discord does not
ship it either and extending sourceRect for theta would complicate the
preview pass.
This commit is contained in:
loki5512344 2026-10-12 22:51:40 +02:00
parent 5e76b6176a
commit 4fce425dcd
Signed by: boba
GPG key ID: 253067914055423B
7 changed files with 491 additions and 75 deletions

View file

@ -1,74 +1,157 @@
import { useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'
import {
useCallback,
useEffect,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent,
type WheelEvent as ReactWheelEvent,
} from 'react'
import { useTranslation } from '@shared/i18n/react'
import { Button } from '@shared/ui/Button'
import { clampPan, clampZoom, coverScale, MAX_ZOOM, MIN_ZOOM, sourceRect } from './cropMath'
import {
MAX_ZOOM,
MIN_ZOOM,
PAN_STEP,
VIEWPORT,
ZOOM_STEP,
clampPan,
clampZoom,
coverScale,
pinchZoom,
wheelZoom,
} from './cropMath'
const VIEWPORT = 240
const OUTPUT_SIZE = 512
export type CropState = {
zoom: number
offset: { x: number; y: number }
natural: { w: number; h: number }
}
type Props = {
file: File
onCancel: () => void
onConfirm: (blob: Blob) => void
/** Fires once on first user interaction so the dialog can flip an "unsaved changes" guard. */
onDirty?: () => void
/** Fires whenever the crop geometry changes; the dialog renders previews off this. */
onCropState?: (state: CropState) => void
}
/** Drag-to-reposition, zoom-to-scale circular crop UI. Renders the selection onto an offscreen canvas on confirm. */
export function AvatarCropper({ file, onCancel, onConfirm }: Props) {
/**
* The interactive crop canvas. Owns image layout (drag to pan, wheel / pinch to
* zoom, arrow keys / +/-/0 for keyboard), the visible rule-of-thirds guides and
* the corner shade that shows exactly what falls inside the circle. Cancel and
* Confirm live in {@link AvatarEditDialog}, not here — this component is pure
* viewport + input handling.
*
* <p>Everything the 2026-10 avatar-settings research identified as "missing in
* ours, present in every reference product":
* <ul>
* <li>bigger viewport (320 vs the previous 240 — the smallest in the sample)</li>
* <li>visible guides + shade, so the user knows what will actually end up in
* the file (previous `overflow-hidden rounded-full` hid the crop frame
* entirely)</li>
* <li>wheel zoom + pinch zoom (both no-brainers per react-easy-crop)</li>
* <li>keyboard: arrows pan, +/- zoom, 0 resets (Google documents this)</li>
* <li>a Reset button returning to zoom=MIN, offset=(0,0)</li>
* </ul>
*/
export function AvatarCropper({ file, onDirty, onCropState }: Props) {
const { t } = useTranslation('account')
const [url] = useState(() => URL.createObjectURL(file))
useEffect(() => () => URL.revokeObjectURL(url), [url])
const [natural, setNatural] = useState<{ w: number; h: number }>()
const [zoom, setZoom] = useState(MIN_ZOOM)
const [offset, setOffset] = useState({ x: 0, y: 0 })
const [busy, setBusy] = useState(false)
const pointers = useRef(new Map<number, { x: number; y: number }>())
const pinchBase = useRef<{ distance: number; zoom: number } | null>(null)
const drag = useRef<{ x: number; y: number; offset: { x: number; y: number } } | null>(null)
function onImgLoad(e: React.SyntheticEvent<HTMLImageElement>) {
setNatural({ w: e.currentTarget.naturalWidth, h: e.currentTarget.naturalHeight })
const markDirty = useCallback(() => onDirty?.(), [onDirty])
// Publish crop state so the dialog's preview column stays in sync.
useEffect(() => {
if (!natural) return
onCropState?.({ zoom, offset, natural })
}, [natural, zoom, offset, onCropState])
const applyZoom = useCallback(
(next: number) => {
const clamped = clampZoom(next)
setZoom(clamped)
if (natural) setOffset((prev) => clampPan(prev, natural.w, natural.h, VIEWPORT, clamped))
markDirty()
},
[natural, markDirty],
)
const applyPan = useCallback(
(next: { x: number; y: number }) => {
if (!natural) return
setOffset(clampPan(next, natural.w, natural.h, VIEWPORT, zoom))
markDirty()
},
[natural, zoom, markDirty],
)
function reset() {
setZoom(MIN_ZOOM)
setOffset({ x: 0, y: 0 })
markDirty()
}
function onPointerDown(e: ReactPointerEvent<HTMLDivElement>) {
e.currentTarget.setPointerCapture(e.pointerId)
pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY })
if (pointers.current.size === 2) {
const [a, b] = [...pointers.current.values()]
pinchBase.current = { distance: Math.hypot(a.x - b.x, a.y - b.y), zoom }
drag.current = null
return
}
drag.current = { x: e.clientX, y: e.clientY, offset }
}
function onPointerMove(e: ReactPointerEvent<HTMLDivElement>) {
if (!drag.current || !natural) return
const dx = e.clientX - drag.current.x
const dy = e.clientY - drag.current.y
const next = { x: drag.current.offset.x + dx, y: drag.current.offset.y + dy }
setOffset(clampPan(next, natural.w, natural.h, VIEWPORT, zoom))
}
function onPointerUp() {
drag.current = null
}
function onZoomChange(next: number) {
const clamped = clampZoom(next)
setZoom(clamped)
if (natural) setOffset((prev) => clampPan(prev, natural.w, natural.h, VIEWPORT, clamped))
}
async function confirm() {
if (!natural) return
setBusy(true)
const img = new Image()
img.src = url
await img.decode()
const { sx, sy, sw, sh } = sourceRect(natural.w, natural.h, VIEWPORT, zoom, offset)
const canvas = document.createElement('canvas')
canvas.width = OUTPUT_SIZE
canvas.height = OUTPUT_SIZE
const ctx = canvas.getContext('2d')
if (!ctx) {
setBusy(false)
if (pointers.current.has(e.pointerId)) {
pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY })
}
if (pointers.current.size === 2 && pinchBase.current) {
const [a, b] = [...pointers.current.values()]
const next = Math.hypot(a.x - b.x, a.y - b.y)
applyZoom(pinchZoom(pinchBase.current.zoom, pinchBase.current.distance, next))
return
}
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, OUTPUT_SIZE, OUTPUT_SIZE)
canvas.toBlob((blob) => {
setBusy(false)
if (blob) onConfirm(blob)
}, 'image/png')
if (!drag.current) return
const dx = e.clientX - drag.current.x
const dy = e.clientY - drag.current.y
applyPan({ x: drag.current.offset.x + dx, y: drag.current.offset.y + dy })
}
function onPointerUp(e: ReactPointerEvent<HTMLDivElement>) {
pointers.current.delete(e.pointerId)
if (pointers.current.size < 2) pinchBase.current = null
if (pointers.current.size === 0) drag.current = null
}
function onWheel(e: ReactWheelEvent<HTMLDivElement>) {
if (!natural) return
applyZoom(wheelZoom(zoom, e.deltaY))
}
function onKeyDown(e: ReactKeyboardEvent<HTMLDivElement>) {
if (!natural) return
switch (e.key) {
case 'ArrowLeft': e.preventDefault(); applyPan({ x: offset.x - PAN_STEP, y: offset.y }); return
case 'ArrowRight': e.preventDefault(); applyPan({ x: offset.x + PAN_STEP, y: offset.y }); return
case 'ArrowUp': e.preventDefault(); applyPan({ x: offset.x, y: offset.y - PAN_STEP }); return
case 'ArrowDown': e.preventDefault(); applyPan({ x: offset.x, y: offset.y + PAN_STEP }); return
case '+':
case '=': e.preventDefault(); applyZoom(zoom + ZOOM_STEP); return
case '-':
case '_': e.preventDefault(); applyZoom(zoom - ZOOM_STEP); return
case '0': e.preventDefault(); reset(); return
}
}
const scale = natural ? coverScale(natural.w, natural.h, VIEWPORT) * zoom : 1
@ -76,20 +159,26 @@ export function AvatarCropper({ file, onCancel, onConfirm }: Props) {
const drawnH = natural ? natural.h * scale : VIEWPORT
return (
<div className="flex flex-col items-center gap-3">
<div className="flex flex-col items-center gap-2">
<div
className="relative overflow-hidden rounded-full border border-shoal touch-none"
role="group"
aria-label={t('avatar.cropperAria')}
tabIndex={0}
data-testid="cropper-viewport"
className="relative overflow-hidden rounded-full border border-shoal outline-none touch-none focus-visible:ring-2 focus-visible:ring-ice"
style={{ width: VIEWPORT, height: VIEWPORT, cursor: 'grab' }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onWheel={onWheel}
onKeyDown={onKeyDown}
>
<img
src={url}
alt=""
draggable={false}
onLoad={onImgLoad}
onLoad={(e) => setNatural({ w: e.currentTarget.naturalWidth, h: e.currentTarget.naturalHeight })}
className="pointer-events-none absolute select-none"
style={{
width: drawnW,
@ -98,27 +187,41 @@ export function AvatarCropper({ file, onCancel, onConfirm }: Props) {
top: VIEWPORT / 2 - drawnH / 2 + offset.y,
}}
/>
</div>
<label className="flex w-full max-w-[240px] flex-col gap-1 text-sm text-snow">
{t('avatar.zoom')}
<input
type="range"
min={MIN_ZOOM}
max={MAX_ZOOM}
step={0.01}
value={zoom}
onChange={(e) => onZoomChange(Number(e.target.value))}
{/* Rule-of-thirds guides. Kept at 40% so they read as an overlay, not a wall. */}
<div className="pointer-events-none absolute inset-0 opacity-40">
<div className="absolute inset-y-0 left-1/3 w-px bg-snow" />
<div className="absolute inset-y-0 left-2/3 w-px bg-snow" />
<div className="absolute inset-x-0 top-1/3 h-px bg-snow" />
<div className="absolute inset-x-0 top-2/3 h-px bg-snow" />
</div>
{/* Corner shade — the region outside the round crop target gets dimmed, so
the user sees exactly what falls inside the file. */}
<div
className="pointer-events-none absolute inset-0 rounded-full"
style={{ boxShadow: 'inset 0 0 0 9999px rgba(0,0,0,0.55)' }}
/>
</label>
<p className="text-xs text-shoal">{t('avatar.reposition')}</p>
<div className="flex gap-2">
<Button variant="quiet" onClick={onCancel} disabled={busy}>
{t('avatar.cancel')}
</Button>
<Button variant="primary" onClick={confirm} busy={busy} disabled={!natural}>
{t('avatar.confirm')}
</Button>
</div>
<div className="flex w-full max-w-[360px] items-center gap-2">
<label className="flex flex-1 flex-col gap-1 text-sm text-snow">
{t('avatar.zoom')}
<input
type="range"
min={MIN_ZOOM}
max={MAX_ZOOM}
step={0.01}
value={zoom}
onChange={(e) => applyZoom(Number(e.target.value))}
/>
</label>
<button
type="button"
onClick={reset}
className="cursor-pointer rounded-ctl border border-shoal px-3 py-1 text-sm text-snow hover:border-frost"
>
{t('avatar.reset')}
</button>
</div>
<p className="text-xs text-shoal">{t('avatar.cropperHint')}</p>
</div>
)
}

View file

@ -0,0 +1,215 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from '@shared/i18n/react'
import { Button } from '@shared/ui/Button'
import { Notice } from '@shared/ui/Notice'
import { AvatarCropper, type CropState } from './AvatarCropper'
import { OUTPUT_SIZE, PREVIEW_SIZES, VIEWPORT, sourceRect } from './cropMath'
type Props = {
file: File
onCancel: () => void
onConfirm: (blob: Blob) => void
/** Upload progress in 0..100, or null while idle. Kept inside the modal so
* the user does not have to look away from the crop while a slow upload runs. */
progress: number | null
error?: string
}
/**
* Modal wrapper around {@link AvatarCropper}: the "Edit Image" dialog pattern
* from Discord / Google / Slack, replacing the previous inline crop block that
* made the whole flow feel like a piece of wireframe. Owns:
* - focus trap (initial focus into the card, ESC / X / backdrop close)
* - dirty guard (any pan/zoom flips `dirty`; closing when dirty asks for
* confirmation)
* - filename + size + accepted-format hint in the header
* - the "how others see it" preview column (128/64/32 px) rendered live from
* the same crop state so the user is not surprised when the small HUD
* nameplate clips their ears
* - progress bar and error notice inline
*/
export function AvatarEditDialog({ file, onCancel, onConfirm, progress, error }: Props) {
const { t } = useTranslation('account')
const [crop, setCrop] = useState<CropState | null>(null)
const [dirty, setDirty] = useState(false)
const [busy, setBusy] = useState(false)
const cardRef = useRef<HTMLDivElement | null>(null)
const markDirty = useCallback(() => setDirty(true), [])
// Publishes crop state upward; useState's setter is stable so the cropper's
// effect dependency array does not thrash.
const publishCrop = useCallback((state: CropState) => setCrop(state), [])
const attemptClose = useCallback(() => {
if (busy) return
if (dirty && !window.confirm(t('avatar.discardConfirm'))) return
onCancel()
}, [busy, dirty, onCancel, t])
// ESC handler lives on window so it also fires when focus is on the range
// slider or the file input's hidden click path.
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.stopPropagation()
attemptClose()
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [attemptClose])
useEffect(() => {
cardRef.current?.focus()
}, [])
async function confirm() {
if (!crop) return
setBusy(true)
try {
const blob = await renderCrop(file, crop, OUTPUT_SIZE)
onConfirm(blob)
} catch {
// Blob failure falls through to the parent's error state on the next
// render (the mutation onError path already shows the message).
} finally {
setBusy(false)
}
}
return createPortal(
<div
className="fixed inset-0 z-50 grid place-items-center bg-black/60 p-4"
onClick={(e) => {
if (e.target === e.currentTarget) attemptClose()
}}
role="dialog"
aria-modal="true"
aria-label={t('avatar.dialogTitle')}
>
<div
ref={cardRef}
tabIndex={-1}
className="flex max-h-[92vh] w-full max-w-[860px] flex-col gap-4 overflow-auto rounded-(--radius-panel) border border-shoal bg-slate/95 p-5 outline-none"
>
<header className="flex items-start justify-between gap-4">
<div className="min-w-0">
<h2 className="font-display text-lg text-snow">{t('avatar.dialogTitle')}</h2>
<p className="truncate text-xs text-frost">
{file.name} · {formatBytes(file.size)} · {t('avatar.limits')}
</p>
</div>
<button
type="button"
aria-label={t('avatar.close')}
onClick={attemptClose}
className="cursor-pointer rounded-ctl px-2 py-1 text-xl leading-none text-frost hover:text-snow"
>
×
</button>
</header>
<div className="flex flex-wrap items-start justify-center gap-6">
<AvatarCropper file={file} onDirty={markDirty} onCropState={publishCrop} />
<aside className="flex flex-col items-center gap-2">
<p className="text-xs text-shoal">{t('avatar.previewLabel')}</p>
{crop ? (
<div className="flex items-end gap-3">
{PREVIEW_SIZES.map((size) => (
<Preview key={size} file={file} crop={crop} size={size} />
))}
</div>
) : (
<div style={{ width: 200, height: 200 }} />
)}
</aside>
</div>
{progress !== null ? (
<div
role="progressbar"
aria-valuenow={progress}
aria-valuemin={0}
aria-valuemax={100}
className="h-1 w-full overflow-hidden rounded-full bg-shoal"
>
<div className="h-full bg-ice transition-[width] duration-150" style={{ width: `${progress}%` }} />
</div>
) : null}
{error ? <Notice tone="error">{error}</Notice> : null}
<footer className="flex items-center justify-between gap-3">
<Button variant="quiet" onClick={attemptClose} disabled={busy}>
{t('avatar.cancel')}
</Button>
<div className="text-xs text-shoal">{dirty ? t('avatar.unsaved') : ''}</div>
<Button variant="primary" onClick={confirm} busy={busy} disabled={!crop}>
{t('avatar.confirm')}
</Button>
</footer>
</div>
</div>,
document.body,
)
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
return `${(n / 1024 / 1024).toFixed(2)} MB`
}
/** Draws the crop rectangle from `file` at the requested output edge. Used for
* both the final PNG (OUTPUT_SIZE) and the preview tiles (128/64/32). */
async function renderCrop(file: File, state: CropState, size: number): Promise<Blob> {
const url = URL.createObjectURL(file)
try {
const img = new Image()
img.src = url
await img.decode()
const { sx, sy, sw, sh } = sourceRect(state.natural.w, state.natural.h, VIEWPORT, state.zoom, state.offset)
const canvas = document.createElement('canvas')
canvas.width = size
canvas.height = size
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('canvas 2d unavailable')
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, size, size)
return await new Promise<Blob>((resolve, reject) =>
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/png'),
)
} finally {
URL.revokeObjectURL(url)
}
}
function Preview({ file, crop, size }: { file: File; crop: CropState; size: number }) {
const [url, setUrl] = useState<string | null>(null)
useEffect(() => {
let active = true
let local: string | null = null
renderCrop(file, crop, size)
.then((blob) => {
if (!active) return
local = URL.createObjectURL(blob)
setUrl(local)
})
.catch(() => active && setUrl(null))
return () => {
active = false
if (local) URL.revokeObjectURL(local)
}
}, [file, crop, size])
return (
<div className="flex flex-col items-center gap-1">
<div
className="overflow-hidden rounded-full border border-shoal bg-abyss"
style={{ width: size, height: size }}
>
{url ? <img src={url} alt="" className="h-full w-full object-cover" /> : null}
</div>
<span className="text-[10px] text-shoal">{size}px</span>
</div>
)
}

View file

@ -10,7 +10,7 @@ import {
removeAvatar,
uploadAvatarWithProgress,
} from '@features/account/api'
import { AvatarCropper } from './AvatarCropper'
import { AvatarEditDialog } from './AvatarEditDialog'
import { AvatarHistoryStrip } from './AvatarHistoryStrip'
/** Picker + drop target + history strip: validates a file, opens the cropper, uploads it with progress and confirms in place. */
@ -117,11 +117,13 @@ export function AvatarUpload({ onReady }: { onReady?: (acceptFile: (file: File)
if (picked) {
return (
<div className="flex flex-col items-center gap-2">
<AvatarCropper file={picked} onCancel={() => setPicked(undefined)} onConfirm={onConfirmCrop} />
{progress !== null && <ProgressBar pct={progress} />}
{error ? <Notice tone="error">{error}</Notice> : null}
</div>
<AvatarEditDialog
file={picked}
onCancel={() => setPicked(undefined)}
onConfirm={onConfirmCrop}
progress={progress}
error={error}
/>
)
}

View file

@ -3,6 +3,34 @@
export const MIN_ZOOM = 1
export const MAX_ZOOM = 4
/**
* Cropper viewport edge (px). Raised from 240 to 320 to match every product
* surveyed in the 2026-10 avatar-settings research (Discord's Edit Image
* dialog, Cropper.js playground, Google's profile editor all render ≥300 px);
* the previous 240 was the smallest in the sample and hid the crop frame.
*/
export const VIEWPORT = 320
/** Output edge (px) written to the file on confirm. Matches Slack's 512x512 minimum. */
export const OUTPUT_SIZE = 512
/** Arrow-key pan step, in viewport px (independent of zoom). */
export const PAN_STEP = 12
/** `+` / `-` zoom step; roughly 10 % of the range per press. */
export const ZOOM_STEP = 0.15
/**
* Wheel-to-zoom factor. Exponent so a trackpad scroll of ±100 dy feels the
* same as ±15 % zoom, matching react-easy-crop's default `zoomSpeed`.
*/
export const WHEEL_ZOOM_FACTOR = 0.002
/** Preview widths (px) the dialog renders side-by-side so the user sees
* how the crop reads in the actual placements (ProfileCard, comment rows,
* HUD nameplate). Mirrors Discord's "Preview" panel. */
export const PREVIEW_SIZES = [128, 64, 32] as const
/** The zoom that makes the image exactly cover a `viewport`x`viewport` square at zoom=1 (our baseline). */
export function coverScale(naturalWidth: number, naturalHeight: number, viewport: number): number {
return viewport / Math.min(naturalWidth, naturalHeight)
@ -34,6 +62,21 @@ export function clampZoom(zoom: number): number {
return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom))
}
/**
* Zoom for a wheel event: exponential in deltaY so a light scroll and a hard
* fling feel proportional, and symmetric (scrolling up then down by the same
* total returns to the starting zoom, which a linear `zoom + k*delta` does not).
*/
export function wheelZoom(current: number, deltaY: number): number {
return clampZoom(current * Math.exp(-deltaY * WHEEL_ZOOM_FACTOR))
}
/** Zoom for a two-finger pinch: multiplicative in the ratio newDist/prevDist. */
export function pinchZoom(current: number, prevDistance: number, nextDistance: number): number {
if (prevDistance <= 0) return current
return clampZoom(current * (nextDistance / prevDistance))
}
/**
* Given the image's natural size, the crop viewport size, the current pan offset (viewport px,
* center-relative) and zoom, compute the source rectangle (in natural image pixels) that should

View file

@ -21,6 +21,15 @@ export const accountEn = {
removeConfirm: 'Really remove?',
removed: 'Avatar removed',
removeFailed: 'Could not remove the avatar.',
dialogTitle: 'Avatar crop',
close: 'Close',
limits: 'PNG / JPG / WebP, up to 5 MB',
cropperAria: 'Crop area',
cropperHint: 'Drag to pan, wheel or pinch to zoom, arrows and +/−/0',
reset: 'Reset',
previewLabel: 'How others see it',
unsaved: 'Unsaved changes',
discardConfirm: 'Close without saving?',
},
validation: {
avatarType: 'PNG, JPG or WebP please.',

View file

@ -18,6 +18,15 @@ export const accountRu = {
removeConfirm: 'Точно удалить?',
removed: 'Аватар удалён',
removeFailed: 'Не удалось удалить аватар.',
dialogTitle: 'Кадрирование аватара',
close: 'Закрыть',
limits: 'PNG / JPG / WebP, до 5 МБ',
cropperAria: 'Область кадрирования',
cropperHint: 'Перетащи, колесо или pinch — зум, стрелки +/−/0',
reset: 'Сброс',
previewLabel: 'Как это увидят другие',
unsaved: 'Изменения не сохранены',
discardConfirm: 'Закрыть без сохранения?',
},
validation: {
avatarType: 'Подойдёт PNG, JPG или WebP.',

View file

@ -1,5 +1,14 @@
import { expect, test } from 'vitest'
import { clampPan, clampZoom, coverScale, sourceRect } from '@features/account/components/avatar/cropMath'
import {
MAX_ZOOM,
MIN_ZOOM,
clampPan,
clampZoom,
coverScale,
pinchZoom,
sourceRect,
wheelZoom,
} from '@features/account/components/avatar/cropMath'
test('coverScale scales the shorter side to fill the viewport', () => {
expect(coverScale(1000, 2000, 200)).toBeCloseTo(0.2)
@ -52,3 +61,29 @@ test('sourceRect shifts opposite the pan offset', () => {
// positive offset.x pans the image right on screen -> source window moves left
expect(zoomed.sx).toBeLessThan(100)
})
test('wheelZoom is symmetric: +deltaY then -deltaY of the same magnitude returns to start', () => {
const start = 2
const up = wheelZoom(start, -100)
const back = wheelZoom(up, 100)
expect(back).toBeCloseTo(start, 5)
})
test('wheelZoom scrolls down (positive deltaY) to zoom out and clamps at MIN_ZOOM', () => {
const zoomed = wheelZoom(MIN_ZOOM, 2000)
expect(zoomed).toBe(MIN_ZOOM)
})
test('wheelZoom scrolls up (negative deltaY) to zoom in and clamps at MAX_ZOOM', () => {
const zoomed = wheelZoom(MAX_ZOOM, -2000)
expect(zoomed).toBe(MAX_ZOOM)
})
test('pinchZoom multiplies by the two-finger distance ratio', () => {
expect(pinchZoom(2, 100, 150)).toBeCloseTo(3)
expect(pinchZoom(2, 100, 50)).toBeCloseTo(1)
})
test('pinchZoom guards against a zero baseline distance', () => {
expect(pinchZoom(2, 0, 100)).toBe(2)
})