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