Files
DiscordClone/packages/shared/src/components/settings/BannerCropModal.tsx
Bryan1029384756 82f8a12e27
Some checks failed
Build and Release / build-and-release (push) Failing after 6m46s
profile
2026-04-19 02:41:25 -05:00

413 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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,
);
});
}