Isolate RNG drafts and fix image editor lifecycle
Some checks failed
Deploy Sokko G / deploy (push) Failing after 2s

This commit is contained in:
Shinuwa 2026-09-11 20:28:07 +02:00
parent 7c0b72f914
commit b470a34b2a
7 changed files with 297 additions and 50 deletions

View file

@ -1,7 +1,8 @@
// 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, useMemo, useRef, useState } from "react";
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;
@ -64,46 +65,43 @@ export function ToolboxCoverEditor({ file, existingCover = "", onClose, onSave }
const frameRef = useRef(null);
const imageBitmapRef = useRef(null);
const dragRef = useRef(null);
const [source, setSource] = useState("");
const [imageSize, setImageSize] = useState({ width: 0, height: 0 });
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 [transform, setTransform] = useState({ x: 0, y: 0, scale: 1 });
const [baseScale, setBaseScale] = useState(1);
const [error, setError] = useState("");
const maxScale = useMemo(() => baseScale * 4, [baseScale]);
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(() => () => imageBitmapRef.current?.close?.(), []);
useEffect(() => {
const nextSource = createPreviewSource(file, existingCover);
setSource(nextSource);
setError("");
return () => {
if (file && nextSource) URL.revokeObjectURL(nextSource);
};
}, [existingCover, file]);
useEffect(() => {
if (!source) return undefined;
if (!nextSource) return undefined;
let cancelled = false;
decodePreviewImage(source, file)
let decodedBitmap;
decodePreviewImage(nextSource, file)
.then((bitmap) => {
if (cancelled) {
bitmap.close?.();
return;
}
imageBitmapRef.current?.close?.();
decodedBitmap = bitmap;
imageBitmapRef.current = bitmap;
setImageSize({ width: bitmap.width, height: bitmap.height });
setPreview({ source: nextSource, width: bitmap.width, height: bitmap.height, error: "" });
})
.catch(() => setError("Image illisible. Choisis un autre fichier."));
.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);
};
}, [file, source]);
}, [existingCover, file, setPreview]);
useEffect(() => {
const frameElement = frameRef.current;
@ -118,13 +116,6 @@ export function ToolboxCoverEditor({ file, existingCover = "", onClose, onSave }
return () => observer.disconnect();
}, []);
useEffect(() => {
if (!frame.width || !frame.height || !imageSize.width || !imageSize.height) return;
const nextBaseScale = Math.max(frame.width / imageSize.width, frame.height / imageSize.height);
setBaseScale(nextBaseScale);
setTransform({ x: 0, y: 0, scale: nextBaseScale });
}, [frame.height, frame.width, imageSize.height, imageSize.width]);
function clampTransform(nextTransform) {
const displayWidth = imageSize.width * nextTransform.scale;
const displayHeight = imageSize.height * nextTransform.scale;

View file

@ -165,10 +165,20 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, editing = fal
const [occurrenceDraft, setOccurrenceDraft] = useState(null);
const [realDrawsDraft, setRealDrawsDraft] = useState(null);
const rateFormRef = useRef(null);
const previousEditingRef = useRef(editing);
const [previousEditing, setPreviousEditing] = useState(editing);
const rateDraft = useDraftForm({ label: "", rate: RNG_SIMULATOR_DEFAULT_RATE });
const rateFormOpen = editing || Boolean(editingRateId);
const activeRate = data.rates.find((rate) => rate.id === data.activeRateId) || data.rates[0] || null;
const draftKey = JSON.stringify([toolboxId, moduleId, activeRate?.id, activeRate?.occurrenceUnit]);
const [previousDraftKey, setPreviousDraftKey] = useState(null);
if (previousDraftKey !== draftKey) {
setPreviousDraftKey(draftKey);
setMultiplierDraft(null);
setCostDraft(null);
setOccurrenceDraft(null);
setRealDrawsDraft(null);
setOccurrenceUnit(activeRate?.occurrenceUnit || "perHour");
}
const summary = useMemo(() => activeRate ? calculateRngSummary({
rate: activeRate.rate,
multiplier: activeRate.multiplier,
@ -197,14 +207,11 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, editing = fal
const averageExceededDraws = Math.max(0, Math.round(averageDrawsDelta));
const averageDrawsTone = realDrawsSummary?.averageExceeded ? (averageExceededDraws > 0 ? "negative" : "") : "positive";
useEffect(() => {
const wasEditing = previousEditingRef.current;
if (wasEditing && !editing) setEditingRateId("");
if (!wasEditing && editing && !editingRateId) rateDraft.reset({ label: "", rate: RNG_SIMULATOR_DEFAULT_RATE });
previousEditingRef.current = editing;
// Le hook useDraftForm expose reset sans identité stable; cette transition ne doit dépendre que de l'ouverture.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editing, editingRateId]);
if (previousEditing !== editing) {
setPreviousEditing(editing);
if (!editing) setEditingRateId("");
if (editing && !editingRateId) rateDraft.reset({ label: "", rate: RNG_SIMULATOR_DEFAULT_RATE });
}
useEffect(() => {
if (!rateFormOpen) return undefined;
@ -219,14 +226,6 @@ export function RngSimulatorModule({ toolboxId, moduleId, context, editing = fal
return () => document.removeEventListener("pointerdown", closeOnOutsidePointer);
}, [rateFormOpen, setEditing]);
useEffect(() => {
setMultiplierDraft(null);
setCostDraft(null);
setOccurrenceDraft(null);
setRealDrawsDraft(null);
setOccurrenceUnit(activeRate?.occurrenceUnit || "perHour");
}, [activeRate?.id, activeRate?.occurrenceUnit]);
function save(nextData) {
context.setModuleData(toolboxId, moduleId, nextData, "rngSimulator");
}