feat(web): crop avatars in the browser with a live message preview [CI]
This commit is contained in:
1 parent
18c871ec85
commit
55f1b70d89
4 files changed
+370
-15
No files matched your search
@@ -0,0 +1,137 @@
|
||||
.editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.main {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.stage {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.canvas {
|
||||
display: block;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.canvas:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* 圆形取景框:圆外压暗,圆内透明;不拦截拖动 */
|
||||
.mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(circle closest-side, transparent calc(100% - 1px), var(--scrim) 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.slider span {
|
||||
flex: 0 0 32px;
|
||||
}
|
||||
|
||||
.slider input[type='range'] {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.preview {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.preview h3 {
|
||||
margin: 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.message,
|
||||
.member {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.member {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.message p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.message small,
|
||||
.member small {
|
||||
margin-inline-start: var(--space-2);
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.member div {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.avatar,
|
||||
.avatarSmall {
|
||||
flex: 0 0 auto;
|
||||
border-radius: var(--radius-avatar);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.avatarSmall {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
color: var(--danger);
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { clampCrop, exportAvatar, paintCrop, type Crop } from './avatarCrop';
|
||||
import styles from './AvatarEditor.module.css';
|
||||
|
||||
const VIEW = 260;
|
||||
|
||||
interface View { zoom: number; x: number; y: number; quarter: number; fine: number }
|
||||
const FRESH: View = { zoom: 1, x: 0, y: 0, quarter: 0, fine: 0 };
|
||||
|
||||
/** 头像预览:同一份裁剪结果按实际使用的尺寸画出来 */
|
||||
function Preview({ image, crop, size }: { image: ImageBitmap; crop: Crop; size: number }) {
|
||||
const ref = useRef<HTMLCanvasElement>(null);
|
||||
useEffect(() => {
|
||||
const canvas = ref.current;
|
||||
const ctx = canvas?.getContext('2d');
|
||||
if (!canvas || !ctx) return;
|
||||
canvas.width = canvas.height = Math.round(size * (window.devicePixelRatio || 1));
|
||||
paintCrop(ctx, image, crop, VIEW, canvas.width);
|
||||
}, [image, crop, size]);
|
||||
return <canvas ref={ref} className={styles.avatar} style={{ width: size, height: size }} aria-hidden="true" />;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
file: File;
|
||||
/** 预览里显示的名称 */
|
||||
name: string;
|
||||
onCancel: () => void;
|
||||
onDone: (dataUrl: string) => void;
|
||||
}
|
||||
|
||||
/** 在浏览器里裁剪头像:拖动、缩放、旋转,右侧按聊天消息和成员列表的样子实时预览 */
|
||||
export function AvatarEditor({ file, name, onCancel, onDone }: Props) {
|
||||
const [image, setImage] = useState<ImageBitmap>();
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [view, setView] = useState<View>(FRESH);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const stage = useRef<HTMLCanvasElement>(null);
|
||||
const drag = useRef<{ x: number; y: number } | null>(null);
|
||||
const crop = useMemo<Crop>(() => ({ zoom: view.zoom, x: view.x, y: view.y, angle: view.quarter * 90 + view.fine }), [view]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let bitmap: ImageBitmap | undefined;
|
||||
createImageBitmap(file).then((value) => {
|
||||
if (!active) { value.close(); return; }
|
||||
bitmap = value; setImage(value); setView(FRESH);
|
||||
}).catch(() => { if (active) setFailed(true); });
|
||||
return () => { active = false; bitmap?.close(); };
|
||||
}, [file]);
|
||||
|
||||
const update = useCallback((next: (value: View) => View) => {
|
||||
if (!image) return;
|
||||
setView((value) => {
|
||||
const moved = next(value);
|
||||
const fixed = clampCrop(image, VIEW, { zoom: moved.zoom, x: moved.x, y: moved.y, angle: moved.quarter * 90 + moved.fine });
|
||||
return { ...moved, x: fixed.x, y: fixed.y };
|
||||
});
|
||||
}, [image]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = stage.current;
|
||||
const ctx = canvas?.getContext('2d');
|
||||
if (!canvas || !ctx || !image) return;
|
||||
canvas.width = canvas.height = Math.round(VIEW * (window.devicePixelRatio || 1));
|
||||
paintCrop(ctx, image, crop, VIEW, canvas.width);
|
||||
}, [image, crop]);
|
||||
|
||||
// 滚轮缩放需要 preventDefault,React 的 onWheel 是被动监听
|
||||
useEffect(() => {
|
||||
const canvas = stage.current;
|
||||
if (!canvas) return;
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
event.preventDefault();
|
||||
update((value) => ({ ...value, zoom: Math.min(4, Math.max(1, value.zoom * Math.exp(-event.deltaY / 400))) }));
|
||||
};
|
||||
canvas.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => canvas.removeEventListener('wheel', onWheel);
|
||||
}, [update, failed]);
|
||||
|
||||
function down(event: ReactPointerEvent) {
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
drag.current = { x: event.clientX, y: event.clientY };
|
||||
}
|
||||
function move(event: ReactPointerEvent) {
|
||||
const from = drag.current;
|
||||
if (!from) return;
|
||||
drag.current = { x: event.clientX, y: event.clientY };
|
||||
const dx = event.clientX - from.x;
|
||||
const dy = event.clientY - from.y;
|
||||
update((value) => ({ ...value, x: value.x + dx, y: value.y + dy }));
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
if (!image || busy) return;
|
||||
setBusy(true);
|
||||
try { onDone(await exportAvatar(image, crop, VIEW)); }
|
||||
catch { setFailed(true); setBusy(false); }
|
||||
}
|
||||
|
||||
if (failed) return <div className={styles.editor}>
|
||||
<p className={styles.error} role="alert">这张图片无法处理,请换一张 PNG、JPEG 或 WebP 图片。</p>
|
||||
<div className={styles.actions}><Button onClick={onCancel}>返回</Button></div>
|
||||
</div>;
|
||||
|
||||
const ready = Boolean(image);
|
||||
return <div className={styles.editor}>
|
||||
<div className={styles.main}>
|
||||
<div className={styles.left}>
|
||||
<div className={styles.stage} style={{ width: VIEW, height: VIEW }}>
|
||||
<canvas ref={stage} className={styles.canvas} style={{ width: VIEW, height: VIEW }} aria-label="拖动图片调整头像位置"
|
||||
onPointerDown={down} onPointerMove={move} onPointerUp={() => { drag.current = null; }} onPointerCancel={() => { drag.current = null; }} />
|
||||
<span className={styles.mask} aria-hidden="true" />
|
||||
</div>
|
||||
<label className={styles.slider}><span>缩放</span>
|
||||
<input type="range" min={1} max={4} step={0.01} value={view.zoom} disabled={!ready}
|
||||
onChange={(event) => update((value) => ({ ...value, zoom: Number(event.target.value) }))} /></label>
|
||||
<div className={styles.slider}><span>旋转</span>
|
||||
<Button icon="rotateLeft" label="向左转 90°" disabled={!ready} onClick={() => update((value) => ({ ...value, quarter: value.quarter - 1 }))} />
|
||||
<Button icon="rotateRight" label="向右转 90°" disabled={!ready} onClick={() => update((value) => ({ ...value, quarter: value.quarter + 1 }))} />
|
||||
<input type="range" min={-45} max={45} step={1} value={view.fine} disabled={!ready} aria-label="微调角度"
|
||||
onChange={(event) => update((value) => ({ ...value, fine: Number(event.target.value) }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.preview} aria-label="头像预览">
|
||||
<h3>预览</h3>
|
||||
<div className={styles.message}>
|
||||
{image ? <Preview image={image} crop={crop} size={40} /> : <span className={styles.avatar} />}
|
||||
<div><p><strong>{name}</strong><small>刚刚</small></p><p>大家好~</p></div>
|
||||
</div>
|
||||
<div className={styles.member}>
|
||||
{image ? <Preview image={image} crop={crop} size={32} /> : <span className={styles.avatarSmall} />}
|
||||
<div><strong>{name}</strong><small>在线</small></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<Button variant="ghost" onClick={() => setView(FRESH)} disabled={!ready}>重置</Button>
|
||||
<Button variant="ghost" onClick={onCancel}>取消</Button>
|
||||
<Button variant="primary" onClick={() => { void confirm(); }} disabled={!ready || busy}>{busy ? '处理中…' : '使用头像'}</Button>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -3,22 +3,18 @@ import { errorMessage } from '@/core-bridge/errors';
|
||||
import { useAccount } from '@/store/account';
|
||||
import { useToast } from '@/store/toast';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { AvatarEditor } from './AvatarEditor';
|
||||
import styles from './AccountSettings.module.css';
|
||||
|
||||
const MAX_AVATAR_BYTES = 250_000;
|
||||
// 原图只做初步检查;裁剪后会重新编码为 256×256,体积由编辑器控制
|
||||
const MAX_SOURCE_BYTES = 20_000_000;
|
||||
|
||||
async function readAvatar(file: File): Promise<string> {
|
||||
if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type) || file.size > MAX_AVATAR_BYTES) throw new Error('请选择 250 KB 以内的 PNG、JPEG 或 WebP 图片');
|
||||
async function checkAvatarSource(file: File): Promise<void> {
|
||||
if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type) || file.size > MAX_SOURCE_BYTES) throw new Error('请选择 20 MB 以内的 PNG、JPEG 或 WebP 图片');
|
||||
const bitmap = await createImageBitmap(file);
|
||||
const valid = bitmap.width > 0 && bitmap.height > 0 && bitmap.width <= 4096 && bitmap.height <= 4096;
|
||||
const valid = bitmap.width > 0 && bitmap.height > 0 && bitmap.width <= 8192 && bitmap.height <= 8192;
|
||||
bitmap.close();
|
||||
if (!valid) throw new Error('头像宽高不能超过 4096 像素');
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result));
|
||||
reader.onerror = () => reject(new Error('头像读取失败'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
if (!valid) throw new Error('图片宽高不能超过 8192 像素');
|
||||
}
|
||||
|
||||
export function ProfileSettings() {
|
||||
@@ -29,6 +25,7 @@ export function ProfileSettings() {
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const [name, setName] = useState(personal?.displayName ?? '');
|
||||
const [avatar, setAvatar] = useState(personal?.avatar);
|
||||
const [editing, setEditing] = useState<File>();
|
||||
useEffect(() => { setName(personal?.displayName ?? ''); setAvatar(personal?.avatar); }, [personal?.displayName, personal?.avatar]);
|
||||
|
||||
const changed = name.trim() !== (personal?.displayName ?? '') || avatar !== personal?.avatar;
|
||||
@@ -36,7 +33,7 @@ export function ProfileSettings() {
|
||||
|
||||
async function choose(file?: File) {
|
||||
if (!file) return;
|
||||
try { setAvatar(await readAvatar(file)); }
|
||||
try { await checkAvatarSource(file); setEditing(file); }
|
||||
catch (cause) { useToast.getState().push({ kind: 'error', text: errorMessage(cause) }); }
|
||||
}
|
||||
async function submit(event: FormEvent) {
|
||||
@@ -46,6 +43,11 @@ export function ProfileSettings() {
|
||||
catch (cause) { useToast.getState().push({ kind: 'error', text: `保存失败:${errorMessage(cause)}` }); }
|
||||
}
|
||||
|
||||
if (editing) return <div className={styles.page}>
|
||||
<h2>编辑头像</h2>
|
||||
<AvatarEditor file={editing} name={shown} onCancel={() => setEditing(undefined)} onDone={(url) => { setAvatar(url); setEditing(undefined); }} />
|
||||
</div>;
|
||||
|
||||
return <form className={styles.page} onSubmit={(event) => { void submit(event); }} aria-label="个人资料">
|
||||
<h2>个人资料</h2>
|
||||
<div className={styles.avatarRow}>
|
||||
@@ -57,15 +59,15 @@ export function ProfileSettings() {
|
||||
<Button icon="image" onClick={() => fileInput.current?.click()}>更换头像</Button>
|
||||
{avatar ? <Button variant="ghost" onClick={() => setAvatar(undefined)}>移除</Button> : null}
|
||||
</div>
|
||||
<span className={styles.hint}>PNG、JPEG 或 WebP,最大 250 KB</span>
|
||||
<span className={styles.hint}>PNG、JPEG 或 WebP,可在浏览器里裁剪</span>
|
||||
</div>
|
||||
</div>
|
||||
<label className={styles.field}>个人名称
|
||||
<input maxLength={48} value={name} placeholder={username} onChange={(event) => setName(event.target.value)} />
|
||||
</label>
|
||||
<div className={styles.field}>用户名
|
||||
<div className={styles.field}>账号 ID
|
||||
<span className={styles.readonly}>{username}</span>
|
||||
<span className={styles.hint}>用户名是固定的账号身份,不能修改。个人名称与头像由服务端保存,在你的所有设备上同步。</span>
|
||||
<span className={styles.hint}>账号 ID 是固定的账号身份,不能修改。个人名称与头像由服务端保存,在你的所有设备上同步。</span>
|
||||
</div>
|
||||
<div className={styles.footer}><Button type="submit" variant="primary" disabled={!changed || busy}>{busy ? '保存中…' : '保存'}</Button></div>
|
||||
</form>;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/** 头像裁剪的几何:图片以视口中心为原点平移、旋转、缩放,圆形取景框始终被图片完全盖住 */
|
||||
export interface Crop {
|
||||
/** 相对「刚好盖住取景框」的放大倍数,≥ 1 */
|
||||
zoom: number;
|
||||
/** 旋转角度(度) */
|
||||
angle: number;
|
||||
/** 图片中心相对视口中心的偏移(视口像素) */
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export const OUTPUT_SIZE = 256;
|
||||
export const MAX_OUTPUT_BYTES = 250_000;
|
||||
|
||||
type Source = CanvasImageSource & { width: number; height: number };
|
||||
|
||||
function scaleOf(source: Source, size: number, zoom: number): number {
|
||||
return (size / Math.min(source.width, source.height)) * zoom;
|
||||
}
|
||||
|
||||
/** 把偏移限制在「取景圆仍在图片内」的范围 */
|
||||
export function clampCrop(source: Source, size: number, crop: Crop): Crop {
|
||||
const s = scaleOf(source, size, crop.zoom);
|
||||
const rad = (crop.angle * Math.PI) / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
// 取景圆心在图片坐标系中的位置(图片像素)
|
||||
const px = (-crop.x * cos - crop.y * sin) / s;
|
||||
const py = (crop.x * sin - crop.y * cos) / s;
|
||||
const reach = size / 2 / s;
|
||||
const limitX = Math.max(0, source.width / 2 - reach);
|
||||
const limitY = Math.max(0, source.height / 2 - reach);
|
||||
const cx = Math.min(limitX, Math.max(-limitX, px));
|
||||
const cy = Math.min(limitY, Math.max(-limitY, py));
|
||||
return { ...crop, x: -(cx * cos - cy * sin) * s, y: -(cx * sin + cy * cos) * s };
|
||||
}
|
||||
|
||||
export function paintCrop(ctx: CanvasRenderingContext2D, image: Source, crop: Crop, from: number, to: number): void {
|
||||
const ratio = to / from;
|
||||
ctx.save();
|
||||
ctx.clearRect(0, 0, to, to);
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.translate(to / 2 + crop.x * ratio, to / 2 + crop.y * ratio);
|
||||
ctx.rotate((crop.angle * Math.PI) / 180);
|
||||
const s = scaleOf(image, from, crop.zoom) * ratio;
|
||||
ctx.scale(s, s);
|
||||
ctx.drawImage(image, -image.width / 2, -image.height / 2);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function blobToDataUrl(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result));
|
||||
reader.onerror = () => reject(new Error('头像读取失败'));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
/** 导出 256×256 的 WebP(浏览器不支持时退回 PNG),质量逐步降低直到不超过上限 */
|
||||
export async function exportAvatar(image: Source, crop: Crop, from: number): Promise<string> {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = OUTPUT_SIZE;
|
||||
canvas.height = OUTPUT_SIZE;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('当前浏览器不支持图片处理');
|
||||
paintCrop(ctx, image, crop, from, OUTPUT_SIZE);
|
||||
for (const quality of [0.92, 0.8, 0.65, 0.5]) {
|
||||
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/webp', quality));
|
||||
if (blob && blob.size <= MAX_OUTPUT_BYTES) return blobToDataUrl(blob);
|
||||
}
|
||||
throw new Error('头像压缩后仍然过大,请换一张图片');
|
||||
}
|
||||
Reference in new issue
Block a user