// 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 (

Image de card

{source && imageSize.width > 0 && ( )}
{error &&

{error}

}
); }