diff --git a/web/src/components/AvatarCropModal.tsx b/web/src/components/AvatarCropModal.tsx new file mode 100644 index 0000000..152b732 --- /dev/null +++ b/web/src/components/AvatarCropModal.tsx @@ -0,0 +1,240 @@ +import { useEffect, useMemo, useRef, useState, type PointerEvent } from "react"; +import { createPortal } from "react-dom"; + +interface AvatarCropModalProps { + open: boolean; + file: File | null; + onCancel: () => void; + onApply: (file: File) => void; +} + +const VIEWPORT_SIZE = 320; +const OUTPUT_SIZE = 500; +const MIN_ZOOM = 1; +const MAX_ZOOM = 4; + +export function AvatarCropModal({ open, file, onCancel, onApply }: AvatarCropModalProps) { + const [imageUrl, setImageUrl] = useState(null); + const [imageEl, setImageEl] = useState(null); + const [zoom, setZoom] = useState(1); + const [position, setPosition] = useState({ x: 0, y: 0 }); + const [processing, setProcessing] = useState(false); + const dragRef = useRef<{ startX: number; startY: number; startPosX: number; startPosY: number } | null>(null); + + useEffect(() => { + if (!open || !file) { + setImageUrl(null); + setImageEl(null); + setZoom(1); + setPosition({ x: 0, y: 0 }); + return; + } + const url = URL.createObjectURL(file); + setImageUrl(url); + setImageEl(null); + setZoom(1); + setPosition({ x: 0, y: 0 }); + return () => { + URL.revokeObjectURL(url); + }; + }, [file, open]); + + const baseScale = useMemo(() => { + if (!imageEl) { + return 1; + } + return Math.max(VIEWPORT_SIZE / imageEl.naturalWidth, VIEWPORT_SIZE / imageEl.naturalHeight); + }, [imageEl]); + + const scale = baseScale * zoom; + + useEffect(() => { + if (!imageEl) { + return; + } + const width = imageEl.naturalWidth * baseScale; + const height = imageEl.naturalHeight * baseScale; + setPosition(clampPosition({ x: (VIEWPORT_SIZE - width) / 2, y: (VIEWPORT_SIZE - height) / 2 }, width, height)); + }, [baseScale, imageEl]); + + useEffect(() => { + if (!open) { + return; + } + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape" && !processing) { + onCancel(); + } + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [onCancel, open, processing]); + + if (!open || !file) { + return null; + } + + function handlePointerDown(event: PointerEvent) { + if (!imageEl || processing) { + return; + } + event.preventDefault(); + dragRef.current = { + startX: event.clientX, + startY: event.clientY, + startPosX: position.x, + startPosY: position.y, + }; + (event.currentTarget as HTMLDivElement).setPointerCapture(event.pointerId); + } + + function handlePointerMove(event: PointerEvent) { + if (!imageEl || !dragRef.current || processing) { + return; + } + const dx = event.clientX - dragRef.current.startX; + const dy = event.clientY - dragRef.current.startY; + const width = imageEl.naturalWidth * scale; + const height = imageEl.naturalHeight * scale; + setPosition(clampPosition({ x: dragRef.current.startPosX + dx, y: dragRef.current.startPosY + dy }, width, height)); + } + + function handlePointerUp(event: PointerEvent) { + dragRef.current = null; + if ((event.currentTarget as HTMLDivElement).hasPointerCapture(event.pointerId)) { + (event.currentTarget as HTMLDivElement).releasePointerCapture(event.pointerId); + } + } + + function handleZoomChange(nextZoom: number) { + if (!imageEl) { + setZoom(nextZoom); + return; + } + const clamped = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, nextZoom)); + const nextScale = baseScale * clamped; + const prevScale = scale; + const cx = VIEWPORT_SIZE / 2; + const cy = VIEWPORT_SIZE / 2; + const nextX = cx - ((cx - position.x) / prevScale) * nextScale; + const nextY = cy - ((cy - position.y) / prevScale) * nextScale; + const width = imageEl.naturalWidth * nextScale; + const height = imageEl.naturalHeight * nextScale; + setZoom(clamped); + setPosition(clampPosition({ x: nextX, y: nextY }, width, height)); + } + + async function handleApply() { + if (!imageEl || !file || processing) { + return; + } + setProcessing(true); + try { + const sourceX = Math.max(0, Math.min(imageEl.naturalWidth, (-position.x) / scale)); + const sourceY = Math.max(0, Math.min(imageEl.naturalHeight, (-position.y) / scale)); + const sourceSize = Math.min(imageEl.naturalWidth - sourceX, imageEl.naturalHeight - sourceY, VIEWPORT_SIZE / scale); + const canvas = document.createElement("canvas"); + canvas.width = OUTPUT_SIZE; + canvas.height = OUTPUT_SIZE; + const ctx = canvas.getContext("2d"); + if (!ctx) { + setProcessing(false); + return; + } + ctx.drawImage(imageEl, sourceX, sourceY, sourceSize, sourceSize, 0, 0, OUTPUT_SIZE, OUTPUT_SIZE); + const blob = await new Promise((resolve) => { + canvas.toBlob((value) => resolve(value), "image/jpeg", 0.86); + }); + if (!blob) { + setProcessing(false); + return; + } + const safeName = file.name.replace(/\.[^.]+$/, "").slice(0, 48) || "avatar"; + const output = new File([blob], `${safeName}_avatar.jpg`, { type: "image/jpeg" }); + onApply(output); + } finally { + setProcessing(false); + } + } + + return createPortal( +
(processing ? null : onCancel())}> +
event.stopPropagation()}> +
+
+

Crop avatar

+ +
+ +
+
+ {imageUrl ? ( + avatar crop { + setImageEl(event.currentTarget); + }} + src={imageUrl} + style={{ + width: imageEl ? imageEl.naturalWidth * scale : "auto", + height: imageEl ? imageEl.naturalHeight * scale : "auto", + transform: `translate(${position.x}px, ${position.y}px)`, + transformOrigin: "top left", + }} + /> + ) : null} +
+
+ +
+ Zoom + handleZoomChange(Number(event.target.value))} + step={0.01} + type="range" + value={zoom} + /> + {zoom.toFixed(2)}x +
+ +
+

Output: 500x500 JPEG

+ +
+
+
+
, + document.body + ); +} + +function clampPosition(pos: { x: number; y: number }, width: number, height: number) { + const minX = Math.min(0, VIEWPORT_SIZE - width); + const maxX = 0; + const minY = Math.min(0, VIEWPORT_SIZE - height); + const maxY = 0; + return { + x: Math.max(minX, Math.min(maxX, pos.x)), + y: Math.max(minY, Math.min(maxY, pos.y)), + }; +} diff --git a/web/src/components/ChatInfoPanel.tsx b/web/src/components/ChatInfoPanel.tsx index 5501f5f..0a616c7 100644 --- a/web/src/components/ChatInfoPanel.tsx +++ b/web/src/components/ChatInfoPanel.tsx @@ -21,6 +21,7 @@ import type { AuthUser, ChatAttachment, ChatDetail, ChatMember, Message, UserSea import { useAuthStore } from "../store/authStore"; import { useChatStore } from "../store/chatStore"; import { useUiStore } from "../store/uiStore"; +import { AvatarCropModal } from "./AvatarCropModal"; import { MediaViewer } from "./MediaViewer"; interface Props { @@ -58,6 +59,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) { const [memberCtx, setMemberCtx] = useState<{ x: number; y: number; member: ChatMember } | null>(null); const [attachmentsTab, setAttachmentsTab] = useState<"all" | "photos" | "videos" | "audio" | "links" | "voice">("all"); const [mediaViewer, setMediaViewer] = useState<{ items: Array<{ url: string; type: "image" | "video"; messageId: number }>; index: number } | null>(null); + const [avatarCropFile, setAvatarCropFile] = useState(null); const myRole = useMemo(() => { if (chat?.my_role) { @@ -314,7 +316,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) { if (!file) { return; } - void uploadChatAvatar(file); + setAvatarCropFile(file); }} type="file" /> @@ -821,6 +823,15 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) { open /> ) : null} + { + setAvatarCropFile(null); + void uploadChatAvatar(processedFile); + }} + onCancel={() => setAvatarCropFile(null)} + open={Boolean(avatarCropFile)} + /> , document.body ); diff --git a/web/src/components/SettingsPanel.tsx b/web/src/components/SettingsPanel.tsx index 87a9114..1591a2f 100644 --- a/web/src/components/SettingsPanel.tsx +++ b/web/src/components/SettingsPanel.tsx @@ -9,6 +9,7 @@ import type { AuthSession, AuthUser } from "../chat/types"; import { useAuthStore } from "../store/authStore"; import { useUiStore } from "../store/uiStore"; import { AppPreferences, getAppPreferences, updateAppPreferences } from "../utils/preferences"; +import { AvatarCropModal } from "./AvatarCropModal"; type SettingsPage = "main" | "general" | "notifications" | "privacy"; @@ -45,6 +46,7 @@ export function SettingsPanel({ open, onClose }: Props) { avatarUrl: "", }); const [profileAvatarUploading, setProfileAvatarUploading] = useState(false); + const [avatarCropFile, setAvatarCropFile] = useState(null); useEffect(() => { if (!me) { @@ -244,7 +246,7 @@ export function SettingsPanel({ open, onClose }: Props) { if (!file) { return; } - void uploadAvatar(file); + setAvatarCropFile(file); }} type="file" /> @@ -605,6 +607,15 @@ export function SettingsPanel({ open, onClose }: Props) { ) : null} + { + setAvatarCropFile(null); + void uploadAvatar(processedFile); + }} + onCancel={() => setAvatarCropFile(null)} + open={Boolean(avatarCropFile)} + /> , document.body );