diff --git a/web/src/features/home/AvatarEditor.module.css b/web/src/features/home/AvatarEditor.module.css new file mode 100644 index 0000000..750bc14 --- /dev/null +++ b/web/src/features/home/AvatarEditor.module.css @@ -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; +} diff --git a/web/src/features/home/AvatarEditor.tsx b/web/src/features/home/AvatarEditor.tsx new file mode 100644 index 0000000..0cac1d1 --- /dev/null +++ b/web/src/features/home/AvatarEditor.tsx @@ -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(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