Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/modules/users/components/AvatarCropDialog.tsx
T

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>
);
}