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 apps/electron/package.json)",
|
||||||
"Bash(grep -E '\"electron\":\\\\s*\"' package.json)",
|
"Bash(grep -E '\"electron\":\\\\s*\"' package.json)",
|
||||||
"Bash(node --check apps/electron/main.cjs)",
|
"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' }}
|
style={{ display: 'none' }}
|
||||||
onChange={handleFilesSelected}
|
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">
|
<Tooltip content="Upload a File" placement="top">
|
||||||
<button
|
<button
|
||||||
ref={plusButtonRef}
|
ref={plusButtonRef}
|
||||||
@@ -1282,6 +1294,7 @@ export function ChannelTextarea({
|
|||||||
<PlusCircle size={26} weight="fill" className={styles.textareaButtonIcon} />
|
<PlusCircle size={26} weight="fill" className={styles.textareaButtonIcon} />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.contentArea}>
|
<div className={styles.contentArea}>
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
|
|
||||||
.banner {
|
.banner {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 90px;
|
aspect-ratio: 5 / 2;
|
||||||
background-color: var(--brand-primary, #5865f2);
|
background-color: var(--brand-primary, #5865f2);
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
background-position: center;
|
background-position: center;
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
|
|
||||||
.banner {
|
.banner {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
height: 210px;
|
aspect-ratio: 5 / 2;
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
background-position: center;
|
background-position: center;
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
.banner {
|
.banner {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
height: 110px;
|
aspect-ratio: 5 / 2;
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
background-position: center;
|
background-position: center;
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
.banner {
|
.banner {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
height: 160px;
|
aspect-ratio: 5 / 2;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
background-position: center;
|
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 { useLogout } from '../../hooks/useLogout';
|
||||||
import { usePlatform } from '../../platform';
|
import { usePlatform } from '../../platform';
|
||||||
import { AvatarCropModal } from './AvatarCropModal';
|
import { AvatarCropModal } from './AvatarCropModal';
|
||||||
|
import { BannerCropModal } from './BannerCropModal';
|
||||||
import { KeybindsTab } from './KeybindsTab';
|
import { KeybindsTab } from './KeybindsTab';
|
||||||
import { MobileUserSettings } from './MobileUserSettings';
|
import { MobileUserSettings } from './MobileUserSettings';
|
||||||
import { LogoutConfirmModal } from '../modals/LogoutConfirmModal';
|
import { LogoutConfirmModal } from '../modals/LogoutConfirmModal';
|
||||||
@@ -226,6 +227,7 @@ export function AccountTab() {
|
|||||||
const [pendingBannerBlob, setPendingBannerBlob] = useState<Blob | null>(null);
|
const [pendingBannerBlob, setPendingBannerBlob] = useState<Blob | null>(null);
|
||||||
const [bannerPreview, setBannerPreview] = useState<string | null>(null);
|
const [bannerPreview, setBannerPreview] = useState<string | null>(null);
|
||||||
const [removeBannerPending, setRemoveBannerPending] = useState(false);
|
const [removeBannerPending, setRemoveBannerPending] = useState(false);
|
||||||
|
const [bannerCropCandidate, setBannerCropCandidate] = useState<File | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (me) {
|
if (me) {
|
||||||
@@ -302,10 +304,24 @@ export function AccountTab() {
|
|||||||
setStatus('Banner must be under 10 MB.');
|
setStatus('Banner must be under 10 MB.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPendingBannerBlob(file);
|
setBannerCropCandidate(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBannerCropped = (blob: Blob) => {
|
||||||
|
setPendingBannerBlob(blob);
|
||||||
if (bannerPreview) URL.revokeObjectURL(bannerPreview);
|
if (bannerPreview) URL.revokeObjectURL(bannerPreview);
|
||||||
setBannerPreview(URL.createObjectURL(file));
|
setBannerPreview(URL.createObjectURL(blob));
|
||||||
setRemoveBannerPending(false);
|
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 = () => {
|
const handleRemoveBanner = () => {
|
||||||
@@ -460,7 +476,8 @@ export function AccountTab() {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
height: 72,
|
position: 'relative',
|
||||||
|
aspectRatio: '5 / 2',
|
||||||
background: currentBanner
|
background: currentBanner
|
||||||
? undefined
|
? undefined
|
||||||
: `linear-gradient(135deg, ${accentColor}, ${accentColor}aa)`,
|
: `linear-gradient(135deg, ${accentColor}, ${accentColor}aa)`,
|
||||||
@@ -468,8 +485,12 @@ export function AccountTab() {
|
|||||||
backgroundSize: 'cover',
|
backgroundSize: 'cover',
|
||||||
backgroundPosition: 'center',
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={pickAvatar}
|
onClick={pickAvatar}
|
||||||
@@ -509,6 +530,7 @@ export function AccountTab() {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div style={{ padding: '48px 18px 16px 18px' }}>
|
<div style={{ padding: '48px 18px 16px 18px' }}>
|
||||||
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>
|
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||||
{displayName || me?.username || 'You'}
|
{displayName || me?.username || 'You'}
|
||||||
@@ -634,7 +656,7 @@ export function AccountTab() {
|
|||||||
alt="Banner preview"
|
alt="Banner preview"
|
||||||
style={{
|
style={{
|
||||||
width: 160,
|
width: 160,
|
||||||
height: 48,
|
aspectRatio: '5 / 2',
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
objectFit: 'cover',
|
objectFit: 'cover',
|
||||||
border: '1px solid var(--background-modifier-accent)',
|
border: '1px solid var(--background-modifier-accent)',
|
||||||
@@ -644,7 +666,7 @@ export function AccountTab() {
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: 160,
|
width: 160,
|
||||||
height: 48,
|
aspectRatio: '5 / 2',
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
background: `linear-gradient(135deg, ${accentColor}, ${accentColor}aa)`,
|
background: `linear-gradient(135deg, ${accentColor}, ${accentColor}aa)`,
|
||||||
border: '1px solid var(--background-modifier-accent)',
|
border: '1px solid var(--background-modifier-accent)',
|
||||||
@@ -831,6 +853,14 @@ export function AccountTab() {
|
|||||||
onCropped={handleCropped}
|
onCropped={handleCropped}
|
||||||
onSkipCrop={handleSkipCrop}
|
onSkipCrop={handleSkipCrop}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<BannerCropModal
|
||||||
|
isOpen={!!bannerCropCandidate}
|
||||||
|
onClose={() => setBannerCropCandidate(null)}
|
||||||
|
file={bannerCropCandidate}
|
||||||
|
onCropped={handleBannerCropped}
|
||||||
|
onSkipCrop={handleBannerSkipCrop}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user