284 lines
11 KiB
TypeScript
284 lines
11 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { FeatherRotateCcw, FeatherX, FeatherZoomIn, FeatherZoomOut } from "@subframe/core";
|
|
import { Button } from "~/ui/components/Button";
|
|
import { IconButton } from "~/ui/components/IconButton";
|
|
import {
|
|
MAX_ZOOM,
|
|
MIN_ZOOM,
|
|
OUTPUT_SIZE,
|
|
ZOOM_STEP,
|
|
type CropOffset,
|
|
centeredOffset,
|
|
clampOffset,
|
|
clampZoom,
|
|
cropRegion,
|
|
offsetAfterZoom,
|
|
} from "../avatarCrop";
|
|
|
|
// The picked file is never stored as-is: the user first decides which square
|
|
// section becomes their picture. Without this step a wide photo would be
|
|
// squeezed into the round avatar slot and cut wherever it happened to land.
|
|
|
|
/** Edge length of the crop viewport in CSS pixels; the round mask sits inside it. */
|
|
const VIEWPORT = 288;
|
|
|
|
/** How far one arrow-key press pans, in CSS pixels. */
|
|
const PAN_STEP = 12;
|
|
|
|
export type AvatarCropDialogProps = {
|
|
/** Data URI of the freshly picked file. */
|
|
source: string;
|
|
submitting?: boolean;
|
|
onCancel: () => void;
|
|
onConfirm: (dataUri: string) => void;
|
|
};
|
|
|
|
export default function AvatarCropDialog({ source, submitting = false, onCancel, onConfirm }: AvatarCropDialogProps) {
|
|
const { t } = useTranslation();
|
|
const imageRef = useRef<HTMLImageElement | null>(null);
|
|
const dragRef = useRef<{ pointerId: number; startX: number; startY: number; startOffset: CropOffset } | null>(null);
|
|
const [natural, setNatural] = useState<{ width: number; height: number } | null>(null);
|
|
const [zoom, setZoom] = useState(MIN_ZOOM);
|
|
const [offset, setOffset] = useState<CropOffset>({ x: 0, y: 0 });
|
|
const [failed, setFailed] = useState(false);
|
|
|
|
const geometry = { imageWidth: natural?.width ?? 0, imageHeight: natural?.height ?? 0, viewport: VIEWPORT, zoom };
|
|
const ready = Boolean(natural) && !failed;
|
|
|
|
const onImageLoad = useCallback((event: React.SyntheticEvent<HTMLImageElement>) => {
|
|
const { naturalWidth, naturalHeight } = event.currentTarget;
|
|
const loaded = { imageWidth: naturalWidth, imageHeight: naturalHeight, viewport: VIEWPORT, zoom: MIN_ZOOM };
|
|
setNatural({ width: naturalWidth, height: naturalHeight });
|
|
setZoom(MIN_ZOOM);
|
|
setOffset(centeredOffset(loaded));
|
|
}, []);
|
|
|
|
function changeZoom(next: number) {
|
|
const nextZoom = clampZoom(next);
|
|
setOffset((current) => offsetAfterZoom(geometry, current, nextZoom));
|
|
setZoom(nextZoom);
|
|
}
|
|
|
|
function pan(dx: number, dy: number) {
|
|
setOffset((current) => clampOffset(geometry, { x: current.x + dx, y: current.y + dy }));
|
|
}
|
|
|
|
function reset() {
|
|
setZoom(MIN_ZOOM);
|
|
setOffset(centeredOffset({ ...geometry, zoom: MIN_ZOOM }));
|
|
}
|
|
|
|
function onPointerDown(event: React.PointerEvent<HTMLDivElement>) {
|
|
if (!ready || event.button !== 0) return;
|
|
dragRef.current = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, startOffset: offset };
|
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
}
|
|
|
|
function onPointerMove(event: React.PointerEvent<HTMLDivElement>) {
|
|
const drag = dragRef.current;
|
|
if (!drag || drag.pointerId !== event.pointerId) return;
|
|
event.preventDefault();
|
|
setOffset(
|
|
clampOffset(geometry, {
|
|
x: drag.startOffset.x + (event.clientX - drag.startX),
|
|
y: drag.startOffset.y + (event.clientY - drag.startY),
|
|
})
|
|
);
|
|
}
|
|
|
|
function endDrag(event: React.PointerEvent<HTMLDivElement>) {
|
|
if (dragRef.current?.pointerId !== event.pointerId) return;
|
|
dragRef.current = null;
|
|
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
}
|
|
}
|
|
|
|
function onKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
|
|
const moves: Record<string, [number, number]> = {
|
|
ArrowLeft: [PAN_STEP, 0],
|
|
ArrowRight: [-PAN_STEP, 0],
|
|
ArrowUp: [0, PAN_STEP],
|
|
ArrowDown: [0, -PAN_STEP],
|
|
};
|
|
const move = moves[event.key];
|
|
if (!move || !ready) return;
|
|
event.preventDefault();
|
|
pan(move[0], move[1]);
|
|
}
|
|
|
|
// Escape closes the dialog like the cancel button — expected from any modal.
|
|
useEffect(() => {
|
|
function onEscape(event: KeyboardEvent) {
|
|
if (event.key === "Escape") onCancel();
|
|
}
|
|
document.addEventListener("keydown", onEscape);
|
|
return () => document.removeEventListener("keydown", onEscape);
|
|
}, [onCancel]);
|
|
|
|
function confirm() {
|
|
const image = imageRef.current;
|
|
if (!image || !ready) return;
|
|
|
|
const region = cropRegion(geometry, offset);
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = OUTPUT_SIZE;
|
|
canvas.height = OUTPUT_SIZE;
|
|
const context = canvas.getContext("2d");
|
|
if (!context) {
|
|
setFailed(true);
|
|
return;
|
|
}
|
|
|
|
// JPEG has no alpha, so a transparent PNG would otherwise bake in black.
|
|
context.fillStyle = "#ffffff";
|
|
context.fillRect(0, 0, OUTPUT_SIZE, OUTPUT_SIZE);
|
|
context.drawImage(image, region.sx, region.sy, region.size, region.size, 0, 0, OUTPUT_SIZE, OUTPUT_SIZE);
|
|
onConfirm(canvas.toDataURL("image/jpeg", 0.92));
|
|
}
|
|
|
|
const scale = ready ? (VIEWPORT / Math.min(geometry.imageWidth, geometry.imageHeight)) * zoom : 1;
|
|
|
|
return (
|
|
<div
|
|
className="bg-surface-neutral-overlay fixed inset-0 z-50 flex flex-col items-center justify-center gap-2 px-4 py-4"
|
|
data-ui-id="ui.settings.profile.avatarCrop.dialog"
|
|
>
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={t("settings.profile.avatar.crop.title")}
|
|
className="border-border-default bg-surface-neutral-secondary mobile:px-4 mobile:py-5 relative flex max-h-[90vh] w-full max-w-[480px] flex-col items-start gap-5 overflow-auto rounded-lg border border-solid px-6 py-6 shadow-lg"
|
|
>
|
|
<IconButton
|
|
className="absolute top-3 right-3"
|
|
variant="ghost"
|
|
size="small"
|
|
icon={<FeatherX />}
|
|
aria-label={t("shared.close")}
|
|
onClick={onCancel}
|
|
data-ui-id="ui.settings.profile.avatarCrop.close"
|
|
/>
|
|
<div className="flex w-full flex-col items-start gap-1">
|
|
<span className="text-heading- font-heading- text-text-neutral-primary">{t("settings.profile.avatar.crop.title")}</span>
|
|
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">
|
|
{t("settings.profile.avatar.crop.subtitle")}
|
|
</span>
|
|
</div>
|
|
|
|
{failed ? (
|
|
<span className="text-body-small-regular-0 font-body-small-regular-0 text-text-feedback-negative" role="alert">
|
|
{t("settings.profile.avatar.crop.failed")}
|
|
</span>
|
|
) : null}
|
|
|
|
<div className="flex w-full flex-col items-center gap-3">
|
|
{/* The pan surface has no native equivalent: it is dragged with a pointer and panned with the
|
|
arrow keys, so it has to take focus. No interactive role fits — "group" plus a label keeps
|
|
it announced without claiming button or slider semantics it does not have. */}
|
|
{/* eslint-disable jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/no-noninteractive-tabindex */}
|
|
<div
|
|
role="group"
|
|
tabIndex={0}
|
|
aria-label={t("settings.profile.avatar.crop.viewportLabel")}
|
|
style={{ width: VIEWPORT, height: VIEWPORT }}
|
|
className="bg-surface-neutral-tertiary focus-visible:shadow-focus relative flex-none touch-none overflow-hidden rounded-lg select-none"
|
|
onPointerDown={onPointerDown}
|
|
onPointerMove={onPointerMove}
|
|
onPointerUp={endDrag}
|
|
onPointerCancel={endDrag}
|
|
onKeyDown={onKeyDown}
|
|
data-ui-id="ui.settings.profile.avatarCrop.viewport"
|
|
>
|
|
<img
|
|
ref={imageRef}
|
|
src={source}
|
|
alt={t("settings.profile.avatar.crop.previewAlt")}
|
|
onLoad={onImageLoad}
|
|
onError={() => setFailed(true)}
|
|
draggable={false}
|
|
style={{
|
|
width: ready ? geometry.imageWidth * scale : VIEWPORT,
|
|
height: ready ? geometry.imageHeight * scale : VIEWPORT,
|
|
transform: `translate(${offset.x}px, ${offset.y}px)`,
|
|
}}
|
|
className="absolute top-0 left-0 max-w-none"
|
|
/>
|
|
{/* Round mask: the dimmed surround shows what gets cut away. */}
|
|
<div aria-hidden="true" className="rounded-round pointer-events-none absolute inset-0 shadow-[0_0_0_9999px_rgb(0_0_0/0.45)]" />
|
|
</div>
|
|
{/* eslint-enable jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/no-noninteractive-tabindex */}
|
|
|
|
<div className="flex w-full items-center gap-3">
|
|
<IconButton
|
|
variant="ghost"
|
|
size="small"
|
|
icon={<FeatherZoomOut />}
|
|
aria-label={t("settings.profile.avatar.crop.zoomOut")}
|
|
disabled={!ready || zoom <= MIN_ZOOM}
|
|
onClick={() => changeZoom(zoom - 10 * ZOOM_STEP)}
|
|
data-ui-id="ui.settings.profile.avatarCrop.zoomOut"
|
|
/>
|
|
<input
|
|
type="range"
|
|
min={MIN_ZOOM}
|
|
max={MAX_ZOOM}
|
|
step={ZOOM_STEP}
|
|
value={zoom}
|
|
disabled={!ready}
|
|
aria-label={t("settings.profile.avatar.crop.zoom")}
|
|
onChange={(event) => changeZoom(Number(event.target.value))}
|
|
className="accent-brand-brand-surface-solid-default h-1 shrink-0 grow basis-0 cursor-pointer"
|
|
data-ui-id="ui.settings.profile.avatarCrop.zoom"
|
|
/>
|
|
<IconButton
|
|
variant="ghost"
|
|
size="small"
|
|
icon={<FeatherZoomIn />}
|
|
aria-label={t("settings.profile.avatar.crop.zoomIn")}
|
|
disabled={!ready || zoom >= MAX_ZOOM}
|
|
onClick={() => changeZoom(zoom + 10 * ZOOM_STEP)}
|
|
data-ui-id="ui.settings.profile.avatarCrop.zoomIn"
|
|
/>
|
|
<IconButton
|
|
variant="ghost"
|
|
size="small"
|
|
icon={<FeatherRotateCcw />}
|
|
aria-label={t("settings.profile.avatar.crop.reset")}
|
|
disabled={!ready}
|
|
onClick={reset}
|
|
data-ui-id="ui.settings.profile.avatarCrop.reset"
|
|
/>
|
|
</div>
|
|
<span className="text-body-extra-small-regular-150 font-body-extra-small-regular-150 text-text-neutral-tertiary text-center">
|
|
{t("settings.profile.avatar.crop.hint")}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="mobile:flex-col-reverse flex w-full items-center justify-end gap-2">
|
|
<Button
|
|
className="mobile:w-full"
|
|
type="button"
|
|
variant="secondary"
|
|
onClick={onCancel}
|
|
data-ui-id="ui.settings.profile.avatarCrop.cancel"
|
|
>
|
|
{t("shared.cancel")}
|
|
</Button>
|
|
<Button
|
|
className="mobile:w-full"
|
|
type="button"
|
|
loading={submitting}
|
|
disabled={!ready}
|
|
onClick={confirm}
|
|
data-ui-id="ui.settings.profile.avatarCrop.confirm"
|
|
>
|
|
{t("settings.profile.avatar.crop.confirm")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|