feat(frontend): avatar crop dialog (viewport 320, wheel/pinch/keyboard, guides+shade, previews, dirty guard)
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:
parent
5e76b6176a
commit
4fce425dcd
7 changed files with 491 additions and 75 deletions
|
|
@ -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 { useTranslation } from '@shared/i18n/react'
|
||||||
import { Button } from '@shared/ui/Button'
|
import {
|
||||||
import { clampPan, clampZoom, coverScale, MAX_ZOOM, MIN_ZOOM, sourceRect } from './cropMath'
|
MAX_ZOOM,
|
||||||
|
MIN_ZOOM,
|
||||||
|
PAN_STEP,
|
||||||
|
VIEWPORT,
|
||||||
|
ZOOM_STEP,
|
||||||
|
clampPan,
|
||||||
|
clampZoom,
|
||||||
|
coverScale,
|
||||||
|
pinchZoom,
|
||||||
|
wheelZoom,
|
||||||
|
} from './cropMath'
|
||||||
|
|
||||||
const VIEWPORT = 240
|
export type CropState = {
|
||||||
const OUTPUT_SIZE = 512
|
zoom: number
|
||||||
|
offset: { x: number; y: number }
|
||||||
|
natural: { w: number; h: number }
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
file: File
|
file: File
|
||||||
onCancel: () => void
|
/** Fires once on first user interaction so the dialog can flip an "unsaved changes" guard. */
|
||||||
onConfirm: (blob: Blob) => void
|
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 { t } = useTranslation('account')
|
||||||
const [url] = useState(() => URL.createObjectURL(file))
|
const [url] = useState(() => URL.createObjectURL(file))
|
||||||
|
useEffect(() => () => URL.revokeObjectURL(url), [url])
|
||||||
const [natural, setNatural] = useState<{ w: number; h: number }>()
|
const [natural, setNatural] = useState<{ w: number; h: number }>()
|
||||||
const [zoom, setZoom] = useState(MIN_ZOOM)
|
const [zoom, setZoom] = useState(MIN_ZOOM)
|
||||||
const [offset, setOffset] = useState({ x: 0, y: 0 })
|
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)
|
const drag = useRef<{ x: number; y: number; offset: { x: number; y: number } } | null>(null)
|
||||||
|
|
||||||
function onImgLoad(e: React.SyntheticEvent<HTMLImageElement>) {
|
const markDirty = useCallback(() => onDirty?.(), [onDirty])
|
||||||
setNatural({ w: e.currentTarget.naturalWidth, h: e.currentTarget.naturalHeight })
|
|
||||||
|
// 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>) {
|
function onPointerDown(e: ReactPointerEvent<HTMLDivElement>) {
|
||||||
e.currentTarget.setPointerCapture(e.pointerId)
|
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 }
|
drag.current = { x: e.clientX, y: e.clientY, offset }
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPointerMove(e: ReactPointerEvent<HTMLDivElement>) {
|
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
|
if (!natural) return
|
||||||
setBusy(true)
|
if (pointers.current.has(e.pointerId)) {
|
||||||
const img = new Image()
|
pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY })
|
||||||
img.src = url
|
}
|
||||||
await img.decode()
|
if (pointers.current.size === 2 && pinchBase.current) {
|
||||||
const { sx, sy, sw, sh } = sourceRect(natural.w, natural.h, VIEWPORT, zoom, offset)
|
const [a, b] = [...pointers.current.values()]
|
||||||
const canvas = document.createElement('canvas')
|
const next = Math.hypot(a.x - b.x, a.y - b.y)
|
||||||
canvas.width = OUTPUT_SIZE
|
applyZoom(pinchZoom(pinchBase.current.zoom, pinchBase.current.distance, next))
|
||||||
canvas.height = OUTPUT_SIZE
|
|
||||||
const ctx = canvas.getContext('2d')
|
|
||||||
if (!ctx) {
|
|
||||||
setBusy(false)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, OUTPUT_SIZE, OUTPUT_SIZE)
|
if (!drag.current) return
|
||||||
canvas.toBlob((blob) => {
|
const dx = e.clientX - drag.current.x
|
||||||
setBusy(false)
|
const dy = e.clientY - drag.current.y
|
||||||
if (blob) onConfirm(blob)
|
applyPan({ x: drag.current.offset.x + dx, y: drag.current.offset.y + dy })
|
||||||
}, 'image/png')
|
}
|
||||||
|
|
||||||
|
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
|
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
|
const drawnH = natural ? natural.h * scale : VIEWPORT
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<div
|
<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' }}
|
style={{ width: VIEWPORT, height: VIEWPORT, cursor: 'grab' }}
|
||||||
onPointerDown={onPointerDown}
|
onPointerDown={onPointerDown}
|
||||||
onPointerMove={onPointerMove}
|
onPointerMove={onPointerMove}
|
||||||
onPointerUp={onPointerUp}
|
onPointerUp={onPointerUp}
|
||||||
onPointerCancel={onPointerUp}
|
onPointerCancel={onPointerUp}
|
||||||
|
onWheel={onWheel}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={url}
|
src={url}
|
||||||
alt=""
|
alt=""
|
||||||
draggable={false}
|
draggable={false}
|
||||||
onLoad={onImgLoad}
|
onLoad={(e) => setNatural({ w: e.currentTarget.naturalWidth, h: e.currentTarget.naturalHeight })}
|
||||||
className="pointer-events-none absolute select-none"
|
className="pointer-events-none absolute select-none"
|
||||||
style={{
|
style={{
|
||||||
width: drawnW,
|
width: drawnW,
|
||||||
|
|
@ -98,8 +187,22 @@ export function AvatarCropper({ file, onCancel, onConfirm }: Props) {
|
||||||
top: VIEWPORT / 2 - drawnH / 2 + offset.y,
|
top: VIEWPORT / 2 - drawnH / 2 + offset.y,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{/* 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>
|
</div>
|
||||||
<label className="flex w-full max-w-[240px] flex-col gap-1 text-sm text-snow">
|
{/* 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)' }}
|
||||||
|
/>
|
||||||
|
</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')}
|
{t('avatar.zoom')}
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
|
|
@ -107,18 +210,18 @@ export function AvatarCropper({ file, onCancel, onConfirm }: Props) {
|
||||||
max={MAX_ZOOM}
|
max={MAX_ZOOM}
|
||||||
step={0.01}
|
step={0.01}
|
||||||
value={zoom}
|
value={zoom}
|
||||||
onChange={(e) => onZoomChange(Number(e.target.value))}
|
onChange={(e) => applyZoom(Number(e.target.value))}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<p className="text-xs text-shoal">{t('avatar.reposition')}</p>
|
<button
|
||||||
<div className="flex gap-2">
|
type="button"
|
||||||
<Button variant="quiet" onClick={onCancel} disabled={busy}>
|
onClick={reset}
|
||||||
{t('avatar.cancel')}
|
className="cursor-pointer rounded-ctl border border-shoal px-3 py-1 text-sm text-snow hover:border-frost"
|
||||||
</Button>
|
>
|
||||||
<Button variant="primary" onClick={confirm} busy={busy} disabled={!natural}>
|
{t('avatar.reset')}
|
||||||
{t('avatar.confirm')}
|
</button>
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs text-shoal">{t('avatar.cropperHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -10,7 +10,7 @@ import {
|
||||||
removeAvatar,
|
removeAvatar,
|
||||||
uploadAvatarWithProgress,
|
uploadAvatarWithProgress,
|
||||||
} from '@features/account/api'
|
} from '@features/account/api'
|
||||||
import { AvatarCropper } from './AvatarCropper'
|
import { AvatarEditDialog } from './AvatarEditDialog'
|
||||||
import { AvatarHistoryStrip } from './AvatarHistoryStrip'
|
import { AvatarHistoryStrip } from './AvatarHistoryStrip'
|
||||||
|
|
||||||
/** Picker + drop target + history strip: validates a file, opens the cropper, uploads it with progress and confirms in place. */
|
/** 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) {
|
if (picked) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-2">
|
<AvatarEditDialog
|
||||||
<AvatarCropper file={picked} onCancel={() => setPicked(undefined)} onConfirm={onConfirmCrop} />
|
file={picked}
|
||||||
{progress !== null && <ProgressBar pct={progress} />}
|
onCancel={() => setPicked(undefined)}
|
||||||
{error ? <Notice tone="error">{error}</Notice> : null}
|
onConfirm={onConfirmCrop}
|
||||||
</div>
|
progress={progress}
|
||||||
|
error={error}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,34 @@
|
||||||
export const MIN_ZOOM = 1
|
export const MIN_ZOOM = 1
|
||||||
export const MAX_ZOOM = 4
|
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). */
|
/** 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 {
|
export function coverScale(naturalWidth: number, naturalHeight: number, viewport: number): number {
|
||||||
return viewport / Math.min(naturalWidth, naturalHeight)
|
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))
|
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,
|
* 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
|
* center-relative) and zoom, compute the source rectangle (in natural image pixels) that should
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,15 @@ export const accountEn = {
|
||||||
removeConfirm: 'Really remove?',
|
removeConfirm: 'Really remove?',
|
||||||
removed: 'Avatar removed',
|
removed: 'Avatar removed',
|
||||||
removeFailed: 'Could not remove the avatar.',
|
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: {
|
validation: {
|
||||||
avatarType: 'PNG, JPG or WebP please.',
|
avatarType: 'PNG, JPG or WebP please.',
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,15 @@ export const accountRu = {
|
||||||
removeConfirm: 'Точно удалить?',
|
removeConfirm: 'Точно удалить?',
|
||||||
removed: 'Аватар удалён',
|
removed: 'Аватар удалён',
|
||||||
removeFailed: 'Не удалось удалить аватар.',
|
removeFailed: 'Не удалось удалить аватар.',
|
||||||
|
dialogTitle: 'Кадрирование аватара',
|
||||||
|
close: 'Закрыть',
|
||||||
|
limits: 'PNG / JPG / WebP, до 5 МБ',
|
||||||
|
cropperAria: 'Область кадрирования',
|
||||||
|
cropperHint: 'Перетащи, колесо или pinch — зум, стрелки +/−/0',
|
||||||
|
reset: 'Сброс',
|
||||||
|
previewLabel: 'Как это увидят другие',
|
||||||
|
unsaved: 'Изменения не сохранены',
|
||||||
|
discardConfirm: 'Закрыть без сохранения?',
|
||||||
},
|
},
|
||||||
validation: {
|
validation: {
|
||||||
avatarType: 'Подойдёт PNG, JPG или WebP.',
|
avatarType: 'Подойдёт PNG, JPG или WebP.',
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,14 @@
|
||||||
import { expect, test } from 'vitest'
|
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', () => {
|
test('coverScale scales the shorter side to fill the viewport', () => {
|
||||||
expect(coverScale(1000, 2000, 200)).toBeCloseTo(0.2)
|
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
|
// positive offset.x pans the image right on screen -> source window moves left
|
||||||
expect(zoomed.sx).toBeLessThan(100)
|
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)
|
||||||
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue