413 lines
10 KiB
TypeScript
413 lines
10 KiB
TypeScript
/**
|
||
* 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,
|
||
);
|
||
});
|
||
}
|