diff --git a/frontend/src/features/account/components/avatar/AvatarCropper.tsx b/frontend/src/features/account/components/avatar/AvatarCropper.tsx
index 3bb1a67a..6268f319 100644
--- a/frontend/src/features/account/components/avatar/AvatarCropper.tsx
+++ b/frontend/src/features/account/components/avatar/AvatarCropper.tsx
@@ -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.
+ *
+ *
Everything the 2026-10 avatar-settings research identified as "missing in
+ * ours, present in every reference product":
+ *
+ * bigger viewport (320 vs the previous 240 — the smallest in the sample)
+ * 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)
+ * wheel zoom + pinch zoom (both no-brainers per react-easy-crop)
+ * keyboard: arrows pan, +/- zoom, 0 resets (Google documents this)
+ * a Reset button returning to zoom=MIN, offset=(0,0)
+ *
+ */
+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())
+ 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) {
- 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) {
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) {
- 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) {
+ 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) {
+ if (!natural) return
+ applyZoom(wheelZoom(zoom, e.deltaY))
+ }
+
+ function onKeyDown(e: ReactKeyboardEvent) {
+ 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 (
-
+
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,
}}
/>
-
-
- {t('avatar.zoom')}
- onZoomChange(Number(e.target.value))}
+ {/* Rule-of-thirds guides. Kept at 40% so they read as an overlay, not a wall. */}
+
+ {/* Corner shade — the region outside the round crop target gets dimmed, so
+ the user sees exactly what falls inside the file. */}
+
-
-
{t('avatar.reposition')}
-
-
- {t('avatar.cancel')}
-
-
- {t('avatar.confirm')}
-
+
+
+ {t('avatar.zoom')}
+ applyZoom(Number(e.target.value))}
+ />
+
+
+ {t('avatar.reset')}
+
+
+
{t('avatar.cropperHint')}
)
}
diff --git a/frontend/src/features/account/components/avatar/AvatarEditDialog.tsx b/frontend/src/features/account/components/avatar/AvatarEditDialog.tsx
new file mode 100644
index 00000000..12536a2a
--- /dev/null
+++ b/frontend/src/features/account/components/avatar/AvatarEditDialog.tsx
@@ -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
(null)
+ const [dirty, setDirty] = useState(false)
+ const [busy, setBusy] = useState(false)
+ const cardRef = useRef(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(
+ {
+ if (e.target === e.currentTarget) attemptClose()
+ }}
+ role="dialog"
+ aria-modal="true"
+ aria-label={t('avatar.dialogTitle')}
+ >
+
+
+
+
+
+
+ {t('avatar.previewLabel')}
+ {crop ? (
+
+ {PREVIEW_SIZES.map((size) => (
+
+ ))}
+
+ ) : (
+
+ )}
+
+
+
+ {progress !== null ? (
+
+ ) : null}
+ {error ?
{error} : null}
+
+
+
+
,
+ 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 {
+ 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((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(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 (
+
+
+ {url ?
: null}
+
+
{size}px
+
+ )
+}
diff --git a/frontend/src/features/account/components/avatar/AvatarUpload.tsx b/frontend/src/features/account/components/avatar/AvatarUpload.tsx
index 13d9ab68..d61c5612 100644
--- a/frontend/src/features/account/components/avatar/AvatarUpload.tsx
+++ b/frontend/src/features/account/components/avatar/AvatarUpload.tsx
@@ -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 (
-
-
setPicked(undefined)} onConfirm={onConfirmCrop} />
- {progress !== null && }
- {error ? {error} : null}
-
+ setPicked(undefined)}
+ onConfirm={onConfirmCrop}
+ progress={progress}
+ error={error}
+ />
)
}
diff --git a/frontend/src/features/account/components/avatar/cropMath.ts b/frontend/src/features/account/components/avatar/cropMath.ts
index f884590b..898fb6fc 100644
--- a/frontend/src/features/account/components/avatar/cropMath.ts
+++ b/frontend/src/features/account/components/avatar/cropMath.ts
@@ -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
diff --git a/frontend/src/features/account/i18n/en.ts b/frontend/src/features/account/i18n/en.ts
index 329ec84e..426107f6 100644
--- a/frontend/src/features/account/i18n/en.ts
+++ b/frontend/src/features/account/i18n/en.ts
@@ -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.',
diff --git a/frontend/src/features/account/i18n/ru.ts b/frontend/src/features/account/i18n/ru.ts
index 3c011ea0..f114dba8 100644
--- a/frontend/src/features/account/i18n/ru.ts
+++ b/frontend/src/features/account/i18n/ru.ts
@@ -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.',
diff --git a/frontend/src/features/account/tests/cropMath.test.ts b/frontend/src/features/account/tests/cropMath.test.ts
index 927f3307..9eefca26 100644
--- a/frontend/src/features/account/tests/cropMath.test.ts
+++ b/frontend/src/features/account/tests/cropMath.test.ts
@@ -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)
+})