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 { 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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue