/** * BannerCropModal — opened after the user picks a banner file in * the profile settings. Same drag/zoom/canvas-export pattern as * AvatarCropModal, but with a 5:2 rectangular crop frame instead * of a circle so every render surface (settings preview, member * popout, member modal, mobile sheet, user-area popout) lines up * on the same crop edges. * * Output is a 1200×480 PNG (matches Discord's profile banner * ratio). Display surfaces use `aspect-ratio: 5 / 2` so the * uploaded image lines up edge-to-edge regardless of width. */ import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, type WheelEvent as ReactWheelEvent, } from 'react'; import { Modal } from '@discord-clone/ui'; import { ArrowClockwise, FrameCorners, } from '@phosphor-icons/react'; import styles from './BannerCropModal.module.css'; // 5:2 rectangle. Editor footprint fits the medium modal width; // output is high-res so the same crop stays sharp on a 600-wide // MemberProfileModal banner and on retina displays. const EDITOR_WIDTH = 480; const EDITOR_HEIGHT = 192; const OUTPUT_WIDTH = 1200; const OUTPUT_HEIGHT = 480; const ZOOM_MIN = 1; const ZOOM_MAX = 4; const ZOOM_STEP = 0.05; interface BannerCropModalProps { isOpen: boolean; onClose: () => void; file: File | null; onCropped: (blob: Blob) => void; onSkipCrop: () => void; } interface Offset { x: number; y: number; } export function BannerCropModal({ isOpen, onClose, file, onCropped, onSkipCrop, }: BannerCropModalProps) { const [imageUrl, setImageUrl] = useState(null); const [imageEl, setImageEl] = useState(null); const [offset, setOffset] = useState({ x: 0, y: 0 }); const [zoom, setZoom] = useState(1); const [rotation, setRotation] = useState(0); const [isSaving, setIsSaving] = useState(false); const [error, setError] = useState(null); const editorRef = useRef(null); const dragRef = useRef<{ pointerId: number; startX: number; startY: number; startOffset: Offset; } | null>(null); useEffect(() => { if (!isOpen || !file) { setImageUrl(null); setImageEl(null); return; } const url = URL.createObjectURL(file); setImageUrl(url); const img = new Image(); img.onload = () => setImageEl(img); img.onerror = () => setError('Could not read that image.'); img.src = url; return () => { URL.revokeObjectURL(url); }; }, [isOpen, file]); useEffect(() => { if (!imageEl) return; setOffset({ x: 0, y: 0 }); setZoom(1); setRotation(0); setError(null); }, [imageEl]); // Cover scale for a non-square viewport: take the larger of the // two axis ratios so the source always fully covers the editor. const coverScale = useMemo(() => { if (!imageEl) return 1; return Math.max( EDITOR_WIDTH / imageEl.naturalWidth, EDITOR_HEIGHT / imageEl.naturalHeight, ); }, [imageEl]); const effectiveScale = coverScale * zoom; const displayWidth = imageEl ? imageEl.naturalWidth * effectiveScale : EDITOR_WIDTH; const displayHeight = imageEl ? imageEl.naturalHeight * effectiveScale : EDITOR_HEIGHT; const clampOffset = useCallback( (raw: Offset): Offset => { const maxX = Math.max(0, (displayWidth - EDITOR_WIDTH) / 2); const maxY = Math.max(0, (displayHeight - EDITOR_HEIGHT) / 2); return { x: Math.max(-maxX, Math.min(maxX, raw.x)), y: Math.max(-maxY, Math.min(maxY, raw.y)), }; }, [displayWidth, displayHeight], ); useEffect(() => { setOffset((prev) => clampOffset(prev)); }, [clampOffset]); const handlePointerDown = (e: ReactPointerEvent) => { if (!imageEl) return; (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); dragRef.current = { pointerId: e.pointerId, startX: e.clientX, startY: e.clientY, startOffset: offset, }; }; const handlePointerMove = (e: ReactPointerEvent) => { const drag = dragRef.current; if (!drag || drag.pointerId !== e.pointerId) return; const dx = e.clientX - drag.startX; const dy = e.clientY - drag.startY; setOffset( clampOffset({ x: drag.startOffset.x + dx, y: drag.startOffset.y + dy, }), ); }; const handlePointerUp = (e: ReactPointerEvent) => { const drag = dragRef.current; if (!drag || drag.pointerId !== e.pointerId) return; try { (e.currentTarget as HTMLDivElement).releasePointerCapture(e.pointerId); } catch { // Already released — non-issue. } dragRef.current = null; }; const handleWheel = (e: ReactWheelEvent) => { if (!imageEl) return; const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP; setZoom((prev) => Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, prev + delta))); }; const handleReset = () => { setOffset({ x: 0, y: 0 }); setZoom(1); setRotation(0); }; const handleRotate = () => { setRotation((prev) => (prev + 90) % 360); }; const handleFitFrame = () => { setZoom(1); setOffset({ x: 0, y: 0 }); }; const handleSkip = () => { onSkipCrop(); onClose(); }; const handleSave = async () => { if (!imageEl) return; setIsSaving(true); setError(null); try { const blob = await renderCrop({ image: imageEl, editorWidth: EDITOR_WIDTH, editorHeight: EDITOR_HEIGHT, outputWidth: OUTPUT_WIDTH, outputHeight: OUTPUT_HEIGHT, offset, scale: effectiveScale, rotation, }); onCropped(blob); onClose(); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to crop image.'); setIsSaving(false); } }; const imageTransform = imageEl ? `translate(-50%, -50%) translate(${offset.x}px, ${offset.y}px) rotate(${rotation}deg) scale(${effectiveScale})` : undefined; return (

Drag to reposition your banner and use the scroll wheel or pinch to zoom. The recommended size is 1200×480 pixels (5:2).

{imageUrl && ( )} {/* Rounded-rectangle crop mask. Same outer-shadow technique as the avatar crop, but the mask matches the 5:2 editor frame so the crop region is identical to the rendered output. */}
setZoom(Number(e.target.value))} disabled={!imageEl} />
{error &&
{error}
}
); } interface RenderCropArgs { image: HTMLImageElement; editorWidth: number; editorHeight: number; outputWidth: number; outputHeight: number; offset: Offset; scale: number; rotation: number; } /** * Bakes the editor transform into a PNG Blob sized * `outputWidth × outputHeight`. Editor and output must share the * same aspect ratio so the uniform `ratio` scalar can map every * transform from editor space into output space. */ function renderCrop(args: RenderCropArgs): Promise { return new Promise((resolve, reject) => { const { image, editorWidth, outputWidth, outputHeight, offset, scale, rotation, } = args; const canvas = document.createElement('canvas'); canvas.width = outputWidth; canvas.height = outputHeight; const ctx = canvas.getContext('2d'); if (!ctx) { reject(new Error('Could not get 2D canvas context.')); return; } // Editor and output share an aspect ratio, so a single // scalar maps transforms between the two spaces. const ratio = outputWidth / editorWidth; ctx.save(); ctx.translate(outputWidth / 2, outputHeight / 2); ctx.translate(offset.x * ratio, offset.y * ratio); ctx.rotate((rotation * Math.PI) / 180); const drawScale = scale * ratio; ctx.drawImage( image, (-image.naturalWidth * drawScale) / 2, (-image.naturalHeight * drawScale) / 2, image.naturalWidth * drawScale, image.naturalHeight * drawScale, ); ctx.restore(); canvas.toBlob( (blob) => { if (!blob) { reject(new Error('Canvas export failed.')); return; } resolve(blob); }, 'image/png', 0.95, ); }); }