profile
Some checks failed
Build and Release / build-and-release (push) Failing after 6m46s

This commit is contained in:
Bryan1029384756
2026-04-19 02:41:25 -05:00
parent 43cfbe65f3
commit 82f8a12e27
9 changed files with 735 additions and 29 deletions

View File

@@ -0,0 +1,412 @@
/**
* 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<string | null>(null);
const [imageEl, setImageEl] = useState<HTMLImageElement | null>(null);
const [offset, setOffset] = useState<Offset>({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [rotation, setRotation] = useState(0);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const editorRef = useRef<HTMLDivElement>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<Modal.Root isOpen={isOpen} onClose={onClose} size="medium">
<Modal.Header title="Crop Banner" onClose={onClose} />
<Modal.Content>
<div className={styles.body}>
<p className={styles.description}>
Drag to reposition your banner and use the scroll wheel or
pinch to zoom. The recommended size is 1200×480 pixels (5:2).
</p>
<div
ref={editorRef}
className={styles.editor}
style={{ width: EDITOR_WIDTH, height: EDITOR_HEIGHT }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onWheel={handleWheel}
>
{imageUrl && (
<img
src={imageUrl}
alt=""
className={styles.editorImage}
style={{
transform: imageTransform,
width: imageEl?.naturalWidth ?? 0,
height: imageEl?.naturalHeight ?? 0,
}}
draggable={false}
/>
)}
{/* 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. */}
<div className={styles.cropMask} aria-hidden />
</div>
<div className={styles.zoomRow}>
<label className={styles.zoomLabel} htmlFor="banner-zoom">
Zoom
</label>
<input
id="banner-zoom"
type="range"
className={styles.zoomSlider}
min={ZOOM_MIN}
max={ZOOM_MAX}
step={ZOOM_STEP}
value={zoom}
onChange={(e) => setZoom(Number(e.target.value))}
disabled={!imageEl}
/>
<button
type="button"
className={styles.iconButton}
onClick={handleFitFrame}
disabled={!imageEl}
aria-label="Fit to frame"
title="Fit to frame"
>
<FrameCorners size={20} weight="regular" />
</button>
<button
type="button"
className={styles.iconButton}
onClick={handleRotate}
disabled={!imageEl}
aria-label="Rotate 90°"
title="Rotate 90°"
>
<ArrowClockwise size={20} weight="regular" />
</button>
</div>
{error && <div className={styles.error}>{error}</div>}
<div className={styles.actions}>
<button
type="button"
className={styles.resetButton}
onClick={handleReset}
disabled={!imageEl}
>
Reset
</button>
<div className={styles.actionsSpacer} />
<button
type="button"
className={styles.secondaryButton}
onClick={onClose}
disabled={isSaving}
>
Cancel
</button>
<button
type="button"
className={styles.tertiaryButton}
onClick={handleSkip}
disabled={isSaving}
title="Upload the original file without cropping"
>
Skip Cropping
</button>
<button
type="button"
className={styles.primaryButton}
onClick={handleSave}
disabled={!imageEl || isSaving}
>
{isSaving ? 'Saving…' : 'Save Banner'}
</button>
</div>
</div>
</Modal.Content>
</Modal.Root>
);
}
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<Blob> {
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,
);
});
}