Compare commits

...

5 commits

Author SHA1 Message Date
3007aa1389 Extract timer module components
All checks were successful
Deploy Sokko G / deploy (push) Successful in 14s
2026-08-14 10:12:40 +02:00
d4fe3c796a Extract table module helpers 2026-08-14 10:07:26 +02:00
df0033739c Extract combos module helpers 2026-08-14 10:02:10 +02:00
fb5feb8c98 Extract equipment planner components 2026-08-14 09:57:39 +02:00
588c1298cd Split toolbox storage by module 2026-08-14 09:45:48 +02:00
33 changed files with 3334 additions and 3117 deletions

View file

@ -8,7 +8,7 @@ Checklist à suivre lors de l'ajout ou de la modification d'un outil, d'une page
- Déclarer l'outil dans `website/src/features/toolboxes/modules/index.jsx`.
- Ajouter les textes nécessaires dans `website/public/data/site.json`.
- Mettre à jour la validation de `site.json` dans `tests/helpers/data-validation.mjs`.
- Mettre à jour la normalisation et le stockage compact dans `website/src/features/toolboxes/storage/toolboxStorage.js`.
- Mettre à jour la normalisation et le stockage compact dans `website/src/features/toolboxes/storage/modules/<outil>.js`, puis vérifier l'export public dans `website/src/features/toolboxes/storage/toolboxStorage.js`.
- Mettre à jour `docs/STORAGE_SCHEMA.md`.
- Vérifier l'import et l'export si l'outil stocke des données.
- Si l'outil a un contenu représentable en texte, ajouter ou vérifier l'import/export texte round-trip dans le panneau d'ajout.

View file

