Compare commits
5 commits
97253acb00
...
3007aa1389
| Author | SHA1 | Date | |
|---|---|---|---|
| 3007aa1389 | |||
| d4fe3c796a | |||
| df0033739c | |||
| fb5feb8c98 | |||
| 588c1298cd |
33 changed files with 3334 additions and 3117 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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: "" } }, {}), "");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: [] }));
|
||||
|
|
|
|||
138
website/src/features/toolboxes/modules/CombosParts.jsx
Normal file
138
website/src/features/toolboxes/modules/CombosParts.jsx
Normal 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>;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
106
website/src/features/toolboxes/modules/EquipmentPlannerItem.jsx
Normal file
106
website/src/features/toolboxes/modules/EquipmentPlannerItem.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
93
website/src/features/toolboxes/modules/TableGridParts.jsx
Normal file
93
website/src/features/toolboxes/modules/TableGridParts.jsx
Normal 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"
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
350
website/src/features/toolboxes/modules/TimerParts.jsx
Normal file
350
website/src/features/toolboxes/modules/TimerParts.jsx
Normal 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
|
||||
})}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
185
website/src/features/toolboxes/modules/combosUtils.js
Normal file
185
website/src/features/toolboxes/modules/combosUtils.js
Normal 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;
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
203
website/src/features/toolboxes/modules/tableUtils.js
Normal file
203
website/src/features/toolboxes/modules/tableUtils.js
Normal 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)
|
||||
};
|
||||
}
|
||||
85
website/src/features/toolboxes/modules/timerOptions.js
Normal file
85
website/src/features/toolboxes/modules/timerOptions.js
Normal 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 "";
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
33
website/src/features/toolboxes/storage/modules/calculator.js
Normal file
33
website/src/features/toolboxes/storage/modules/calculator.js
Normal 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 };
|
||||
}
|
||||
74
website/src/features/toolboxes/storage/modules/checklist.js
Normal file
74
website/src/features/toolboxes/storage/modules/checklist.js
Normal 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 };
|
||||
}
|
||||
86
website/src/features/toolboxes/storage/modules/combos.js
Normal file
86
website/src/features/toolboxes/storage/modules/combos.js
Normal 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;
|
||||
}
|
||||
28
website/src/features/toolboxes/storage/modules/counters.js
Normal file
28
website/src/features/toolboxes/storage/modules/counters.js
Normal 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;
|
||||
}
|
||||
34
website/src/features/toolboxes/storage/modules/drawing.js
Normal file
34
website/src/features/toolboxes/storage/modules/drawing.js
Normal 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)
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
27
website/src/features/toolboxes/storage/modules/index.js
Normal file
27
website/src/features/toolboxes/storage/modules/index.js
Normal 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
|
||||
};
|
||||
48
website/src/features/toolboxes/storage/modules/links.js
Normal file
48
website/src/features/toolboxes/storage/modules/links.js
Normal 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;
|
||||
}
|
||||
119
website/src/features/toolboxes/storage/modules/notepad.js
Normal file
119
website/src/features/toolboxes/storage/modules/notepad.js
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function decodeBasicEntities(value) {
|
||||
return String(value || "")
|
||||
.replace(/ /gi, " ")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/gi, ">")
|
||||
.replace(/"/gi, "\"")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/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;
|
||||
}
|
||||
56
website/src/features/toolboxes/storage/modules/shared.js
Normal file
56
website/src/features/toolboxes/storage/modules/shared.js
Normal 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;
|
||||
}
|
||||
63
website/src/features/toolboxes/storage/modules/table.js
Normal file
63
website/src/features/toolboxes/storage/modules/table.js
Normal 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;
|
||||
}
|
||||
129
website/src/features/toolboxes/storage/modules/taskPlanner.js
Normal file
129
website/src/features/toolboxes/storage/modules/taskPlanner.js
Normal 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;
|
||||
}
|
||||
114
website/src/features/toolboxes/storage/modules/timer.js
Normal file
114
website/src/features/toolboxes/storage/modules/timer.js
Normal 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
Loading…
Add table
Add a link
Reference in a new issue