This commit is contained in:
@@ -60,7 +60,13 @@
|
||||
"Bash(grep -E '\"electron\":\\\\s*\"' package.json apps/electron/package.json)",
|
||||
"Bash(grep -E '\"electron\":\\\\s*\"' package.json)",
|
||||
"Bash(node --check apps/electron/main.cjs)",
|
||||
"Bash(node --check apps/electron/preload.cjs)"
|
||||
"Bash(node --check apps/electron/preload.cjs)",
|
||||
"Bash(awk 'NR>=508 && NR<=535 {print NR\": \"$0}' packages/shared/src/components/settings/UserSettingsModal.tsx)",
|
||||
"Bash(awk 'NR>=475 && NR<=535 {print NR\": \"$0}' packages/shared/src/components/settings/UserSettingsModal.tsx)",
|
||||
"Bash(awk 'NR>=535 && NR<=560 {print NR\": \"$0}' packages/shared/src/components/settings/UserSettingsModal.tsx)",
|
||||
"Bash(awk 'NR>=460 && NR<=480 {print NR\": \"$0}' packages/shared/src/components/settings/UserSettingsModal.tsx)",
|
||||
"Bash(python3 -)",
|
||||
"Bash(awk 'NR>=525 && NR<=540 {print NR\": \"$0}' packages/shared/src/components/settings/UserSettingsModal.tsx)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1270,6 +1270,18 @@ export function ChannelTextarea({
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFilesSelected}
|
||||
/>
|
||||
{isMobile ? (
|
||||
<button
|
||||
ref={plusButtonRef}
|
||||
type="button"
|
||||
className={styles.textareaButton}
|
||||
aria-label="Upload a file or create a poll"
|
||||
onClick={() => setShowPlusMenu((v) => !v)}
|
||||
disabled={isUploading || !channelKey}
|
||||
>
|
||||
<PlusCircle size={26} weight="fill" className={styles.textareaButtonIcon} />
|
||||
</button>
|
||||
) : (
|
||||
<Tooltip content="Upload a File" placement="top">
|
||||
<button
|
||||
ref={plusButtonRef}
|
||||
@@ -1282,6 +1294,7 @@ export function ChannelTextarea({
|
||||
<PlusCircle size={26} weight="fill" className={styles.textareaButtonIcon} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.contentArea}>
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
|
||||
.banner {
|
||||
width: 100%;
|
||||
height: 90px;
|
||||
aspect-ratio: 5 / 2;
|
||||
background-color: var(--brand-primary, #5865f2);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
|
||||
.banner {
|
||||
flex: 0 0 auto;
|
||||
height: 210px;
|
||||
aspect-ratio: 5 / 2;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
.banner {
|
||||
flex: 0 0 auto;
|
||||
height: 110px;
|
||||
aspect-ratio: 5 / 2;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
.banner {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
height: 160px;
|
||||
aspect-ratio: 5 / 2;
|
||||
width: 100%;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
/* ── Crop Banner modal body ──────────────────────────────────
|
||||
Mirrors AvatarCropModal.module.css; the only meaningful
|
||||
difference is `.cropMask` which is a rounded rectangle here
|
||||
(matching the 5:2 banner frame) instead of a circle. */
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.4;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.editor {
|
||||
position: relative;
|
||||
align-self: center;
|
||||
background-color: #000;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.editor:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.editorImage {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform-origin: center center;
|
||||
pointer-events: none;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
image-rendering: auto;
|
||||
}
|
||||
|
||||
.cropMask {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 6px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.85);
|
||||
box-sizing: border-box;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.zoomRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.zoomLabel {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-primary-muted, #a0a3a8);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.zoomSlider {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background-color: var(--background-modifier-accent);
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.zoomSlider:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.zoomSlider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--brand-primary);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.zoomSlider::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--brand-primary);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary-muted, #a0a3a8);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.iconButton:hover:not(:disabled) {
|
||||
background-color: var(--background-modifier-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.iconButton:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 10px 14px;
|
||||
background-color: hsl(0, calc(60% * var(--saturation-factor, 1)), 22%);
|
||||
color: hsl(0, calc(80% * var(--saturation-factor, 1)), 85%);
|
||||
border-radius: 6px;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.actionsSpacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.resetButton {
|
||||
height: 40px;
|
||||
padding: 0 18px;
|
||||
background: var(--background-secondary-alt);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s;
|
||||
}
|
||||
|
||||
.resetButton:hover:not(:disabled) {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.resetButton:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.secondaryButton {
|
||||
height: 40px;
|
||||
padding: 0 18px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s;
|
||||
}
|
||||
|
||||
.secondaryButton:hover:not(:disabled) {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.tertiaryButton {
|
||||
height: 40px;
|
||||
padding: 0 18px;
|
||||
background: var(--background-secondary-alt);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s;
|
||||
}
|
||||
|
||||
.tertiaryButton:hover:not(:disabled) {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.primaryButton {
|
||||
height: 40px;
|
||||
padding: 0 20px;
|
||||
background-color: var(--brand-primary);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--text-on-brand-primary, #fff);
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: filter 0.12s;
|
||||
}
|
||||
|
||||
.primaryButton:hover:not(:disabled) {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.primaryButton:active:not(:disabled) {
|
||||
filter: brightness(0.92);
|
||||
}
|
||||
|
||||
.primaryButton:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.secondaryButton:disabled,
|
||||
.tertiaryButton:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
412
packages/shared/src/components/settings/BannerCropModal.tsx
Normal file
412
packages/shared/src/components/settings/BannerCropModal.tsx
Normal 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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import { useLogout } from '../../hooks/useLogout';
|
||||
import { usePlatform } from '../../platform';
|
||||
import { AvatarCropModal } from './AvatarCropModal';
|
||||
import { BannerCropModal } from './BannerCropModal';
|
||||
import { KeybindsTab } from './KeybindsTab';
|
||||
import { MobileUserSettings } from './MobileUserSettings';
|
||||
import { LogoutConfirmModal } from '../modals/LogoutConfirmModal';
|
||||
@@ -226,6 +227,7 @@ export function AccountTab() {
|
||||
const [pendingBannerBlob, setPendingBannerBlob] = useState<Blob | null>(null);
|
||||
const [bannerPreview, setBannerPreview] = useState<string | null>(null);
|
||||
const [removeBannerPending, setRemoveBannerPending] = useState(false);
|
||||
const [bannerCropCandidate, setBannerCropCandidate] = useState<File | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (me) {
|
||||
@@ -302,10 +304,24 @@ export function AccountTab() {
|
||||
setStatus('Banner must be under 10 MB.');
|
||||
return;
|
||||
}
|
||||
setPendingBannerBlob(file);
|
||||
setBannerCropCandidate(file);
|
||||
};
|
||||
|
||||
const handleBannerCropped = (blob: Blob) => {
|
||||
setPendingBannerBlob(blob);
|
||||
if (bannerPreview) URL.revokeObjectURL(bannerPreview);
|
||||
setBannerPreview(URL.createObjectURL(file));
|
||||
setBannerPreview(URL.createObjectURL(blob));
|
||||
setRemoveBannerPending(false);
|
||||
setBannerCropCandidate(null);
|
||||
};
|
||||
|
||||
const handleBannerSkipCrop = () => {
|
||||
if (!bannerCropCandidate) return;
|
||||
setPendingBannerBlob(bannerCropCandidate);
|
||||
if (bannerPreview) URL.revokeObjectURL(bannerPreview);
|
||||
setBannerPreview(URL.createObjectURL(bannerCropCandidate));
|
||||
setRemoveBannerPending(false);
|
||||
setBannerCropCandidate(null);
|
||||
};
|
||||
|
||||
const handleRemoveBanner = () => {
|
||||
@@ -460,7 +476,8 @@ export function AccountTab() {
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: 72,
|
||||
position: 'relative',
|
||||
aspectRatio: '5 / 2',
|
||||
background: currentBanner
|
||||
? undefined
|
||||
: `linear-gradient(135deg, ${accentColor}, ${accentColor}aa)`,
|
||||
@@ -468,8 +485,12 @@ export function AccountTab() {
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
/>
|
||||
<div style={{ position: 'absolute', top: 36, left: 18 }}>
|
||||
>
|
||||
{/* Avatar straddles the banner's bottom edge so the
|
||||
overlap stays consistent regardless of banner
|
||||
height (which now scales with the wrapper width
|
||||
via aspect-ratio: 5/2). */}
|
||||
<div style={{ position: 'absolute', bottom: -44, left: 18 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={pickAvatar}
|
||||
@@ -509,6 +530,7 @@ export function AccountTab() {
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '48px 18px 16px 18px' }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||
{displayName || me?.username || 'You'}
|
||||
@@ -634,7 +656,7 @@ export function AccountTab() {
|
||||
alt="Banner preview"
|
||||
style={{
|
||||
width: 160,
|
||||
height: 48,
|
||||
aspectRatio: '5 / 2',
|
||||
borderRadius: 6,
|
||||
objectFit: 'cover',
|
||||
border: '1px solid var(--background-modifier-accent)',
|
||||
@@ -644,7 +666,7 @@ export function AccountTab() {
|
||||
<div
|
||||
style={{
|
||||
width: 160,
|
||||
height: 48,
|
||||
aspectRatio: '5 / 2',
|
||||
borderRadius: 6,
|
||||
background: `linear-gradient(135deg, ${accentColor}, ${accentColor}aa)`,
|
||||
border: '1px solid var(--background-modifier-accent)',
|
||||
@@ -831,6 +853,14 @@ export function AccountTab() {
|
||||
onCropped={handleCropped}
|
||||
onSkipCrop={handleSkipCrop}
|
||||
/>
|
||||
|
||||
<BannerCropModal
|
||||
isOpen={!!bannerCropCandidate}
|
||||
onClose={() => setBannerCropCandidate(null)}
|
||||
file={bannerCropCandidate}
|
||||
onCropped={handleBannerCropped}
|
||||
onSkipCrop={handleBannerSkipCrop}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user