204 lines
8 KiB
JavaScript
204 lines
8 KiB
JavaScript
// Rôle : édite une image utilisateur en cover rectangulaire de card toolbox.
|
|
/* eslint-disable react-refresh/only-export-components -- expose aussi les helpers purs de calcul/export du crop. */
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { Icon } from "../../components/Icon.jsx";
|
|
import { useKeyedState } from "../../hooks/useKeyedState.js";
|
|
import { lockBodyScroll } from "../../utils/bodyScrollLock.js";
|
|
|
|
const OUTPUT_WIDTH = 620;
|
|
const OUTPUT_HEIGHT = 248;
|
|
const WEBP_QUALITY = 0.82;
|
|
|
|
function clamp(value, min, max) {
|
|
return Math.min(max, Math.max(min, value));
|
|
}
|
|
|
|
function canvasToDataUrl(canvas) {
|
|
const webp = canvas.toDataURL("image/webp", WEBP_QUALITY);
|
|
return webp.startsWith("data:image/webp") ? webp : canvas.toDataURL("image/jpeg", WEBP_QUALITY);
|
|
}
|
|
|
|
export function getCoverCropSourceRect({ imageWidth, imageHeight, frameWidth, frameHeight, scale, offsetX, offsetY }) {
|
|
const displayWidth = imageWidth * scale;
|
|
const displayHeight = imageHeight * scale;
|
|
const sourceX = ((displayWidth - frameWidth) / 2 - offsetX) / scale;
|
|
const sourceY = ((displayHeight - frameHeight) / 2 - offsetY) / scale;
|
|
const x = clamp(sourceX, 0, imageWidth);
|
|
const y = clamp(sourceY, 0, imageHeight);
|
|
return {
|
|
x,
|
|
y,
|
|
width: Math.min(imageWidth - x, frameWidth / scale),
|
|
height: Math.min(imageHeight - y, frameHeight / scale)
|
|
};
|
|
}
|
|
|
|
export async function createToolboxCoverDataUrl(bitmap, frame, transform) {
|
|
const crop = getCoverCropSourceRect({
|
|
imageWidth: bitmap.width,
|
|
imageHeight: bitmap.height,
|
|
frameWidth: frame.width,
|
|
frameHeight: frame.height,
|
|
scale: transform.scale,
|
|
offsetX: transform.x,
|
|
offsetY: transform.y
|
|
});
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = OUTPUT_WIDTH;
|
|
canvas.height = OUTPUT_HEIGHT;
|
|
canvas.getContext("2d").drawImage(bitmap, crop.x, crop.y, crop.width, crop.height, 0, 0, OUTPUT_WIDTH, OUTPUT_HEIGHT);
|
|
return canvasToDataUrl(canvas);
|
|
}
|
|
|
|
function createPreviewSource(file, existingCover) {
|
|
if (file) return URL.createObjectURL(file);
|
|
return existingCover || "";
|
|
}
|
|
|
|
async function decodePreviewImage(source, file) {
|
|
if (file) return createImageBitmap(file);
|
|
const response = await fetch(source);
|
|
return createImageBitmap(await response.blob());
|
|
}
|
|
|
|
export function ToolboxCoverEditor({ file, existingCover = "", onClose, onSave }) {
|
|
const frameRef = useRef(null);
|
|
const imageBitmapRef = useRef(null);
|
|
const dragRef = useRef(null);
|
|
const [preview, setPreview] = useKeyedState(file || existingCover, () => ({ source: "", width: 0, height: 0, error: "" }));
|
|
const { source, error } = preview;
|
|
const imageSize = preview;
|
|
const [frame, setFrame] = useState({ width: 0, height: 0 });
|
|
const baseScale = frame.width && frame.height && imageSize.width && imageSize.height
|
|
? Math.max(frame.width / imageSize.width, frame.height / imageSize.height) : 1;
|
|
const transformKey = JSON.stringify([source, frame.width, frame.height, imageSize.width, imageSize.height]);
|
|
const [transform, setTransform] = useKeyedState(transformKey, () => ({ x: 0, y: 0, scale: baseScale }));
|
|
const maxScale = baseScale * 4;
|
|
|
|
useEffect(() => lockBodyScroll(), []);
|
|
|
|
useEffect(() => {
|
|
const nextSource = createPreviewSource(file, existingCover);
|
|
if (!nextSource) return undefined;
|
|
let cancelled = false;
|
|
let decodedBitmap;
|
|
decodePreviewImage(nextSource, file)
|
|
.then((bitmap) => {
|
|
if (cancelled) {
|
|
bitmap.close?.();
|
|
return;
|
|
}
|
|
decodedBitmap = bitmap;
|
|
imageBitmapRef.current = bitmap;
|
|
setPreview({ source: nextSource, width: bitmap.width, height: bitmap.height, error: "" });
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setPreview({ source: "", width: 0, height: 0, error: "Image illisible. Choisis un autre fichier." });
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
decodedBitmap?.close?.();
|
|
if (imageBitmapRef.current === decodedBitmap) imageBitmapRef.current = null;
|
|
if (file) URL.revokeObjectURL(nextSource);
|
|
};
|
|
}, [existingCover, file, setPreview]);
|
|
|
|
useEffect(() => {
|
|
const frameElement = frameRef.current;
|
|
if (!frameElement) return undefined;
|
|
const updateFrame = () => {
|
|
const rect = frameElement.getBoundingClientRect();
|
|
setFrame({ width: rect.width, height: rect.height });
|
|
};
|
|
updateFrame();
|
|
const observer = new ResizeObserver(updateFrame);
|
|
observer.observe(frameElement);
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
function clampTransform(nextTransform) {
|
|
const displayWidth = imageSize.width * nextTransform.scale;
|
|
const displayHeight = imageSize.height * nextTransform.scale;
|
|
const maxX = Math.max(0, (displayWidth - frame.width) / 2);
|
|
const maxY = Math.max(0, (displayHeight - frame.height) / 2);
|
|
return {
|
|
scale: nextTransform.scale,
|
|
x: clamp(nextTransform.x, -maxX, maxX),
|
|
y: clamp(nextTransform.y, -maxY, maxY)
|
|
};
|
|
}
|
|
|
|
function updateScale(value) {
|
|
const scale = clamp(Number(value) || baseScale, baseScale, maxScale);
|
|
setTransform((current) => clampTransform({ ...current, scale }));
|
|
}
|
|
|
|
function startDrag(event) {
|
|
if (!imageSize.width) return;
|
|
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
dragRef.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, start: transform };
|
|
}
|
|
|
|
function moveDrag(event) {
|
|
const drag = dragRef.current;
|
|
if (!drag || drag.pointerId !== event.pointerId) return;
|
|
setTransform(clampTransform({
|
|
...drag.start,
|
|
x: drag.start.x + event.clientX - drag.x,
|
|
y: drag.start.y + event.clientY - drag.y
|
|
}));
|
|
}
|
|
|
|
function endDrag(event) {
|
|
if (dragRef.current?.pointerId === event.pointerId) dragRef.current = null;
|
|
}
|
|
|
|
async function saveCover() {
|
|
const bitmap = imageBitmapRef.current;
|
|
if (!bitmap || !frame.width || !frame.height) return;
|
|
onSave(await createToolboxCoverDataUrl(bitmap, frame, transform));
|
|
}
|
|
|
|
return (
|
|
<div className="confirm-modal-root toolbox-cover-editor-root">
|
|
<div className="confirm-backdrop nds-modal-backdrop" onClick={onClose} />
|
|
<section className="confirm-modal nds-modal toolbox-cover-editor" role="dialog" aria-modal="true" aria-labelledby="toolbox-cover-editor-title">
|
|
<header>
|
|
<h2 id="toolbox-cover-editor-title">Image de card</h2>
|
|
<button className="nds-icon-button" data-size="xs" type="button" onClick={onClose} aria-label="Fermer" title="Fermer"><Icon name="close" /></button>
|
|
</header>
|
|
<div
|
|
className="toolbox-cover-crop-frame"
|
|
ref={frameRef}
|
|
onPointerDown={startDrag}
|
|
onPointerMove={moveDrag}
|
|
onPointerUp={endDrag}
|
|
onPointerCancel={endDrag}
|
|
>
|
|
{source && imageSize.width > 0 && (
|
|
<img
|
|
src={source}
|
|
alt=""
|
|
draggable="false"
|
|
style={{
|
|
width: `${imageSize.width * transform.scale}px`,
|
|
height: `${imageSize.height * transform.scale}px`,
|
|
transform: `translate(-50%, -50%) translate(${transform.x}px, ${transform.y}px)`
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
{error && <p className="nds-inline-notice toolbox-cover-editor-error" data-variant="danger">{error}</p>}
|
|
<label className="toolbox-cover-zoom-field">
|
|
<span>Zoom</span>
|
|
<input className="nds-input" data-size="sm" type="range" min={baseScale} max={maxScale} step={Math.max(0.001, baseScale / 50)} value={transform.scale} onChange={(event) => updateScale(event.target.value)} />
|
|
</label>
|
|
<footer className="nds-modal-footer">
|
|
<button className="nds-button" data-size="sm" type="button" onClick={() => setTransform({ x: 0, y: 0, scale: baseScale })}>Réinitialiser</button>
|
|
<button className="nds-button" data-size="sm" type="button" onClick={onClose}>Annuler</button>
|
|
<button className="nds-button" data-size="sm" data-variant="primary" type="button" disabled={!imageSize.width || Boolean(error)} onClick={saveCover}>Valider</button>
|
|
</footer>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|