feat(web): crop avatars in the browser with a live message preview [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful

This commit is contained in:
justme committed 2026-10-16 17:54:32 +08:00
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;
}
+143
View File
@@ -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>;
}
+17 -15
View File
@@ -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>;
+73
View File
@@ -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('头像压缩后仍然过大,请换一张图片');
}