From 4fce425dcdaf0f179400425fb3f2b1bc5ec3f07a Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Mon, 12 Oct 2026 22:51:40 +0200 Subject: [PATCH] feat(frontend): avatar crop dialog (viewport 320, wheel/pinch/keyboard, guides+shade, previews, dirty guard) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../components/avatar/AvatarCropper.tsx | 239 +++++++++++++----- .../components/avatar/AvatarEditDialog.tsx | 215 ++++++++++++++++ .../components/avatar/AvatarUpload.tsx | 14 +- .../account/components/avatar/cropMath.ts | 43 ++++ frontend/src/features/account/i18n/en.ts | 9 + frontend/src/features/account/i18n/ru.ts | 9 + .../features/account/tests/cropMath.test.ts | 37 ++- 7 files changed, 491 insertions(+), 75 deletions(-) create mode 100644 frontend/src/features/account/components/avatar/AvatarEditDialog.tsx 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": + *

+ */ +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, }} /> -
-