@ -4,6 +4,9 @@ import { test } from "node:test";
import assert from "node:assert/strict";
import { SOCKET_SHAPES, formatValue, getSocketItemName, setItemCategory } from "../website/src/features/toolboxes/modules/equipmentPlannerOptions.js";
import { applyDueResets, getTaskParentMap, getTaskTreeIds, moveTask } from "../website/src/features/toolboxes/modules/taskPlannerUtils.js";
import { createInput, getInputLabel, normalizeCustomKey, parseHoldSeconds } from "../website/src/features/toolboxes/modules/combosUtils.js";
import { getBoundsFromStartAndSize, getSelectionBounds, moveSelectionCells, parseTsvBlock, pasteCellBlock } from "../website/src/features/toolboxes/modules/tableUtils.js";
import { canToggleAutoRefresh, getCountdownValidationError, isAlertGuarded } from "../website/src/features/toolboxes/modules/timerOptions.js";
test("toolbox storage, cards and pages are wired", async () => {
const source = await readFile("website/src/main.jsx", "utf8");
@ -11,6 +14,10 @@ test("toolbox storage, cards and pages are wired", async () => {
const toolboxPages = await readFile("website/src/features/toolboxes/ToolboxPages.jsx", "utf8");
const toolboxActions = await readFile("website/src/features/toolboxes/useToolboxActions.js", "utf8");
const toolboxStorage = await readFile("website/src/features/toolboxes/storage/toolboxStorage.js", "utf8");
const storageCompactorRegistry = await readFile("website/src/features/toolboxes/storage/modules/index.js", "utf8");
const checklistStorage = await readFile("website/src/features/toolboxes/storage/modules/checklist.js", "utf8");
const timerStorage = await readFile("website/src/features/toolboxes/storage/modules/timer.js", "utf8");
const equipmentStorage = await readFile("website/src/features/toolboxes/storage/modules/equipmentPlanner.js", "utf8");
const indexedToolboxesHook = await readFile("website/src/features/toolboxes/storage/useIndexedToolboxes.js", "utf8");
const storageQuota = await readFile("website/src/components/StorageQuota.jsx", "utf8");
const importButton = await readFile("website/src/components/ImportButton.jsx", "utf8");
@ -31,25 +38,27 @@ test("toolbox storage, cards and pages are wired", async () => {
assert.match(toolboxStorage, /TOOLBOX_ICON_FILES/);
assert.match(toolboxStorage, /export function normalizeToolboxIcon/);
assert.match(toolboxStorage, /export function createGlobalExportPayload/);
assert.match(toolboxStorage, /qtyTarget/);
assert.match(toolboxStorage, /qtyCurrent/);
assert.match(toolboxStorage, /hideCompletedSections/);
assert.match(toolboxStorage, /hideCompletedSectionsFully/);
assert.match(toolboxStorage, /hideWhenComplete/);
assert.match(toolboxStorage, /collapsed/);
assert.match(checklistStorage, /qtyTarget/);
assert.match(checklistStorage, /qtyCurrent/);
assert.match(checklistStorage, /hideCompletedSections/);
assert.match(checklistStorage, /hideCompletedSectionsFully/);
assert.match(checklistStorage, /hideWhenComplete/);
assert.match(equipmentStorage, /collapsed/);
assert.match(toolboxStorage, /module\.scrollable/);
assert.match(toolboxStorage, /export function normalizeLinksData/);
assert.match(toolboxStorage, /export function normalizeCountersData/);
assert.match(toolboxStorage, /export function normalizeCombosData/);
assert.match(toolboxStorage, /export function normalizeCalculatorData/);
assert.match(toolboxStorage, /export function normalizeTableData/);
assert.match(toolboxStorage, /export function normalizeTimerData/);
assert.match(toolboxStorage, /export function normalizeTaskPlannerData/);
assert.match(toolboxStorage, /export function normalizeEquipmentPlannerData/);
assert.match(toolboxStorage, /export function summarizeEquipmentPlannerData/);
assert.match(toolboxStorage, /export function normalizeImageAnnotationData/);
assert.match(toolboxStorage, /normalizeLinksData/);
assert.match(toolboxStorage, /normalizeCountersData/);
assert.match(toolboxStorage, /normalizeCombosData/);
assert.match(toolboxStorage, /normalizeCalculatorData/);
assert.match(toolboxStorage, /normalizeTableData/);
assert.match(toolboxStorage, /normalizeTimerData/);
assert.match(toolboxStorage, /normalizeTaskPlannerData/);
assert.match(toolboxStorage, /normalizeEquipmentPlannerData/);
assert.match(toolboxStorage, /summarizeEquipmentPlannerData/);
assert.match(toolboxStorage, /normalizeImageAnnotationData/);
assert.match(toolboxStorage, /export function compactModuleDataForStorage\(type, value\)/);
assert.match(toolboxStorage, /scrollResults/);
assert.match(storageCompactorRegistry, /export const MODULE_DATA_COMPACTORS/);
assert.match(storageCompactorRegistry, /timer: compactTimerDataForStorage/);
assert.match(timerStorage, /scrollResults/);
assert.match(toolboxCard, /DEFAULT_TOOLBOX_ICON/);
assert.match(toolboxCard, /export function ToolboxCard/);
assert.match(toolboxCard, /export function ToolboxIconPicker/);
@ -108,14 +117,24 @@ test("toolbox module registry and modules expose expected behavior", async () =>
const moduleMetadataRegistry = await readFile("website/src/features/toolboxes/toolboxModuleRegistry.js", "utf8");
const notepadModule = await readFile("website/src/features/toolboxes/modules/NotepadModule.jsx", "utf8");
const combosModule = await readFile("website/src/features/toolboxes/modules/CombosModule.jsx", "utf8");
const combosParts = await readFile("website/src/features/toolboxes/modules/CombosParts.jsx", "utf8");
const combosUtils = await readFile("website/src/features/toolboxes/modules/combosUtils.js", "utf8");
const calculatorModule = await readFile("website/src/features/toolboxes/modules/CalculatorModule.jsx", "utf8");
const tableModule = await readFile("website/src/features/toolboxes/modules/TableModule.jsx", "utf8");
const tableGridParts = await readFile("website/src/features/toolboxes/modules/TableGridParts.jsx", "utf8");
const tableUtils = await readFile("website/src/features/toolboxes/modules/tableUtils.js", "utf8");
const tableFormulaEngine = await readFile("website/src/features/toolboxes/modules/tableFormulaEngine.js", "utf8");
const timerModule = await readFile("website/src/features/toolboxes/modules/TimerModule.jsx", "utf8");
const timerParts = await readFile("website/src/features/toolboxes/modules/TimerParts.jsx", "utf8");
const timerOptions = await readFile("website/src/features/toolboxes/modules/timerOptions.js", "utf8");
const taskPlannerModule = await readFile("website/src/features/toolboxes/modules/TaskPlannerModule.jsx", "utf8");
const taskPlannerItems = await readFile("website/src/features/toolboxes/modules/TaskPlannerItems.jsx", "utf8");
const taskPlannerUtils = await readFile("website/src/features/toolboxes/modules/taskPlannerUtils.js", "utf8");
const equipmentPlannerModule = await readFile("website/src/features/toolboxes/modules/EquipmentPlannerModule.jsx", "utf8");
const equipmentPlannerEditors = await readFile("website/src/features/toolboxes/modules/EquipmentPlannerEditors.jsx", "utf8");
const equipmentPlannerItem = await readFile("website/src/features/toolboxes/modules/EquipmentPlannerItem.jsx", "utf8");
const equipmentPlannerPickers = await readFile("website/src/features/toolboxes/modules/EquipmentPlannerPickers.jsx", "utf8");
const equipmentPlannerSummary = await readFile("website/src/features/toolboxes/modules/EquipmentPlannerSummary.jsx", "utf8");
const equipmentPlannerOptions = await readFile("website/src/features/toolboxes/modules/equipmentPlannerOptions.js", "utf8");
const checklistModule = await readFile("website/src/features/toolboxes/modules/ChecklistModule.jsx", "utf8");
const imagesModule = await readFile("website/src/features/toolboxes/modules/ImagesModule.jsx", "utf8");
@ -170,26 +189,26 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(notepadModule, /TextExchangeActions/);
assert.match(notepadModule, /type="notepad"/);
assert.match(combosModule, /export function CombosModule/);
assert.match(combosModule, /keyboardMouse/);
assert.match(combosModule, /Nintendo 64/);
assert.match(combosModule, /ComboInputToken/);
assert.match(combosModule, /ComboSequence/);
assert.match(combosUtils, /keyboardMouse/);
assert.match(combosUtils, /Nintendo 64/);
assert.match(combosParts, /export function ComboInputToken/);
assert.match(combosParts, /export function ComboSequence/);
assert.match(combosModule, /keyboardLayout/);
assert.match(combosModule, /simultaneousMode/);
assert.match(combosModule, /SPECIAL_INPUT_GROUP/);
assert.match(combosModule, /grab/);
assert.match(combosModule, /protect/);
assert.match(combosModule, /shoot/);
assert.match(combosUtils, /SPECIAL_INPUT_GROUP/);
assert.match(combosUtils, /grab/);
assert.match(combosUtils, /protect/);
assert.match(combosUtils, /shoot/);
assert.match(combosModule, /holdMs/);
assert.match(combosModule, /toggleSelectedInputHold/);
assert.match(combosModule, /combo-hold-field/);
assert.match(combosModule, /useInlineEdit/);
assert.match(combosParts, /useInlineEdit/);
assert.match(combosModule, /useGroupedReorder/);
assert.match(combosModule, /reorderFeatures/);
assert.match(combosModule, /namespace: `combos-\$\{moduleId\}`/);
assert.match(combosModule, /getGroupBoundaryProps/);
assert.match(combosModule, /combos-category-boundary-drop-zone/);
assert.match(combosModule, /function setComboItemCategory/);
assert.match(combosUtils, /function setComboItemCategory/);
assert.match(combosModule, /setItemGroup: setComboItemCategory/);
assert.doesNotMatch(combosModule, /canMoveItem:/);
assert.doesNotMatch(combosModule, /canMoveGroup:/);
@ -228,11 +247,11 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(tableModule, /draggingSelectionRef/);
assert.match(tableModule, /movingSelectionRef/);
assert.match(tableModule, /copiedSelectionRef/);
assert.match(tableModule, /moveSelectionCells/);
assert.match(tableModule, /pasteCellBlock/);
assert.match(tableModule, /parseTsvBlock/);
assert.match(tableModule, /is-move-target/);
assert.match(tableModule, /isMultiCellSelection/);
assert.match(tableUtils, /moveSelectionCells/);
assert.match(tableUtils, /pasteCellBlock/);
assert.match(tableUtils, /parseTsvBlock/);
assert.match(tableGridParts, /is-move-target/);
assert.match(tableUtils, /isMultiCellSelection/);
assert.match(tableModule, /handleCellKeyDown/);
assert.match(tableModule, /clearSelection/);
assert.match(tableModule, /event\.key === "Delete"/);
@ -240,10 +259,10 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(tableModule, /ArrowDown/);
assert.match(tableModule, /ArrowLeft/);
assert.match(tableModule, /ArrowRight/);
assert.match(tableModule, /function HeaderLabelInput/);
assert.match(tableModule, /useInlineEdit/);
assert.match(tableGridParts, /export function HeaderLabelInput/);
assert.match(tableGridParts, /useInlineEdit/);
assert.match(tableModule, /TextExchangeActions/);
assert.match(tableModule, /event\.target\.select/);
assert.match(tableGridParts, /event\.target\.select/);
assert.match(tableFormulaEngine, /export function evaluateTableCell/);
assert.doesNotMatch(tableFormulaEngine, /Function\(/);
assert.doesNotMatch(tableFormulaEngine, /\beval\(/);
@ -251,7 +270,7 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(timerModule, /Tabs/);
assert.match(timerModule, /stopwatch/);
assert.match(timerModule, /countdown/);
assert.match(timerModule, /useInlineEdit/);
assert.match(timerParts, /useInlineEdit/);
assert.match(timerModule, /useDraftForm/);
assert.match(timerModule, /normalizeTimerData/);
assert.match(timerModule, /TextExchangeActions/);
@ -277,11 +296,16 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(equipmentPlannerModule, /export function EquipmentPlannerModule/);
assert.match(equipmentPlannerModule, /normalizeEquipmentPlannerData/);
assert.match(equipmentPlannerModule, /summarizeEquipmentPlannerData/);
assert.match(equipmentPlannerModule, /socketItems/);
assert.match(equipmentPlannerModule, /socketLinks/);
assert.match(equipmentPlannerModule, /EquipmentItem/);
assert.match(equipmentPlannerModule, /EquipmentPlannerSummary/);
assert.match(equipmentPlannerEditors, /socketItems/);
assert.match(equipmentPlannerEditors, /socketLinks/);
assert.match(equipmentPlannerModule, /useGroupedReorder/);
assert.match(equipmentPlannerModule, /reorderFeatures/);
assert.match(equipmentPlannerEditors, /reorderFeatures/);
assert.match(equipmentPlannerModule, /TextExchangeActions/);
assert.match(equipmentPlannerItem, /export function EquipmentItem/);
assert.match(equipmentPlannerPickers, /export function IconChoiceDropdown/);
assert.match(equipmentPlannerSummary, /export function EquipmentPlannerSummary/);
assert.match(equipmentPlannerOptions, /export const EQUIPMENT_ICONS/);
assert.match(equipmentPlannerOptions, /export function formatValue/);
assert.match(checklistModule, /export function ChecklistModule/);
@ -350,7 +374,11 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(timerModule, /useGroupedReorder/);
assert.match(timerModule, /reorderEnabled={!data\.sortResults}/);
assert.match(timerModule, /parentId=\{moduleId\}/);
assert.match(timerModule, /timer-drag-handle/);
assert.match(timerParts, /timer-drag-handle/);
assert.match(timerParts, /export function StopwatchControls/);
assert.match(timerParts, /export function CountdownControls/);
assert.match(timerParts, /export function CountdownList/);
assert.match(timerOptions, /export const ALERT_MIN_DELAY_MS/);
assert.match(textImport, /export function parseColonImportLines/);
assert.match(textImport, /export function exportModuleText/);
assert.match(textImport, /export function importModuleText/);
@ -396,3 +424,46 @@ test("equipment planner options expose stable formatting and socket helpers", ()
assert.deepEqual(setItemCategory({ id: "item", category: "DPS" }, ""), { id: "item" });
assert.equal(getSocketItemName({ socketItems: [{ id: "gem", name: "Ruby" }] }, "gem"), "Ruby");
});
test("combos helpers keep labels, hold parsing and custom keys stable", () => {
assert.deepEqual(createInput("button", "a", 1500), { kind: "button", value: "a", holdMs: 1500 });
assert.deepEqual(createInput("button", "a"), { kind: "button", value: "a" });
assert.equal(getInputLabel({ kind: "key", value: "q" }), "Q");
assert.equal(getInputLabel({ kind: "mouse", value: "left" }), "Clic G");
assert.equal(parseHoldSeconds("120s"), 99);
assert.equal(parseHoldSeconds("abc"), 0);
assert.equal(normalizeCustomKey(" Ctrl Alt Shift "), "Ctrl Alt Shift");
});
test("table helpers keep selection, movement and TSV behavior stable", () => {
const selection = {
anchor: { rowIndex: 2, columnIndex: 3 },
focus: { rowIndex: 1, columnIndex: 1 }
};
assert.deepEqual(getSelectionBounds(selection), {
startRow: 1,
endRow: 2,
startColumn: 1,
endColumn: 3
});
assert.deepEqual(parseTsvBlock("A\tB\nC\tD"), {
height: 2,
width: 2,
values: [["A", "B"], ["C", "D"]]
});
const moved = moveSelectionCells({ A1: "a", B1: "b", A2: "c" }, getBoundsFromStartAndSize(0, 0, 1, 2), getBoundsFromStartAndSize(1, 1, 1, 2));
assert.deepEqual(moved, { A2: "c", B2: "a", C2: "b" });
const pasted = pasteCellBlock({}, parseTsvBlock("A\t\nC\tD"), 0, 0, 2, 2);
assert.deepEqual(pasted.cells, { A1: "A", A2: "C", B2: "D" });
});
test("timer options keep alert guards and countdown validation stable", () => {
assert.equal(isAlertGuarded({ type: "interval", intervalMs: 60_000 }), true);
assert.equal(isAlertGuarded({ type: "interval", intervalMs: 600_000 }), false);
assert.equal(canToggleAutoRefresh({ type: "daily_time" }), true);
assert.equal(canToggleAutoRefresh({ type: "interval", intervalMs: 60_000 }), false);
assert.equal(getCountdownValidationError("duration", { duration: { hours: "", minutes: "", seconds: "" } }, {}), "Valeur requise.");
assert.equal(getCountdownValidationError("daily_time", { time: { hours: "12", minutes: "30", seconds: "" } }, {}), "");
});

View file

@ -1,333 +1,25 @@
// Rôle : fournit l'outil Combos avec palettes d'inputs, rendu visuel et échange texte.
import { useMemo, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import {
applyGroupedReorderOperation,
getGroupedEntries,
useGroupedReorder
} from "../../../hooks/useGroupedReorder.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
const DEVICE_OPTIONS = [
{ value: "playstation", label: "PlayStation" },
{ value: "xbox", label: "Xbox" },
{ value: "switch", label: "Switch" },
{ value: "n64", label: "Nintendo 64" },
{ value: "keyboardMouse", label: "Clavier/Souris" }
];
const SPECIAL_INPUT_GROUP = { title: "Spéciale", column: "actions", inputs: [["button", "fist"], ["button", "foot"], ["button", "grab"], ["button", "protect"], ["button", "shoot"]] };
const DEVICE_GROUPS = {
playstation: [
{ title: "Directions", kind: "directions", column: "movement", inputs: [["direction", "up-left"], ["direction", "up"], ["direction", "up-right"], ["direction", "left"], null, ["direction", "right"], ["direction", "down-left"], ["direction", "down"], ["direction", "down-right"]] },
{ title: "Actions", column: "actions", inputs: [["button", "triangle"], ["button", "circle"], ["button", "cross"], ["button", "square"], ["button", "l1"], ["button", "r1"], ["button", "l2"], ["button", "r2"]] },
SPECIAL_INPUT_GROUP,
{ title: "Système", inputs: [["button", "options"], ["button", "share"], ["button", "l3"], ["button", "r3"]] }
],
xbox: [
{ title: "Directions", kind: "directions", column: "movement", inputs: [["direction", "up-left"], ["direction", "up"], ["direction", "up-right"], ["direction", "left"], null, ["direction", "right"], ["direction", "down-left"], ["direction", "down"], ["direction", "down-right"]] },
{ title: "Actions", column: "actions", inputs: [["button", "a"], ["button", "b"], ["button", "x"], ["button", "y"], ["button", "lb"], ["button", "rb"], ["button", "lt"], ["button", "rt"]] },
SPECIAL_INPUT_GROUP,
{ title: "Système", inputs: [["button", "menu"], ["button", "view"], ["button", "ls"], ["button", "rs"]] }
],
switch: [
{ title: "Directions", kind: "directions", column: "movement", inputs: [["direction", "up-left"], ["direction", "up"], ["direction", "up-right"], ["direction", "left"], null, ["direction", "right"], ["direction", "down-left"], ["direction", "down"], ["direction", "down-right"]] },
{ title: "Actions", column: "actions", inputs: [["button", "a"], ["button", "b"], ["button", "x"], ["button", "y"], ["button", "l"], ["button", "r"], ["button", "zl"], ["button", "zr"]] },
SPECIAL_INPUT_GROUP,
{ title: "Système", inputs: [["button", "plus"], ["button", "minus"], ["button", "ls"], ["button", "rs"]] }
],
n64: [
{ title: "Stick", kind: "directions", column: "movement", inputs: [["direction", "stick-up-left"], ["direction", "stick-up"], ["direction", "stick-up-right"], ["direction", "stick-left"], null, ["direction", "stick-right"], ["direction", "stick-down-left"], ["direction", "stick-down"], ["direction", "stick-down-right"]] },
{ title: "D-Pad", kind: "directions", column: "movement", inputs: [["direction", "up-left"], ["direction", "up"], ["direction", "up-right"], ["direction", "left"], null, ["direction", "right"], ["direction", "down-left"], ["direction", "down"], ["direction", "down-right"]] },
{ title: "Actions", column: "actions", inputs: [["button", "a"], ["button", "b"], ["button", "z"], ["button", "start"], ["button", "l"], ["button", "r"]] },
SPECIAL_INPUT_GROUP,
{ title: "C-buttons", kind: "directions", inputs: [["button", "c-up"], ["button", "c-down"], ["button", "c-left"], ["button", "c-right"]] }
]
};
const KEYBOARD_GROUPS = {
azerty: [
{ title: "ZQSD", kind: "directions", column: "movement", inputs: [null, ["key", "z"], null, ["key", "q"], ["key", "s"], ["key", "d"], null, null, null] },
{ title: "Flèches", kind: "directions", column: "movement", inputs: [null, ["direction", "up"], null, ["direction", "left"], ["direction", "down"], ["direction", "right"], null, null, null] },
{ title: "Touches", column: "actions", inputs: [["key", "ctrl"], ["key", "alt"], ["key", "shift"], ["key", "space"], ["key", "enter"], ["key", "tab"], ["key", "e"], ["key", "a"], ["key", "r"], ["key", "f"]] },
SPECIAL_INPUT_GROUP,
{ title: "Souris", inputs: [["mouse", "left"], ["mouse", "right"], ["mouse", "middle"], ["mouse", "wheel-up"], ["mouse", "wheel-down"]] }
],
qwerty: [
{ title: "WASD", kind: "directions", column: "movement", inputs: [null, ["key", "w"], null, ["key", "a"], ["key", "s"], ["key", "d"], null, null, null] },
{ title: "Flèches", kind: "directions", column: "movement", inputs: [null, ["direction", "up"], null, ["direction", "left"], ["direction", "down"], ["direction", "right"], null, null, null] },
{ title: "Touches", column: "actions", inputs: [["key", "ctrl"], ["key", "alt"], ["key", "shift"], ["key", "space"], ["key", "enter"], ["key", "tab"], ["key", "e"], ["key", "q"], ["key", "r"], ["key", "f"]] },
SPECIAL_INPUT_GROUP,
{ title: "Souris", inputs: [["mouse", "left"], ["mouse", "right"], ["mouse", "middle"], ["mouse", "wheel-up"], ["mouse", "wheel-down"]] }
]
};
const INPUT_LABELS = {
triangle: "△",
circle: "○",
cross: "×",
square: "□",
options: "Options",
share: "Share",
up: "↑",
down: "↓",
left: "←",
right: "→",
"up-left": "↖",
"up-right": "↗",
"down-left": "↙",
"down-right": "↘",
"stick-up": "↑",
"stick-down": "↓",
"stick-left": "←",
"stick-right": "→",
"stick-up-left": "↖",
"stick-up-right": "↗",
"stick-down-left": "↙",
"stick-down-right": "↘",
plus: "+",
minus: "-",
"c-up": "C↑",
"c-down": "C↓",
"c-left": "C←",
"c-right": "C→",
fist: "Poing",
foot: "Pied",
grab: "Grab",
protect: "Protect",
shoot: "Shoot",
space: "Espace"
};
const INPUT_ICONS = {
fist: "fist",
foot: "foot",
grab: "grab",
protect: "shield",
shoot: "gun"
};
const MOUSE_LABELS = {
left: "Clic G",
right: "Clic D",
middle: "Molette",
"wheel-up": "Molette ↑",
"wheel-down": "Molette ↓"
};
function getInputLabel(input) {
const value = String(input?.value || "");
if (input?.kind === "key" && value.length === 1) return value.toUpperCase();
if (input?.kind === "mouse") return MOUSE_LABELS[value] || value;
return INPUT_LABELS[value] || value.toUpperCase();
}
function inputKey(input) {
return `${input.kind}:${input.value}:${input.hold ? "hold" : ""}:${input.holdMs || 0}`;
}
function createInput(kind, value, holdMs = 0) {
const input = { kind, value };
if (holdMs > 0) input.holdMs = holdMs;
return input;
}
function getHoldSeconds(input) {
return Math.round((Number(input?.holdMs) || 0) / 1000);
}
function formatHoldLabel(input) {
const seconds = getHoldSeconds(input);
if (!seconds) return input?.hold ? "hold" : "";
return `${seconds}s`;
}
function parseHoldSeconds(value) {
const seconds = Number.parseInt(String(value || "").replace(/[^\d]/g, ""), 10);
if (!Number.isInteger(seconds) || seconds <= 0) return 0;
return Math.min(99, seconds);
}
function isDirectionInput(input) {
return input?.kind === "direction";
}
function createCombo(context, id, name, category, inputs, fallbackName, device) {
const combo = {
id: id || context.uid("combo"),
name: name.trim() || fallbackName,
device,
inputs: inputs.map((step) => step.map((input) => ({ ...input }))).filter((step) => step.length)
};
if (category.trim()) combo.category = category.trim();
return combo;
}
function normalizeCustomKey(value) {
return String(value || "")
.trim()
.replace(/\s+/g, " ")
.slice(0, 16);
}
function getPaletteGroups(device, keyboardLayout) {
return device === "keyboardMouse" ? KEYBOARD_GROUPS[keyboardLayout] : DEVICE_GROUPS[device] || DEVICE_GROUPS.playstation;
}
function getPaletteColumns(groups) {
return [
groups.filter((group) => group.column === "movement"),
groups.filter((group) => group.column !== "movement")
];
}
function getComboCategory(combo) {
return String(combo?.category || "").trim();
}
function setComboItemCategory(combo, category) {
const nextCombo = { ...combo };
if (category) nextCombo.category = category;
else delete nextCombo.category;
return nextCombo;
}
function ComboInputToken({ input, device, palette = false, onClick, onDragStart }) {
const label = getInputLabel(input);
const iconName = INPUT_ICONS[input.value];
const holdLabel = !palette ? formatHoldLabel(input) : "";
const classNames = [
"combo-input-token",
`combo-input-${input.kind}`,
iconName ? "combo-input-icon" : "",
holdLabel ? "has-hold" : "",
input.kind === "key" && label.length === 1 ? "combo-input-short" : "",
`combo-device-${device}`,
`combo-value-${String(input.value || "").replace(/[^a-z0-9]+/gi, "-").toLowerCase()}`
].filter(Boolean).join(" ");
if (palette) {
return (
<button className={classNames} type="button" draggable onClick={onClick} onDragStart={onDragStart} title={label} aria-label={label}>
<span>{iconName ? <Icon name={iconName} /> : label}</span>
</button>
);
}
return (
<span className={classNames} draggable={Boolean(onDragStart)} onClick={onClick} onDragStart={onDragStart} title={holdLabel ? `${label} · ${holdLabel}` : label}>
<span>{iconName ? <Icon name={iconName} /> : label}</span>
{holdLabel && <em className="combo-input-hold">{holdLabel}</em>}
</span>
);
}
function ComboStep({ step, stepIndex, device, active = false, selectedInputIndex = -1, onClick, onSelectInput, onDropInput, onDragInput }) {
const className = [
"combo-step-inputs",
step.length > 1 ? "is-simultaneous" : "",
step.length <= 1 ? "is-single" : "",
active ? "is-active" : ""
].filter(Boolean).join(" ");
const content = step.length ? step.map((input, inputIndex) => (
<span className={`combo-step-input ${selectedInputIndex === inputIndex ? "is-selected" : ""}`} key={`${inputKey(input)}-${inputIndex}`}>
{inputIndex > 0 && <span className="combo-plus" aria-hidden="true">+</span>}
<ComboInputToken
input={input}
device={device}
onClick={onSelectInput ? (event) => {
event.stopPropagation();
onSelectInput(stepIndex, inputIndex);
} : null}
onDragStart={onDragInput ? (event) => onDragInput(event, stepIndex, inputIndex, input) : null}
/>
</span>
)) : <span className="combo-empty-step">Étape vide</span>;
if (onClick) {
return (
<button
type="button"
className={className}
onClick={onClick}
onDragOver={(event) => {
if (!onDropInput) return;
event.preventDefault();
}}
onDrop={(event) => {
if (!onDropInput) return;
event.preventDefault();
onDropInput(event);
}}
>
{content}
</button>
);
}
return <span className={className}>{content}</span>;
}
function ComboSequence({ inputs, device, emptyLabel, activeStepIndex = -1, selectedInput, onSelectStep, onSelectInput, onDropInput, onDropNewStep, onDragInput }) {
const visibleInputs = inputs.map((step, index) => ({ step, index })).filter(({ step }) => step.length || onSelectStep);
if (!visibleInputs.length) return <span className="combo-empty-sequence">{emptyLabel}</span>;
return (
<div
className="combo-sequence"
onDragOver={(event) => {
if (!onDropNewStep) return;
event.preventDefault();
}}
onDrop={(event) => {
if (!onDropNewStep || event.target.closest(".combo-step-inputs")) return;
event.preventDefault();
onDropNewStep(event);
}}
>
{visibleInputs.map(({ step, index: stepIndex }, visibleIndex) => {
const nextStep = visibleInputs[visibleIndex + 1]?.step || [];
const hideSeparator = step.every(isDirectionInput) && nextStep.every(isDirectionInput);
return (
<span className="combo-step" key={`${stepIndex}-${step.map(inputKey).join("+")}`}>
<ComboStep
step={step}
stepIndex={stepIndex}
device={device}
active={activeStepIndex === stepIndex}
selectedInputIndex={selectedInput?.stepIndex === stepIndex ? selectedInput.inputIndex : -1}
onClick={onSelectStep ? () => onSelectStep(stepIndex) : null}
onSelectInput={onSelectInput}
onDropInput={onDropInput ? (event) => onDropInput(event, stepIndex) : null}
onDragInput={onDragInput}
/>
{visibleIndex < visibleInputs.length - 1 && !hideSeparator && <span className="combo-step-separator" aria-hidden="true">›</span>}
</span>
);
})}
</div>
);
}
function InlineTextInput({ value, onCommit, className, ariaLabel, placeholder = "" }) {
const inlineEdit = useInlineEdit({
value,
onCommit,
blurOnEscape: true
});
return (
<input
{...inlineEdit.getInputProps({
className,
placeholder,
"aria-label": ariaLabel
})}
/>
);
}
import { ComboInputToken, ComboSequence, InlineTextInput } from "./CombosParts.jsx";
import {
DEVICE_OPTIONS,
createCombo,
createInput,
getComboCategory,
getHoldSeconds,
getPaletteColumns,
getPaletteGroups,
normalizeCustomKey,
parseHoldSeconds,
setComboItemCategory
} from "./combosUtils.js";
export function CombosModule({ toolboxId, moduleId, context, editing }) {
const data = context.normalizeCombosData(context.getModuleData(toolboxId, moduleId, { combos: [] }));

View file

@ -0,0 +1,138 @@
// Rôle : rend les tokens, étapes et séquences visuelles de l'outil Combos.
import { Icon } from "../../../components/Icon.jsx";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { formatHoldLabel, getInputLabel, inputKey, INPUT_ICONS, isDirectionInput } from "./combosUtils.js";
export function ComboInputToken({ input, device, palette = false, onClick, onDragStart }) {
const label = getInputLabel(input);
const iconName = INPUT_ICONS[input.value];
const holdLabel = !palette ? formatHoldLabel(input) : "";
const classNames = [
"combo-input-token",
`combo-input-${input.kind}`,
iconName ? "combo-input-icon" : "",
holdLabel ? "has-hold" : "",
input.kind === "key" && label.length === 1 ? "combo-input-short" : "",
`combo-device-${device}`,
`combo-value-${String(input.value || "").replace(/[^a-z0-9]+/gi, "-").toLowerCase()}`
].filter(Boolean).join(" ");
if (palette) {
return (
<button className={classNames} type="button" draggable onClick={onClick} onDragStart={onDragStart} title={label} aria-label={label}>
<span>{iconName ? <Icon name={iconName} /> : label}</span>
</button>
);
}
return (
<span className={classNames} draggable={Boolean(onDragStart)} onClick={onClick} onDragStart={onDragStart} title={holdLabel ? `${label} · ${holdLabel}` : label}>
<span>{iconName ? <Icon name={iconName} /> : label}</span>
{holdLabel && <em className="combo-input-hold">{holdLabel}</em>}
</span>
);
}
export function ComboSequence({ inputs, device, emptyLabel, activeStepIndex = -1, selectedInput, onSelectStep, onSelectInput, onDropInput, onDropNewStep, onDragInput }) {
const visibleInputs = inputs.map((step, index) => ({ step, index })).filter(({ step }) => step.length || onSelectStep);
if (!visibleInputs.length) return <span className="combo-empty-sequence">{emptyLabel}</span>;
return (
<div
className="combo-sequence"
onDragOver={(event) => {
if (!onDropNewStep) return;
event.preventDefault();
}}
onDrop={(event) => {
if (!onDropNewStep || event.target.closest(".combo-step-inputs")) return;
event.preventDefault();
onDropNewStep(event);
}}
>
{visibleInputs.map(({ step, index: stepIndex }, visibleIndex) => {
const nextStep = visibleInputs[visibleIndex + 1]?.step || [];
const hideSeparator = step.every(isDirectionInput) && nextStep.every(isDirectionInput);
return (
<span className="combo-step" key={`${stepIndex}-${step.map(inputKey).join("+")}`}>
<ComboStep
step={step}
stepIndex={stepIndex}
device={device}
active={activeStepIndex === stepIndex}
selectedInputIndex={selectedInput?.stepIndex === stepIndex ? selectedInput.inputIndex : -1}
onClick={onSelectStep ? () => onSelectStep(stepIndex) : null}
onSelectInput={onSelectInput}
onDropInput={onDropInput ? (event) => onDropInput(event, stepIndex) : null}
onDragInput={onDragInput}
/>
{visibleIndex < visibleInputs.length - 1 && !hideSeparator && <span className="combo-step-separator" aria-hidden="true">›</span>}
</span>
);
})}
</div>
);
}
export function InlineTextInput({ value, onCommit, className, ariaLabel, placeholder = "" }) {
const inlineEdit = useInlineEdit({
value,
onCommit,
blurOnEscape: true
});
return (
<input
{...inlineEdit.getInputProps({
className,
placeholder,
"aria-label": ariaLabel
})}
/>
);
}
function ComboStep({ step, stepIndex, device, active = false, selectedInputIndex = -1, onClick, onSelectInput, onDropInput, onDragInput }) {
const className = [
"combo-step-inputs",
step.length > 1 ? "is-simultaneous" : "",
step.length <= 1 ? "is-single" : "",
active ? "is-active" : ""
].filter(Boolean).join(" ");
const content = step.length ? step.map((input, inputIndex) => (
<span className={`combo-step-input ${selectedInputIndex === inputIndex ? "is-selected" : ""}`} key={`${inputKey(input)}-${inputIndex}`}>
{inputIndex > 0 && <span className="combo-plus" aria-hidden="true">+</span>}
<ComboInputToken
input={input}
device={device}
onClick={onSelectInput ? (event) => {
event.stopPropagation();
onSelectInput(stepIndex, inputIndex);
} : null}
onDragStart={onDragInput ? (event) => onDragInput(event, stepIndex, inputIndex, input) : null}
/>
</span>
)) : <span className="combo-empty-step">Étape vide</span>;
if (onClick) {
return (
<button
type="button"
className={className}
onClick={onClick}
onDragOver={(event) => {
if (!onDropInput) return;
event.preventDefault();
}}
onDrop={(event) => {
if (!onDropInput) return;
event.preventDefault();
onDropInput(event);
}}
>
{content}
</button>
);
}
return <span className={className}>{content}</span>;
}

View file

@ -0,0 +1,493 @@
// Rôle : contient les panneaux d'affichage et d'édition détaillée du planificateur d'équipements.
import { useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { applyGroupedReorderOperation, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import {
TRAIT_ICONS,
createLocalId,
formatValue,
getCharacteristicCategories,
getSocketItemName,
setItemCategory
} from "./equipmentPlannerOptions.js";
import { ColorChoiceDropdown, IconChoiceDropdown, ShapeChoiceDropdown, SocketShapeIcon } from "./EquipmentPlannerPickers.jsx";
export function EquipmentDisplay({ equipment, textContent }) {
const hasStats = equipment.characteristics.length > 0;
const hasSockets = equipment.socketItems.length > 0;
const hasCraft = Boolean(equipment.obtain || equipment.materials.length);
if (!hasStats && !hasSockets && !hasCraft) {
return <p className="equipment-planner-muted">{textContent.emptyEquipmentDetails || "Aucun détail renseigné."}</p>;
}
return (
<div className="equipment-planner-display-grid">
{hasStats && (
<section className="equipment-planner-display-panel">
<h4>{textContent.displayStatsTitle || "Statistiques"}</h4>
<ul>
{equipment.characteristics.map((trait) => (
<li className="equipment-planner-display-stat" key={trait.id}>
<Icon name={trait.icon || "sword"} />
<span>{trait.name}</span>
{trait.value !== "" && <b>{formatValue(trait.value)}</b>}
</li>
))}
</ul>
</section>
)}
{hasSockets && (
<section className="equipment-planner-display-panel">
<h4>{textContent.displaySocketTitle || "Objets sertis"}</h4>
<ul>
{equipment.socketItems.map((socketItem) => (
<li className="equipment-planner-display-socket" key={socketItem.id}>
<SocketShapeIcon shape={socketItem.shape} color={socketItem.color} />
<div>
<strong>{socketItem.name}</strong>
{socketItem.bonuses.map((bonus) => (
<small key={bonus.id}>{bonus.name}{bonus.value !== "" ? ` +${formatValue(bonus.value)}` : ""}</small>
))}
</div>
</li>
))}
</ul>
</section>
)}
{hasCraft && (
<section className="equipment-planner-display-panel">
<h4>{textContent.craftTab || "Craft"}</h4>
{equipment.obtain ? <p>{equipment.obtain}</p> : null}
{equipment.materials.length ? (
<ul>
{equipment.materials.map((material) => <li key={material.id}><span>{material.name}</span><b>x{material.qty}</b></li>)}
</ul>
) : null}
</section>
)}
</div>
);
}
export function TabIcon({ tab }) {
if (tab === "stats") return <span className="equipment-planner-percent-icon" aria-hidden="true">%</span>;
if (tab === "sockets") return <Icon name="jewel" />;
return <Icon name="hammer" />;
}
export function CharacteristicsEditor({ equipment, textContent, editing, onUpdate }) {
const [draft, setDraft] = useState({ icon: "sword", category: "", name: "", value: "" });
const categories = getCharacteristicCategories(equipment);
const {
itemReorder,
groupReorder,
getItemProps,
getGroupProps,
isItemDragging,
isItemDropTarget,
isGroupDragging,
isGroupDropTarget,
getDropPlacement
} = useGroupedReorder({
namespace: `equipment-characteristics-${equipment.id}`,
items: equipment.characteristics,
getItemId: (trait) => trait.id,
getItemGroup: (trait) => trait.category,
getEffectiveGroup: (trait) => trait.category,
reorderFeatures: {
item: { groupChange: true },
group: { reorder: true, itemDrop: true }
},
onItemMove: (operation) => onUpdate((current) => applyGroupedReorderOperation(current, {
operation,
itemsKey: "characteristics",
groupOrderKey: "categoryOrder",
collapsedGroupsKey: "collapsedCategories",
getItemGroup: (trait) => trait.category,
setItemGroup: setItemCategory
})),
onGroupMove: (operation) => onUpdate((current) => applyGroupedReorderOperation(current, {
operation,
itemsKey: "characteristics",
groupOrderKey: "categoryOrder",
collapsedGroupsKey: "collapsedCategories",
getItemGroup: (trait) => trait.category,
setItemGroup: setItemCategory
}))
});
const reorder = { itemReorder, groupReorder, getItemProps, getGroupProps, isItemDragging, isItemDropTarget, isGroupDragging, isGroupDropTarget, getDropPlacement };
function addTrait(event) {
event.preventDefault();
const cleanName = draft.name.trim();
if (!cleanName) return;
onUpdate((current) => ({
...current,
characteristics: [...current.characteristics, { id: createLocalId("trait"), icon: draft.icon, category: draft.category.trim(), name: cleanName, value: draft.value.trim() }],
categoryOrder: draft.category.trim() && !current.categoryOrder.includes(draft.category.trim()) ? [...current.categoryOrder, draft.category.trim()] : current.categoryOrder
}));
setDraft({ icon: draft.icon, category: draft.category, name: "", value: "" });
}
function updateTrait(traitId, updater) {
onUpdate((current) => ({ ...current, characteristics: current.characteristics.map((trait) => trait.id === traitId ? updater(trait) : trait).filter((trait) => trait.name) }));
}
return (
<section className="equipment-planner-panel">
<h4>{textContent.characteristicsTitle || "Caractéristiques"}</h4>
{editing && (
<form className="inline-form equipment-planner-trait-form" onSubmit={addTrait}>
<IconChoiceDropdown
label={textContent.traitIconLabel || "Icône"}
value={draft.icon}
options={TRAIT_ICONS.map((icon) => ({ icon, label: icon }))}
onChange={(icon) => setDraft((current) => ({ ...current, icon }))}
/>
<input value={draft.category} onChange={(event) => setDraft((current) => ({ ...current, category: event.target.value }))} placeholder={textContent.categoryPlaceholder || "Catégorie"} />
<input value={draft.name} onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))} placeholder={textContent.traitNamePlaceholder || "Nom"} />
<input value={draft.value} onChange={(event) => setDraft((current) => ({ ...current, value: event.target.value }))} placeholder={textContent.traitValuePlaceholder || "Valeur"} />
<button>{textContent.addTraitButton || "Ajouter"}</button>
</form>
)}
<TraitCategoryList
traits={equipment.characteristics}
categories={categories}
collapsedCategories={equipment.collapsedCategories}
reorder={reorder}
textContent={textContent}
onToggleCategory={(category) => onUpdate((current) => ({
...current,
collapsedCategories: current.collapsedCategories.includes(category)
? current.collapsedCategories.filter((item) => item !== category)
: [...current.collapsedCategories, category]
}))}
onUpdateTrait={updateTrait}
editing={editing}
/>
</section>
);
}
function TraitCategoryList({ traits, categories, collapsedCategories, reorder, textContent, onToggleCategory, onUpdateTrait, editing }) {
const ungrouped = traits.filter((trait) => !trait.category);
function renderTrait(trait) {
const traitClassName = [
"equipment-planner-trait",
reorder.isItemDragging(trait.id) ? "is-dragging" : "",
reorder.isItemDropTarget(trait.id) ? "is-drop-target" : "",
reorder.getDropPlacement("item", trait.id) === "after" ? "drop-after" : ""
].filter(Boolean).join(" ");
return (
<EquipmentTraitRow
key={trait.id}
trait={trait}
className={traitClassName}
itemProps={reorder.getItemProps({ itemId: trait.id, groupId: trait.category })}
editing={editing}
textContent={textContent}
onStartDrag={(event) => reorder.itemReorder.startDrag(event, trait.id)}
onUpdateTrait={onUpdateTrait}
/>
);
}
return (
<div className="equipment-planner-trait-list">
{ungrouped.length ? <ul>{ungrouped.map(renderTrait)}</ul> : null}
{categories.map((category) => {
const categoryTraits = traits.filter((trait) => trait.category === category);
const collapsed = collapsedCategories.includes(category);
const className = [
"equipment-planner-trait-category",
collapsed ? "is-collapsed" : "",
reorder.isGroupDragging(category) ? "is-dragging" : "",
reorder.isGroupDropTarget(category) ? "is-drop-target" : ""
].filter(Boolean).join(" ");
return (
<section className={className} key={category} {...reorder.getGroupProps({ groupId: category })}>
<div className="equipment-planner-trait-category-header">
<button type="button" className="task-planner-category-drag-handle" onPointerDown={(event) => reorder.groupReorder.startDrag(event, { groupId: category })} title={textContent.reorderCategoryTitle || "Déplacer la catégorie"}>
<Icon name="drag" />
</button>
<strong>{category}</strong>
<span>{categoryTraits.length}</span>
<button type="button" className="checklist-section-collapse-button" onClick={() => onToggleCategory(category)} aria-expanded={!collapsed}>
<Icon name={collapsed ? "chevron-down" : "chevron-up"} />
</button>
</div>
{!collapsed && <ul>{categoryTraits.map(renderTrait)}</ul>}
</section>
);
})}
{!traits.length && <p className="equipment-planner-muted">{textContent.emptyCharacteristics || "Aucune caractéristique."}</p>}
</div>
);
}
function EquipmentTraitRow({ trait, className, itemProps, editing, textContent, onStartDrag, onUpdateTrait }) {
const categoryEdit = useInlineEdit({
value: trait.category || "",
onCommit: (category) => onUpdateTrait(trait.id, (current) => ({ ...current, category }))
});
const nameEdit = useInlineEdit({
value: trait.name,
transform: (draft) => String(draft || "").trim() || trait.name,
onCommit: (name) => onUpdateTrait(trait.id, (current) => ({ ...current, name }))
});
const valueEdit = useInlineEdit({
value: formatValue(trait.value),
onCommit: (value) => onUpdateTrait(trait.id, (current) => ({ ...current, value }))
});
return (
<li className={className} {...itemProps}>
{editing ? (
<>
<button type="button" className="task-planner-drag-handle" onPointerDown={onStartDrag} title={textContent.reorderTraitTitle || "Déplacer"}>
<Icon name="drag" />
</button>
<IconChoiceDropdown
label={textContent.traitIconLabel || "Icône"}
value={trait.icon || "sword"}
options={TRAIT_ICONS.map((icon) => ({ icon, label: icon }))}
onChange={(icon) => onUpdateTrait(trait.id, (current) => ({ ...current, icon }))}
/>
<input {...categoryEdit.getInputProps({ placeholder: textContent.categoryPlaceholder || "Catégorie" })} />
<input {...nameEdit.getInputProps({ placeholder: textContent.traitNamePlaceholder || "Nom" })} />
<input {...valueEdit.getInputProps({ placeholder: textContent.traitValuePlaceholder || "Valeur" })} />
<button type="button" className="task-planner-delete-button danger" onClick={() => onUpdateTrait(trait.id, () => ({ ...trait, name: "" }))} title={textContent.deleteTraitTitle || "Supprimer"}>
<Icon name="trash" />
</button>
</>
) : (
<div className="equipment-planner-trait-readonly">
<Icon name={trait.icon || "sword"} />
<span>{trait.name}</span>
{trait.value !== "" && <b>{formatValue(trait.value)}</b>}
</div>
)}
</li>
);
}
export function MaterialsEditor({ equipment, textContent, editing, onUpdate }) {
const [draft, setDraft] = useState({ name: "", qty: 1 });
function addMaterial(event) {
event.preventDefault();
const cleanName = draft.name.trim();
if (!cleanName) return;
onUpdate((current) => ({
...current,
materials: [...current.materials, { id: createLocalId("material"), name: cleanName, qty: Math.max(1, Number.parseInt(draft.qty, 10) || 1) }]
}));
setDraft({ name: "", qty: 1 });
}
return (
<section className="equipment-planner-panel">
<h4>{textContent.materialsTitle || "Matériaux"}</h4>
{editing && (
<form className="inline-form equipment-planner-material-form" onSubmit={addMaterial}>
<input value={draft.name} onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))} placeholder={textContent.materialNamePlaceholder || "Matériau"} />
<input type="number" min="1" value={draft.qty} onChange={(event) => setDraft((current) => ({ ...current, qty: event.target.value }))} aria-label={textContent.materialQtyLabel || "Quantité"} />
<button>{textContent.addMaterialButton || "Ajouter"}</button>
</form>
)}
<ul className="equipment-planner-materials">
{equipment.materials.map((material) => (
<MaterialRow
key={material.id}
material={material}
editing={editing}
textContent={textContent}
onUpdate={onUpdate}
/>
))}
</ul>
</section>
);
}
function MaterialRow({ material, editing, textContent, onUpdate }) {
const nameEdit = useInlineEdit({
value: material.name,
transform: (draft) => String(draft || "").trim() || material.name,
onCommit: (name) => onUpdate((current) => ({ ...current, materials: current.materials.map((item) => item.id === material.id ? { ...item, name } : item) }))
});
return (
<li>
{editing ? (
<>
<input {...nameEdit.getInputProps()} />
<input type="number" min="1" value={material.qty} onChange={(event) => onUpdate((current) => ({ ...current, materials: current.materials.map((item) => item.id === material.id ? { ...item, qty: Math.max(1, Number.parseInt(event.target.value, 10) || 1) } : item) }))} />
<button type="button" className="task-planner-delete-button danger" onClick={() => onUpdate((current) => ({ ...current, materials: current.materials.filter((item) => item.id !== material.id) }))} title={textContent.deleteMaterialTitle || "Supprimer"}>
<Icon name="trash" />
</button>
</>
) : (
<div className="equipment-planner-material-readonly">
<span>{material.name}</span>
<b>x{material.qty}</b>
</div>
)}
</li>
);
}
export function SocketItemsEditor({ equipment, textContent, editing, onUpdate }) {
const [draft, setDraft] = useState({ name: "", shape: "jewel", color: "yellow" });
function addSocketItem(event) {
event.preventDefault();
const cleanName = draft.name.trim();
if (!cleanName) return;
onUpdate((current) => ({
...current,
socketItems: [...current.socketItems, { id: createLocalId("socket"), name: cleanName, shape: draft.shape, color: draft.color, bonuses: [] }]
}));
setDraft({ ...draft, name: "" });
}
function updateSocketItem(socketItemId, updater) {
onUpdate((current) => ({
...current,
socketItems: current.socketItems.map((socketItem) => socketItem.id === socketItemId ? updater(socketItem) : socketItem).filter((socketItem) => socketItem.name)
}));
}
function deleteSocketItem(socketItemId) {
onUpdate((current) => ({
...current,
socketItems: current.socketItems.filter((socketItem) => socketItem.id !== socketItemId),
socketLinks: current.socketLinks.filter((link) => link.fromSocketItemId !== socketItemId && link.toSocketItemId !== socketItemId)
}));
}
function addSocketLink(fromSocketItemId, toSocketItemId) {
if (!toSocketItemId || fromSocketItemId === toSocketItemId) return;
const pair = [fromSocketItemId, toSocketItemId].sort().join(":");
if (equipment.socketLinks.some((link) => [link.fromSocketItemId, link.toSocketItemId].sort().join(":") === pair)) return;
onUpdate((current) => ({
...current,
socketLinks: [...current.socketLinks, { id: createLocalId("socket-link"), fromSocketItemId, toSocketItemId }]
}));
}
return (
<section className="equipment-planner-panel">
<h4>{textContent.socketItemsTitle || "Objets sertis"}</h4>
{editing && (
<form className="inline-form equipment-planner-socket-form" onSubmit={addSocketItem}>
<input value={draft.name} onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))} placeholder={textContent.socketNamePlaceholder || "Joyau, materia..."} />
<ShapeChoiceDropdown label={textContent.socketShapeLabel || "Forme"} value={draft.shape} onChange={(shape) => setDraft((current) => ({ ...current, shape }))} />
<ColorChoiceDropdown label={textContent.socketColorLabel || "Couleur"} value={draft.color} onChange={(color) => setDraft((current) => ({ ...current, color }))} />
<button>{textContent.addSocketButton || "Ajouter"}</button>
</form>
)}
<div className="equipment-planner-socket-grid">
{equipment.socketItems.map((socketItem) => (
<SocketItemEditor
key={socketItem.id}
equipment={equipment}
socketItem={socketItem}
textContent={textContent}
editing={editing}
onUpdate={(updater) => updateSocketItem(socketItem.id, updater)}
onDelete={() => deleteSocketItem(socketItem.id)}
onAddLink={(toSocketItemId) => addSocketLink(socketItem.id, toSocketItemId)}
onDeleteLink={(linkId) => onUpdate((current) => ({ ...current, socketLinks: current.socketLinks.filter((link) => link.id !== linkId) }))}
/>
))}
</div>
</section>
);
}
function SocketItemEditor({ equipment, socketItem, textContent, editing, onUpdate, onDelete, onAddLink, onDeleteLink }) {
const [bonusDraft, setBonusDraft] = useState({ name: "", value: "" });
const links = equipment.socketLinks.filter((link) => link.fromSocketItemId === socketItem.id || link.toSocketItemId === socketItem.id);
const nameEdit = useInlineEdit({
value: socketItem.name,
transform: (draft) => String(draft || "").trim() || socketItem.name,
onCommit: (name) => onUpdate((current) => ({ ...current, name }))
});
function addBonus(event) {
event.preventDefault();
const cleanName = bonusDraft.name.trim();
if (!cleanName) return;
onUpdate((current) => ({
...current,
bonuses: [...current.bonuses, { id: createLocalId("bonus"), category: "", name: cleanName, value: bonusDraft.value.trim() }]
}));
setBonusDraft({ name: "", value: "" });
}
return (
<article className="equipment-planner-socket-card">
<div className="equipment-planner-socket-heading">
<SocketShapeIcon shape={socketItem.shape} color={socketItem.color} />
{editing ? (
<>
<input {...nameEdit.getInputProps()} />
<button type="button" className="task-planner-delete-button danger" onClick={onDelete} title={textContent.deleteSocketTitle || "Supprimer"}>
<Icon name="trash" />
</button>
</>
) : (
<strong>{socketItem.name}</strong>
)}
</div>
{editing && (
<>
<div className="equipment-planner-socket-controls">
<ShapeChoiceDropdown label={textContent.socketShapeLabel || "Forme"} value={socketItem.shape} onChange={(shape) => onUpdate((current) => ({ ...current, shape }))} />
<ColorChoiceDropdown label={textContent.socketColorLabel || "Couleur"} value={socketItem.color} onChange={(color) => onUpdate((current) => ({ ...current, color }))} />
{equipment.socketItems.length > 1 && (
<select defaultValue="" onChange={(event) => { onAddLink(event.target.value); event.target.value = ""; }} aria-label={textContent.addSocketLinkLabel || "Lier à"}>
<option value="">{textContent.addSocketLinkLabel || "Lier à"}</option>
{equipment.socketItems.filter((item) => item.id !== socketItem.id).map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
)}
</div>
<form className="inline-form equipment-planner-bonus-form" onSubmit={addBonus}>
<input value={bonusDraft.name} onChange={(event) => setBonusDraft((current) => ({ ...current, name: event.target.value }))} placeholder={textContent.bonusNamePlaceholder || "Bonus"} />
<input value={bonusDraft.value} onChange={(event) => setBonusDraft((current) => ({ ...current, value: event.target.value }))} placeholder={textContent.traitValuePlaceholder || "Valeur"} />
<button aria-label={textContent.addBonusButton || "Ajouter"} title={textContent.addBonusButton || "Ajouter"}>+</button>
</form>
</>
)}
<ul className="equipment-planner-socket-bonuses">
{socketItem.bonuses.map((bonus) => (
<li key={bonus.id}>
<span>{bonus.category ? `${bonus.category} · ` : ""}{bonus.name}</span>
<b>{formatValue(bonus.value)}</b>
{editing && (
<button type="button" className="task-planner-delete-button danger" onClick={() => onUpdate((current) => ({ ...current, bonuses: current.bonuses.filter((item) => item.id !== bonus.id) }))} title={textContent.deleteBonusTitle || "Supprimer"}>
<Icon name="trash" />
</button>
)}
</li>
))}
</ul>
{links.length ? (
<div className={`equipment-planner-socket-links ${editing ? "is-editing" : ""}`}>
{links.map((link) => {
const otherSocketItemId = link.fromSocketItemId === socketItem.id ? link.toSocketItemId : link.fromSocketItemId;
return (
<button key={link.id} type="button" className="equipment-planner-link-chip" onClick={editing ? () => onDeleteLink(link.id) : undefined} title={editing ? textContent.deleteSocketLinkTitle || "Retirer le lien" : getSocketItemName(equipment, otherSocketItemId)}>
<span>{getSocketItemName(equipment, otherSocketItemId)}</span>
{editing && <Icon name="close" />}
</button>
);
})}
</div>
) : null}
</article>
);
}

View file

@ -0,0 +1,106 @@
// Rôle : rend une ligne d'équipement et délègue ses panneaux d'édition spécialisés.
import { useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { DETAIL_TABS, getEquipmentTabLabel } from "./equipmentPlannerOptions.js";
import { CharacteristicsEditor, EquipmentDisplay, MaterialsEditor, SocketItemsEditor, TabIcon } from "./EquipmentPlannerEditors.jsx";
export function EquipmentItem({ equipment, reorder, textContent, onUpdate, onDelete }) {
const [activeTab, setActiveTab] = useState("stats");
const [detailEditing, setDetailEditing] = useState(false);
const nameEdit = useInlineEdit({
value: equipment.name,
onCommit: (name) => onUpdate((current) => ({ ...current, name: name || current.name })),
blurOnEscape: true
});
const obtainEdit = useInlineEdit({
value: equipment.obtain || "",
onCommit: (obtain) => onUpdate((current) => ({ ...current, obtain })),
commitOnEnter: false
});
const className = [
"equipment-planner-item",
equipment.active ? "is-active" : "is-inactive",
reorder.isItemDragging(equipment.id) ? "is-dragging" : "",
reorder.isItemDropTarget(equipment.id) ? "is-drop-target" : "",
reorder.getDropPlacement("item", equipment.id) === "after" ? "drop-after" : ""
].filter(Boolean).join(" ");
return (
<li className={className} {...reorder.getItemProps({ itemId: equipment.id })}>
<div className="equipment-planner-line">
<button
className="task-planner-drag-handle"
type="button"
onPointerDown={(event) => reorder.itemReorder.startDrag(event, equipment.id)}
aria-label={`${textContent.reorderEquipmentTitle || "Déplacer"} ${equipment.name}`}
title={textContent.reorderEquipmentTitle || "Déplacer"}
>
<Icon name="drag" />
</button>
<div className="equipment-planner-identity">
<span className="equipment-planner-type-icon" aria-hidden="true">
<Icon name={equipment.icon || "shield"} />
</span>
<strong {...nameEdit.getContentEditableProps({ className: "equipment-planner-name", title: textContent.renameEquipmentTitle || "Renommer" })}>{equipment.name}</strong>
</div>
<label className="equipment-planner-include-switch">
<input
type="checkbox"
checked={equipment.active}
onChange={(event) => onUpdate((current) => ({ ...current, active: event.target.checked }))}
/>
<span aria-hidden="true" />
<b>{textContent.includeInSummaryLabel || "Inclure dans le résumé"}</b>
</label>
<button
type="button"
className={`task-planner-settings-button equipment-planner-row-edit-button ${detailEditing ? "active" : ""}`}
onClick={() => setDetailEditing((value) => !value)}
aria-pressed={detailEditing}
title={detailEditing ? textContent.viewModeTitle || "Mode affichage" : textContent.editModeTitle || "Mode édition"}
>
<Icon name="edit" />
</button>
<button type="button" className="task-planner-delete-button danger" onClick={onDelete} aria-label={`${textContent.deleteEquipmentTitle || "Supprimer"} ${equipment.name}`} title={textContent.deleteEquipmentTitle || "Supprimer"}>
<Icon name="trash" />
</button>
</div>
<div className="equipment-planner-details">
{detailEditing ? (
<>
<div className="equipment-planner-tabs" role="tablist" aria-label={textContent.tabsLabel || "Ajout de caractéristiques"}>
{DETAIL_TABS.map((tab) => (
<button
key={tab}
className={`equipment-planner-tab ${activeTab === tab ? "active" : ""}`}
type="button"
role="tab"
aria-selected={activeTab === tab}
onClick={() => setActiveTab(tab)}
title={getEquipmentTabLabel(tab, textContent)}
>
<TabIcon tab={tab} />
<span>{getEquipmentTabLabel(tab, textContent)}</span>
</button>
))}
</div>
{activeTab === "stats" && <CharacteristicsEditor equipment={equipment} textContent={textContent} editing onUpdate={onUpdate} />}
{activeTab === "sockets" && <SocketItemsEditor equipment={equipment} textContent={textContent} editing onUpdate={onUpdate} />}
{activeTab === "craft" && (
<section className="equipment-planner-craft-tab">
<label className="equipment-planner-field">
<span>{textContent.obtainLabel || "Obtention"}</span>
<textarea {...obtainEdit.getInputProps({ placeholder: textContent.obtainPlaceholder || "Comment obtenir ou créer cet équipement" })} />
</label>
<MaterialsEditor equipment={equipment} textContent={textContent} editing onUpdate={onUpdate} />
</section>
)}
</>
) : (
<EquipmentDisplay equipment={equipment} textContent={textContent} />
)}
</div>
</li>
);
}

View file

@ -1,37 +1,11 @@
// Rôle : fournit l'outil de planification d'équipements, sertissages, résumé et échange texte.
import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Icon } from "../../../components/Icon.jsx";
import { Tooltip } from "../../../components/Tooltip.jsx";
import { applyGroupedReorderOperation, moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { useMemo, useState } from "react";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import {
DETAIL_TABS,
EQUIPMENT_ICONS,
SOCKET_COLORS,
SOCKET_COLOR_VALUES,
SOCKET_SHAPES,
TRAIT_ICONS,
createLocalId,
formatValue,
getCharacteristicCategories,
getSocketItemName,
setItemCategory
} from "./equipmentPlannerOptions.js";
function SocketShapeIcon({ shape, color = "gray", className = "" }) {
return (
<span className={`equipment-planner-socket-icon color-${color} ${className}`.trim()} style={{ "--equipment-socket-icon-color": SOCKET_COLOR_VALUES[color] || SOCKET_COLOR_VALUES.gray }} aria-hidden="true">
<Icon name={shape} />
</span>
);
}
function SummaryTotalIcon({ total }) {
if (SOCKET_SHAPES.includes(total.icon)) return <SocketShapeIcon shape={total.icon} color={total.color || "gray"} />;
return <Icon name={total.icon || "sword"} />;
}
import { EQUIPMENT_ICONS } from "./equipmentPlannerOptions.js";
import { EquipmentItem } from "./EquipmentPlannerItem.jsx";
import { IconChoiceDropdown } from "./EquipmentPlannerPickers.jsx";
import { EquipmentPlannerSummary } from "./EquipmentPlannerSummary.jsx";
export function EquipmentPlannerModule({ toolboxId, moduleId, context, editing }) {
const data = context.normalizeEquipmentPlannerData(context.getModuleData(toolboxId, moduleId, { types: [], equipments: [] }));
@ -161,849 +135,3 @@ export function EquipmentPlannerModule({ toolboxId, moduleId, context, editing }
</div>
);
}
function EquipmentPlannerSummary({ summary, textContent }) {
const hasSourceTotals = summary.equipmentTotals.length || summary.socketTotals.length;
const sourceColumnCount = [summary.equipmentTotals.length, summary.socketTotals.length].filter(Boolean).length;
return (
<section className="equipment-planner-summary" aria-label={textContent.summaryTitle || "Résumé"}>
<div className="equipment-planner-summary-header">
<h3>{textContent.summaryTitle || "Résumé"}</h3>
<span>{summary.activeEquipmentCount} {summary.activeEquipmentCount > 1 ? textContent.activeEquipmentsPlural || "équipements actifs" : textContent.activeEquipmentsSingular || "équipement actif"}</span>
</div>
<div className="equipment-planner-summary-grid">
{hasSourceTotals ? (
<div className={`equipment-planner-summary-source-grid columns-${sourceColumnCount}`}>
{summary.equipmentTotals.length ? <SummaryTotalsPanel title={textContent.equipmentTotalsTitle || "Statistiques de l'équipement"} totals={summary.equipmentTotals} textContent={textContent} /> : null}
{summary.socketTotals.length ? <SummaryTotalsPanel title={textContent.socketTotalsTitle || "Objets sertis"} totals={summary.socketTotals} textContent={textContent} /> : null}
</div>
) : null}
{summary.mixedTotals.length ? <SummaryTotalsPanel title={textContent.mixedTotalsTitle || "Totaux fusionnés"} totals={summary.mixedTotals} textContent={textContent} /> : null}
{!summary.totals.length ? (
<div className="equipment-planner-summary-panel">
<strong>{textContent.numericTotalsTitle || "Totaux"}</strong>
<p>{textContent.emptyTotals || "Aucun total numérique."}</p>
</div>
) : null}
{summary.textBonuses.length ? (
<div className="equipment-planner-summary-panel equipment-planner-summary-text-bonuses">
<strong>{textContent.textBonusesTitle || "Bonus"}</strong>
<ul>
{summary.textBonuses.map((bonus) => (
<li key={`${bonus.source}:${bonus.id}`}>
<span>{bonus.category ? `${bonus.category} · ` : ""}{bonus.name}</span>
<small>{bonus.source}{bonus.value ? ` · ${bonus.value}` : ""}</small>
</li>
))}
</ul>
</div>
) : null}
</div>
</section>
);
}
function SummaryTotalsPanel({ title, totals, textContent }) {
return (
<div className="equipment-planner-summary-panel">
<strong>{title}</strong>
<ul>
{totals.map((total) => (
<Tooltip
as="li"
className="equipment-planner-summary-total"
content={<SummaryTotalTooltip total={total} textContent={textContent} />}
key={total.name}
position="top-start"
>
<SummaryTotalIcon total={total} />
<span>{total.name}</span>
<b>{formatValue(total.value)}</b>
</Tooltip>
))}
</ul>
</div>
);
}
function SummaryTotalTooltip({ total, textContent }) {
return (
<span className="equipment-planner-summary-tooltip">
<strong>{textContent.summaryTooltipTitle || "Provenance"}</strong>
<span>
{(total.contributions || []).map((contribution) => (
<span className="equipment-planner-summary-tooltip-row" key={`${contribution.sourceKind}:${contribution.id}:${contribution.sourceName}`}>
<Icon name={contribution.equipmentIcon || "shield"} />
<span>{contribution.sourceName || contribution.equipmentName}</span>
<b>{formatValue(contribution.value)}</b>
</span>
))}
</span>
</span>
);
}
function IconChoiceDropdown({ className = "", label, value, options, onChange }) {
const selected = options.find((option) => option.icon === value) || options[0];
return (
<FloatingPicker
className={className}
label={label}
columns={Math.min(options.length, 5)}
renderTrigger={({ open, toggle }) => (
<button
className={`equipment-planner-picker-trigger ${open ? "active" : ""}`}
type="button"
onClick={toggle}
aria-label={label}
aria-expanded={open}
title={selected.label}
>
<Icon name={selected.icon} />
<Icon name="dropdown" />
</button>
)}
>
{({ close }) => (
<div className="equipment-planner-picker-grid">
{options.map((option) => (
<button
key={option.icon}
className={`equipment-planner-picker-option ${option.icon === value ? "active" : ""}`}
type="button"
onClick={() => {
onChange(option.icon);
close();
}}
aria-label={option.label}
title={option.label}
>
<Icon name={option.icon} />
</button>
))}
</div>
)}
</FloatingPicker>
);
}
function ShapeChoiceDropdown({ label, value, onChange }) {
return (
<FloatingPicker
className="equipment-planner-shape-picker"
label={label}
columns={SOCKET_SHAPES.length}
renderTrigger={({ open, toggle }) => (
<button className={`equipment-planner-picker-trigger ${open ? "active" : ""}`} type="button" onClick={toggle} aria-label={label} aria-expanded={open} title={label}>
<SocketShapeIcon shape={value} />
<Icon name="dropdown" />
</button>
)}
>
{({ close }) => (
<div className="equipment-planner-picker-grid">
{SOCKET_SHAPES.map((shape) => (
<button
key={shape}
className={`equipment-planner-picker-option ${shape === value ? "active" : ""}`}
type="button"
onClick={() => {
onChange(shape);
close();
}}
aria-label={shape}
title={shape}
>
<SocketShapeIcon shape={shape} />
</button>
))}
</div>
)}
</FloatingPicker>
);
}
function ColorChoiceDropdown({ label, value, onChange }) {
return (
<FloatingPicker
label={label}
columns={6}
renderTrigger={({ open, toggle }) => (
<button className={`equipment-planner-picker-trigger ${open ? "active" : ""}`} type="button" onClick={toggle} aria-label={label} aria-expanded={open} title={value}>
<span className={`equipment-planner-color-swatch color-${value}`} aria-hidden="true" />
<Icon name="dropdown" />
</button>
)}
>
{({ close }) => (
<div className="equipment-planner-color-grid">
{SOCKET_COLORS.map((color) => (
<button
key={color}
className={`equipment-planner-color-option ${color === value ? "active" : ""}`}
type="button"
onClick={() => {
onChange(color);
close();
}}
aria-label={color}
title={color}
>
<span className={`equipment-planner-color-swatch color-${color}`} aria-hidden="true" />
</button>
))}
</div>
)}
</FloatingPicker>
);
}
function FloatingPicker({ className = "", label, columns = 5, renderTrigger, children }) {
const [open, setOpen] = useState(false);
const [menuStyle, setMenuStyle] = useState({});
const rootRef = useRef(null);
const menuRef = useRef(null);
useEffect(() => {
if (!open) return undefined;
function placeMenu() {
const trigger = rootRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const width = Math.max(0, Math.min(220, window.innerWidth - 16));
const left = Math.min(Math.max(8, rect.left), Math.max(8, window.innerWidth - width - 8));
setMenuStyle({
top: `${Math.min(rect.bottom + 6, window.innerHeight - 8)}px`,
left: `${left}px`,
maxWidth: `${width}px`
});
}
function handlePointerDown(event) {
if (rootRef.current?.contains(event.target) || menuRef.current?.contains(event.target)) return;
setOpen(false);
}
function handleKeyDown(event) {
if (event.key === "Escape") setOpen(false);
}
placeMenu();
window.addEventListener("resize", placeMenu);
window.addEventListener("scroll", placeMenu, true);
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("resize", placeMenu);
window.removeEventListener("scroll", placeMenu, true);
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
function close() {
setOpen(false);
}
function toggle() {
setOpen((value) => !value);
}
return (
<div className={`equipment-planner-picker ${className} ${open ? "is-open" : ""}`.trim()} ref={rootRef} aria-label={label}>
{renderTrigger({ open, toggle, close })}
{open && createPortal(
<div className="equipment-planner-picker-menu is-open" ref={menuRef} style={{ ...menuStyle, "--picker-columns": columns }} aria-label={label}>
{children({ close })}
</div>,
document.body
)}
</div>
);
}
function EquipmentItem({ equipment, reorder, textContent, onUpdate, onDelete }) {
const [activeTab, setActiveTab] = useState("stats");
const [detailEditing, setDetailEditing] = useState(false);
const nameEdit = useInlineEdit({
value: equipment.name,
onCommit: (name) => onUpdate((current) => ({ ...current, name: name || current.name })),
blurOnEscape: true
});
const obtainEdit = useInlineEdit({
value: equipment.obtain || "",
onCommit: (obtain) => onUpdate((current) => ({ ...current, obtain })),
commitOnEnter: false
});
const className = [
"equipment-planner-item",
equipment.active ? "is-active" : "is-inactive",
reorder.isItemDragging(equipment.id) ? "is-dragging" : "",
reorder.isItemDropTarget(equipment.id) ? "is-drop-target" : "",
reorder.getDropPlacement("item", equipment.id) === "after" ? "drop-after" : ""
].filter(Boolean).join(" ");
return (
<li className={className} {...reorder.getItemProps({ itemId: equipment.id })}>
<div className="equipment-planner-line">
<button
className="task-planner-drag-handle"
type="button"
onPointerDown={(event) => reorder.itemReorder.startDrag(event, equipment.id)}
aria-label={`${textContent.reorderEquipmentTitle || "Déplacer"} ${equipment.name}`}
title={textContent.reorderEquipmentTitle || "Déplacer"}
>
<Icon name="drag" />
</button>
<div className="equipment-planner-identity">
<span className="equipment-planner-type-icon" aria-hidden="true">
<Icon name={equipment.icon || "shield"} />
</span>
<strong {...nameEdit.getContentEditableProps({ className: "equipment-planner-name", title: textContent.renameEquipmentTitle || "Renommer" })}>{equipment.name}</strong>
</div>
<label className="equipment-planner-include-switch">
<input
type="checkbox"
checked={equipment.active}
onChange={(event) => onUpdate((current) => ({ ...current, active: event.target.checked }))}
/>
<span aria-hidden="true" />
<b>{textContent.includeInSummaryLabel || "Inclure dans le résumé"}</b>
</label>
<button
type="button"
className={`task-planner-settings-button equipment-planner-row-edit-button ${detailEditing ? "active" : ""}`}
onClick={() => setDetailEditing((value) => !value)}
aria-pressed={detailEditing}
title={detailEditing ? textContent.viewModeTitle || "Mode affichage" : textContent.editModeTitle || "Mode édition"}
>
<Icon name="edit" />
</button>
<button type="button" className="task-planner-delete-button danger" onClick={onDelete} aria-label={`${textContent.deleteEquipmentTitle || "Supprimer"} ${equipment.name}`} title={textContent.deleteEquipmentTitle || "Supprimer"}>
<Icon name="trash" />
</button>
</div>
<div className="equipment-planner-details">
{detailEditing ? (
<>
<div className="equipment-planner-tabs" role="tablist" aria-label={textContent.tabsLabel || "Ajout de caractéristiques"}>
{DETAIL_TABS.map((tab) => (
<button
key={tab}
className={`equipment-planner-tab ${activeTab === tab ? "active" : ""}`}
type="button"
role="tab"
aria-selected={activeTab === tab}
onClick={() => setActiveTab(tab)}
title={getTabLabel(tab, textContent)}
>
<TabIcon tab={tab} />
<span>{getTabLabel(tab, textContent)}</span>
</button>
))}
</div>
{activeTab === "stats" && <CharacteristicsEditor equipment={equipment} textContent={textContent} editing onUpdate={onUpdate} />}
{activeTab === "sockets" && <SocketItemsEditor equipment={equipment} textContent={textContent} editing onUpdate={onUpdate} />}
{activeTab === "craft" && (
<section className="equipment-planner-craft-tab">
<label className="equipment-planner-field">
<span>{textContent.obtainLabel || "Obtention"}</span>
<textarea {...obtainEdit.getInputProps({ placeholder: textContent.obtainPlaceholder || "Comment obtenir ou créer cet équipement" })} />
</label>
<MaterialsEditor equipment={equipment} textContent={textContent} editing onUpdate={onUpdate} />
</section>
)}
</>
) : (
<EquipmentDisplay equipment={equipment} textContent={textContent} />
)}
</div>
</li>
);
}
function EquipmentDisplay({ equipment, textContent }) {
const hasStats = equipment.characteristics.length > 0;
const hasSockets = equipment.socketItems.length > 0;
const hasCraft = Boolean(equipment.obtain || equipment.materials.length);
if (!hasStats && !hasSockets && !hasCraft) {
return <p className="equipment-planner-muted">{textContent.emptyEquipmentDetails || "Aucun détail renseigné."}</p>;
}
return (
<div className="equipment-planner-display-grid">
{hasStats && (
<section className="equipment-planner-display-panel">
<h4>{textContent.displayStatsTitle || "Statistiques"}</h4>
<ul>
{equipment.characteristics.map((trait) => (
<li className="equipment-planner-display-stat" key={trait.id}>
<Icon name={trait.icon || "sword"} />
<span>{trait.name}</span>
{trait.value !== "" && <b>{formatValue(trait.value)}</b>}
</li>
))}
</ul>
</section>
)}
{hasSockets && (
<section className="equipment-planner-display-panel">
<h4>{textContent.displaySocketTitle || "Objets sertis"}</h4>
<ul>
{equipment.socketItems.map((socketItem) => (
<li className="equipment-planner-display-socket" key={socketItem.id}>
<SocketShapeIcon shape={socketItem.shape} color={socketItem.color} />
<div>
<strong>{socketItem.name}</strong>
{socketItem.bonuses.map((bonus) => (
<small key={bonus.id}>{bonus.name}{bonus.value !== "" ? ` +${formatValue(bonus.value)}` : ""}</small>
))}
</div>
</li>
))}
</ul>
</section>
)}
{hasCraft && (
<section className="equipment-planner-display-panel">
<h4>{textContent.craftTab || "Craft"}</h4>
{equipment.obtain ? <p>{equipment.obtain}</p> : null}
{equipment.materials.length ? (
<ul>
{equipment.materials.map((material) => <li key={material.id}><span>{material.name}</span><b>x{material.qty}</b></li>)}
</ul>
) : null}
</section>
)}
</div>
);
}
function getTabLabel(tab, textContent) {
if (tab === "stats") return textContent.statsTab || "Stats / affixe";
if (tab === "sockets") return textContent.socketsTab || "Objets à sertir";
return textContent.craftTab || "Craft";
}
function TabIcon({ tab }) {
if (tab === "stats") return <span className="equipment-planner-percent-icon" aria-hidden="true">%</span>;
if (tab === "sockets") return <Icon name="jewel" />;
return <Icon name="hammer" />;
}
function CharacteristicsEditor({ equipment, textContent, editing, onUpdate }) {
const [draft, setDraft] = useState({ icon: "sword", category: "", name: "", value: "" });
const categories = getCharacteristicCategories(equipment);
const {
itemReorder,
groupReorder,
getItemProps,
getGroupProps,
isItemDragging,
isItemDropTarget,
isGroupDragging,
isGroupDropTarget,
getDropPlacement
} = useGroupedReorder({
namespace: `equipment-characteristics-${equipment.id}`,
items: equipment.characteristics,
getItemId: (trait) => trait.id,
getItemGroup: (trait) => trait.category,
getEffectiveGroup: (trait) => trait.category,
reorderFeatures: {
item: { groupChange: true },
group: { reorder: true, itemDrop: true }
},
onItemMove: (operation) => onUpdate((current) => applyGroupedReorderOperation(current, {
operation,
itemsKey: "characteristics",
groupOrderKey: "categoryOrder",
collapsedGroupsKey: "collapsedCategories",
getItemGroup: (trait) => trait.category,
setItemGroup: setItemCategory
})),
onGroupMove: (operation) => onUpdate((current) => applyGroupedReorderOperation(current, {
operation,
itemsKey: "characteristics",
groupOrderKey: "categoryOrder",
collapsedGroupsKey: "collapsedCategories",
getItemGroup: (trait) => trait.category,
setItemGroup: setItemCategory
}))
});
const reorder = { itemReorder, groupReorder, getItemProps, getGroupProps, isItemDragging, isItemDropTarget, isGroupDragging, isGroupDropTarget, getDropPlacement };
function addTrait(event) {
event.preventDefault();
const cleanName = draft.name.trim();
if (!cleanName) return;
onUpdate((current) => ({
...current,
characteristics: [...current.characteristics, { id: createLocalId("trait"), icon: draft.icon, category: draft.category.trim(), name: cleanName, value: draft.value.trim() }],
categoryOrder: draft.category.trim() && !current.categoryOrder.includes(draft.category.trim()) ? [...current.categoryOrder, draft.category.trim()] : current.categoryOrder
}));
setDraft({ icon: draft.icon, category: draft.category, name: "", value: "" });
}
function updateTrait(traitId, updater) {
onUpdate((current) => ({ ...current, characteristics: current.characteristics.map((trait) => trait.id === traitId ? updater(trait) : trait).filter((trait) => trait.name) }));
}
return (
<section className="equipment-planner-panel">
<h4>{textContent.characteristicsTitle || "Caractéristiques"}</h4>
{editing && (
<form className="inline-form equipment-planner-trait-form" onSubmit={addTrait}>
<IconChoiceDropdown
label={textContent.traitIconLabel || "Icône"}
value={draft.icon}
options={TRAIT_ICONS.map((icon) => ({ icon, label: icon }))}
onChange={(icon) => setDraft((current) => ({ ...current, icon }))}
/>
<input value={draft.category} onChange={(event) => setDraft((current) => ({ ...current, category: event.target.value }))} placeholder={textContent.categoryPlaceholder || "Catégorie"} />
<input value={draft.name} onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))} placeholder={textContent.traitNamePlaceholder || "Nom"} />
<input value={draft.value} onChange={(event) => setDraft((current) => ({ ...current, value: event.target.value }))} placeholder={textContent.traitValuePlaceholder || "Valeur"} />
<button>{textContent.addTraitButton || "Ajouter"}</button>
</form>
)}
<TraitCategoryList
traits={equipment.characteristics}
categories={categories}
collapsedCategories={equipment.collapsedCategories}
reorder={reorder}
textContent={textContent}
onToggleCategory={(category) => onUpdate((current) => ({
...current,
collapsedCategories: current.collapsedCategories.includes(category)
? current.collapsedCategories.filter((item) => item !== category)
: [...current.collapsedCategories, category]
}))}
onUpdateTrait={updateTrait}
editing={editing}
/>
</section>
);
}
function TraitCategoryList({ traits, categories, collapsedCategories, reorder, textContent, onToggleCategory, onUpdateTrait, editing }) {
const ungrouped = traits.filter((trait) => !trait.category);
function renderTrait(trait) {
const traitClassName = [
"equipment-planner-trait",
reorder.isItemDragging(trait.id) ? "is-dragging" : "",
reorder.isItemDropTarget(trait.id) ? "is-drop-target" : "",
reorder.getDropPlacement("item", trait.id) === "after" ? "drop-after" : ""
].filter(Boolean).join(" ");
return (
<EquipmentTraitRow
key={trait.id}
trait={trait}
className={traitClassName}
itemProps={reorder.getItemProps({ itemId: trait.id, groupId: trait.category })}
editing={editing}
textContent={textContent}
onStartDrag={(event) => reorder.itemReorder.startDrag(event, trait.id)}
onUpdateTrait={onUpdateTrait}
/>
);
}
return (
<div className="equipment-planner-trait-list">
{ungrouped.length ? <ul>{ungrouped.map(renderTrait)}</ul> : null}
{categories.map((category) => {
const categoryTraits = traits.filter((trait) => trait.category === category);
const collapsed = collapsedCategories.includes(category);
const className = [
"equipment-planner-trait-category",
collapsed ? "is-collapsed" : "",
reorder.isGroupDragging(category) ? "is-dragging" : "",
reorder.isGroupDropTarget(category) ? "is-drop-target" : ""
].filter(Boolean).join(" ");
return (
<section className={className} key={category} {...reorder.getGroupProps({ groupId: category })}>
<div className="equipment-planner-trait-category-header">
<button type="button" className="task-planner-category-drag-handle" onPointerDown={(event) => reorder.groupReorder.startDrag(event, { groupId: category })} title={textContent.reorderCategoryTitle || "Déplacer la catégorie"}>
<Icon name="drag" />
</button>
<strong>{category}</strong>
<span>{categoryTraits.length}</span>
<button type="button" className="checklist-section-collapse-button" onClick={() => onToggleCategory(category)} aria-expanded={!collapsed}>
<Icon name={collapsed ? "chevron-down" : "chevron-up"} />
</button>
</div>
{!collapsed && <ul>{categoryTraits.map(renderTrait)}</ul>}
</section>
);
})}
{!traits.length && <p className="equipment-planner-muted">{textContent.emptyCharacteristics || "Aucune caractéristique."}</p>}
</div>
);
}
function EquipmentTraitRow({ trait, className, itemProps, editing, textContent, onStartDrag, onUpdateTrait }) {
const categoryEdit = useInlineEdit({
value: trait.category || "",
onCommit: (category) => onUpdateTrait(trait.id, (current) => ({ ...current, category }))
});
const nameEdit = useInlineEdit({
value: trait.name,
transform: (draft) => String(draft || "").trim() || trait.name,
onCommit: (name) => onUpdateTrait(trait.id, (current) => ({ ...current, name }))
});
const valueEdit = useInlineEdit({
value: formatValue(trait.value),
onCommit: (value) => onUpdateTrait(trait.id, (current) => ({ ...current, value }))
});
return (
<li className={className} {...itemProps}>
{editing ? (
<>
<button type="button" className="task-planner-drag-handle" onPointerDown={onStartDrag} title={textContent.reorderTraitTitle || "Déplacer"}>
<Icon name="drag" />
</button>
<IconChoiceDropdown
label={textContent.traitIconLabel || "Icône"}
value={trait.icon || "sword"}
options={TRAIT_ICONS.map((icon) => ({ icon, label: icon }))}
onChange={(icon) => onUpdateTrait(trait.id, (current) => ({ ...current, icon }))}
/>
<input {...categoryEdit.getInputProps({ placeholder: textContent.categoryPlaceholder || "Catégorie" })} />
<input {...nameEdit.getInputProps({ placeholder: textContent.traitNamePlaceholder || "Nom" })} />
<input {...valueEdit.getInputProps({ placeholder: textContent.traitValuePlaceholder || "Valeur" })} />
<button type="button" className="task-planner-delete-button danger" onClick={() => onUpdateTrait(trait.id, () => ({ ...trait, name: "" }))} title={textContent.deleteTraitTitle || "Supprimer"}>
<Icon name="trash" />
</button>
</>
) : (
<div className="equipment-planner-trait-readonly">
<Icon name={trait.icon || "sword"} />
<span>{trait.name}</span>
{trait.value !== "" && <b>{formatValue(trait.value)}</b>}
</div>
)}
</li>
);
}
function MaterialsEditor({ equipment, textContent, editing, onUpdate }) {
const [draft, setDraft] = useState({ name: "", qty: 1 });
function addMaterial(event) {
event.preventDefault();
const cleanName = draft.name.trim();
if (!cleanName) return;
onUpdate((current) => ({
...current,
materials: [...current.materials, { id: createLocalId("material"), name: cleanName, qty: Math.max(1, Number.parseInt(draft.qty, 10) || 1) }]
}));
setDraft({ name: "", qty: 1 });
}
return (
<section className="equipment-planner-panel">
<h4>{textContent.materialsTitle || "Matériaux"}</h4>
{editing && (
<form className="inline-form equipment-planner-material-form" onSubmit={addMaterial}>
<input value={draft.name} onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))} placeholder={textContent.materialNamePlaceholder || "Matériau"} />
<input type="number" min="1" value={draft.qty} onChange={(event) => setDraft((current) => ({ ...current, qty: event.target.value }))} aria-label={textContent.materialQtyLabel || "Quantité"} />
<button>{textContent.addMaterialButton || "Ajouter"}</button>
</form>
)}
<ul className="equipment-planner-materials">
{equipment.materials.map((material) => (
<MaterialRow
key={material.id}
material={material}
editing={editing}
textContent={textContent}
onUpdate={onUpdate}
/>
))}
</ul>
</section>
);
}
function MaterialRow({ material, editing, textContent, onUpdate }) {
const nameEdit = useInlineEdit({
value: material.name,
transform: (draft) => String(draft || "").trim() || material.name,
onCommit: (name) => onUpdate((current) => ({ ...current, materials: current.materials.map((item) => item.id === material.id ? { ...item, name } : item) }))
});
return (
<li>
{editing ? (
<>
<input {...nameEdit.getInputProps()} />
<input type="number" min="1" value={material.qty} onChange={(event) => onUpdate((current) => ({ ...current, materials: current.materials.map((item) => item.id === material.id ? { ...item, qty: Math.max(1, Number.parseInt(event.target.value, 10) || 1) } : item) }))} />
<button type="button" className="task-planner-delete-button danger" onClick={() => onUpdate((current) => ({ ...current, materials: current.materials.filter((item) => item.id !== material.id) }))} title={textContent.deleteMaterialTitle || "Supprimer"}>
<Icon name="trash" />
</button>
</>
) : (
<div className="equipment-planner-material-readonly">
<span>{material.name}</span>
<b>x{material.qty}</b>
</div>
)}
</li>
);
}
function SocketItemsEditor({ equipment, textContent, editing, onUpdate }) {
const [draft, setDraft] = useState({ name: "", shape: "jewel", color: "yellow" });
function addSocketItem(event) {
event.preventDefault();
const cleanName = draft.name.trim();
if (!cleanName) return;
onUpdate((current) => ({
...current,
socketItems: [...current.socketItems, { id: createLocalId("socket"), name: cleanName, shape: draft.shape, color: draft.color, bonuses: [] }]
}));
setDraft({ ...draft, name: "" });
}
function updateSocketItem(socketItemId, updater) {
onUpdate((current) => ({
...current,
socketItems: current.socketItems.map((socketItem) => socketItem.id === socketItemId ? updater(socketItem) : socketItem).filter((socketItem) => socketItem.name)
}));
}
function deleteSocketItem(socketItemId) {
onUpdate((current) => ({
...current,
socketItems: current.socketItems.filter((socketItem) => socketItem.id !== socketItemId),
socketLinks: current.socketLinks.filter((link) => link.fromSocketItemId !== socketItemId && link.toSocketItemId !== socketItemId)
}));
}
function addSocketLink(fromSocketItemId, toSocketItemId) {
if (!toSocketItemId || fromSocketItemId === toSocketItemId) return;
const pair = [fromSocketItemId, toSocketItemId].sort().join(":");
if (equipment.socketLinks.some((link) => [link.fromSocketItemId, link.toSocketItemId].sort().join(":") === pair)) return;
onUpdate((current) => ({
...current,
socketLinks: [...current.socketLinks, { id: createLocalId("socket-link"), fromSocketItemId, toSocketItemId }]
}));
}
return (
<section className="equipment-planner-panel">
<h4>{textContent.socketItemsTitle || "Objets sertis"}</h4>
{editing && (
<form className="inline-form equipment-planner-socket-form" onSubmit={addSocketItem}>
<input value={draft.name} onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))} placeholder={textContent.socketNamePlaceholder || "Joyau, materia..."} />
<ShapeChoiceDropdown label={textContent.socketShapeLabel || "Forme"} value={draft.shape} onChange={(shape) => setDraft((current) => ({ ...current, shape }))} />
<ColorChoiceDropdown label={textContent.socketColorLabel || "Couleur"} value={draft.color} onChange={(color) => setDraft((current) => ({ ...current, color }))} />
<button>{textContent.addSocketButton || "Ajouter"}</button>
</form>
)}
<div className="equipment-planner-socket-grid">
{equipment.socketItems.map((socketItem) => (
<SocketItemEditor
key={socketItem.id}
equipment={equipment}
socketItem={socketItem}
textContent={textContent}
editing={editing}
onUpdate={(updater) => updateSocketItem(socketItem.id, updater)}
onDelete={() => deleteSocketItem(socketItem.id)}
onAddLink={(toSocketItemId) => addSocketLink(socketItem.id, toSocketItemId)}
onDeleteLink={(linkId) => onUpdate((current) => ({ ...current, socketLinks: current.socketLinks.filter((link) => link.id !== linkId) }))}
/>
))}
</div>
</section>
);
}
function SocketItemEditor({ equipment, socketItem, textContent, editing, onUpdate, onDelete, onAddLink, onDeleteLink }) {
const [bonusDraft, setBonusDraft] = useState({ name: "", value: "" });
const links = equipment.socketLinks.filter((link) => link.fromSocketItemId === socketItem.id || link.toSocketItemId === socketItem.id);
const nameEdit = useInlineEdit({
value: socketItem.name,
transform: (draft) => String(draft || "").trim() || socketItem.name,
onCommit: (name) => onUpdate((current) => ({ ...current, name }))
});
function addBonus(event) {
event.preventDefault();
const cleanName = bonusDraft.name.trim();
if (!cleanName) return;
onUpdate((current) => ({
...current,
bonuses: [...current.bonuses, { id: createLocalId("bonus"), category: "", name: cleanName, value: bonusDraft.value.trim() }]
}));
setBonusDraft({ name: "", value: "" });
}
return (
<article className="equipment-planner-socket-card">
<div className="equipment-planner-socket-heading">
<SocketShapeIcon shape={socketItem.shape} color={socketItem.color} />
{editing ? (
<>
<input {...nameEdit.getInputProps()} />
<button type="button" className="task-planner-delete-button danger" onClick={onDelete} title={textContent.deleteSocketTitle || "Supprimer"}>
<Icon name="trash" />
</button>
</>
) : (
<strong>{socketItem.name}</strong>
)}
</div>
{editing && (
<>
<div className="equipment-planner-socket-controls">
<ShapeChoiceDropdown label={textContent.socketShapeLabel || "Forme"} value={socketItem.shape} onChange={(shape) => onUpdate((current) => ({ ...current, shape }))} />
<ColorChoiceDropdown label={textContent.socketColorLabel || "Couleur"} value={socketItem.color} onChange={(color) => onUpdate((current) => ({ ...current, color }))} />
{equipment.socketItems.length > 1 && (
<select defaultValue="" onChange={(event) => { onAddLink(event.target.value); event.target.value = ""; }} aria-label={textContent.addSocketLinkLabel || "Lier à"}>
<option value="">{textContent.addSocketLinkLabel || "Lier à"}</option>
{equipment.socketItems.filter((item) => item.id !== socketItem.id).map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
)}
</div>
<form className="inline-form equipment-planner-bonus-form" onSubmit={addBonus}>
<input value={bonusDraft.name} onChange={(event) => setBonusDraft((current) => ({ ...current, name: event.target.value }))} placeholder={textContent.bonusNamePlaceholder || "Bonus"} />
<input value={bonusDraft.value} onChange={(event) => setBonusDraft((current) => ({ ...current, value: event.target.value }))} placeholder={textContent.traitValuePlaceholder || "Valeur"} />
<button aria-label={textContent.addBonusButton || "Ajouter"} title={textContent.addBonusButton || "Ajouter"}>+</button>
</form>
</>
)}
<ul className="equipment-planner-socket-bonuses">
{socketItem.bonuses.map((bonus) => (
<li key={bonus.id}>
<span>{bonus.category ? `${bonus.category} · ` : ""}{bonus.name}</span>
<b>{formatValue(bonus.value)}</b>
{editing && (
<button type="button" className="task-planner-delete-button danger" onClick={() => onUpdate((current) => ({ ...current, bonuses: current.bonuses.filter((item) => item.id !== bonus.id) }))} title={textContent.deleteBonusTitle || "Supprimer"}>
<Icon name="trash" />
</button>
)}
</li>
))}
</ul>
{links.length ? (
<div className={`equipment-planner-socket-links ${editing ? "is-editing" : ""}`}>
{links.map((link) => {
const otherSocketItemId = link.fromSocketItemId === socketItem.id ? link.toSocketItemId : link.fromSocketItemId;
return (
<button key={link.id} type="button" className="equipment-planner-link-chip" onClick={editing ? () => onDeleteLink(link.id) : undefined} title={editing ? textContent.deleteSocketLinkTitle || "Retirer le lien" : getSocketItemName(equipment, otherSocketItemId)}>
<span>{getSocketItemName(equipment, otherSocketItemId)}</span>
{editing && <Icon name="close" />}
</button>
);
})}
</div>
) : null}
</article>
);
}

View file

@ -0,0 +1,193 @@
// Rôle : regroupe les menus flottants de sélection d'icônes, formes et couleurs de l'équipement.
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Icon } from "../../../components/Icon.jsx";
import { SOCKET_COLORS, SOCKET_COLOR_VALUES, SOCKET_SHAPES } from "./equipmentPlannerOptions.js";
export function SocketShapeIcon({ shape, color = "gray", className = "" }) {
return (
<span className={`equipment-planner-socket-icon color-${color} ${className}`.trim()} style={{ "--equipment-socket-icon-color": SOCKET_COLOR_VALUES[color] || SOCKET_COLOR_VALUES.gray }} aria-hidden="true">
<Icon name={shape} />
</span>
);
}
export function IconChoiceDropdown({ className = "", label, value, options, onChange }) {
const selected = options.find((option) => option.icon === value) || options[0];
return (
<FloatingPicker
className={className}
label={label}
columns={Math.min(options.length, 5)}
renderTrigger={({ open, toggle }) => (
<button
className={`equipment-planner-picker-trigger ${open ? "active" : ""}`}
type="button"
onClick={toggle}
aria-label={label}
aria-expanded={open}
title={selected.label}
>
<Icon name={selected.icon} />
<Icon name="dropdown" />
</button>
)}
>
{({ close }) => (
<div className="equipment-planner-picker-grid">
{options.map((option) => (
<button
key={option.icon}
className={`equipment-planner-picker-option ${option.icon === value ? "active" : ""}`}
type="button"
onClick={() => {
onChange(option.icon);
close();
}}
aria-label={option.label}
title={option.label}
>
<Icon name={option.icon} />
</button>
))}
</div>
)}
</FloatingPicker>
);
}
export function ShapeChoiceDropdown({ label, value, onChange }) {
return (
<FloatingPicker
className="equipment-planner-shape-picker"
label={label}
columns={SOCKET_SHAPES.length}
renderTrigger={({ open, toggle }) => (
<button className={`equipment-planner-picker-trigger ${open ? "active" : ""}`} type="button" onClick={toggle} aria-label={label} aria-expanded={open} title={label}>
<SocketShapeIcon shape={value} />
<Icon name="dropdown" />
</button>
)}
>
{({ close }) => (
<div className="equipment-planner-picker-grid">
{SOCKET_SHAPES.map((shape) => (
<button
key={shape}
className={`equipment-planner-picker-option ${shape === value ? "active" : ""}`}
type="button"
onClick={() => {
onChange(shape);
close();
}}
aria-label={shape}
title={shape}
>
<SocketShapeIcon shape={shape} />
</button>
))}
</div>
)}
</FloatingPicker>
);
}
export function ColorChoiceDropdown({ label, value, onChange }) {
return (
<FloatingPicker
label={label}
columns={6}
renderTrigger={({ open, toggle }) => (
<button className={`equipment-planner-picker-trigger ${open ? "active" : ""}`} type="button" onClick={toggle} aria-label={label} aria-expanded={open} title={value}>
<span className={`equipment-planner-color-swatch color-${value}`} aria-hidden="true" />
<Icon name="dropdown" />
</button>
)}
>
{({ close }) => (
<div className="equipment-planner-color-grid">
{SOCKET_COLORS.map((color) => (
<button
key={color}
className={`equipment-planner-color-option ${color === value ? "active" : ""}`}
type="button"
onClick={() => {
onChange(color);
close();
}}
aria-label={color}
title={color}
>
<span className={`equipment-planner-color-swatch color-${color}`} aria-hidden="true" />
</button>
))}
</div>
)}
</FloatingPicker>
);
}
function FloatingPicker({ className = "", label, columns = 5, renderTrigger, children }) {
const [open, setOpen] = useState(false);
const [menuStyle, setMenuStyle] = useState({});
const rootRef = useRef(null);
const menuRef = useRef(null);
useEffect(() => {
if (!open) return undefined;
function placeMenu() {
const trigger = rootRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const width = Math.max(0, Math.min(220, window.innerWidth - 16));
const left = Math.min(Math.max(8, rect.left), Math.max(8, window.innerWidth - width - 8));
setMenuStyle({
top: `${Math.min(rect.bottom + 6, window.innerHeight - 8)}px`,
left: `${left}px`,
maxWidth: `${width}px`
});
}
function handlePointerDown(event) {
if (rootRef.current?.contains(event.target) || menuRef.current?.contains(event.target)) return;
setOpen(false);
}
function handleKeyDown(event) {
if (event.key === "Escape") setOpen(false);
}
placeMenu();
window.addEventListener("resize", placeMenu);
window.addEventListener("scroll", placeMenu, true);
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("resize", placeMenu);
window.removeEventListener("scroll", placeMenu, true);
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
function close() {
setOpen(false);
}
function toggle() {
setOpen((value) => !value);
}
return (
<div className={`equipment-planner-picker ${className} ${open ? "is-open" : ""}`.trim()} ref={rootRef} aria-label={label}>
{renderTrigger({ open, toggle, close })}
{open && createPortal(
<div className="equipment-planner-picker-menu is-open" ref={menuRef} style={{ ...menuStyle, "--picker-columns": columns }} aria-label={label}>
{children({ close })}
</div>,
document.body
)}
</div>
);
}

View file

@ -0,0 +1,91 @@
// Rôle : affiche le résumé agrégé du planificateur d'équipements.
import { Icon } from "../../../components/Icon.jsx";
import { Tooltip } from "../../../components/Tooltip.jsx";
import { formatValue, SOCKET_SHAPES } from "./equipmentPlannerOptions.js";
import { SocketShapeIcon } from "./EquipmentPlannerPickers.jsx";
export function EquipmentPlannerSummary({ summary, textContent }) {
const hasSourceTotals = summary.equipmentTotals.length || summary.socketTotals.length;
const sourceColumnCount = [summary.equipmentTotals.length, summary.socketTotals.length].filter(Boolean).length;
return (
<section className="equipment-planner-summary" aria-label={textContent.summaryTitle || "Résumé"}>
<div className="equipment-planner-summary-header">
<h3>{textContent.summaryTitle || "Résumé"}</h3>
<span>{summary.activeEquipmentCount} {summary.activeEquipmentCount > 1 ? textContent.activeEquipmentsPlural || "équipements actifs" : textContent.activeEquipmentsSingular || "équipement actif"}</span>
</div>
<div className="equipment-planner-summary-grid">
{hasSourceTotals ? (
<div className={`equipment-planner-summary-source-grid columns-${sourceColumnCount}`}>
{summary.equipmentTotals.length ? <SummaryTotalsPanel title={textContent.equipmentTotalsTitle || "Statistiques de l'équipement"} totals={summary.equipmentTotals} textContent={textContent} /> : null}
{summary.socketTotals.length ? <SummaryTotalsPanel title={textContent.socketTotalsTitle || "Objets sertis"} totals={summary.socketTotals} textContent={textContent} /> : null}
</div>
) : null}
{summary.mixedTotals.length ? <SummaryTotalsPanel title={textContent.mixedTotalsTitle || "Totaux fusionnés"} totals={summary.mixedTotals} textContent={textContent} /> : null}
{!summary.totals.length ? (
<div className="equipment-planner-summary-panel">
<strong>{textContent.numericTotalsTitle || "Totaux"}</strong>
<p>{textContent.emptyTotals || "Aucun total numérique."}</p>
</div>
) : null}
{summary.textBonuses.length ? (
<div className="equipment-planner-summary-panel equipment-planner-summary-text-bonuses">
<strong>{textContent.textBonusesTitle || "Bonus"}</strong>
<ul>
{summary.textBonuses.map((bonus) => (
<li key={`${bonus.source}:${bonus.id}`}>
<span>{bonus.category ? `${bonus.category} · ` : ""}{bonus.name}</span>
<small>{bonus.source}{bonus.value ? ` · ${bonus.value}` : ""}</small>
</li>
))}
</ul>
</div>
) : null}
</div>
</section>
);
}
function SummaryTotalsPanel({ title, totals, textContent }) {
return (
<div className="equipment-planner-summary-panel">
<strong>{title}</strong>
<ul>
{totals.map((total) => (
<Tooltip
as="li"
className="equipment-planner-summary-total"
content={<SummaryTotalTooltip total={total} textContent={textContent} />}
key={total.name}
position="top-start"
>
<SummaryTotalIcon total={total} />
<span>{total.name}</span>
<b>{formatValue(total.value)}</b>
</Tooltip>
))}
</ul>
</div>
);
}
function SummaryTotalIcon({ total }) {
if (SOCKET_SHAPES.includes(total.icon)) return <SocketShapeIcon shape={total.icon} color={total.color || "gray"} />;
return <Icon name={total.icon || "sword"} />;
}
function SummaryTotalTooltip({ total, textContent }) {
return (
<span className="equipment-planner-summary-tooltip">
<strong>{textContent.summaryTooltipTitle || "Provenance"}</strong>
<span>
{(total.contributions || []).map((contribution) => (
<span className="equipment-planner-summary-tooltip-row" key={`${contribution.sourceKind}:${contribution.id}:${contribution.sourceName}`}>
<Icon name={contribution.equipmentIcon || "shield"} />
<span>{contribution.sourceName || contribution.equipmentName}</span>
<b>{formatValue(contribution.value)}</b>
</span>
))}
</span>
</span>
);
}

View file

@ -0,0 +1,93 @@
// Rôle : rend les en-têtes et lignes de cellules du module Table.
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { cellAddress } from "./tableFormulaEngine.js";
import { isCellInBounds, isCellSelected } from "./tableUtils.js";
export function TableRow({ rowIndex, columns, cells, evaluatedCells, editingCell, highlightedCell, textContent, rowLabel, selection, movePreview, onRowLabelChange, onFocus, onBlur, onCellChange, onFormulaSelectionStart, onFormulaSelectionExtend, onStartMoveSelection, onMoveSelection, onSelectCell, onExtendSelection, onPasteSelection, draggingSelectionRef, onCellKeyDown, onStartEditing, registerInput }) {
return (
<>
<HeaderLabelInput
className="table-row-header table-header-input"
value={rowLabel || String(rowIndex + 1)}
fallback={String(rowIndex + 1)}
onSave={(value) => onRowLabelChange(rowIndex, value)}
aria-label={`${textContent.rowLabel || "Intitulé de ligne"} ${rowIndex + 1}`}
title={`${rowIndex + 1} - ${textContent.rowFormulaTitle || "Référence conservée dans les formules"}`}
/>
{Array.from({ length: columns }, (_, columnIndex) => {
const address = cellAddress(rowIndex, columnIndex);
const rawValue = cells[address] || "";
const evaluated = evaluatedCells[address];
const isEditing = editingCell === address;
const isFormula = rawValue.startsWith("=");
const displayValue = isEditing ? rawValue : evaluated?.display || rawValue;
const className = [
"table-cell-input",
isEditing ? "is-editing" : "",
isFormula ? "is-formula" : "",
evaluated?.error && evaluated.error !== "empty" && !isEditing ? "has-error" : "",
isCellSelected(selection, rowIndex, columnIndex) ? "is-selected" : "",
isCellInBounds(movePreview, rowIndex, columnIndex) ? "is-move-target" : "",
highlightedCell === address ? "is-referenced" : ""
].filter(Boolean).join(" ");
return (
<input
key={address}
ref={(element) => registerInput(address, element)}
className={className}
value={displayValue}
onMouseDown={(event) => {
if (onFormulaSelectionStart(rowIndex, columnIndex)) {
event.preventDefault();
} else if (!event.shiftKey && onStartMoveSelection(rowIndex, columnIndex)) {
event.preventDefault();
} else {
draggingSelectionRef.current = true;
onSelectCell(rowIndex, columnIndex, event.shiftKey);
}
}}
onDoubleClick={() => onStartEditing(address, null, { select: true })}
onMouseEnter={() => {
if (onFormulaSelectionExtend(rowIndex, columnIndex)) return;
if (onMoveSelection(rowIndex, columnIndex)) return;
if (draggingSelectionRef.current) onExtendSelection(rowIndex, columnIndex);
}}
onFocus={() => onFocus(address)}
onBlur={() => onBlur(address)}
onChange={(event) => onCellChange(address, event.target.value)}
onKeyDown={(event) => onCellKeyDown(event, rowIndex, columnIndex, address, isEditing)}
onPaste={(event) => onPasteSelection(event, rowIndex, columnIndex, isEditing)}
aria-label={`${textContent.cellLabel || "Cellule"} ${address}`}
title={evaluated?.error && evaluated.error !== "empty" ? textContent.errorTitle || "Formule invalide" : address}
inputMode="text"
readOnly={!isEditing}
spellCheck="false"
/>
);
})}
</>
);
}
export function HeaderLabelInput({ className, value, fallback, onSave, ...props }) {
const inlineEdit = useInlineEdit({
value,
onCommit: onSave,
transform: (draft) => String(draft || ""),
blurOnEscape: true
});
return (
<input
{...inlineEdit.getInputProps({
...props,
className,
onFocus: (event) => {
if (value === fallback) window.requestAnimationFrame(() => event.target.select());
},
spellCheck: "false"
})}
/>
);
}

View file

@ -1,210 +1,30 @@
// Rôle : fournit l'outil tableau avec cellules libres, formules simples et échange TSV.
import { useEffect, useMemo, useRef, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { cellAddress, columnIndexToName, evaluateTable, parseCellAddress } from "./tableFormulaEngine.js";
const DEFAULT_ROWS = 10;
const DEFAULT_COLUMNS = 6;
const MAX_ROWS = 50;
const MAX_COLUMNS = 20;
const MIN_COLUMN_WIDTH = 96;
const MAX_COLUMN_WIDTH = 220;
const MIN_ROW_HEADER_WIDTH = 42;
const MAX_ROW_HEADER_WIDTH = 180;
function cleanCells(cells, rows, columns) {
const nextCells = {};
for (let rowIndex = 0; rowIndex < rows; rowIndex += 1) {
for (let columnIndex = 0; columnIndex < columns; columnIndex += 1) {
const address = cellAddress(rowIndex, columnIndex);
const value = String(cells?.[address] || "");
if (value.trim()) nextCells[address] = value;
}
}
return nextCells;
}
function cleanLabels(labels, length, getFallback) {
const nextLabels = {};
for (let index = 0; index < length; index += 1) {
const value = String(labels?.[index] || "").slice(0, 80);
if (value.trim() && value !== String(getFallback(index))) nextLabels[index] = value;
}
return nextLabels;
}
function estimateTextWidth(value, minWidth, maxWidth) {
const length = String(value || "").length;
return Math.min(maxWidth, Math.max(minWidth, 24 + length * 8));
}
function getColumnWidths(data, evaluatedCells) {
return Array.from({ length: data.columns }, (_, columnIndex) => {
let width = estimateTextWidth(data.columnLabels?.[columnIndex] || columnIndexToName(columnIndex), MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH);
for (let rowIndex = 0; rowIndex < data.rows; rowIndex += 1) {
const address = cellAddress(rowIndex, columnIndex);
const value = evaluatedCells[address]?.display || data.cells[address] || "";
width = Math.max(width, estimateTextWidth(value, MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH));
}
return width;
});
}
function getRowHeaderWidth(data) {
return Array.from({ length: data.rows }, (_, rowIndex) => data.rowLabels?.[rowIndex] || rowIndex + 1)
.reduce((width, label) => Math.max(width, estimateTextWidth(label, MIN_ROW_HEADER_WIDTH, MAX_ROW_HEADER_WIDTH)), MIN_ROW_HEADER_WIDTH);
}
function getSelectionBounds(selection) {
if (!selection?.anchor || !selection?.focus) return null;
return {
startRow: Math.min(selection.anchor.rowIndex, selection.focus.rowIndex),
endRow: Math.max(selection.anchor.rowIndex, selection.focus.rowIndex),
startColumn: Math.min(selection.anchor.columnIndex, selection.focus.columnIndex),
endColumn: Math.max(selection.anchor.columnIndex, selection.focus.columnIndex)
};
}
function isCellSelected(selection, rowIndex, columnIndex) {
const bounds = getSelectionBounds(selection);
return Boolean(bounds && rowIndex >= bounds.startRow && rowIndex <= bounds.endRow && columnIndex >= bounds.startColumn && columnIndex <= bounds.endColumn);
}
function isCellInBounds(bounds, rowIndex, columnIndex) {
return Boolean(bounds && rowIndex >= bounds.startRow && rowIndex <= bounds.endRow && columnIndex >= bounds.startColumn && columnIndex <= bounds.endColumn);
}
function isMultiCellSelection(selection) {
const bounds = getSelectionBounds(selection);
return Boolean(bounds && (bounds.startRow !== bounds.endRow || bounds.startColumn !== bounds.endColumn));
}
function clampCellPosition(rowIndex, columnIndex, rows, columns) {
return {
rowIndex: Math.min(rows - 1, Math.max(0, rowIndex)),
columnIndex: Math.min(columns - 1, Math.max(0, columnIndex))
};
}
function getRangeAddresses(selection) {
const bounds = getSelectionBounds(selection);
if (!bounds) return [];
const addresses = [];
for (let rowIndex = bounds.startRow; rowIndex <= bounds.endRow; rowIndex += 1) {
for (let columnIndex = bounds.startColumn; columnIndex <= bounds.endColumn; columnIndex += 1) {
addresses.push(cellAddress(rowIndex, columnIndex));
}
}
return addresses;
}
function getFormulaRangeText(selection) {
const addresses = getRangeAddresses(selection);
if (!addresses.length) return "";
return addresses.length === 1 ? addresses[0] : `(${addresses.join("+")})`;
}
function getCellBlock(cells, bounds) {
if (!bounds) return null;
const values = [];
for (let rowIndex = bounds.startRow; rowIndex <= bounds.endRow; rowIndex += 1) {
const row = [];
for (let columnIndex = bounds.startColumn; columnIndex <= bounds.endColumn; columnIndex += 1) {
row.push(cells[cellAddress(rowIndex, columnIndex)] || "");
}
values.push(row);
}
return {
height: bounds.endRow - bounds.startRow + 1,
width: bounds.endColumn - bounds.startColumn + 1,
values
};
}
function parseTsvBlock(text) {
const normalizedText = String(text || "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const lines = normalizedText.endsWith("\n") ? normalizedText.slice(0, -1).split("\n") : normalizedText.split("\n");
if (!lines.length || (lines.length === 1 && !lines[0])) return null;
const values = lines.map((line) => line.split("\t"));
return {
height: values.length,
width: Math.max(...values.map((row) => row.length)),
values
};
}
function getBoundsFromStartAndSize(rowIndex, columnIndex, height, width) {
return {
startRow: rowIndex,
endRow: rowIndex + height - 1,
startColumn: columnIndex,
endColumn: columnIndex + width - 1
};
}
function isSameBounds(firstBounds, secondBounds) {
return Boolean(firstBounds && secondBounds
&& firstBounds.startRow === secondBounds.startRow
&& firstBounds.endRow === secondBounds.endRow
&& firstBounds.startColumn === secondBounds.startColumn
&& firstBounds.endColumn === secondBounds.endColumn);
}
function moveSelectionCells(cells, sourceBounds, targetBounds) {
const nextCells = { ...cells };
const snapshot = [];
for (let rowIndex = sourceBounds.startRow; rowIndex <= sourceBounds.endRow; rowIndex += 1) {
for (let columnIndex = sourceBounds.startColumn; columnIndex <= sourceBounds.endColumn; columnIndex += 1) {
const value = cells[cellAddress(rowIndex, columnIndex)] || "";
snapshot.push({
rowOffset: rowIndex - sourceBounds.startRow,
columnOffset: columnIndex - sourceBounds.startColumn,
value
});
delete nextCells[cellAddress(rowIndex, columnIndex)];
}
}
for (let rowIndex = targetBounds.startRow; rowIndex <= targetBounds.endRow; rowIndex += 1) {
for (let columnIndex = targetBounds.startColumn; columnIndex <= targetBounds.endColumn; columnIndex += 1) {
delete nextCells[cellAddress(rowIndex, columnIndex)];
}
}
snapshot.forEach(({ rowOffset, columnOffset, value }) => {
if (!String(value || "").trim()) return;
nextCells[cellAddress(targetBounds.startRow + rowOffset, targetBounds.startColumn + columnOffset)] = value;
});
return nextCells;
}
function pasteCellBlock(cells, block, targetRow, targetColumn, rows, columns) {
const clippedHeight = Math.min(block?.height || 0, rows - targetRow);
const clippedWidth = Math.min(block?.width || 0, columns - targetColumn);
if (clippedHeight <= 0 || clippedWidth <= 0) return null;
const nextCells = { ...cells };
for (let rowOffset = 0; rowOffset < clippedHeight; rowOffset += 1) {
for (let columnOffset = 0; columnOffset < clippedWidth; columnOffset += 1) {
const address = cellAddress(targetRow + rowOffset, targetColumn + columnOffset);
const value = String(block.values[rowOffset]?.[columnOffset] || "");
if (value.trim()) {
nextCells[address] = value;
} else {
delete nextCells[address];
}
}
}
return {
cells: nextCells,
bounds: getBoundsFromStartAndSize(targetRow, targetColumn, clippedHeight, clippedWidth)
};
}
import { HeaderLabelInput, TableRow } from "./TableGridParts.jsx";
import {
DEFAULT_COLUMNS,
DEFAULT_ROWS,
MAX_COLUMNS,
MAX_ROWS,
clampCellPosition,
cleanCells,
cleanLabels,
getBoundsFromStartAndSize,
getCellBlock,
getColumnWidths,
getFormulaRangeText,
getRowHeaderWidth,
getSelectionBounds,
isCellSelected,
isMultiCellSelection,
isSameBounds,
moveSelectionCells,
parseTsvBlock,
pasteCellBlock
} from "./tableUtils.js";
export function TableModule({ toolboxId, moduleId, context, editing }) {
const data = context.normalizeTableData(context.getModuleData(toolboxId, moduleId, { rows: DEFAULT_ROWS, columns: DEFAULT_COLUMNS, cells: {} }));
@ -716,92 +536,3 @@ export function TableModule({ toolboxId, moduleId, context, editing }) {
</div>
);
}
function TableRow({ rowIndex, columns, cells, evaluatedCells, editingCell, highlightedCell, textContent, rowLabel, selection, movePreview, onRowLabelChange, onFocus, onBlur, onCellChange, onFormulaSelectionStart, onFormulaSelectionExtend, onStartMoveSelection, onMoveSelection, onSelectCell, onExtendSelection, onPasteSelection, draggingSelectionRef, onCellKeyDown, onStartEditing, registerInput }) {
return (
<>
<HeaderLabelInput
className="table-row-header table-header-input"
value={rowLabel || String(rowIndex + 1)}
fallback={String(rowIndex + 1)}
onSave={(value) => onRowLabelChange(rowIndex, value)}
aria-label={`${textContent.rowLabel || "Intitulé de ligne"} ${rowIndex + 1}`}
title={`${rowIndex + 1} - ${textContent.rowFormulaTitle || "Référence conservée dans les formules"}`}
/>
{Array.from({ length: columns }, (_, columnIndex) => {
const address = cellAddress(rowIndex, columnIndex);
const rawValue = cells[address] || "";
const evaluated = evaluatedCells[address];
const isEditing = editingCell === address;
const isFormula = rawValue.startsWith("=");
const displayValue = isEditing ? rawValue : evaluated?.display || rawValue;
const className = [
"table-cell-input",
isEditing ? "is-editing" : "",
isFormula ? "is-formula" : "",
evaluated?.error && evaluated.error !== "empty" && !isEditing ? "has-error" : "",
isCellSelected(selection, rowIndex, columnIndex) ? "is-selected" : "",
isCellInBounds(movePreview, rowIndex, columnIndex) ? "is-move-target" : "",
highlightedCell === address ? "is-referenced" : ""
].filter(Boolean).join(" ");
return (
<input
key={address}
ref={(element) => registerInput(address, element)}
className={className}
value={displayValue}
onMouseDown={(event) => {
if (onFormulaSelectionStart(rowIndex, columnIndex)) {
event.preventDefault();
} else if (!event.shiftKey && onStartMoveSelection(rowIndex, columnIndex)) {
event.preventDefault();
} else {
draggingSelectionRef.current = true;
onSelectCell(rowIndex, columnIndex, event.shiftKey);
}
}}
onDoubleClick={() => onStartEditing(address, null, { select: true })}
onMouseEnter={() => {
if (onFormulaSelectionExtend(rowIndex, columnIndex)) return;
if (onMoveSelection(rowIndex, columnIndex)) return;
if (draggingSelectionRef.current) onExtendSelection(rowIndex, columnIndex);
}}
onFocus={() => onFocus(address)}
onBlur={() => onBlur(address)}
onChange={(event) => onCellChange(address, event.target.value)}
onKeyDown={(event) => onCellKeyDown(event, rowIndex, columnIndex, address, isEditing)}
onPaste={(event) => onPasteSelection(event, rowIndex, columnIndex, isEditing)}
aria-label={`${textContent.cellLabel || "Cellule"} ${address}`}
title={evaluated?.error && evaluated.error !== "empty" ? textContent.errorTitle || "Formule invalide" : address}
inputMode="text"
readOnly={!isEditing}
spellCheck="false"
/>
);
})}
</>
);
}
function HeaderLabelInput({ className, value, fallback, onSave, ...props }) {
const inlineEdit = useInlineEdit({
value,
onCommit: onSave,
transform: (draft) => String(draft || ""),
blurOnEscape: true
});
return (
<input
{...inlineEdit.getInputProps({
...props,
className,
onFocus: (event) => {
if (value === fallback) window.requestAnimationFrame(() => event.target.select());
},
spellCheck: "false"
})}
/>
);
}

View file

@ -3,118 +3,31 @@ import { useEffect, useMemo, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { Tabs } from "../../../components/Tabs.jsx";
import { useDraftForm } from "../../../hooks/useDraftForm.js";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import {
formatDuration,
formatDurationWithCentiseconds,
formatTargetTime,
getCentiseconds,
getCountdownTargetMs,
getDailyTargetMs,
getIntervalAnchorMs,
getNowMs,
getTimePatternTargetMs,
getTimePatternRecurrenceMs,
timePartsToDurationMs,
timePartsToString
} from "./timerUtils.js";
import { TextExchangeActions } from "./TextExchangeActions.jsx";
const TIMER_TABS = [
{ id: "stopwatch", label: "Chronomètre", icon: "stopwatch" },
{ id: "countdown", label: "Compte à rebours", icon: "hourglass" }
];
const COUNTDOWN_TYPES = ["duration", "daily_time", "time_pattern", "interval"];
const EMPTY_TIME_PARTS = { hours: "", minutes: "", seconds: "" };
const DEFAULT_COUNTDOWN_FORM = {
label: "",
type: "duration",
duration: EMPTY_TIME_PARTS,
time: EMPTY_TIME_PARTS,
pattern: EMPTY_TIME_PARTS,
interval: EMPTY_TIME_PARTS,
intervalStartTime: EMPTY_TIME_PARTS
};
const ALERT_MODES = [
{ mode: "off", icon: "sound-mute" },
{ mode: "visible", icon: "sound-min" },
{ mode: "site", icon: "sound-max" }
];
const ALERT_MIN_DELAY_MS = 5 * 60 * 1000;
const AUTO_REFRESH_COUNTDOWN_TYPES = new Set(["time_pattern", "interval"]);
const TOGGLE_REFRESH_COUNTDOWN_TYPES = new Set(["daily_time", "interval"]);
function isAlertGuarded(countdown) {
if (countdown?.type === "interval") return Number(countdown.intervalMs) < ALERT_MIN_DELAY_MS;
if (countdown?.type === "time_pattern") return getTimePatternRecurrenceMs(countdown.pattern) < ALERT_MIN_DELAY_MS;
return false;
}
function getAlertGuardMessage(textContent) {
return textContent.alertTooSoonTitle || "Alerte indisponible pour une répétition inférieure à 5 minutes";
}
function canToggleAutoRefresh(countdown) {
return TOGGLE_REFRESH_COUNTDOWN_TYPES.has(countdown?.type) && !isAlertGuarded(countdown);
}
function TimerValue({ ms, compact = false, showCentiseconds = true }) {
if (!showCentiseconds) return <span className="timer-value">{formatDuration(ms)}</span>;
if (!compact) return <span className="timer-value">{formatDurationWithCentiseconds(ms)}</span>;
return (
<span className="timer-value is-compact">
<span>{formatDuration(ms)}</span>
<small>{getCentiseconds(ms)}</small>
</span>
);
}
function getCountdownMetaParts(countdown, textContent) {
if (countdown.type === "duration") return [`${textContent.durationType || "Durée"} ${formatDuration(countdown.durationMs || 0)}`];
if (countdown.type === "daily_time") return [`${textContent.dailyTimeType || "Heure précise"} ${countdown.time}`];
if (countdown.type === "time_pattern") return [`${textContent.timePatternType || "Heure / minute / seconde"} ${countdown.pattern}`];
if (countdown.type === "interval") {
const startLabel = countdown.startMode === "time" && countdown.startTime
? `${textContent.intervalStartTimeLabel || "Départ"} ${countdown.startTime}`
: `${textContent.intervalStartTimeLabel || "Départ"} ${formatTargetTime(countdown.anchorAt)}`;
return [`${textContent.intervalType || "Intervalle"} ${formatDuration(countdown.intervalMs || 0)}`, startLabel];
}
return [];
}
function handleEditableKeyDown(event, callback) {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
callback();
}
function hasTimePartValue(parts) {
return Boolean(String(parts?.hours || "").trim() || String(parts?.minutes || "").trim() || String(parts?.seconds || "").trim());
}
function getCountdownValidationError(type, draft, textContent) {
const requiredError = textContent.requiredError || "Valeur requise.";
const invalidError = textContent.invalidError || "Valeur invalide.";
if (type === "duration" && timePartsToDurationMs(draft.duration) <= 0) {
return requiredError;
}
if (type === "daily_time" && !timePartsToString(draft.time)) {
return invalidError;
}
if (type === "time_pattern") {
if (!hasTimePartValue(draft.pattern)) return requiredError;
if (!timePartsToString(draft.pattern, { allowWildcard: true })) return invalidError;
}
if (type === "interval") {
const startTimeHasValue = hasTimePartValue(draft.intervalStartTime);
if (timePartsToDurationMs(draft.interval) <= 0) return requiredError;
if (startTimeHasValue && !timePartsToString(draft.intervalStartTime)) return invalidError;
}
return "";
}
import { CountdownControls, CountdownList, StopwatchControls, StopwatchLapList } from "./TimerParts.jsx";
import {
ALERT_MIN_DELAY_MS,
AUTO_REFRESH_COUNTDOWN_TYPES,
COUNTDOWN_TYPES,
DEFAULT_COUNTDOWN_FORM,
TIMER_TABS,
TOGGLE_REFRESH_COUNTDOWN_TYPES,
canToggleAutoRefresh,
getAlertGuardMessage,
getCountdownValidationError,
hasTimePartValue,
isAlertGuarded
} from "./timerOptions.js";
export function TimerModule({ toolboxId, moduleId, context, editing }) {
const data = context.normalizeTimerData(context.getModuleData(toolboxId, moduleId, { activeTab: "stopwatch" }));
@ -411,326 +324,3 @@ export function TimerModule({ toolboxId, moduleId, context, editing }) {
</div>
);
}
function TimerAlertModeSwitch({ mode, blocked, textContent, onChange }) {
const labels = {
off: textContent.alertOffTitle || "Pas d'alerte",
visible: textContent.alertVisibleTitle || "Alerte quand la toolbox est affichée",
site: textContent.alertSiteTitle || "Alerte sur tout le site"
};
const blockedTitle = getAlertGuardMessage(textContent);
return (
<div
className={`timer-alert-mode-switch ${blocked ? "is-blocked" : ""}`}
role="group"
aria-label={textContent.alertModeTitle || "Mode d'alerte"}
title={blocked ? blockedTitle : undefined}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
{ALERT_MODES.map((option) => {
const label = labels[option.mode];
const disabled = Boolean(blocked);
return (
<button
key={option.mode}
className={`timer-alert-mode-button ${!blocked && mode === option.mode ? "active" : ""}`}
type="button"
onClick={() => onChange(option.mode)}
disabled={disabled}
aria-pressed={mode === option.mode}
aria-label={disabled ? blockedTitle : label}
title={disabled ? blockedTitle : label}
>
<Icon name={option.icon} />
</button>
);
})}
</div>
);
}
function StopwatchControls({ textContent, elapsedMs, running, onStart, onPause, onReset, onLap }) {
return (
<div className="timer-control-panel" role="tabpanel">
<span>{textContent.stopwatchTitle || "Chronomètre à étapes"}</span>
<strong><TimerValue ms={elapsedMs} compact /></strong>
<div className="timer-actions">
<button className="primary timer-action-button" type="button" onClick={running ? onPause : onStart} aria-label={running ? textContent.pauseButton || "Pause" : textContent.startButton || "Lancer"} title={running ? textContent.pauseButton || "Pause" : textContent.startButton || "Lancer"}>
<Icon name={running ? "pause-circle" : "play-circle"} />
</button>
<button className="timer-action-button" type="button" onClick={onLap} disabled={!elapsedMs} aria-label={textContent.lapButton || "Étape"} title={textContent.lapButton || "Étape"}>
<Icon name="record-circle" />
</button>
<button className="danger timer-action-button" type="button" onClick={onReset} aria-label={textContent.resetTitle || "Réinitialiser"} title={textContent.resetTitle || "Réinitialiser"}>
<Icon name="stop-circle" />
</button>
</div>
</div>
);
}
function CountdownControls({ textContent, form, error, onChange, onSubmit }) {
return (
<form className="timer-control-panel timer-countdown-form" role="tabpanel" onSubmit={onSubmit} aria-describedby={error ? "timer-countdown-error" : undefined}>
<label>
<span>{textContent.countdownLabel || "Libellé"}</span>
<input value={form.label} onChange={(event) => onChange({ label: event.target.value })} placeholder={textContent.countdownLabelPlaceholder || "Boss, event, craft..."} />
</label>
<label>
<span>{textContent.countdownTypeLabel || "Type"}</span>
<select value={form.type} onChange={(event) => onChange({ type: event.target.value })}>
<option value="duration">{textContent.durationType || "Durée"}</option>
<option value="daily_time">{textContent.dailyTimeType || "Heure précise"}</option>
<option value="time_pattern">{textContent.timePatternType || "Pattern horaire"}</option>
<option value="interval">{textContent.intervalType || "Intervalle"}</option>
</select>
</label>
{form.type === "duration" && (
<label>
<span className="timer-field-spacer" aria-hidden="true" />
<span className="sr-only">{textContent.durationLabel || "Durée"}</span>
<TimePartsInput value={form.duration} onChange={(duration) => onChange({ duration })} />
</label>
)}
{form.type === "daily_time" && (
<label>
<span className="timer-field-spacer" aria-hidden="true" />
<span className="sr-only">{textContent.dailyTimeLabel || "Heure"}</span>
<TimePartsInput value={form.time} onChange={(time) => onChange({ time })} />
</label>
)}
{form.type === "time_pattern" && (
<label>
<span className="timer-field-spacer" aria-hidden="true" />
<span className="sr-only">{textContent.timePatternLabel || "Heure / minute / seconde"}</span>
<TimePartsInput value={form.pattern} onChange={(pattern) => onChange({ pattern })} allowWildcard />
</label>
)}
{form.type === "interval" && (
<div className="timer-interval-fields">
<label>
<span>{textContent.intervalLabel || "Toutes les"}</span>
<TimePartsInput value={form.interval} onChange={(interval) => onChange({ interval })} />
</label>
<label>
<span>{textContent.intervalStartTimeLabel || "Départ"}</span>
<TimePartsInput value={form.intervalStartTime} onChange={(intervalStartTime) => onChange({ intervalStartTime })} />
</label>
</div>
)}
{error && (
<p id="timer-countdown-error" className="timer-form-error" role="alert">
<Icon name="close" />
<span>{error}</span>
</p>
)}
<button className="primary" type="submit">{textContent.addCountdownButton || "Ajouter"}</button>
</form>
);
}
function TimePartsInput({ value, onChange, allowWildcard = false }) {
function updatePart(part, nextValue) {
const cleanValue = allowWildcard && /^x+$/i.test(nextValue.trim()) ? "X" : nextValue.replace(/[^\d]/g, "").slice(0, 2);
onChange({ ...value, [part]: cleanValue });
}
return (
<div className="time-parts-input" role="group" aria-label="Format heure minute seconde">
<input value={value.hours} onChange={(event) => updatePart("hours", event.target.value)} placeholder="hh" inputMode={allowWildcard ? "text" : "numeric"} />
<span>:</span>
<input value={value.minutes} onChange={(event) => updatePart("minutes", event.target.value)} placeholder="mm" inputMode={allowWildcard ? "text" : "numeric"} />
<span>:</span>
<input value={value.seconds} onChange={(event) => updatePart("seconds", event.target.value)} placeholder="ss" inputMode={allowWildcard ? "text" : "numeric"} />
</div>
);
}
function StopwatchLapList({ laps, textContent, onRename, onDelete }) {
const [editingId, setEditingId] = useState("");
if (!laps.length) return <p className="muted">{textContent.emptySteps || "Aucune étape enregistrée."}</p>;
return (
<ul className="tool-split-entry-list timer-list">
{laps.map((lap, index) => (
<li key={lap.id} className={`timer-list-item ${editingId === lap.id ? "is-editing" : ""}`}>
<div className="tool-split-entry timer-entry has-single-action">
{editingId === lap.id ? (
<EditableTimerLabel
value={lap.label}
placeholder={`${textContent.stepLabel || "Étape"} ${laps.length - index}`}
ariaLabel={textContent.lapLabelAriaLabel || "Libellé de l'étape"}
onRename={(label) => onRename(lap.id, label)}
onDone={() => setEditingId("")}
>
<TimerValue ms={lap.elapsedMs} />
</EditableTimerLabel>
) : (
<span
className="tool-split-entry-summary timer-entry-summary"
role="button"
tabIndex={0}
onClick={() => setEditingId(lap.id)}
onKeyDown={(event) => handleEditableKeyDown(event, () => setEditingId(lap.id))}
>
<span className="timer-entry-line">
<TimerValue ms={lap.elapsedMs} />
<span>{lap.label || `${textContent.stepLabel || "Étape"} ${laps.length - index}`}</span>
</span>
</span>
)}
<button className="danger" type="button" onClick={() => onDelete(lap.id)} aria-label={textContent.deleteTitle || "Supprimer"} title={textContent.deleteTitle || "Supprimer"}>
<Icon name="trash" />
</button>
</div>
</li>
))}
</ul>
);
}
function CountdownList({ countdowns, nowMs, textContent, reorder, reorderEnabled, parentId, onRename, onAlertModeChange, onReset, onAutoRefreshToggle, onDelete }) {
const [editingId, setEditingId] = useState("");
if (!countdowns.length) return <p className="muted">{textContent.emptyCountdowns || "Aucun compte à rebours configuré."}</p>;
return (
<ul className="tool-split-entry-list timer-list">
{countdowns.map((countdown) => {
const remainingMs = countdown.targetMs - nowMs;
const expired = remainingMs <= 0;
const canReset = countdown.type === "duration";
const canToggleRefresh = TOGGLE_REFRESH_COUNTDOWN_TYPES.has(countdown.type);
const refreshBlocked = canToggleRefresh && !canToggleAutoRefresh(countdown);
const refreshTitle = refreshBlocked
? getAlertGuardMessage(textContent)
: textContent.autoRefreshTitle || "Répéter automatiquement";
const className = [
"timer-list-item",
expired ? "is-expired" : "",
editingId === countdown.id ? "is-editing" : "",
reorderEnabled && reorder.isItemDragging(countdown.id) ? "is-dragging" : "",
reorderEnabled && reorder.isItemDropTarget(countdown.id) ? "is-drop-target" : "",
reorderEnabled && reorder.getDropPlacement("item", countdown.id) === "after" ? "drop-after" : ""
].filter(Boolean).join(" ");
const reorderProps = reorderEnabled ? reorder.getItemProps({ itemId: countdown.id, parentId }) : {};
return (
<li key={countdown.id} className={className} {...reorderProps}>
<div className={`tool-split-entry timer-entry has-single-action ${reorderEnabled ? "has-drag-handle" : ""} ${editingId !== countdown.id ? "has-inline-controls" : ""} ${(canReset || canToggleRefresh) && editingId !== countdown.id ? "has-inline-reset" : ""}`}>
{reorderEnabled && (
<button
className="timer-drag-handle"
type="button"
onPointerDown={(event) => reorder.itemReorder.startDrag(event, countdown.id)}
aria-label={`${textContent.reorderTitle || "Déplacer"} ${countdown.label}`}
title={textContent.reorderTitle || "Déplacer"}
>
<Icon name="drag" />
</button>
)}
{editingId === countdown.id ? (
<EditableTimerLabel
value={countdown.label}
placeholder={textContent.defaultCountdownLabel || "Timer"}
ariaLabel={textContent.countdownLabel || "Libellé"}
onRename={(label) => onRename(countdown.id, label)}
onDone={() => setEditingId("")}
>
<TimerValue ms={remainingMs} showCentiseconds={false} />
</EditableTimerLabel>
) : (
<span
className="tool-split-entry-summary timer-entry-summary"
role="button"
tabIndex={0}
onClick={() => setEditingId(countdown.id)}
onKeyDown={(event) => handleEditableKeyDown(event, () => setEditingId(countdown.id))}
>
<span className="timer-entry-line">
<TimerValue ms={remainingMs} showCentiseconds={false} />
<span>{countdown.label}</span>
<span className="timer-inline-controls">
{canReset && (
<button
className="timer-inline-reset"
type="button"
onClick={(event) => {
event.stopPropagation();
onReset(countdown);
}}
aria-label={textContent.resetTitle || "Réinitialiser"}
title={textContent.resetTitle || "Réinitialiser"}
>
<Icon name="refresh" />
</button>
)}
{canToggleRefresh && (
<button
className={`timer-inline-refresh-toggle ${countdown.autoRefresh ? "active" : ""} ${refreshBlocked ? "is-blocked" : ""}`}
type="button"
onClick={(event) => {
event.stopPropagation();
onAutoRefreshToggle(countdown.id);
}}
disabled={refreshBlocked}
aria-pressed={Boolean(countdown.autoRefresh)}
aria-label={refreshTitle}
title={refreshTitle}
>
<Icon name="refresh" />
<i aria-hidden="true" />
</button>
)}
<TimerAlertModeSwitch
mode={countdown.alertMode}
blocked={AUTO_REFRESH_COUNTDOWN_TYPES.has(countdown.type) && isAlertGuarded(countdown)}
textContent={textContent}
onChange={(alertMode) => onAlertModeChange(countdown.id, alertMode)}
/>
</span>
</span>
</span>
)}
<button className="danger" type="button" onClick={() => onDelete(countdown.id)} aria-label={textContent.deleteTitle || "Supprimer"} title={textContent.deleteTitle || "Supprimer"}>
<Icon name="trash" />
</button>
</div>
<p className="timer-entry-meta">
<span>
{getCountdownMetaParts(countdown, textContent).map((part, index) => (
<b key={`${part}-${index}`}>{part}</b>
))}
</span>
<em>
<b>{expired ? textContent.finishedLabel || "Terminé" : `${textContent.targetLabel || "Prochaine occurrence"} ${formatTargetTime(countdown.targetMs)}`}</b>
</em>
</p>
</li>
);
})}
</ul>
);
}
function EditableTimerLabel({ value, placeholder, ariaLabel, children, onRename, onDone }) {
const inlineEdit = useInlineEdit({
value,
onCommit: onRename,
onDone,
autoFocus: true,
commitUnchanged: true
});
return (
<>
<span className="tool-split-entry-value timer-entry-value is-readonly">{children}</span>
<input
{...inlineEdit.getInputProps({
className: "tool-split-entry-label timer-lap-label",
placeholder,
"aria-label": ariaLabel
})}
/>
</>
);
}

View file

@ -0,0 +1,350 @@
// Rôle : rend les contrôles, valeurs et listes de résultats du module Timer.
import { useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { formatDuration, formatDurationWithCentiseconds, formatTargetTime, getCentiseconds } from "./timerUtils.js";
import {
ALERT_MODES,
AUTO_REFRESH_COUNTDOWN_TYPES,
TOGGLE_REFRESH_COUNTDOWN_TYPES,
canToggleAutoRefresh,
getAlertGuardMessage,
getCountdownMetaParts,
handleEditableKeyDown,
isAlertGuarded
} from "./timerOptions.js";
export function StopwatchControls({ textContent, elapsedMs, running, onStart, onPause, onReset, onLap }) {
return (
<div className="timer-control-panel" role="tabpanel">
<span>{textContent.stopwatchTitle || "Chronomètre à étapes"}</span>
<strong><TimerValue ms={elapsedMs} compact /></strong>
<div className="timer-actions">
<button className="primary timer-action-button" type="button" onClick={running ? onPause : onStart} aria-label={running ? textContent.pauseButton || "Pause" : textContent.startButton || "Lancer"} title={running ? textContent.pauseButton || "Pause" : textContent.startButton || "Lancer"}>
<Icon name={running ? "pause-circle" : "play-circle"} />
</button>
<button className="timer-action-button" type="button" onClick={onLap} disabled={!elapsedMs} aria-label={textContent.lapButton || "Étape"} title={textContent.lapButton || "Étape"}>
<Icon name="record-circle" />
</button>
<button className="danger timer-action-button" type="button" onClick={onReset} aria-label={textContent.resetTitle || "Réinitialiser"} title={textContent.resetTitle || "Réinitialiser"}>
<Icon name="stop-circle" />
</button>
</div>
</div>
);
}
export function CountdownControls({ textContent, form, error, onChange, onSubmit }) {
return (
<form className="timer-control-panel timer-countdown-form" role="tabpanel" onSubmit={onSubmit} aria-describedby={error ? "timer-countdown-error" : undefined}>
<label>
<span>{textContent.countdownLabel || "Libellé"}</span>
<input value={form.label} onChange={(event) => onChange({ label: event.target.value })} placeholder={textContent.countdownLabelPlaceholder || "Boss, event, craft..."} />
</label>
<label>
<span>{textContent.countdownTypeLabel || "Type"}</span>
<select value={form.type} onChange={(event) => onChange({ type: event.target.value })}>
<option value="duration">{textContent.durationType || "Durée"}</option>
<option value="daily_time">{textContent.dailyTimeType || "Heure précise"}</option>
<option value="time_pattern">{textContent.timePatternType || "Pattern horaire"}</option>
<option value="interval">{textContent.intervalType || "Intervalle"}</option>
</select>
</label>
{form.type === "duration" && (
<label>
<span className="timer-field-spacer" aria-hidden="true" />
<span className="sr-only">{textContent.durationLabel || "Durée"}</span>
<TimePartsInput value={form.duration} onChange={(duration) => onChange({ duration })} />
</label>
)}
{form.type === "daily_time" && (
<label>
<span className="timer-field-spacer" aria-hidden="true" />
<span className="sr-only">{textContent.dailyTimeLabel || "Heure"}</span>
<TimePartsInput value={form.time} onChange={(time) => onChange({ time })} />
</label>
)}
{form.type === "time_pattern" && (
<label>
<span className="timer-field-spacer" aria-hidden="true" />
<span className="sr-only">{textContent.timePatternLabel || "Heure / minute / seconde"}</span>
<TimePartsInput value={form.pattern} onChange={(pattern) => onChange({ pattern })} allowWildcard />
</label>
)}
{form.type === "interval" && (
<div className="timer-interval-fields">
<label>
<span>{textContent.intervalLabel || "Toutes les"}</span>
<TimePartsInput value={form.interval} onChange={(interval) => onChange({ interval })} />
</label>
<label>
<span>{textContent.intervalStartTimeLabel || "Départ"}</span>
<TimePartsInput value={form.intervalStartTime} onChange={(intervalStartTime) => onChange({ intervalStartTime })} />
</label>
</div>
)}
{error && (
<p id="timer-countdown-error" className="timer-form-error" role="alert">
<Icon name="close" />
<span>{error}</span>
</p>
)}
<button className="primary" type="submit">{textContent.addCountdownButton || "Ajouter"}</button>
</form>
);
}
export function StopwatchLapList({ laps, textContent, onRename, onDelete }) {
const [editingId, setEditingId] = useState("");
if (!laps.length) return <p className="muted">{textContent.emptySteps || "Aucune étape enregistrée."}</p>;
return (
<ul className="tool-split-entry-list timer-list">
{laps.map((lap, index) => (
<li key={lap.id} className={`timer-list-item ${editingId === lap.id ? "is-editing" : ""}`}>
<div className="tool-split-entry timer-entry has-single-action">
{editingId === lap.id ? (
<EditableTimerLabel
value={lap.label}
placeholder={`${textContent.stepLabel || "Étape"} ${laps.length - index}`}
ariaLabel={textContent.lapLabelAriaLabel || "Libellé de l'étape"}
onRename={(label) => onRename(lap.id, label)}
onDone={() => setEditingId("")}
>
<TimerValue ms={lap.elapsedMs} />
</EditableTimerLabel>
) : (
<span
className="tool-split-entry-summary timer-entry-summary"
role="button"
tabIndex={0}
onClick={() => setEditingId(lap.id)}
onKeyDown={(event) => handleEditableKeyDown(event, () => setEditingId(lap.id))}
>
<span className="timer-entry-line">
<TimerValue ms={lap.elapsedMs} />
<span>{lap.label || `${textContent.stepLabel || "Étape"} ${laps.length - index}`}</span>
</span>
</span>
)}
<button className="danger" type="button" onClick={() => onDelete(lap.id)} aria-label={textContent.deleteTitle || "Supprimer"} title={textContent.deleteTitle || "Supprimer"}>
<Icon name="trash" />
</button>
</div>
</li>
))}
</ul>
);
}
export function CountdownList({ countdowns, nowMs, textContent, reorder, reorderEnabled, parentId, onRename, onAlertModeChange, onReset, onAutoRefreshToggle, onDelete }) {
const [editingId, setEditingId] = useState("");
if (!countdowns.length) return <p className="muted">{textContent.emptyCountdowns || "Aucun compte à rebours configuré."}</p>;
return (
<ul className="tool-split-entry-list timer-list">
{countdowns.map((countdown) => {
const remainingMs = countdown.targetMs - nowMs;
const expired = remainingMs <= 0;
const canReset = countdown.type === "duration";
const canToggleRefresh = TOGGLE_REFRESH_COUNTDOWN_TYPES.has(countdown.type);
const refreshBlocked = canToggleRefresh && !canToggleAutoRefresh(countdown);
const refreshTitle = refreshBlocked
? getAlertGuardMessage(textContent)
: textContent.autoRefreshTitle || "Répéter automatiquement";
const className = [
"timer-list-item",
expired ? "is-expired" : "",
editingId === countdown.id ? "is-editing" : "",
reorderEnabled && reorder.isItemDragging(countdown.id) ? "is-dragging" : "",
reorderEnabled && reorder.isItemDropTarget(countdown.id) ? "is-drop-target" : "",
reorderEnabled && reorder.getDropPlacement("item", countdown.id) === "after" ? "drop-after" : ""
].filter(Boolean).join(" ");
const reorderProps = reorderEnabled ? reorder.getItemProps({ itemId: countdown.id, parentId }) : {};
return (
<li key={countdown.id} className={className} {...reorderProps}>
<div className={`tool-split-entry timer-entry has-single-action ${reorderEnabled ? "has-drag-handle" : ""} ${editingId !== countdown.id ? "has-inline-controls" : ""} ${(canReset || canToggleRefresh) && editingId !== countdown.id ? "has-inline-reset" : ""}`}>
{reorderEnabled && (
<button
className="timer-drag-handle"
type="button"
onPointerDown={(event) => reorder.itemReorder.startDrag(event, countdown.id)}
aria-label={`${textContent.reorderTitle || "Déplacer"} ${countdown.label}`}
title={textContent.reorderTitle || "Déplacer"}
>
<Icon name="drag" />
</button>
)}
{editingId === countdown.id ? (
<EditableTimerLabel
value={countdown.label}
placeholder={textContent.defaultCountdownLabel || "Timer"}
ariaLabel={textContent.countdownLabel || "Libellé"}
onRename={(label) => onRename(countdown.id, label)}
onDone={() => setEditingId("")}
>
<TimerValue ms={remainingMs} showCentiseconds={false} />
</EditableTimerLabel>
) : (
<span
className="tool-split-entry-summary timer-entry-summary"
role="button"
tabIndex={0}
onClick={() => setEditingId(countdown.id)}
onKeyDown={(event) => handleEditableKeyDown(event, () => setEditingId(countdown.id))}
>
<span className="timer-entry-line">
<TimerValue ms={remainingMs} showCentiseconds={false} />
<span>{countdown.label}</span>
<span className="timer-inline-controls">
{canReset && (
<button
className="timer-inline-reset"
type="button"
onClick={(event) => {
event.stopPropagation();
onReset(countdown);
}}
aria-label={textContent.resetTitle || "Réinitialiser"}
title={textContent.resetTitle || "Réinitialiser"}
>
<Icon name="refresh" />
</button>
)}
{canToggleRefresh && (
<button
className={`timer-inline-refresh-toggle ${countdown.autoRefresh ? "active" : ""} ${refreshBlocked ? "is-blocked" : ""}`}
type="button"
onClick={(event) => {
event.stopPropagation();
onAutoRefreshToggle(countdown.id);
}}
disabled={refreshBlocked}
aria-pressed={Boolean(countdown.autoRefresh)}
aria-label={refreshTitle}
title={refreshTitle}
>
<Icon name="refresh" />
<i aria-hidden="true" />
</button>
)}
<TimerAlertModeSwitch
mode={countdown.alertMode}
blocked={AUTO_REFRESH_COUNTDOWN_TYPES.has(countdown.type) && isAlertGuarded(countdown)}
textContent={textContent}
onChange={(alertMode) => onAlertModeChange(countdown.id, alertMode)}
/>
</span>
</span>
</span>
)}
<button className="danger" type="button" onClick={() => onDelete(countdown.id)} aria-label={textContent.deleteTitle || "Supprimer"} title={textContent.deleteTitle || "Supprimer"}>
<Icon name="trash" />
</button>
</div>
<p className="timer-entry-meta">
<span>
{getCountdownMetaParts(countdown, textContent).map((part, index) => (
<b key={`${part}-${index}`}>{part}</b>
))}
</span>
<em>
<b>{expired ? textContent.finishedLabel || "Terminé" : `${textContent.targetLabel || "Prochaine occurrence"} ${formatTargetTime(countdown.targetMs)}`}</b>
</em>
</p>
</li>
);
})}
</ul>
);
}
function TimerValue({ ms, compact = false, showCentiseconds = true }) {
if (!showCentiseconds) return <span className="timer-value">{formatDuration(ms)}</span>;
if (!compact) return <span className="timer-value">{formatDurationWithCentiseconds(ms)}</span>;
return (
<span className="timer-value is-compact">
<span>{formatDuration(ms)}</span>
<small>{getCentiseconds(ms)}</small>
</span>
);
}
function TimerAlertModeSwitch({ mode, blocked, textContent, onChange }) {
const labels = {
off: textContent.alertOffTitle || "Pas d'alerte",
visible: textContent.alertVisibleTitle || "Alerte quand la toolbox est affichée",
site: textContent.alertSiteTitle || "Alerte sur tout le site"
};
const blockedTitle = getAlertGuardMessage(textContent);
return (
<div
className={`timer-alert-mode-switch ${blocked ? "is-blocked" : ""}`}
role="group"
aria-label={textContent.alertModeTitle || "Mode d'alerte"}
title={blocked ? blockedTitle : undefined}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
{ALERT_MODES.map((option) => {
const label = labels[option.mode];
const disabled = Boolean(blocked);
return (
<button
key={option.mode}
className={`timer-alert-mode-button ${!blocked && mode === option.mode ? "active" : ""}`}
type="button"
onClick={() => onChange(option.mode)}
disabled={disabled}
aria-pressed={mode === option.mode}
aria-label={disabled ? blockedTitle : label}
title={disabled ? blockedTitle : label}
>
<Icon name={option.icon} />
</button>
);
})}
</div>
);
}
function TimePartsInput({ value, onChange, allowWildcard = false }) {
function updatePart(part, nextValue) {
const cleanValue = allowWildcard && /^x+$/i.test(nextValue.trim()) ? "X" : nextValue.replace(/[^\d]/g, "").slice(0, 2);
onChange({ ...value, [part]: cleanValue });
}
return (
<div className="time-parts-input" role="group" aria-label="Format heure minute seconde">
<input value={value.hours} onChange={(event) => updatePart("hours", event.target.value)} placeholder="hh" inputMode={allowWildcard ? "text" : "numeric"} />
<span>:</span>
<input value={value.minutes} onChange={(event) => updatePart("minutes", event.target.value)} placeholder="mm" inputMode={allowWildcard ? "text" : "numeric"} />
<span>:</span>
<input value={value.seconds} onChange={(event) => updatePart("seconds", event.target.value)} placeholder="ss" inputMode={allowWildcard ? "text" : "numeric"} />
</div>
);
}
function EditableTimerLabel({ value, placeholder, ariaLabel, children, onRename, onDone }) {
const inlineEdit = useInlineEdit({
value,
onCommit: onRename,
onDone,
autoFocus: true,
commitUnchanged: true
});
return (
<>
<span className="tool-split-entry-value timer-entry-value is-readonly">{children}</span>
<input
{...inlineEdit.getInputProps({
className: "tool-split-entry-label timer-lap-label",
placeholder,
"aria-label": ariaLabel
})}
/>
</>
);
}

View file

@ -0,0 +1,185 @@
// Rôle : centralise les options, palettes et helpers purs de l'outil Combos.
export const DEVICE_OPTIONS = [
{ value: "playstation", label: "PlayStation" },
{ value: "xbox", label: "Xbox" },
{ value: "switch", label: "Switch" },
{ value: "n64", label: "Nintendo 64" },
{ value: "keyboardMouse", label: "Clavier/Souris" }
];
export const SPECIAL_INPUT_GROUP = { title: "Spéciale", column: "actions", inputs: [["button", "fist"], ["button", "foot"], ["button", "grab"], ["button", "protect"], ["button", "shoot"]] };
export const DEVICE_GROUPS = {
playstation: [
{ title: "Directions", kind: "directions", column: "movement", inputs: [["direction", "up-left"], ["direction", "up"], ["direction", "up-right"], ["direction", "left"], null, ["direction", "right"], ["direction", "down-left"], ["direction", "down"], ["direction", "down-right"]] },
{ title: "Actions", column: "actions", inputs: [["button", "triangle"], ["button", "circle"], ["button", "cross"], ["button", "square"], ["button", "l1"], ["button", "r1"], ["button", "l2"], ["button", "r2"]] },
SPECIAL_INPUT_GROUP,
{ title: "Système", inputs: [["button", "options"], ["button", "share"], ["button", "l3"], ["button", "r3"]] }
],
xbox: [
{ title: "Directions", kind: "directions", column: "movement", inputs: [["direction", "up-left"], ["direction", "up"], ["direction", "up-right"], ["direction", "left"], null, ["direction", "right"], ["direction", "down-left"], ["direction", "down"], ["direction", "down-right"]] },
{ title: "Actions", column: "actions", inputs: [["button", "a"], ["button", "b"], ["button", "x"], ["button", "y"], ["button", "lb"], ["button", "rb"], ["button", "lt"], ["button", "rt"]] },
SPECIAL_INPUT_GROUP,
{ title: "Système", inputs: [["button", "menu"], ["button", "view"], ["button", "ls"], ["button", "rs"]] }
],
switch: [
{ title: "Directions", kind: "directions", column: "movement", inputs: [["direction", "up-left"], ["direction", "up"], ["direction", "up-right"], ["direction", "left"], null, ["direction", "right"], ["direction", "down-left"], ["direction", "down"], ["direction", "down-right"]] },
{ title: "Actions", column: "actions", inputs: [["button", "a"], ["button", "b"], ["button", "x"], ["button", "y"], ["button", "l"], ["button", "r"], ["button", "zl"], ["button", "zr"]] },
SPECIAL_INPUT_GROUP,
{ title: "Système", inputs: [["button", "plus"], ["button", "minus"], ["button", "ls"], ["button", "rs"]] }
],
n64: [
{ title: "Stick", kind: "directions", column: "movement", inputs: [["direction", "stick-up-left"], ["direction", "stick-up"], ["direction", "stick-up-right"], ["direction", "stick-left"], null, ["direction", "stick-right"], ["direction", "stick-down-left"], ["direction", "stick-down"], ["direction", "stick-down-right"]] },
{ title: "D-Pad", kind: "directions", column: "movement", inputs: [["direction", "up-left"], ["direction", "up"], ["direction", "up-right"], ["direction", "left"], null, ["direction", "right"], ["direction", "down-left"], ["direction", "down"], ["direction", "down-right"]] },
{ title: "Actions", column: "actions", inputs: [["button", "a"], ["button", "b"], ["button", "z"], ["button", "start"], ["button", "l"], ["button", "r"]] },
SPECIAL_INPUT_GROUP,
{ title: "C-buttons", kind: "directions", inputs: [["button", "c-up"], ["button", "c-down"], ["button", "c-left"], ["button", "c-right"]] }
]
};
export const KEYBOARD_GROUPS = {
azerty: [
{ title: "ZQSD", kind: "directions", column: "movement", inputs: [null, ["key", "z"], null, ["key", "q"], ["key", "s"], ["key", "d"], null, null, null] },
{ title: "Flèches", kind: "directions", column: "movement", inputs: [null, ["direction", "up"], null, ["direction", "left"], ["direction", "down"], ["direction", "right"], null, null, null] },
{ title: "Touches", column: "actions", inputs: [["key", "ctrl"], ["key", "alt"], ["key", "shift"], ["key", "space"], ["key", "enter"], ["key", "tab"], ["key", "e"], ["key", "a"], ["key", "r"], ["key", "f"]] },
SPECIAL_INPUT_GROUP,
{ title: "Souris", inputs: [["mouse", "left"], ["mouse", "right"], ["mouse", "middle"], ["mouse", "wheel-up"], ["mouse", "wheel-down"]] }
],
qwerty: [
{ title: "WASD", kind: "directions", column: "movement", inputs: [null, ["key", "w"], null, ["key", "a"], ["key", "s"], ["key", "d"], null, null, null] },
{ title: "Flèches", kind: "directions", column: "movement", inputs: [null, ["direction", "up"], null, ["direction", "left"], ["direction", "down"], ["direction", "right"], null, null, null] },
{ title: "Touches", column: "actions", inputs: [["key", "ctrl"], ["key", "alt"], ["key", "shift"], ["key", "space"], ["key", "enter"], ["key", "tab"], ["key", "e"], ["key", "q"], ["key", "r"], ["key", "f"]] },
SPECIAL_INPUT_GROUP,
{ title: "Souris", inputs: [["mouse", "left"], ["mouse", "right"], ["mouse", "middle"], ["mouse", "wheel-up"], ["mouse", "wheel-down"]] }
]
};
export const INPUT_LABELS = {
triangle: "△",
circle: "○",
cross: "×",
square: "□",
options: "Options",
share: "Share",
up: "↑",
down: "↓",
left: "←",
right: "→",
"up-left": "↖",
"up-right": "↗",
"down-left": "↙",
"down-right": "↘",
"stick-up": "↑",
"stick-down": "↓",
"stick-left": "←",
"stick-right": "→",
"stick-up-left": "↖",
"stick-up-right": "↗",
"stick-down-left": "↙",
"stick-down-right": "↘",
plus: "+",
minus: "-",
"c-up": "C↑",
"c-down": "C↓",
"c-left": "C←",
"c-right": "C→",
fist: "Poing",
foot: "Pied",
grab: "Grab",
protect: "Protect",
shoot: "Shoot",
space: "Espace"
};
export const INPUT_ICONS = {
fist: "fist",
foot: "foot",
grab: "grab",
protect: "shield",
shoot: "gun"
};
export const MOUSE_LABELS = {
left: "Clic G",
right: "Clic D",
middle: "Molette",
"wheel-up": "Molette ↑",
"wheel-down": "Molette ↓"
};
export function getInputLabel(input) {
const value = String(input?.value || "");
if (input?.kind === "key" && value.length === 1) return value.toUpperCase();
if (input?.kind === "mouse") return MOUSE_LABELS[value] || value;
return INPUT_LABELS[value] || value.toUpperCase();
}
export function inputKey(input) {
return `${input.kind}:${input.value}:${input.hold ? "hold" : ""}:${input.holdMs || 0}`;
}
export function createInput(kind, value, holdMs = 0) {
const input = { kind, value };
if (holdMs > 0) input.holdMs = holdMs;
return input;
}
export function getHoldSeconds(input) {
return Math.round((Number(input?.holdMs) || 0) / 1000);
}
export function formatHoldLabel(input) {
const seconds = getHoldSeconds(input);
if (!seconds) return input?.hold ? "hold" : "";
return `${seconds}s`;
}
export function parseHoldSeconds(value) {
const seconds = Number.parseInt(String(value || "").replace(/[^\d]/g, ""), 10);
if (!Number.isInteger(seconds) || seconds <= 0) return 0;
return Math.min(99, seconds);
}
export function isDirectionInput(input) {
return input?.kind === "direction";
}
export function createCombo(context, id, name, category, inputs, fallbackName, device) {
const combo = {
id: id || context.uid("combo"),
name: name.trim() || fallbackName,
device,
inputs: inputs.map((step) => step.map((input) => ({ ...input }))).filter((step) => step.length)
};
if (category.trim()) combo.category = category.trim();
return combo;
}
export function normalizeCustomKey(value) {
return String(value || "")
.trim()
.replace(/\s+/g, " ")
.slice(0, 16);
}
export function getPaletteGroups(device, keyboardLayout) {
return device === "keyboardMouse" ? KEYBOARD_GROUPS[keyboardLayout] : DEVICE_GROUPS[device] || DEVICE_GROUPS.playstation;
}
export function getPaletteColumns(groups) {
return [
groups.filter((group) => group.column === "movement"),
groups.filter((group) => group.column !== "movement")
];
}
export function getComboCategory(combo) {
return String(combo?.category || "").trim();
}
export function setComboItemCategory(combo, category) {
const nextCombo = { ...combo };
if (category) nextCombo.category = category;
else delete nextCombo.category;
return nextCombo;
}

View file

@ -62,3 +62,9 @@ export function createLocalId(prefix) {
export function getSocketItemName(equipment, socketItemId) {
return equipment.socketItems.find((socketItem) => socketItem.id === socketItemId)?.name || "";
}
export function getEquipmentTabLabel(tab, textContent) {
if (tab === "stats") return textContent.statsTab || "Stats / affixe";
if (tab === "sockets") return textContent.socketsTab || "Objets à sertir";
return textContent.craftTab || "Craft";
}

View file

@ -0,0 +1,203 @@
// Rôle : regroupe les limites, dimensions et helpers purs de sélection/copie du tableau.
import { cellAddress, columnIndexToName } from "./tableFormulaEngine.js";
export const DEFAULT_ROWS = 10;
export const DEFAULT_COLUMNS = 6;
export const MAX_ROWS = 50;
export const MAX_COLUMNS = 20;
export const MIN_COLUMN_WIDTH = 96;
export const MAX_COLUMN_WIDTH = 220;
export const MIN_ROW_HEADER_WIDTH = 42;
export const MAX_ROW_HEADER_WIDTH = 180;
export function cleanCells(cells, rows, columns) {
const nextCells = {};
for (let rowIndex = 0; rowIndex < rows; rowIndex += 1) {
for (let columnIndex = 0; columnIndex < columns; columnIndex += 1) {
const address = cellAddress(rowIndex, columnIndex);
const value = String(cells?.[address] || "");
if (value.trim()) nextCells[address] = value;
}
}
return nextCells;
}
export function cleanLabels(labels, length, getFallback) {
const nextLabels = {};
for (let index = 0; index < length; index += 1) {
const value = String(labels?.[index] || "").slice(0, 80);
if (value.trim() && value !== String(getFallback(index))) nextLabels[index] = value;
}
return nextLabels;
}
export function estimateTextWidth(value, minWidth, maxWidth) {
const length = String(value || "").length;
return Math.min(maxWidth, Math.max(minWidth, 24 + length * 8));
}
export function getColumnWidths(data, evaluatedCells) {
return Array.from({ length: data.columns }, (_, columnIndex) => {
let width = estimateTextWidth(data.columnLabels?.[columnIndex] || columnIndexToName(columnIndex), MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH);
for (let rowIndex = 0; rowIndex < data.rows; rowIndex += 1) {
const address = cellAddress(rowIndex, columnIndex);
const value = evaluatedCells[address]?.display || data.cells[address] || "";
width = Math.max(width, estimateTextWidth(value, MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH));
}
return width;
});
}
export function getRowHeaderWidth(data) {
return Array.from({ length: data.rows }, (_, rowIndex) => data.rowLabels?.[rowIndex] || rowIndex + 1)
.reduce((width, label) => Math.max(width, estimateTextWidth(label, MIN_ROW_HEADER_WIDTH, MAX_ROW_HEADER_WIDTH)), MIN_ROW_HEADER_WIDTH);
}
export function getSelectionBounds(selection) {
if (!selection?.anchor || !selection?.focus) return null;
return {
startRow: Math.min(selection.anchor.rowIndex, selection.focus.rowIndex),
endRow: Math.max(selection.anchor.rowIndex, selection.focus.rowIndex),
startColumn: Math.min(selection.anchor.columnIndex, selection.focus.columnIndex),
endColumn: Math.max(selection.anchor.columnIndex, selection.focus.columnIndex)
};
}
export function isCellSelected(selection, rowIndex, columnIndex) {
const bounds = getSelectionBounds(selection);
return Boolean(bounds && rowIndex >= bounds.startRow && rowIndex <= bounds.endRow && columnIndex >= bounds.startColumn && columnIndex <= bounds.endColumn);
}
export function isCellInBounds(bounds, rowIndex, columnIndex) {
return Boolean(bounds && rowIndex >= bounds.startRow && rowIndex <= bounds.endRow && columnIndex >= bounds.startColumn && columnIndex <= bounds.endColumn);
}
export function isMultiCellSelection(selection) {
const bounds = getSelectionBounds(selection);
return Boolean(bounds && (bounds.startRow !== bounds.endRow || bounds.startColumn !== bounds.endColumn));
}
export function clampCellPosition(rowIndex, columnIndex, rows, columns) {
return {
rowIndex: Math.min(rows - 1, Math.max(0, rowIndex)),
columnIndex: Math.min(columns - 1, Math.max(0, columnIndex))
};
}
export function getRangeAddresses(selection) {
const bounds = getSelectionBounds(selection);
if (!bounds) return [];
const addresses = [];
for (let rowIndex = bounds.startRow; rowIndex <= bounds.endRow; rowIndex += 1) {
for (let columnIndex = bounds.startColumn; columnIndex <= bounds.endColumn; columnIndex += 1) {
addresses.push(cellAddress(rowIndex, columnIndex));
}
}
return addresses;
}
export function getFormulaRangeText(selection) {
const addresses = getRangeAddresses(selection);
if (!addresses.length) return "";
return addresses.length === 1 ? addresses[0] : `(${addresses.join("+")})`;
}
export function getCellBlock(cells, bounds) {
if (!bounds) return null;
const values = [];
for (let rowIndex = bounds.startRow; rowIndex <= bounds.endRow; rowIndex += 1) {
const row = [];
for (let columnIndex = bounds.startColumn; columnIndex <= bounds.endColumn; columnIndex += 1) {
row.push(cells[cellAddress(rowIndex, columnIndex)] || "");
}
values.push(row);
}
return {
height: bounds.endRow - bounds.startRow + 1,
width: bounds.endColumn - bounds.startColumn + 1,
values
};
}
export function parseTsvBlock(text) {
const normalizedText = String(text || "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const lines = normalizedText.endsWith("\n") ? normalizedText.slice(0, -1).split("\n") : normalizedText.split("\n");
if (!lines.length || (lines.length === 1 && !lines[0])) return null;
const values = lines.map((line) => line.split("\t"));
return {
height: values.length,
width: Math.max(...values.map((row) => row.length)),
values
};
}
export function getBoundsFromStartAndSize(rowIndex, columnIndex, height, width) {
return {
startRow: rowIndex,
endRow: rowIndex + height - 1,
startColumn: columnIndex,
endColumn: columnIndex + width - 1
};
}
export function isSameBounds(firstBounds, secondBounds) {
return Boolean(firstBounds && secondBounds
&& firstBounds.startRow === secondBounds.startRow
&& firstBounds.endRow === secondBounds.endRow
&& firstBounds.startColumn === secondBounds.startColumn
&& firstBounds.endColumn === secondBounds.endColumn);
}
export function moveSelectionCells(cells, sourceBounds, targetBounds) {
const nextCells = { ...cells };
const snapshot = [];
for (let rowIndex = sourceBounds.startRow; rowIndex <= sourceBounds.endRow; rowIndex += 1) {
for (let columnIndex = sourceBounds.startColumn; columnIndex <= sourceBounds.endColumn; columnIndex += 1) {
const value = cells[cellAddress(rowIndex, columnIndex)] || "";
snapshot.push({
rowOffset: rowIndex - sourceBounds.startRow,
columnOffset: columnIndex - sourceBounds.startColumn,
value
});
delete nextCells[cellAddress(rowIndex, columnIndex)];
}
}
for (let rowIndex = targetBounds.startRow; rowIndex <= targetBounds.endRow; rowIndex += 1) {
for (let columnIndex = targetBounds.startColumn; columnIndex <= targetBounds.endColumn; columnIndex += 1) {
delete nextCells[cellAddress(rowIndex, columnIndex)];
}
}
snapshot.forEach(({ rowOffset, columnOffset, value }) => {
if (!String(value || "").trim()) return;
nextCells[cellAddress(targetBounds.startRow + rowOffset, targetBounds.startColumn + columnOffset)] = value;
});
return nextCells;
}
export function pasteCellBlock(cells, block, targetRow, targetColumn, rows, columns) {
const clippedHeight = Math.min(block?.height || 0, rows - targetRow);
const clippedWidth = Math.min(block?.width || 0, columns - targetColumn);
if (clippedHeight <= 0 || clippedWidth <= 0) return null;
const nextCells = { ...cells };
for (let rowOffset = 0; rowOffset < clippedHeight; rowOffset += 1) {
for (let columnOffset = 0; columnOffset < clippedWidth; columnOffset += 1) {
const address = cellAddress(targetRow + rowOffset, targetColumn + columnOffset);
const value = String(block.values[rowOffset]?.[columnOffset] || "");
if (value.trim()) {
nextCells[address] = value;
} else {
delete nextCells[address];
}
}
}
return {
cells: nextCells,
bounds: getBoundsFromStartAndSize(targetRow, targetColumn, clippedHeight, clippedWidth)
};
}

View file

@ -0,0 +1,85 @@
// Rôle : centralise les options, validations et helpers purs du module Timer.
import { formatDuration, formatTargetTime, getTimePatternRecurrenceMs, timePartsToDurationMs, timePartsToString } from "./timerUtils.js";
export const TIMER_TABS = [
{ id: "stopwatch", label: "Chronomètre", icon: "stopwatch" },
{ id: "countdown", label: "Compte à rebours", icon: "hourglass" }
];
export const COUNTDOWN_TYPES = ["duration", "daily_time", "time_pattern", "interval"];
export const EMPTY_TIME_PARTS = { hours: "", minutes: "", seconds: "" };
export const DEFAULT_COUNTDOWN_FORM = {
label: "",
type: "duration",
duration: EMPTY_TIME_PARTS,
time: EMPTY_TIME_PARTS,
pattern: EMPTY_TIME_PARTS,
interval: EMPTY_TIME_PARTS,
intervalStartTime: EMPTY_TIME_PARTS
};
export const ALERT_MODES = [
{ mode: "off", icon: "sound-mute" },
{ mode: "visible", icon: "sound-min" },
{ mode: "site", icon: "sound-max" }
];
export const ALERT_MIN_DELAY_MS = 5 * 60 * 1000;
export const AUTO_REFRESH_COUNTDOWN_TYPES = new Set(["time_pattern", "interval"]);
export const TOGGLE_REFRESH_COUNTDOWN_TYPES = new Set(["daily_time", "interval"]);
export function isAlertGuarded(countdown) {
if (countdown?.type === "interval") return Number(countdown.intervalMs) < ALERT_MIN_DELAY_MS;
if (countdown?.type === "time_pattern") return getTimePatternRecurrenceMs(countdown.pattern) < ALERT_MIN_DELAY_MS;
return false;
}
export function getAlertGuardMessage(textContent) {
return textContent.alertTooSoonTitle || "Alerte indisponible pour une répétition inférieure à 5 minutes";
}
export function canToggleAutoRefresh(countdown) {
return TOGGLE_REFRESH_COUNTDOWN_TYPES.has(countdown?.type) && !isAlertGuarded(countdown);
}
export function getCountdownMetaParts(countdown, textContent) {
if (countdown.type === "duration") return [`${textContent.durationType || "Durée"} ${formatDuration(countdown.durationMs || 0)}`];
if (countdown.type === "daily_time") return [`${textContent.dailyTimeType || "Heure précise"} ${countdown.time}`];
if (countdown.type === "time_pattern") return [`${textContent.timePatternType || "Heure / minute / seconde"} ${countdown.pattern}`];
if (countdown.type === "interval") {
const startLabel = countdown.startMode === "time" && countdown.startTime
? `${textContent.intervalStartTimeLabel || "Départ"} ${countdown.startTime}`
: `${textContent.intervalStartTimeLabel || "Départ"} ${formatTargetTime(countdown.anchorAt)}`;
return [`${textContent.intervalType || "Intervalle"} ${formatDuration(countdown.intervalMs || 0)}`, startLabel];
}
return [];
}
export function handleEditableKeyDown(event, callback) {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
callback();
}
export function hasTimePartValue(parts) {
return Boolean(String(parts?.hours || "").trim() || String(parts?.minutes || "").trim() || String(parts?.seconds || "").trim());
}
export function getCountdownValidationError(type, draft, textContent) {
const requiredError = textContent.requiredError || "Valeur requise.";
const invalidError = textContent.invalidError || "Valeur invalide.";
if (type === "duration" && timePartsToDurationMs(draft.duration) <= 0) {
return requiredError;
}
if (type === "daily_time" && !timePartsToString(draft.time)) {
return invalidError;
}
if (type === "time_pattern") {
if (!hasTimePartValue(draft.pattern)) return requiredError;
if (!timePartsToString(draft.pattern, { allowWildcard: true })) return invalidError;
}
if (type === "interval") {
const startTimeHasValue = hasTimePartValue(draft.intervalStartTime);
if (timePartsToDurationMs(draft.interval) <= 0) return requiredError;
if (startTimeHasValue && !timePartsToString(draft.intervalStartTime)) return invalidError;
}
return "";
}

View file

@ -1,173 +0,0 @@
// Rôle : fournit les compacteurs de données par type d'outil toolbox.
export function createModuleDataCompactors(deps) {
return {
notepad(value) {
const normalized = deps.normalizeNotepadData(value);
const compact = {};
if (normalized.html && normalized.text) compact.html = normalized.html;
if (normalized.text) compact.text = normalized.text;
if (normalized.updatedAt) compact.updatedAt = normalized.updatedAt;
if (normalized.drawingMode === "permanent") compact.drawingMode = "permanent";
if (normalized.drawingMode === "permanent" && normalized.drawings.strokes.length) compact.drawings = normalized.drawings;
return Object.keys(compact).length ? compact : null;
},
checklist(value) {
const normalized = deps.normalizeChecklistData(value);
const sections = normalized.sections.map(deps.compactChecklistSectionForStorage).filter((section) => section.items.length);
if (!sections.length) return null;
const settings = {
...(normalized.hideCompletedSections ? { hideCompletedSections: true } : {}),
...(normalized.hideCompletedSectionsFully ? { hideCompletedSectionsFully: true } : {})
};
if (sections.length === 1 && !sections[0].title && typeof sections[0].hideWhenComplete !== "boolean") return { ...settings, items: sections[0].items };
return { ...settings, sections };
},
images(value) {
const images = (Array.isArray(value?.images) ? value.images : [])
.filter((image) => image?.dataUrl)
.map((image) => {
const compact = { id: image.id || deps.uid("image"), dataUrl: image.dataUrl };
if (image.label) compact.label = image.label;
return compact;
});
return images.length ? { images } : null;
},
imageAnnotation(value) {
const normalized = deps.normalizeImageAnnotationData(value);
if (!normalized.image) return null;
const compact = {
image: normalized.image,
markers: normalized.markers.map((marker) => {
const compact = { id: marker.id, x: marker.x, y: marker.y };
if (marker.label) compact.label = marker.label;
return compact;
})
};
if (normalized.drawings.strokes.length) compact.drawings = normalized.drawings;
return compact;
},
links(value) {
const links = deps.normalizeLinksData(value).links.map((link) => {
const compact = { id: link.id, url: link.url };
if (link.title) compact.title = link.title;
return compact;
});
return links.length ? { links } : null;
},
counters(value) {
const counters = deps.normalizeCountersData(value).counters.map((counter) => ({
id: counter.id,
label: counter.label,
value: counter.value
}));
return counters.length ? { counters } : null;
},
combos(value) {
const normalized = deps.normalizeCombosData(value);
const compact = {
combos: normalized.combos.map((combo) => {
const compactCombo = { id: combo.id, name: combo.name, inputs: combo.inputs };
if (combo.category) compactCombo.category = combo.category;
if (combo.device) compactCombo.device = combo.device;
return compactCombo;
})
};
if (normalized.device !== "playstation") compact.device = normalized.device;
if (normalized.collapsedCategories.length) compact.collapsedCategories = normalized.collapsedCategories;
if (normalized.categoryOrder.length) compact.categoryOrder = normalized.categoryOrder;
if (!compact.combos.length) delete compact.combos;
return compact.combos ? compact : null;
},
calculator(value) {
const normalized = deps.normalizeCalculatorData(value);
const entries = normalized.entries.map((entry) => {
const compact = { id: entry.id, label: entry.label, value: entry.value };
if (entry.parentId) compact.parentId = entry.parentId;
return compact;
});
if (!entries.length && !normalized.scrollResults) return null;
return normalized.scrollResults ? { entries, scrollResults: true } : { entries };
},
table(value) {
const normalized = deps.normalizeTableData(value);
const compact = { cells: normalized.cells };
if (normalized.rows !== 10) compact.rows = normalized.rows;
if (normalized.columns !== 6) compact.columns = normalized.columns;
if (Object.keys(normalized.rowLabels).length) compact.rowLabels = normalized.rowLabels;
if (Object.keys(normalized.columnLabels).length) compact.columnLabels = normalized.columnLabels;
return Object.keys(compact.cells).length || compact.rows || compact.columns || compact.rowLabels || compact.columnLabels ? compact : null;
},
timer(value) {
const normalized = deps.normalizeTimerData(value);
const compact = {};
if (normalized.activeTab !== "stopwatch") compact.activeTab = normalized.activeTab;
if (normalized.scrollResults) compact.scrollResults = true;
if (normalized.sortResults) compact.sortResults = true;
if (normalized.stopwatch.elapsedMs || normalized.stopwatch.startedAt || normalized.stopwatch.laps.length) {
compact.stopwatch = {
...(normalized.stopwatch.elapsedMs ? { elapsedMs: normalized.stopwatch.elapsedMs } : {}),
...(normalized.stopwatch.startedAt ? { startedAt: normalized.stopwatch.startedAt } : {}),
...(normalized.stopwatch.laps.length ? { laps: normalized.stopwatch.laps } : {})
};
}
if (normalized.countdowns.length) {
compact.countdowns = normalized.countdowns.map((countdown) => {
const { alertMode, autoRefresh, startMode, startTime, ...compactCountdown } = countdown;
if (alertMode !== "off") compactCountdown.alertMode = alertMode;
if (autoRefresh) compactCountdown.autoRefresh = true;
if (startMode === "time" && startTime) {
compactCountdown.startMode = startMode;
compactCountdown.startTime = startTime;
}
return compactCountdown;
});
}
return Object.keys(compact).length ? compact : null;
},
taskPlanner(value) {
const normalized = deps.normalizeTaskPlannerData(value);
const compact = {};
if (normalized.weeklyResetDay !== deps.defaultTaskPlannerWeeklyResetDay) compact.weeklyResetDay = normalized.weeklyResetDay;
if (normalized.resetTime !== deps.defaultTaskPlannerResetTime) compact.resetTime = normalized.resetTime;
if (normalized.lastResetAt) compact.lastResetAt = normalized.lastResetAt;
if (normalized.hideCompleted) compact.hideCompleted = true;
if (normalized.collapsedCategories.length) compact.collapsedCategories = normalized.collapsedCategories;
if (normalized.tasks.length) {
compact.tasks = normalized.tasks.map((task) => {
const compactTask = { id: task.id, title: task.title, type: task.type };
if (task.description) compactTask.description = task.description;
if (task.category) compactTask.category = task.category;
if (task.checked) compactTask.checked = true;
if (task.checked && task.checkedAt) compactTask.checkedAt = task.checkedAt;
if (task.type === "daily" && task.dailyResetTime && task.dailyResetTime !== normalized.resetTime) compactTask.dailyResetTime = task.dailyResetTime;
if (task.type === "weekly" && task.weeklyResetDay !== undefined) compactTask.weeklyResetDay = task.weeklyResetDay;
return compactTask;
});
}
if (normalized.categoryOrder.length) compact.categoryOrder = normalized.categoryOrder;
if (normalized.relations.length) {
compact.relations = normalized.relations.map((relation) => {
const compactRelation = { id: relation.id, fromTaskId: relation.fromTaskId, toTaskId: relation.toTaskId };
if (relation.prerequisite) compactRelation.prerequisite = true;
return compactRelation;
});
}
return Object.keys(compact).length ? compact : null;
},
equipmentPlanner(value) {
const normalized = deps.normalizeEquipmentPlannerData(value);
if (!normalized.equipments.length) return null;
const compact = {
types: normalized.types.map((type) => {
const compactType = { id: type.id, title: type.title };
if (type.icon !== "shield") compactType.icon = type.icon;
if (type.collapsed) compactType.collapsed = true;
return compactType;
}),
equipments: normalized.equipments.map(deps.compactEquipmentPlannerEquipmentForStorage)
};
if (normalized.typeOrder.length) compact.typeOrder = normalized.typeOrder;
return compact;
}
};
}

View file

@ -0,0 +1,33 @@
// Rôle : normalise et compacte les données du calculateur.
import { uid } from "./shared.js";
function normalizeCalculatorValue(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
export function normalizeCalculatorData(data) {
const entries = (data?.entries || [])
.map((entry) => ({
id: entry?.id || uid("calc"),
parentId: String(entry?.parentId || ""),
label: String(entry?.label || "").trim(),
value: normalizeCalculatorValue(entry?.value)
}));
const entryIds = new Set(entries.map((entry) => entry.id));
return {
scrollResults: data?.scrollResults === true,
entries: entries.map((entry) => entryIds.has(entry.parentId) ? entry : { ...entry, parentId: "" })
};
}
export function compactCalculatorDataForStorage(value) {
const normalized = normalizeCalculatorData(value);
const entries = normalized.entries.map((entry) => {
const compact = { id: entry.id, label: entry.label, value: entry.value };
if (entry.parentId) compact.parentId = entry.parentId;
return compact;
});
if (!entries.length && !normalized.scrollResults) return null;
return normalized.scrollResults ? { entries, scrollResults: true } : { entries };
}

View file

@ -0,0 +1,74 @@
// Rôle : normalise et compacte les données de checklist.
import { parsePositiveInt, uid } from "./shared.js";
export function clampQty(value, target) {
const parsed = Number.parseInt(value, 10);
const safeValue = Number.isFinite(parsed) ? parsed : 0;
return Math.min(safeValue, Math.max(1, target));
}
function normalizeChecklistItem(item) {
const qtyTarget = Math.max(1, parsePositiveInt(item?.qtyTarget, 1));
const qtyCurrent = clampQty(item?.qtyCurrent, qtyTarget);
return {
id: item?.id || uid("item"),
label: String(item?.label || "").trim(),
qtyTarget,
qtyCurrent
};
}
function normalizeChecklistSection(section, fallbackTitle = "") {
const normalized = {
id: section?.id || uid("section"),
title: String(section?.title ?? fallbackTitle).trim(),
items: (section?.items || []).map(normalizeChecklistItem).filter((item) => item.label)
};
if (typeof section?.hideWhenComplete === "boolean") normalized.hideWhenComplete = section.hideWhenComplete;
if (section?.collapsed === true) normalized.collapsed = true;
return normalized;
}
export function normalizeChecklistData(data) {
const hideCompletedSections = Boolean(data?.hideCompletedSections);
const hideCompletedSectionsFully = Boolean(data?.hideCompletedSectionsFully);
const sections = Array.isArray(data?.sections)
? data.sections.map((section) => normalizeChecklistSection(section)).filter((section) => section.items.length)
: [];
const simpleItems = (data?.items || []).map(normalizeChecklistItem).filter((item) => item.label);
if (!sections.length && simpleItems.length) return { hideCompletedSections, hideCompletedSectionsFully, sections: [{ id: uid("section"), title: "", items: simpleItems }], items: simpleItems };
const items = sections.flatMap((section) => section.items);
return { hideCompletedSections, hideCompletedSectionsFully, sections, items };
}
function compactChecklistItemForStorage(item) {
const normalized = normalizeChecklistItem(item);
const compact = { id: normalized.id, label: normalized.label };
if (normalized.qtyTarget !== 1) compact.qtyTarget = normalized.qtyTarget;
if (normalized.qtyCurrent !== 0) compact.qtyCurrent = normalized.qtyCurrent;
return compact;
}
export function compactChecklistSectionForStorage(section) {
const normalized = normalizeChecklistSection(section);
const compact = {
id: normalized.id,
items: normalized.items.map(compactChecklistItemForStorage)
};
if (normalized.title) compact.title = normalized.title;
if (typeof normalized.hideWhenComplete === "boolean") compact.hideWhenComplete = normalized.hideWhenComplete;
if (normalized.collapsed === true) compact.collapsed = true;
return compact;
}
export function compactChecklistDataForStorage(value) {
const normalized = normalizeChecklistData(value);
const sections = normalized.sections.map(compactChecklistSectionForStorage).filter((section) => section.items.length);
if (!sections.length) return null;
const settings = {
...(normalized.hideCompletedSections ? { hideCompletedSections: true } : {}),
...(normalized.hideCompletedSectionsFully ? { hideCompletedSectionsFully: true } : {})
};
if (sections.length === 1 && !sections[0].title && typeof sections[0].hideWhenComplete !== "boolean") return { ...settings, items: sections[0].items };
return { ...settings, sections };
}

View file

@ -0,0 +1,86 @@
// Rôle : normalise et compacte les combos d'inputs.
import { uid } from "./shared.js";
const COMBO_DEVICES = new Set(["playstation", "xbox", "switch", "n64", "keyboardMouse"]);
const COMBO_INPUT_KINDS = new Set(["button", "direction", "key", "mouse"]);
function normalizeComboInput(input) {
const kind = COMBO_INPUT_KINDS.has(input?.kind) ? input.kind : "";
const value = String(input?.value || "").trim().slice(0, 24);
if (!kind || !value) return null;
const holdMs = Number(input?.holdMs);
const normalizedHoldMs = Number.isFinite(holdMs) && holdMs > 0
? Math.min(99000, Math.round(holdMs / 1000) * 1000)
: 0;
const normalized = { kind, value };
if (input?.hold === true || normalizedHoldMs > 0) normalized.hold = true;
if (normalizedHoldMs > 0) normalized.holdMs = normalizedHoldMs;
return normalized;
}
function normalizeComboSteps(inputs) {
return (Array.isArray(inputs) ? inputs : [])
.slice(0, 64)
.map((step) => (Array.isArray(step) ? step : [])
.map(normalizeComboInput)
.filter(Boolean)
.slice(0, 8))
.filter((step) => step.length);
}
function normalizeCombo(combo) {
const name = String(combo?.name || "").trim().slice(0, 80);
const category = String(combo?.category || "").trim().slice(0, 80);
const device = COMBO_DEVICES.has(combo?.device) ? combo.device : "";
const inputs = normalizeComboSteps(combo?.inputs);
if (!name && !inputs.length) return null;
const normalized = {
id: combo?.id || uid("combo"),
name: name || "Combo",
inputs
};
if (category) normalized.category = category;
if (device) normalized.device = device;
return normalized;
}
function normalizeComboCategoryOrder(value, combos) {
const categories = combos.map((combo) => combo.category).filter(Boolean);
const orderedCategories = (Array.isArray(value) ? value : [])
.map((category) => String(category || "").trim().slice(0, 80))
.filter((category, index, order) => category && categories.includes(category) && order.indexOf(category) === index);
return [
...orderedCategories,
...categories.filter((category) => !orderedCategories.includes(category))
];
}
export function normalizeCombosData(data) {
const combos = (Array.isArray(data?.combos) ? data.combos : []).map(normalizeCombo).filter(Boolean);
const categoryOrder = normalizeComboCategoryOrder(data?.categoryOrder, combos);
return {
device: COMBO_DEVICES.has(data?.device) ? data.device : "playstation",
combos,
collapsedCategories: (Array.isArray(data?.collapsedCategories) ? data.collapsedCategories : [])
.map((category) => String(category || "").trim().slice(0, 80))
.filter((category, index, categories) => category && categoryOrder.includes(category) && categories.indexOf(category) === index),
categoryOrder
};
}
export function compactCombosDataForStorage(value) {
const normalized = normalizeCombosData(value);
const compact = {
combos: normalized.combos.map((combo) => {
const compactCombo = { id: combo.id, name: combo.name, inputs: combo.inputs };
if (combo.category) compactCombo.category = combo.category;
if (combo.device) compactCombo.device = combo.device;
return compactCombo;
})
};
if (normalized.device !== "playstation") compact.device = normalized.device;
if (normalized.collapsedCategories.length) compact.collapsedCategories = normalized.collapsedCategories;
if (normalized.categoryOrder.length) compact.categoryOrder = normalized.categoryOrder;
if (!compact.combos.length) delete compact.combos;
return compact.combos ? compact : null;
}

View file

@ -0,0 +1,28 @@
// Rôle : normalise et compacte les compteurs toolbox.
import { uid } from "./shared.js";
function normalizeCounterValue(value) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : 0;
}
export function normalizeCountersData(data) {
return {
counters: (data?.counters || [])
.map((counter) => ({
id: counter?.id || uid("counter"),
label: String(counter?.label || "").trim(),
value: normalizeCounterValue(counter?.value)
}))
.filter((counter) => counter.label)
};
}
export function compactCountersDataForStorage(value) {
const counters = normalizeCountersData(value).counters.map((counter) => ({
id: counter.id,
label: counter.label,
value: counter.value
}));
return counters.length ? { counters } : null;
}

View file

@ -0,0 +1,34 @@
// Rôle : normalise les traits de dessin persistés par les outils annotables.
import { uid } from "./shared.js";
export const NOTEPAD_ALLOWED_COLORS = new Set(["#f5f7ff", "#b4bdd3", "#f6c453", "#22d3ee", "#8b5cf6", "#d946ef", "#101426", "#202745"]);
function normalizeDrawingPoint(point, options = {}) {
const x = Number(point?.x);
const y = Number(point?.y);
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
const max = Number(options.max);
return {
x: Math.min(Number.isFinite(max) ? max : Number.POSITIVE_INFINITY, Math.max(0, Math.round(x * 100) / 100)),
y: Math.min(Number.isFinite(max) ? max : Number.POSITIVE_INFINITY, Math.max(0, Math.round(y * 100) / 100))
};
}
function normalizeDrawingStroke(stroke, options = {}) {
const points = (Array.isArray(stroke?.points) ? stroke.points : []).map((point) => normalizeDrawingPoint(point, options)).filter(Boolean);
if (points.length < 2) return null;
const color = NOTEPAD_ALLOWED_COLORS.has(String(stroke?.color || "").toLowerCase()) ? String(stroke.color).toLowerCase() : "#f6c453";
const width = Math.min(24, Math.max(1, Number(stroke?.width) || 4));
return {
id: stroke?.id || uid("stroke"),
color,
width: Math.round(width * 10) / 10,
points
};
}
export function normalizeDrawingData(data, options = {}) {
return {
strokes: (Array.isArray(data?.strokes) ? data.strokes : []).map((stroke) => normalizeDrawingStroke(stroke, options)).filter(Boolean)
};
}

View file

@ -0,0 +1,345 @@
// Rôle : normalise, résume et compacte les données du planificateur d'équipements.
import { parsePositiveInt, uid } from "./shared.js";
const EQUIPMENT_SOCKET_SHAPES = new Set(["ball", "jewel"]);
const EQUIPMENT_SOCKET_COLORS = new Set(["red", "orange", "amber", "yellow", "lime", "green", "teal", "cyan", "blue", "indigo", "violet", "purple", "pink", "rose", "white", "gray", "black"]);
const EQUIPMENT_TYPE_ICONS = new Set(["boot", "chest-armor", "helmet", "glove", "shield", "sword", "gun", "earring", "necklace", "ring"]);
const EQUIPMENT_TRAIT_ICONS = new Set(["sword", "shield"]);
function normalizeEquipmentPlannerType(type) {
const title = String(type?.title || "").trim().slice(0, 80);
if (!title) return null;
const icon = EQUIPMENT_TYPE_ICONS.has(type?.icon) ? type.icon : "shield";
return {
id: type?.id || uid("equipmentType"),
title,
icon,
collapsed: type?.collapsed === true
};
}
function normalizeEquipmentPlannerValue(value) {
if (typeof value === "number") return Number.isFinite(value) ? Math.round(value * 1000) / 1000 : "";
const text = String(value ?? "").trim().slice(0, 80);
if (!text) return "";
const numeric = Number(text.replace(",", "."));
return Number.isFinite(numeric) && /^[-+]?\d+(?:[,.]\d+)?$/.test(text) ? Math.round(numeric * 1000) / 1000 : text;
}
function normalizeEquipmentPlannerTrait(trait, prefix = "trait") {
const name = String(trait?.name || "").trim().slice(0, 80);
if (!name) return null;
return {
id: trait?.id || uid(prefix),
category: String(trait?.category || "").trim().slice(0, 80),
icon: EQUIPMENT_TRAIT_ICONS.has(trait?.icon) ? trait.icon : "sword",
name,
value: normalizeEquipmentPlannerValue(trait?.value)
};
}
function normalizeEquipmentPlannerMaterial(material) {
const name = String(material?.name || "").trim().slice(0, 80);
if (!name) return null;
return {
id: material?.id || uid("material"),
name,
qty: Math.max(1, parsePositiveInt(material?.qty, 1))
};
}
function normalizeEquipmentPlannerSocketItem(socketItem) {
const name = String(socketItem?.name || "").trim().slice(0, 80);
if (!name) return null;
const shape = EQUIPMENT_SOCKET_SHAPES.has(socketItem?.shape) ? socketItem.shape : "jewel";
const color = EQUIPMENT_SOCKET_COLORS.has(socketItem?.color) ? socketItem.color : "yellow";
return {
id: socketItem?.id || uid("socketItem"),
name,
shape,
color,
bonuses: (Array.isArray(socketItem?.bonuses) ? socketItem.bonuses : [])
.map((bonus) => normalizeEquipmentPlannerTrait(bonus, "socketBonus"))
.filter(Boolean)
};
}
function normalizeEquipmentPlannerCategoryOrder(value, categories) {
const available = new Set(categories.filter(Boolean));
const ordered = [];
(Array.isArray(value) ? value : []).forEach((category) => {
const normalized = String(category || "").trim().slice(0, 80);
if (normalized && available.has(normalized) && !ordered.includes(normalized)) ordered.push(normalized);
});
categories.forEach((category) => {
if (category && !ordered.includes(category)) ordered.push(category);
});
return ordered;
}
function normalizeEquipmentPlannerEquipment(equipment, typeIds) {
const name = String(equipment?.name || "").trim().slice(0, 100);
const typeId = String(equipment?.typeId || "");
if (!name || !typeIds.has(typeId)) return null;
const characteristics = (Array.isArray(equipment?.characteristics) ? equipment.characteristics : [])
.map((trait) => normalizeEquipmentPlannerTrait(trait, "trait"))
.filter(Boolean);
const socketItems = (Array.isArray(equipment?.socketItems) ? equipment.socketItems : [])
.map(normalizeEquipmentPlannerSocketItem)
.filter(Boolean);
const socketItemIds = new Set(socketItems.map((socketItem) => socketItem.id));
const socketLinks = (Array.isArray(equipment?.socketLinks) ? equipment.socketLinks : [])
.map((link) => {
const fromSocketItemId = String(link?.fromSocketItemId || "");
const toSocketItemId = String(link?.toSocketItemId || "");
if (!socketItemIds.has(fromSocketItemId) || !socketItemIds.has(toSocketItemId) || fromSocketItemId === toSocketItemId) return null;
const pairKey = [fromSocketItemId, toSocketItemId].sort().join(":");
return {
id: link?.id || uid("socketLink"),
fromSocketItemId,
toSocketItemId,
pairKey
};
})
.filter(Boolean)
.filter((link, index, links) => links.findIndex((item) => item.pairKey === link.pairKey) === index)
.map(({ pairKey: _pairKey, ...link }) => link);
const categories = [
...characteristics.map((trait) => trait.category),
...socketItems.flatMap((socketItem) => socketItem.bonuses.map((bonus) => bonus.category))
].filter(Boolean);
const categoryOrder = normalizeEquipmentPlannerCategoryOrder(equipment?.categoryOrder, categories);
return {
id: equipment?.id || uid("equipment"),
typeId,
name,
icon: EQUIPMENT_TYPE_ICONS.has(equipment?.icon) ? equipment.icon : "shield",
active: equipment?.active !== false,
obtain: String(equipment?.obtain || "").trim().slice(0, 500),
characteristics,
socketItems,
socketLinks,
materials: (Array.isArray(equipment?.materials) ? equipment.materials : [])
.map(normalizeEquipmentPlannerMaterial)
.filter(Boolean),
categoryOrder,
collapsedCategories: (Array.isArray(equipment?.collapsedCategories) ? equipment.collapsedCategories : [])
.map((category) => String(category || "").trim().slice(0, 80))
.filter((category, index, collapsedCategories) => category && categoryOrder.includes(category) && collapsedCategories.indexOf(category) === index)
};
}
export function normalizeEquipmentPlannerData(data) {
const rawTypes = (Array.isArray(data?.types) ? data.types : []).map(normalizeEquipmentPlannerType).filter(Boolean);
const typeIds = new Set(rawTypes.map((type) => type.id));
const equipments = (Array.isArray(data?.equipments) ? data.equipments : [])
.map((equipment) => normalizeEquipmentPlannerEquipment(equipment, typeIds))
.filter(Boolean);
const usedTypeIds = new Set(equipments.map((equipment) => equipment.typeId));
const types = rawTypes.filter((type) => usedTypeIds.has(type.id));
const orderedTypeIds = (Array.isArray(data?.typeOrder) ? data.typeOrder : [])
.map((typeId) => String(typeId || ""))
.filter((typeId, index, order) => usedTypeIds.has(typeId) && order.indexOf(typeId) === index);
return {
types,
equipments,
typeOrder: [
...orderedTypeIds,
...types.map((type) => type.id).filter((typeId) => !orderedTypeIds.includes(typeId))
]
};
}
export function summarizeEquipmentPlannerData(data) {
const normalized = normalizeEquipmentPlannerData(data);
const totals = new Map();
const textBonuses = [];
const activeEquipments = normalized.equipments.filter((equipment) => equipment.active);
function incrementCount(counts, key) {
counts.set(key, (counts.get(key) || 0) + 1);
}
function pickMostUsedKey(counts) {
return [...counts.entries()].reduce((picked, [key, count]) => {
if (!picked) return { key, count };
if (count !== picked.count) return count > picked.count ? { key, count } : picked;
return key.localeCompare(picked.key) < 0 ? { key, count } : picked;
}, null);
}
function parseIconKey(key) {
const [icon, color = ""] = String(key || "").split(":");
return { icon: icon || "sword", color };
}
function pickSummaryIcon(iconSources) {
return parseIconKey(pickMostUsedKey(iconSources.direct)?.key || pickMostUsedKey(iconSources.socket)?.key || "sword:");
}
function getSummarySourceKind(sourceKinds) {
if (sourceKinds.size !== 1) return "mixed";
return sourceKinds.has("direct") ? "direct" : "socket";
}
function collectEntry(entry, contribution, icon, sourceKind, color = "") {
if (typeof entry.value === "number") {
const existing = totals.get(entry.name);
const iconKey = icon || "sword";
const colorKey = sourceKind === "socket" ? color || "gray" : "";
const iconSources = existing?.iconSources || { direct: new Map(), socket: new Map() };
const sourceKinds = existing?.sourceKinds || new Set();
const contributions = existing?.contributions || [];
sourceKinds.add(sourceKind);
incrementCount(iconSources[sourceKind], `${iconKey}:${colorKey}`);
const pickedIcon = pickSummaryIcon(iconSources);
totals.set(entry.name, {
name: entry.name,
icon: pickedIcon.icon,
color: pickedIcon.color,
sourceKind: getSummarySourceKind(sourceKinds),
iconSources,
sourceKinds,
contributions: [
...contributions,
{
id: entry.id,
sourceKind,
equipmentIcon: contribution.equipmentIcon,
equipmentName: contribution.equipmentName,
sourceName: contribution.sourceName,
value: entry.value
}
],
value: (existing?.value || 0) + entry.value
});
return;
}
textBonuses.push({
id: entry.id,
category: entry.category,
name: entry.name,
value: entry.value,
source: contribution.sourceName
});
}
activeEquipments.forEach((equipment) => {
equipment.characteristics.forEach((trait) => collectEntry(
trait,
{ equipmentIcon: equipment.icon || "shield", equipmentName: equipment.name, sourceName: equipment.name },
trait.icon || "sword",
"direct"
));
equipment.socketItems.forEach((socketItem) => {
socketItem.bonuses.forEach((bonus) => collectEntry(
bonus,
{ equipmentIcon: equipment.icon || "shield", equipmentName: equipment.name, sourceName: `${equipment.name} · ${socketItem.name}` },
socketItem.shape || "jewel",
"socket",
socketItem.color
));
});
});
const numericTotals = [...totals.values()]
.map(({ iconSources: _iconSources, sourceKinds: _sourceKinds, color, sourceKind, ...total }) => ({
...total,
...(color ? { color } : {}),
sourceKind
}))
.sort((a, b) => a.name.localeCompare(b.name));
return {
activeEquipmentCount: activeEquipments.length,
totals: numericTotals,
equipmentTotals: numericTotals.filter((total) => total.sourceKind === "direct"),
socketTotals: numericTotals.filter((total) => total.sourceKind === "socket"),
mixedTotals: numericTotals.filter((total) => total.sourceKind === "mixed"),
textBonuses,
socketItems: activeEquipments.flatMap((equipment) => equipment.socketItems.map((socketItem) => ({
...socketItem,
equipmentId: equipment.id,
equipmentName: equipment.name
}))),
socketLinks: activeEquipments.flatMap((equipment) => equipment.socketLinks.map((link) => ({
...link,
equipmentId: equipment.id,
equipmentName: equipment.name
})))
};
}
function compactEquipmentPlannerTraitForStorage(trait, prefix = "trait") {
const normalized = normalizeEquipmentPlannerTrait(trait, prefix);
if (!normalized) return null;
const compact = { id: normalized.id, name: normalized.name };
if (normalized.category) compact.category = normalized.category;
if (normalized.icon !== "sword") compact.icon = normalized.icon;
if (normalized.value !== "") compact.value = normalized.value;
return compact;
}
function compactEquipmentPlannerMaterialForStorage(material) {
const normalized = normalizeEquipmentPlannerMaterial(material);
return normalized ? normalized : null;
}
function compactEquipmentPlannerSocketItemForStorage(socketItem) {
const normalized = normalizeEquipmentPlannerSocketItem(socketItem);
if (!normalized) return null;
const compact = {
id: normalized.id,
name: normalized.name,
shape: normalized.shape,
color: normalized.color
};
if (normalized.bonuses.length) {
compact.bonuses = normalized.bonuses
.map((bonus) => compactEquipmentPlannerTraitForStorage(bonus, "socketBonus"))
.filter(Boolean);
}
return compact;
}
function compactEquipmentPlannerEquipmentForStorage(equipment) {
const compact = {
id: equipment.id,
typeId: equipment.typeId,
name: equipment.name
};
if (equipment.icon !== "shield") compact.icon = equipment.icon;
if (equipment.active === false) compact.active = false;
if (equipment.obtain) compact.obtain = equipment.obtain;
if (equipment.characteristics.length) {
compact.characteristics = equipment.characteristics
.map((trait) => compactEquipmentPlannerTraitForStorage(trait, "trait"))
.filter(Boolean);
}
if (equipment.socketItems.length) {
compact.socketItems = equipment.socketItems
.map(compactEquipmentPlannerSocketItemForStorage)
.filter(Boolean);
}
if (equipment.socketLinks.length) compact.socketLinks = equipment.socketLinks;
if (equipment.materials.length) compact.materials = equipment.materials.map(compactEquipmentPlannerMaterialForStorage).filter(Boolean);
if (equipment.categoryOrder.length) compact.categoryOrder = equipment.categoryOrder;
if (equipment.collapsedCategories.length) compact.collapsedCategories = equipment.collapsedCategories;
return compact;
}
export function compactEquipmentPlannerDataForStorage(value) {
const normalized = normalizeEquipmentPlannerData(value);
if (!normalized.equipments.length) return null;
const compact = {
types: normalized.types.map((type) => {
const compactType = { id: type.id, title: type.title };
if (type.icon !== "shield") compactType.icon = type.icon;
if (type.collapsed) compactType.collapsed = true;
return compactType;
}),
equipments: normalized.equipments.map(compactEquipmentPlannerEquipmentForStorage)
};
if (normalized.typeOrder.length) compact.typeOrder = normalized.typeOrder;
return compact;
}

View file

@ -0,0 +1,52 @@
// Rôle : normalise et compacte les images simples et annotations d'image.
import { normalizeDrawingData } from "./drawing.js";
import { uid } from "./shared.js";
function clampPercent(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return 0;
return Math.min(100, Math.max(0, parsed));
}
export function normalizeImageAnnotationData(data) {
const image = String(data?.image || "");
const markers = (data?.markers || [])
.map((marker) => ({
id: marker?.id || uid("marker"),
x: clampPercent(marker?.x),
y: clampPercent(marker?.y),
label: String(marker?.label || "").trim()
}));
return { image, markers, drawings: normalizeDrawingData(data?.drawings, { max: 100 }) };
}
export function compactImagesDataForStorage(value) {
const images = (Array.isArray(value?.images) ? value.images : [])
.filter((image) => image?.dataUrl)
.map((image) => {
const compact = { id: image.id || uid("image"), dataUrl: image.dataUrl };
if (image.label) compact.label = image.label;
return compact;
});
return images.length ? { images } : null;
}
export function compactImageAnnotationDataForStorage(value) {
const normalized = normalizeImageAnnotationData(value);
if (!normalized.image) return null;
const compact = {
image: normalized.image,
markers: normalized.markers.map((marker) => {
const compact = { id: marker.id, x: marker.x, y: marker.y };
if (marker.label) compact.label = marker.label;
return compact;
})
};
if (normalized.drawings.strokes.length) compact.drawings = normalized.drawings;
return compact;
}
export function labelFromFileName(name) {
if (!name || name === "image.png") return "";
return name.replace(/\.[^.]+$/, "").trim();
}

View file

@ -0,0 +1,27 @@
// Rôle : expose le registre strict des compacteurs par type d'outil toolbox.
import { compactCalculatorDataForStorage } from "./calculator.js";
import { compactChecklistDataForStorage } from "./checklist.js";
import { compactCombosDataForStorage } from "./combos.js";
import { compactCountersDataForStorage } from "./counters.js";
import { compactEquipmentPlannerDataForStorage } from "./equipmentPlanner.js";
import { compactImageAnnotationDataForStorage, compactImagesDataForStorage } from "./imageAnnotation.js";
import { compactLinksDataForStorage } from "./links.js";
import { compactNotepadDataForStorage } from "./notepad.js";
import { compactTableDataForStorage } from "./table.js";
import { compactTaskPlannerDataForStorage } from "./taskPlanner.js";
import { compactTimerDataForStorage } from "./timer.js";
export const MODULE_DATA_COMPACTORS = {
notepad: compactNotepadDataForStorage,
checklist: compactChecklistDataForStorage,
images: compactImagesDataForStorage,
imageAnnotation: compactImageAnnotationDataForStorage,
links: compactLinksDataForStorage,
counters: compactCountersDataForStorage,
combos: compactCombosDataForStorage,
calculator: compactCalculatorDataForStorage,
table: compactTableDataForStorage,
timer: compactTimerDataForStorage,
taskPlanner: compactTaskPlannerDataForStorage,
equipmentPlanner: compactEquipmentPlannerDataForStorage
};

View file

@ -0,0 +1,48 @@
// Rôle : normalise et compacte les liens toolbox.
import { uid } from "./shared.js";
export function normalizeUrl(value) {
const cleanValue = String(value || "").trim();
if (!cleanValue) return "";
try {
const url = new URL(cleanValue.includes("://") ? cleanValue : `https://${cleanValue}`);
if (url.protocol !== "http:" && url.protocol !== "https:") return "";
return url.href;
} catch {
return "";
}
}
export function hostnameFromUrl(value) {
try {
return new URL(value).hostname.replace(/^www\./, "");
} catch {
return value;
}
}
export function normalizeLinksData(data) {
return {
links: (data?.links || [])
.map((link) => {
const url = normalizeUrl(link?.url);
if (!url) return null;
return {
id: link?.id || uid("link"),
title: String(link?.title || "").trim(),
url
};
})
.filter(Boolean)
};
}
export function compactLinksDataForStorage(value) {
const links = normalizeLinksData(value).links.map((link) => {
const compact = { id: link.id, url: link.url };
if (link.title) compact.title = link.title;
return compact;
});
return links.length ? { links } : null;
}

View file

@ -0,0 +1,119 @@
// Rôle : normalise, nettoie et compacte les données du bloc-notes riche.
import { NOTEPAD_ALLOWED_COLORS, normalizeDrawingData } from "./drawing.js";
import { normalizeIsoDate } from "./shared.js";
const NOTEPAD_ALLOWED_TAGS = new Set(["p", "div", "br", "strong", "b", "em", "i", "u", "s", "strike", "ul", "ol", "li", "h3", "h4", "span", "mark"]);
const NOTEPAD_RGB_COLOR_MAP = {
"rgb(245, 247, 255)": "#f5f7ff",
"rgb(180, 189, 211)": "#b4bdd3",
"rgb(246, 196, 83)": "#f6c453",
"rgb(34, 211, 238)": "#22d3ee",
"rgb(139, 92, 246)": "#8b5cf6",
"rgb(217, 70, 239)": "#d946ef",
"rgb(16, 20, 38)": "#101426",
"rgb(32, 39, 69)": "#202745"
};
function escapeHtml(value) {
return String(value || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function decodeBasicEntities(value) {
return String(value || "")
.replace(/&nbsp;/gi, " ")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&quot;/gi, "\"")
.replace(/&#39;/g, "'")
.replace(/&amp;/gi, "&");
}
function normalizeStyleAttribute(value) {
const declarations = String(value || "")
.split(";")
.map((item) => item.trim())
.filter(Boolean);
const allowed = [];
declarations.forEach((declaration) => {
const [property, rawValue] = declaration.split(":").map((item) => item?.trim().toLowerCase());
if (!["color", "background-color"].includes(property)) return;
const color = NOTEPAD_RGB_COLOR_MAP[rawValue] || rawValue;
if (!NOTEPAD_ALLOWED_COLORS.has(color)) return;
allowed.push(`${property}: ${color}`);
});
return allowed.join("; ");
}
export function sanitizeNotepadHtml(value) {
const source = String(value || "")
.replace(/<script[\s\S]*?<\/script>/gi, "")
.replace(/<style[\s\S]*?<\/style>/gi, "");
let html = "";
let cursor = 0;
source.replace(/<\/?([a-z0-9]+)([^>]*)>/gi, (match, rawTag, rawAttributes, offset) => {
html += escapeHtml(source.slice(cursor, offset));
cursor = offset + match.length;
const tag = rawTag.toLowerCase();
if (!NOTEPAD_ALLOWED_TAGS.has(tag)) return "";
if (match.startsWith("</")) {
if (tag !== "br") html += `</${tag}>`;
return "";
}
if (tag === "br") {
html += "<br>";
return "";
}
const styleMatch = String(rawAttributes || "").match(/\sstyle=(?:"([^"]*)"|'([^']*)')/i);
const style = normalizeStyleAttribute(styleMatch?.[1] || styleMatch?.[2] || "");
html += style ? `<${tag} style="${style}">` : `<${tag}>`;
return "";
});
html += escapeHtml(source.slice(cursor));
return html.trim();
}
function textToNotepadHtml(value) {
return String(value || "")
.split(/\r?\n/)
.map((line) => line ? `<p>${escapeHtml(line)}</p>` : "<p><br></p>")
.join("");
}
function notepadHtmlToText(value) {
const withBreaks = String(value || "")
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/(p|div|h3|h4|li)>/gi, "\n")
.replace(/<li[^>]*>/gi, "- ");
return decodeBasicEntities(withBreaks.replace(/<[^>]*>/g, ""))
.split("\n")
.map((line) => line.trimEnd())
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
export function normalizeNotepadData(data) {
const source = data && typeof data === "object" ? data : {};
const legacyText = String(source.text || "");
const html = sanitizeNotepadHtml(source.html ? source.html : textToNotepadHtml(legacyText));
const text = notepadHtmlToText(html) || legacyText.trim();
const updatedAt = normalizeIsoDate(source.updatedAt);
const drawingMode = source.drawingMode === "permanent" ? "permanent" : "temporary";
const drawings = normalizeDrawingData(source.drawings);
return { html, text, updatedAt, drawingMode, drawings };
}
export function compactNotepadDataForStorage(value) {
const normalized = normalizeNotepadData(value);
const compact = {};
if (normalized.html && normalized.text) compact.html = normalized.html;
if (normalized.text) compact.text = normalized.text;
if (normalized.updatedAt) compact.updatedAt = normalized.updatedAt;
if (normalized.drawingMode === "permanent") compact.drawingMode = "permanent";
if (normalized.drawingMode === "permanent" && normalized.drawings.strokes.length) compact.drawings = normalized.drawings;
return Object.keys(compact).length ? compact : null;
}

View file

@ -0,0 +1,56 @@
// Rôle : fournit les primitives communes de normalisation et d'identifiants du stockage toolbox.
export const ID_PREFIXES = {
tbx: "t",
mod: "m",
item: "i",
section: "g",
image: "s",
link: "l",
counter: "c",
combo: "o",
calc: "r",
marker: "k",
timer: "z",
task: "a",
relation: "e",
stroke: "d",
equipmentType: "y",
equipment: "q",
trait: "v",
material: "n",
socketItem: "j",
socketLink: "u",
socketBonus: "b"
};
const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
function randomToken(length = 6) {
const bytes = new Uint8Array(length);
if (globalThis.crypto?.getRandomValues) {
globalThis.crypto.getRandomValues(bytes);
return [...bytes].map((byte) => ID_ALPHABET[byte % ID_ALPHABET.length]).join("");
}
return Math.random().toString(36).slice(2, 2 + length).padEnd(length, "0");
}
export function uid(prefix) {
return `${ID_PREFIXES[prefix] || "x"}${randomToken()}`;
}
export function parsePositiveInt(value, fallback = 1) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
export function normalizeIsoDate(value) {
const text = String(value || "").trim();
if (!text) return "";
const timestamp = Date.parse(text);
return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : "";
}
export function normalizeTimestamp(value) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
}

View file

@ -0,0 +1,63 @@
// Rôle : normalise et compacte les cellules et libellés de l'outil table.
function clampTableSize(value, fallback, max) {
const parsed = Number.parseInt(value, 10);
return Number.isInteger(parsed) ? Math.min(max, Math.max(1, parsed)) : fallback;
}
function columnIndexToName(index) {
let value = Math.max(0, Number(index) || 0) + 1;
let name = "";
while (value > 0) {
const remainder = (value - 1) % 26;
name = String.fromCharCode(65 + remainder) + name;
value = Math.floor((value - 1) / 26);
}
return name;
}
function tableCellAddress(rowIndex, columnIndex) {
return `${columnIndexToName(columnIndex)}${rowIndex + 1}`;
}
function normalizeTableLabels(labels, length, getFallback) {
const sourceLabels = labels && typeof labels === "object" ? labels : {};
const normalized = {};
for (let index = 0; index < length; index += 1) {
const value = String(sourceLabels[index] || "").slice(0, 80);
if (value.trim() && value !== String(getFallback(index))) normalized[index] = value;
}
return normalized;
}
export function normalizeTableData(data) {
const rows = clampTableSize(data?.rows, 10, 50);
const columns = clampTableSize(data?.columns, 6, 20);
const sourceCells = data?.cells && typeof data.cells === "object" ? data.cells : {};
const cells = {};
for (let rowIndex = 0; rowIndex < rows; rowIndex += 1) {
for (let columnIndex = 0; columnIndex < columns; columnIndex += 1) {
const address = tableCellAddress(rowIndex, columnIndex);
const value = String(sourceCells[address] || "");
if (value.trim()) cells[address] = value.slice(0, 500);
}
}
return {
rows,
columns,
cells,
rowLabels: normalizeTableLabels(data?.rowLabels, rows, (rowIndex) => rowIndex + 1),
columnLabels: normalizeTableLabels(data?.columnLabels, columns, columnIndexToName)
};
}
export function compactTableDataForStorage(value) {
const normalized = normalizeTableData(value);
const compact = { cells: normalized.cells };
if (normalized.rows !== 10) compact.rows = normalized.rows;
if (normalized.columns !== 6) compact.columns = normalized.columns;
if (Object.keys(normalized.rowLabels).length) compact.rowLabels = normalized.rowLabels;
if (Object.keys(normalized.columnLabels).length) compact.columnLabels = normalized.columnLabels;
return Object.keys(compact.cells).length || compact.rows || compact.columns || compact.rowLabels || compact.columnLabels ? compact : null;
}

View file

@ -0,0 +1,129 @@
// Rôle : normalise et compacte les données persistées du task planner.
import { normalizeTimestamp, uid } from "./shared.js";
export const DEFAULT_TASK_PLANNER_WEEKLY_RESET_DAY = 1;
export const DEFAULT_TASK_PLANNER_RESET_TIME = "00:00";
const TASK_TYPES = new Set(["unique", "daily", "weekly"]);
function isClockTimeString(value) {
if (!/^\d{2}:\d{2}$/.test(String(value || ""))) return false;
const [hours, minutes] = String(value).split(":").map(Number);
return Number.isInteger(hours) && Number.isInteger(minutes) && hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59;
}
function normalizeWeekDay(value, fallback = DEFAULT_TASK_PLANNER_WEEKLY_RESET_DAY) {
const parsed = Number.parseInt(value, 10);
return Number.isInteger(parsed) && parsed >= 0 && parsed <= 6 ? parsed : fallback;
}
function normalizeTaskPlannerTask(task) {
const title = String(task?.title || "").trim() || "Tâche";
const type = TASK_TYPES.has(task?.type) ? task.type : "unique";
const category = String(task?.category || "").trim();
const normalized = {
id: task?.id || uid("task"),
title,
description: String(task?.description || "").trim(),
type,
checked: task?.checked === true,
checkedAt: normalizeTimestamp(task?.checkedAt)
};
if (category) normalized.category = category;
if (type === "daily" && isClockTimeString(task?.dailyResetTime)) normalized.dailyResetTime = task.dailyResetTime;
if (type === "weekly" && task?.weeklyResetDay !== undefined) normalized.weeklyResetDay = normalizeWeekDay(task.weeklyResetDay);
if (!normalized.checked) normalized.checkedAt = 0;
return normalized;
}
function normalizeTaskPlannerCategoryOrder(value, tasks) {
const categories = new Set(tasks.map((task) => task.category).filter(Boolean));
const ordered = [];
(Array.isArray(value) ? value : []).forEach((category) => {
const normalized = String(category || "").trim();
if (normalized && categories.has(normalized) && !ordered.includes(normalized)) ordered.push(normalized);
});
tasks.forEach((task) => {
if (task.category && !ordered.includes(task.category)) ordered.push(task.category);
});
return ordered;
}
function normalizeTaskPlannerRelations(data, taskIds) {
const seen = new Set();
const seenChildren = new Set();
return (Array.isArray(data?.relations) ? data.relations : [])
.map((relation) => {
const fromTaskId = String(relation?.fromTaskId || "");
const toTaskId = String(relation?.toTaskId || "");
if (!taskIds.has(fromTaskId) || !taskIds.has(toTaskId) || fromTaskId === toTaskId) return null;
const key = `${fromTaskId}:${toTaskId}`;
if (seen.has(key) || seenChildren.has(fromTaskId)) return null;
seen.add(key);
seenChildren.add(fromTaskId);
return {
id: relation?.id || uid("relation"),
fromTaskId,
toTaskId,
prerequisite: relation?.prerequisite === true
};
})
.filter(Boolean);
}
export function normalizeTaskPlannerData(data) {
const rawTasks = (Array.isArray(data?.tasks) ? data.tasks : []).map(normalizeTaskPlannerTask).filter(Boolean);
const rawTaskIds = new Set(rawTasks.map((task) => task.id));
const relations = normalizeTaskPlannerRelations(data, rawTaskIds);
const childTaskIds = new Set(relations.map((relation) => relation.fromTaskId));
const tasks = rawTasks.map((task) => {
if (!childTaskIds.has(task.id) || !task.category) return task;
const normalized = { ...task };
delete normalized.category;
return normalized;
});
const taskIds = new Set(tasks.map((task) => task.id));
const categoryOrder = normalizeTaskPlannerCategoryOrder(data?.categoryOrder, tasks);
return {
weeklyResetDay: normalizeWeekDay(data?.weeklyResetDay),
resetTime: isClockTimeString(data?.resetTime) ? data.resetTime : DEFAULT_TASK_PLANNER_RESET_TIME,
lastResetAt: normalizeTimestamp(data?.lastResetAt),
hideCompleted: data?.hideCompleted === true,
collapsedCategories: (Array.isArray(data?.collapsedCategories) ? data.collapsedCategories : [])
.map((category) => String(category || "").trim())
.filter((category, index, categories) => category && categoryOrder.includes(category) && categories.indexOf(category) === index),
categoryOrder,
tasks,
relations: relations.filter((relation) => taskIds.has(relation.fromTaskId) && taskIds.has(relation.toTaskId))
};
}
export function compactTaskPlannerDataForStorage(value) {
const normalized = normalizeTaskPlannerData(value);
const compact = {};
if (normalized.weeklyResetDay !== DEFAULT_TASK_PLANNER_WEEKLY_RESET_DAY) compact.weeklyResetDay = normalized.weeklyResetDay;
if (normalized.resetTime !== DEFAULT_TASK_PLANNER_RESET_TIME) compact.resetTime = normalized.resetTime;
if (normalized.lastResetAt) compact.lastResetAt = normalized.lastResetAt;
if (normalized.hideCompleted) compact.hideCompleted = true;
if (normalized.collapsedCategories.length) compact.collapsedCategories = normalized.collapsedCategories;
if (normalized.tasks.length) {
compact.tasks = normalized.tasks.map((task) => {
const compactTask = { id: task.id, title: task.title, type: task.type };
if (task.description) compactTask.description = task.description;
if (task.category) compactTask.category = task.category;
if (task.checked) compactTask.checked = true;
if (task.checked && task.checkedAt) compactTask.checkedAt = task.checkedAt;
if (task.type === "daily" && task.dailyResetTime && task.dailyResetTime !== normalized.resetTime) compactTask.dailyResetTime = task.dailyResetTime;
if (task.type === "weekly" && task.weeklyResetDay !== undefined) compactTask.weeklyResetDay = task.weeklyResetDay;
return compactTask;
});
}
if (normalized.categoryOrder.length) compact.categoryOrder = normalized.categoryOrder;
if (normalized.relations.length) {
compact.relations = normalized.relations.map((relation) => {
const compactRelation = { id: relation.id, fromTaskId: relation.fromTaskId, toTaskId: relation.toTaskId };
if (relation.prerequisite) compactRelation.prerequisite = true;
return compactRelation;
});
}
return Object.keys(compact).length ? compact : null;
}

View file

@ -0,0 +1,114 @@
// Rôle : normalise et compacte les chronomètres et comptes à rebours.
import { uid } from "./shared.js";
const TIMER_TABS = new Set(["stopwatch", "countdown"]);
const COUNTDOWN_TYPES = new Set(["duration", "daily_time", "time_pattern", "interval"]);
const TIMER_ALERT_MODES = new Set(["off", "visible", "site"]);
const TIMER_INTERVAL_START_MODES = new Set(["now", "time"]);
const TIMER_MIN_AUTO_REFRESH_MS = 5 * 60 * 1000;
function isTimeString(value, allowWildcard = false) {
const pattern = allowWildcard ? /^(?:\d{2}|X):(?:\d{2}|X):(?:\d{2}|X)$/ : /^\d{2}:\d{2}:\d{2}$/;
if (!pattern.test(String(value || ""))) return false;
return String(value).split(":").every((part, index) => {
if (part === "X") return allowWildcard;
const number = Number(part);
return Number.isInteger(number) && number >= 0 && number <= (index === 0 ? 23 : 59);
});
}
export function normalizeTimerData(data) {
const stopwatch = data?.stopwatch || {};
const laps = (Array.isArray(stopwatch.laps) ? stopwatch.laps : [])
.map((lap) => ({
id: lap?.id || uid("timer"),
label: String(lap?.label || "").trim(),
elapsedMs: Math.max(0, Number(lap?.elapsedMs) || 0)
}))
.filter((lap) => lap.elapsedMs > 0);
const countdowns = (Array.isArray(data?.countdowns) ? data.countdowns : [])
.map((countdown) => {
const type = COUNTDOWN_TYPES.has(countdown?.type) ? countdown.type : "duration";
const normalized = {
id: countdown?.id || uid("timer"),
label: String(countdown?.label || "Timer").trim() || "Timer",
type,
alertMode: TIMER_ALERT_MODES.has(countdown?.alertMode) ? countdown.alertMode : "off"
};
if (type === "duration") {
const durationMs = Math.max(0, Number(countdown?.durationMs) || 0);
const targetAt = Math.max(0, Number(countdown?.targetAt) || 0);
return durationMs > 0 && targetAt > 0 ? { ...normalized, durationMs, targetAt } : null;
}
if (type === "daily_time") {
const time = String(countdown?.time || "").trim();
const targetAt = Math.max(0, Number(countdown?.targetAt) || 0);
return isTimeString(time) && targetAt > 0 ? { ...normalized, time, targetAt, autoRefresh: countdown?.autoRefresh === true } : null;
}
if (type === "time_pattern") {
const pattern = String(countdown?.pattern || "").trim().toUpperCase();
return isTimeString(pattern, true) ? { ...normalized, pattern } : null;
}
if (type === "interval") {
const intervalMs = Math.max(0, Number(countdown?.intervalMs) || 0);
const anchorAt = Math.max(0, Number(countdown?.anchorAt) || 0);
const targetAt = Math.max(0, Number(countdown?.targetAt) || 0);
const startMode = TIMER_INTERVAL_START_MODES.has(countdown?.startMode) ? countdown.startMode : "now";
const startTime = String(countdown?.startTime || "").trim();
const autoRefresh = countdown?.autoRefresh === true || (countdown?.autoRefresh !== false && !targetAt && intervalMs >= TIMER_MIN_AUTO_REFRESH_MS);
return intervalMs > 0
? {
...normalized,
intervalMs,
anchorAt,
targetAt: targetAt || anchorAt + intervalMs,
startMode: startMode === "time" && isTimeString(startTime) ? "time" : "now",
...(startMode === "time" && isTimeString(startTime) ? { startTime } : {}),
autoRefresh: autoRefresh && intervalMs >= TIMER_MIN_AUTO_REFRESH_MS
}
: null;
}
return null;
})
.filter(Boolean);
return {
activeTab: TIMER_TABS.has(data?.activeTab) ? data.activeTab : "stopwatch",
scrollResults: data?.scrollResults === true,
sortResults: data?.sortResults === true,
stopwatch: {
elapsedMs: Math.max(0, Number(stopwatch.elapsedMs) || 0),
startedAt: Math.max(0, Number(stopwatch.startedAt) || 0),
laps
},
countdowns
};
}
export function compactTimerDataForStorage(value) {
const normalized = normalizeTimerData(value);
const compact = {};
if (normalized.activeTab !== "stopwatch") compact.activeTab = normalized.activeTab;
if (normalized.scrollResults) compact.scrollResults = true;
if (normalized.sortResults) compact.sortResults = true;
if (normalized.stopwatch.elapsedMs || normalized.stopwatch.startedAt || normalized.stopwatch.laps.length) {
compact.stopwatch = {
...(normalized.stopwatch.elapsedMs ? { elapsedMs: normalized.stopwatch.elapsedMs } : {}),
...(normalized.stopwatch.startedAt ? { startedAt: normalized.stopwatch.startedAt } : {}),
...(normalized.stopwatch.laps.length ? { laps: normalized.stopwatch.laps } : {})
};
}
if (normalized.countdowns.length) {
compact.countdowns = normalized.countdowns.map((countdown) => {
const { alertMode, autoRefresh, startMode, startTime, ...compactCountdown } = countdown;
if (alertMode !== "off") compactCountdown.alertMode = alertMode;
if (autoRefresh) compactCountdown.autoRefresh = true;
if (startMode === "time" && startTime) {
compactCountdown.startMode = startMode;
compactCountdown.startTime = startTime;
}
return compactCountdown;
});
}
return Object.keys(compact).length ? compact : null;
}

File diff suppressed because it is too large Load diff