diff --git a/docs/STORAGE_SCHEMA.md b/docs/STORAGE_SCHEMA.md
index 01a75b7..f2cf6b3 100644
--- a/docs/STORAGE_SCHEMA.md
+++ b/docs/STORAGE_SCHEMA.md
@@ -800,6 +800,47 @@ Stockage compact :
- `mode: "group"` interdit chaque nom de la combinaison avec tous les autres noms de cette combinaison.
- les exclusions invalides, dupliquées ou qui référencent un joueur absent sont ignorées.
+## Outil Simulateur de chance
+
+Type : `rngSimulator`
+
+```json
+{
+ "activeRateId": "rate1",
+ "rates": [
+ {
+ "id": "rate1",
+ "label": "Drop rare",
+ "rate": "2.500"
+ },
+ {
+ "id": "rate2",
+ "label": "Ultra rare",
+ "rate": "0.000004"
+ }
+ ],
+ "settings": {
+ "multiplier": 50,
+ "simulationDraws": 1000,
+ "perHour": 30,
+ "perDay": 120,
+ "perMonth": 3600
+ }
+}
+```
+
+Stockage compact :
+
+- `rates` contient les taux enregistrés du module; un taux sans libellé est ignoré.
+- `rate` est une chaîne en pourcentage afin de conserver la précision saisie, par exemple `"2.500"` ou `"0.000004"`.
+- un taux doit être strictement supérieur à `0` et inférieur ou égal à `100`; les valeurs supérieures sont bornées à `"100"`.
+- `activeRateId` référence un taux existant; sinon le premier taux est sélectionné.
+- `settings.multiplier` est un bonus ou malus en pourcentage appliqué au taux actif pour les calculs et simulations, vaut 0 par défaut et le taux effectif est borné entre 0% et 100%.
+- `settings.simulationDraws` est borné de 1 à 1 000 000 et vaut 1000 par défaut.
+- `settings.perHour`, `settings.perDay` et `settings.perMonth` sont numériques, positifs ou nuls, et valent 0 par défaut.
+- `settings` est omis si tous ses champs ont leurs valeurs par défaut.
+- l'historique des simulations n'est pas persisté en IndexedDB; il est limité aux 20 dernières entrées en `sessionStorage`.
+
## Outil Combos
Type : `combos`
diff --git a/tests/helpers/data-validation.mjs b/tests/helpers/data-validation.mjs
index 0d411db..56bbdbf 100644
--- a/tests/helpers/data-validation.mjs
+++ b/tests/helpers/data-validation.mjs
@@ -310,6 +310,60 @@ export function validateSiteContent(site) {
"toolboxes.modules.randomizer.exportSuccess",
"toolboxes.modules.randomizer.importError",
"toolboxes.modules.randomizer.exportInfo",
+ "toolboxes.modules.rngSimulator.ratesTitle",
+ "toolboxes.modules.rngSimulator.addRateTitle",
+ "toolboxes.modules.rngSimulator.editRateTitle",
+ "toolboxes.modules.rngSimulator.emptyRates",
+ "toolboxes.modules.rngSimulator.labelLabel",
+ "toolboxes.modules.rngSimulator.labelPlaceholder",
+ "toolboxes.modules.rngSimulator.rateLabel",
+ "toolboxes.modules.rngSimulator.rateLabelRequired",
+ "toolboxes.modules.rngSimulator.rateInvalid",
+ "toolboxes.modules.rngSimulator.addRateButton",
+ "toolboxes.modules.rngSimulator.saveRateButton",
+ "toolboxes.modules.rngSimulator.cancelButton",
+ "toolboxes.modules.rngSimulator.deleteTitle",
+ "toolboxes.modules.rngSimulator.noActiveRateTitle",
+ "toolboxes.modules.rngSimulator.simulationDrawsLabel",
+ "toolboxes.modules.rngSimulator.perHourLabel",
+ "toolboxes.modules.rngSimulator.perDayLabel",
+ "toolboxes.modules.rngSimulator.perMonthLabel",
+ "toolboxes.modules.rngSimulator.probabilitySectionTitle",
+ "toolboxes.modules.rngSimulator.multiplierLabel",
+ "toolboxes.modules.rngSimulator.clearMultiplierButton",
+ "toolboxes.modules.rngSimulator.averageDrawsLabel",
+ "toolboxes.modules.rngSimulator.simulationChanceLabel",
+ "toolboxes.modules.rngSimulator.averageTimeTitle",
+ "toolboxes.modules.rngSimulator.occurrenceCountLabel",
+ "toolboxes.modules.rngSimulator.hourTimeLabel",
+ "toolboxes.modules.rngSimulator.dayTimeLabel",
+ "toolboxes.modules.rngSimulator.monthTimeLabel",
+ "toolboxes.modules.rngSimulator.chanceTargetsTitle",
+ "toolboxes.modules.rngSimulator.drawsUnit",
+ "toolboxes.modules.rngSimulator.hoursUnit",
+ "toolboxes.modules.rngSimulator.daysUnit",
+ "toolboxes.modules.rngSimulator.monthsUnit",
+ "toolboxes.modules.rngSimulator.emptySummary",
+ "toolboxes.modules.rngSimulator.simulationTitle",
+ "toolboxes.modules.rngSimulator.simulateButton",
+ "toolboxes.modules.rngSimulator.clearHistoryButton",
+ "toolboxes.modules.rngSimulator.emptyHistory",
+ "toolboxes.modules.rngSimulator.successesLabel",
+ "toolboxes.modules.rngSimulator.failsLabel",
+ "toolboxes.modules.rngSimulator.observedRateLabel",
+ "toolboxes.modules.rngSimulator.multiplierShortLabel",
+ "toolboxes.modules.rngSimulator.longestFailStreakLabel",
+ "toolboxes.modules.rngSimulator.importPlaceholder",
+ "toolboxes.modules.rngSimulator.importOpenButton",
+ "toolboxes.modules.rngSimulator.exportOpenButton",
+ "toolboxes.modules.rngSimulator.importModalTitle",
+ "toolboxes.modules.rngSimulator.exportModalTitle",
+ "toolboxes.modules.rngSimulator.importButton",
+ "toolboxes.modules.rngSimulator.exportButton",
+ "toolboxes.modules.rngSimulator.importSuccess",
+ "toolboxes.modules.rngSimulator.exportSuccess",
+ "toolboxes.modules.rngSimulator.importError",
+ "toolboxes.modules.rngSimulator.exportInfo",
"toolboxes.modules.combos.deviceLabel",
"toolboxes.modules.combos.keyboardLayoutLabel",
"toolboxes.modules.combos.categoryPlaceholder",
@@ -711,7 +765,7 @@ export function validateSiteContent(site) {
});
const libraryToolDocs = valueAt(site, "library.toolDocs");
- ["memos", "notepad", "checklist", "counters", "randomizer", "images", "imageAnnotation", "links", "calculator", "table", "goalTree", "timer", "calendar", "taskPlanner", "combos", "equipmentPlanner"].forEach((toolType) => {
+ ["memos", "notepad", "checklist", "counters", "randomizer", "rngSimulator", "images", "imageAnnotation", "links", "calculator", "table", "goalTree", "timer", "calendar", "taskPlanner", "combos", "equipmentPlanner"].forEach((toolType) => {
const docs = libraryToolDocs?.[toolType];
assert.equal(typeof docs, "object", `library.toolDocs.${toolType} must be an object`);
assert.ok(Array.isArray(docs.controls), `library.toolDocs.${toolType}.controls must be an array`);
diff --git a/tests/toolbox-modules.test.mjs b/tests/toolbox-modules.test.mjs
index be5d2f3..f521dcb 100644
--- a/tests/toolbox-modules.test.mjs
+++ b/tests/toolbox-modules.test.mjs
@@ -12,8 +12,9 @@ import { getMonthOccurrenceSegments, getWeekGridLayout, getWeekMinutePosition, l
import { appendChecklistItems, moveChecklistItem, moveChecklistItemToCategory, renameChecklistSection } from "../website/src/features/toolboxes/modules/checklistUtils.js";
import { formatUpdatedAt, isHtmlEmpty, queryBlockFormat, queryCommandState } from "../website/src/features/toolboxes/modules/notepadUtils.js";
import { createBalancedTeams, drawWeightedEntries, filterExclusionsForPlayers, getDiceShape, rollDice } from "../website/src/features/toolboxes/modules/randomizerUtils.js";
+import { calculateAtLeastOneProbability, calculateDrawsForChance, calculateRngSummary, getEffectiveRate, normalizeRateString, simulateRngDraws } from "../website/src/features/toolboxes/modules/rngSimulatorUtils.js";
import { getTimePatternRecurrenceMs, getTimePatternTargetMs } from "../website/src/features/toolboxes/modules/timerUtils.js";
-import { compactModuleDataForStorage, createToolboxExportPayload, moduleStorageKey, normalizeCalculatorData, normalizeChecklistData, normalizeCombosData, normalizeCountersData, normalizeEquipmentPlannerData, normalizeGoalTreeData, normalizeImageAnnotationData, normalizeLinksData, normalizeMemosData, normalizeModuleOrder, normalizeNotepadData, normalizeRandomizerData, normalizeTableData, normalizeTaskPlannerData, normalizeTimerData, normalizeToolbox, normalizeUrl, summarizeEquipmentPlannerData } from "../website/src/features/toolboxes/storage/toolboxStorage.js";
+import { compactModuleDataForStorage, createToolboxExportPayload, moduleStorageKey, normalizeCalculatorData, normalizeChecklistData, normalizeCombosData, normalizeCountersData, normalizeEquipmentPlannerData, normalizeGoalTreeData, normalizeImageAnnotationData, normalizeLinksData, normalizeMemosData, normalizeModuleOrder, normalizeNotepadData, normalizeRandomizerData, normalizeRngSimulatorData, normalizeTableData, normalizeTaskPlannerData, normalizeTimerData, normalizeToolbox, normalizeUrl, summarizeEquipmentPlannerData } from "../website/src/features/toolboxes/storage/toolboxStorage.js";
import { applyGroupedReorderOperation, completeGroupOrder, getBoundaryItemId, getGroupedEntries, moveGroupOrder, moveGroupOrderToEnd, moveGroupOrderToStart, moveItem, moveItemGroup } from "../website/src/hooks/useGroupedReorder.js";
function createTextImportContext() {
@@ -34,6 +35,7 @@ function createTextImportContext() {
normalizeEquipmentPlannerData,
normalizeImageAnnotationData,
normalizeRandomizerData,
+ normalizeRngSimulatorData,
normalizeUrl
};
}
@@ -284,6 +286,94 @@ test("randomizer text exchange round trips full configuration", () => {
);
});
+test("rng simulator preserves rate precision and compacts storage", () => {
+ assert.equal(normalizeRateString("2.500"), "2.500");
+ assert.equal(normalizeRateString("0.001"), "0.001");
+ assert.equal(normalizeRateString("0.000004%"), "0.000004");
+ assert.equal(normalizeRateString("100.500"), "100");
+
+ const normalized = normalizeRngSimulatorData({
+ activeRateId: "rate2",
+ rates: [
+ { id: "rate1", label: "Drop rare", rate: "2.500" },
+ { id: "rate2", label: "Ultra rare", rate: "0.000004" },
+ { id: "bad", label: "", rate: "4" }
+ ],
+ settings: { multiplier: 50, simulationDraws: 2000, perHour: 30, perDay: 120, perMonth: 3600 },
+ history: [
+ { id: "h1", at: "2026-08-30T12:00:00.000Z", label: "Drop rare", rate: "2.500", multiplier: 50, draws: 100, successes: 4, fails: 400, longestFailStreak: 40 }
+ ]
+ });
+
+ assert.equal(normalized.activeRateId, "rate2");
+ assert.deepEqual(normalized.rates.map((rate) => [rate.label, rate.rate]), [["Drop rare", "2.500"], ["Ultra rare", "0.000004"]]);
+ assert.equal(normalized.history, undefined);
+ assert.equal(compactModuleDataForStorage("rngSimulator", normalizeRngSimulatorData({})), null);
+ assert.equal(compactModuleDataForStorage("rngSimulator", normalized).settings.multiplier, 50);
+ assert.equal(compactModuleDataForStorage("rngSimulator", normalized).settings.perMonth, 3600);
+ assert.equal(compactModuleDataForStorage("rngSimulator", normalized).history, undefined);
+});
+
+test("rng simulator calculates probability milestones and deterministic simulations", () => {
+ assert.equal(calculateAtLeastOneProbability("100", 50), 1);
+ assert.equal(calculateDrawsForChance("50", 0.5), 1);
+ assert.equal(calculateDrawsForChance("1", 0.5), 69);
+ assert.equal(getEffectiveRate("10", 50), 15);
+ assert.equal(getEffectiveRate("10", -50), 5);
+ assert.equal(getEffectiveRate("75", 100), 100);
+
+ const hourlySummary = calculateRngSummary({ rate: "25", simulationDraws: 8, perHour: 2, perDay: 0, perMonth: 0 });
+ assert.equal(hourlySummary.timeByFrequency.hour, 2);
+ assert.equal(hourlySummary.timeByFrequency.day, 2 / 24);
+ assert.equal(hourlySummary.timeByFrequency.month, 2 / 24 / 30);
+ const multipliedSummary = calculateRngSummary({ rate: "10", multiplier: 100, simulationDraws: 1, perHour: 0, perDay: 0, perMonth: 0 });
+ assert.equal(multipliedSummary.effectiveRate, 20);
+ assert.equal(multipliedSummary.averageDraws, 5);
+
+ const result = simulateRngDraws("25", 8, (() => {
+ const values = [0.1, 0.9, 0.2, 0.8, 0.7, 0.1, 0.6, 0.5];
+ let index = 0;
+ return () => values[index++];
+ })());
+
+ assert.deepEqual(result, {
+ draws: 8,
+ successes: 3,
+ fails: 5,
+ observedRate: 37.5,
+ longestFailStreak: 2
+ });
+
+ const multipliedResult = simulateRngDraws("10", 4, (() => {
+ const values = [0.15, 0.25, 0.05, 0.9];
+ let index = 0;
+ return () => values[index++];
+ })(), 100);
+ assert.equal(multipliedResult.successes, 2);
+});
+
+test("rng simulator text exchange round trips rates and settings", () => {
+ const context = createTextImportContext();
+ const rngSimulator = normalizeRngSimulatorData({
+ activeRateId: "rate2",
+ rates: [
+ { id: "rate1", label: "Drop rare", rate: "2.500" },
+ { id: "rate2", label: "Ultra rare", rate: "0.000004" }
+ ],
+ settings: { multiplier: 50, simulationDraws: 5000, perHour: 40, perDay: 120, perMonth: 3600 }
+ });
+ const exported = exportModuleText("rngSimulator", rngSimulator);
+ assert.doesNotMatch(exported, /\[history\]/);
+ const imported = importModuleText("rngSimulator", exported, context);
+ assert.equal(imported.ok, true);
+ assert.deepEqual(imported.data.rates.map((rate) => [rate.label, rate.rate]), [["Drop rare", "2.500"], ["Ultra rare", "0.000004"]]);
+ assert.equal(imported.data.rates.find((rate) => rate.id === imported.data.activeRateId)?.label, "Ultra rare");
+ assert.equal(imported.data.settings.multiplier, 50);
+ assert.equal(imported.data.settings.simulationDraws, 5000);
+ assert.equal(imported.data.settings.perHour, 40);
+ assert.equal(imported.data.history, undefined);
+});
+
test("notepad helpers keep empty html and invalid command states stable", () => {
assert.equal(isHtmlEmpty("
", ""), true);
assert.equal(isHtmlEmpty("
", ""), true);
diff --git a/website/public/data/library.json b/website/public/data/library.json
index bcd3ed0..eba7b6c 100644
--- a/website/public/data/library.json
+++ b/website/public/data/library.json
@@ -49,6 +49,10 @@
"id": "mg",
"type": "randomizer"
},
+ {
+ "id": "mh",
+ "type": "rngSimulator"
+ },
{
"id": "m9",
"type": "timer"
@@ -238,6 +242,7 @@
"m8",
"mf",
"mg",
+ "mh",
"m9",
"md",
"me",
@@ -261,6 +266,7 @@
"m8",
"mf",
"mg",
+ "mh",
"m9",
"md",
"me",
@@ -4759,6 +4765,28 @@
]
}
},
+ "mh": {
+ "activeRateId": "fnn88zy",
+ "rates": [
+ {
+ "id": "fnn88zy",
+ "label": "Elten Piece",
+ "rate": "0.000004"
+ },
+ {
+ "id": "pen-eklecta",
+ "label": "Pen Eklecta",
+ "rate": "20.02"
+ }
+ ],
+ "settings": {
+ "multiplier": 320,
+ "simulationDraws": 100000,
+ "perHour": 60,
+ "perDay": 0,
+ "perMonth": 0
+ }
+ },
"m9": {
"activeTab": "countdown",
"stopwatch": {
diff --git a/website/public/data/site.json b/website/public/data/site.json
index 91e6eed..96d31a2 100644
--- a/website/public/data/site.json
+++ b/website/public/data/site.json
@@ -459,7 +459,7 @@
{
"group": "Historique",
"icon": "rubber",
- "title": "Effacer l'historique",
+ "title": "Supprimer les simulations",
"preview": {
"kind": "button",
"label": "Effacer"
@@ -491,6 +491,61 @@
"example": "@activeTab: teams\n[dice]\nfaces: 6\nrolls: 5\n\n[wheel]\ndraws: 3\n\n- Oui | weight=2\n- Non | weight=2\n- Peut etre | weight=1\n\n[teams]\nteamCount: 4\nplayerSelectionMode: all\nplayerSelectionCount: 6\n\n- Dracaufeu | score=0\n- Tortank | score=0\n- Florizarre | score=0\n- Dracolosse | score=0\n- Ectoplasma | score=0\n- Alakazam | score=0\n- Mackogneur | score=0\n- Cizayox | score=0\n- Tyranocif | score=0\n- Gardevoir | score=0\n- Drattak | score=0\n- Métalosse | score=0\n- Carchacrok | score=0\n- Lucario | score=0\n- Trioxhydre | score=0\n- Amphinobi | score=0\n- Archéduc | score=0\n- Félinferno | score=0\n- Corvaillus | score=0\n- Lanssorien | score=0\n- Raichu | score=0\n- Artikodin | score=0\n- Électhor | score=0\n- Sulfura | score=0\n! Dracolosse / Drattak / Carchacrok / Lanssorien | mode=group\n! Artikodin / Électhor / Sulfura | mode=group"
}
},
+ "rngSimulator": {
+ "controls": [
+ {
+ "group": "Taux",
+ "icon": "clover",
+ "title": "Enregistrer un taux",
+ "preview": {
+ "kind": "textButton",
+ "label": "%"
+ }
+ },
+ {
+ "group": "Simulation",
+ "icon": "play-circle",
+ "title": "Simuler",
+ "preview": {
+ "kind": "iconButton",
+ "icon": "play-circle"
+ }
+ },
+ {
+ "group": "Historique",
+ "icon": "rubber",
+ "title": "Effacer l'historique",
+ "preview": {
+ "kind": "iconButton",
+ "icon": "rubber"
+ }
+ }
+ ],
+ "features": [
+ {
+ "title": "Taux sauvegardés",
+ "text": "Chaque taux possède un libellé et garde la précision saisie, même pour des probabilités très faibles.",
+ "details": [
+ "La saisie par défaut utilise trois décimales, mais le stockage conserve la chaîne exacte normalisée.",
+ "Un taux comme 0.000004% reste disponible tel quel pour les calculs et les simulations.",
+ "La sélection active permet de comparer rapidement plusieurs drops ou chances de réussite."
+ ]
+ },
+ {
+ "title": "Lecture probabiliste",
+ "text": "L'outil affiche la moyenne d'essais avant réussite, le temps moyen selon votre rythme et les paliers de chance cumulée.",
+ "details": [
+ "Les occurrences par heure, jour et mois sont indépendantes pour s'adapter à différents usages.",
+ "Les paliers 50%, 90%, 95% et 99% donnent un repère concret sur la variance.",
+ "La simulation agrège réussites, échecs et pire série d'échecs sans stocker chaque tirage."
+ ]
+ }
+ ],
+ "importFormat": {
+ "text": "Le format texte contient les sections [rates] et [settings]. Les taux sont exprimés en pourcentage.",
+ "example": "@activeRateId: fnn88zy\n[rates]\n- Elten Piece | rate=0.000004 | active=true\n- Pen Eklecta | rate=20.02\n\n[settings]\nmultiplier: 320\nsimulationDraws: 100000\nperHour: 60\nperDay: 0\nperMonth: 0"
+ }
+ },
"images": {
"controls": [
{
@@ -2038,6 +2093,62 @@
"importError": "Format de randomizer invalide.",
"exportInfo": "Le format texte conserve les paramètres du dé, de la roue et des équipes."
},
+ "rngSimulator": {
+ "ratesTitle": "Taux enregistrés",
+ "addRateTitle": "Ajouter un taux",
+ "editRateTitle": "Modifier un taux",
+ "emptyRates": "Aucun taux enregistré.",
+ "labelLabel": "Libellé",
+ "labelPlaceholder": "Drop rare",
+ "rateLabel": "Taux (%)",
+ "rateLabelRequired": "Libellé requis.",
+ "rateInvalid": "Taux invalide. Utilisez une valeur supérieure à 0 et inférieure ou égale à 100.",
+ "addRateButton": "Ajouter",
+ "saveRateButton": "Enregistrer",
+ "cancelButton": "Annuler",
+ "deleteTitle": "Supprimer",
+ "noActiveRateTitle": "Aucun taux actif",
+ "simulationDrawsLabel": "Tirages simulés",
+ "perHourLabel": "Par heure",
+ "perDayLabel": "Par jour",
+ "perMonthLabel": "Par mois",
+ "probabilitySectionTitle": "Probabilités avec multiplicateur",
+ "multiplierLabel": "Multiplicateur (%)",
+ "clearMultiplierButton": "Réinitialiser le multiplicateur",
+ "averageDrawsLabel": "Moyenne avant réussite après multiplicateur",
+ "simulationChanceLabel": "Chance sur les tirages saisis après multiplicateur",
+ "averageTimeTitle": "Temps moyen avant réussite",
+ "occurrenceCountLabel": "Nombre d'occurrences",
+ "hourTimeLabel": "En heures",
+ "dayTimeLabel": "En jours",
+ "monthTimeLabel": "En mois",
+ "chanceTargetsTitle": "Tirages pour atteindre une chance cumulée",
+ "drawsUnit": "tirages",
+ "hoursUnit": "h",
+ "daysUnit": "j",
+ "monthsUnit": "mois",
+ "emptySummary": "Ajoutez un taux pour afficher les calculs.",
+ "simulationTitle": "Résultats simulés",
+ "simulateButton": "Simuler",
+ "clearHistoryButton": "Supprimer les simulations",
+ "emptyHistory": "Aucune simulation.",
+ "successesLabel": "Réussites",
+ "failsLabel": "Fails",
+ "observedRateLabel": "Taux de réussite",
+ "multiplierShortLabel": "Bonus",
+ "longestFailStreakLabel": "Pire série",
+ "importPlaceholder": "@activeRateId: fnn88zy\n[rates]\n- Elten Piece | rate=0.000004 | active=true\n- Pen Eklecta | rate=20.02\n\n[settings]\nmultiplier: 320\nsimulationDraws: 100000\nperHour: 60\nperDay: 0\nperMonth: 0",
+ "importOpenButton": "Importer du texte",
+ "exportOpenButton": "Exporter en format texte",
+ "importModalTitle": "Importer un simulateur de chance",
+ "exportModalTitle": "Exporter un simulateur de chance",
+ "importButton": "Importer le texte",
+ "exportButton": "Copier le texte",
+ "importSuccess": "Import du simulateur de chance terminé.",
+ "exportSuccess": "Export du simulateur de chance copié.",
+ "importError": "Format de simulateur de chance invalide.",
+ "exportInfo": "Le format texte conserve les taux enregistrés et les paramètres."
+ },
"combos": {
"importPlaceholder": "@device: playstation\n# Neutral\nHadoken | device=playstation | direction:down > direction:right > button:cross",
"importOpenButton": "Importer du texte",
diff --git a/website/public/static/icons/clover.svg b/website/public/static/icons/clover.svg
new file mode 100644
index 0000000..d162308
--- /dev/null
+++ b/website/public/static/icons/clover.svg
@@ -0,0 +1,18 @@
+
+
\ No newline at end of file
diff --git a/website/src/features/toolboxes/ToolboxPages.jsx b/website/src/features/toolboxes/ToolboxPages.jsx
index 821a498..48aa2e0 100644
--- a/website/src/features/toolboxes/ToolboxPages.jsx
+++ b/website/src/features/toolboxes/ToolboxPages.jsx
@@ -27,6 +27,7 @@ import {
normalizeModuleOrder,
normalizeNotepadData,
normalizeRandomizerData,
+ normalizeRngSimulatorData,
normalizeTableData,
normalizeTaskPlannerData,
normalizeTimerData,
@@ -202,6 +203,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
normalizeImageAnnotationData,
normalizeNotepadData,
normalizeRandomizerData,
+ normalizeRngSimulatorData,
normalizeTableData,
normalizeTimerData,
normalizeTaskPlannerData,
diff --git a/website/src/features/toolboxes/modules/ChecklistParts.jsx b/website/src/features/toolboxes/modules/ChecklistParts.jsx
index 29bbdde..1546bdb 100644
--- a/website/src/features/toolboxes/modules/ChecklistParts.jsx
+++ b/website/src/features/toolboxes/modules/ChecklistParts.jsx
@@ -1,5 +1,5 @@
// Rôle : rend les sections et items de checklist avec édition inline et quantités.
-import { useState } from "react";
+import { useEffect, useRef, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import {
@@ -119,6 +119,7 @@ export function ChecklistSection({ section, context, data, reorder, onRenameSect
onDeleteItem={onDeleteItem}
editing={editingItemId === item.id}
onToggleEditing={() => setEditingItemId(editingItemId === item.id ? "" : item.id)}
+ onCloseEditing={() => setEditingItemId("")}
/>
))}
@@ -127,7 +128,8 @@ export function ChecklistSection({ section, context, data, reorder, onRenameSect
);
}
-function ChecklistItem({ item, section, context, reorder, items, save, onUpdateItem, onMoveItemToCategory, onDeleteItem, editing, onToggleEditing }) {
+function ChecklistItem({ item, section, context, reorder, items, save, onUpdateItem, onMoveItemToCategory, onDeleteItem, editing, onToggleEditing, onCloseEditing }) {
+ const itemRef = useRef(null);
const done = context.clampQty(item.qtyCurrent, item.qtyTarget) >= item.qtyTarget;
const textContent = context.moduleText?.checklist || {};
const className = [
@@ -149,6 +151,15 @@ function ChecklistItem({ item, section, context, reorder, items, save, onUpdateI
onCommit: (title) => onMoveItemToCategory(item.id, title)
});
+ useEffect(() => {
+ if (!editing) return undefined;
+ function handleOutsidePointerDown(event) {
+ if (!itemRef.current?.contains(event.target)) onCloseEditing();
+ }
+ document.addEventListener("pointerdown", handleOutsidePointerDown);
+ return () => document.removeEventListener("pointerdown", handleOutsidePointerDown);
+ }, [editing, onCloseEditing]);
+
function updateItem(updater) {
save(items.map((entry) => entry.id === item.id ? updater(entry) : entry));
}
@@ -161,7 +172,7 @@ function ChecklistItem({ item, section, context, reorder, items, save, onUpdateI
}
return (
-
+
-