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`.
|
- Déclarer l'outil dans `website/src/features/toolboxes/modules/index.jsx`.
|
||||||
- Ajouter les textes nécessaires dans `website/public/data/site.json`.
|
- 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 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`.
|
- Mettre à jour `docs/STORAGE_SCHEMA.md`.
|
||||||
- Vérifier l'import et l'export si l'outil stocke des données.
|
- 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.
|
- 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 assert from "node:assert/strict";
|
||||||
import { SOCKET_SHAPES, formatValue, getSocketItemName, setItemCategory } from "../website/src/features/toolboxes/modules/equipmentPlannerOptions.js";
|
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 { 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 () => {
|
test("toolbox storage, cards and pages are wired", async () => {
|
||||||
const source = await readFile("website/src/main.jsx", "utf8");
|
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 toolboxPages = await readFile("website/src/features/toolboxes/ToolboxPages.jsx", "utf8");
|
||||||
const toolboxActions = await readFile("website/src/features/toolboxes/useToolboxActions.js", "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 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 indexedToolboxesHook = await readFile("website/src/features/toolboxes/storage/useIndexedToolboxes.js", "utf8");
|
||||||
const storageQuota = await readFile("website/src/components/StorageQuota.jsx", "utf8");
|
const storageQuota = await readFile("website/src/components/StorageQuota.jsx", "utf8");
|
||||||
const importButton = await readFile("website/src/components/ImportButton.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, /TOOLBOX_ICON_FILES/);
|
||||||
assert.match(toolboxStorage, /export function normalizeToolboxIcon/);
|
assert.match(toolboxStorage, /export function normalizeToolboxIcon/);
|
||||||
assert.match(toolboxStorage, /export function createGlobalExportPayload/);
|
assert.match(toolboxStorage, /export function createGlobalExportPayload/);
|
||||||
assert.match(toolboxStorage, /qtyTarget/);
|
assert.match(checklistStorage, /qtyTarget/);
|
||||||
assert.match(toolboxStorage, /qtyCurrent/);
|
assert.match(checklistStorage, /qtyCurrent/);
|
||||||
assert.match(toolboxStorage, /hideCompletedSections/);
|
assert.match(checklistStorage, /hideCompletedSections/);
|
||||||
assert.match(toolboxStorage, /hideCompletedSectionsFully/);
|
assert.match(checklistStorage, /hideCompletedSectionsFully/);
|
||||||
assert.match(toolboxStorage, /hideWhenComplete/);
|
assert.match(checklistStorage, /hideWhenComplete/);
|
||||||
assert.match(toolboxStorage, /collapsed/);
|
assert.match(equipmentStorage, /collapsed/);
|
||||||
assert.match(toolboxStorage, /module\.scrollable/);
|
assert.match(toolboxStorage, /module\.scrollable/);
|
||||||
assert.match(toolboxStorage, /export function normalizeLinksData/);
|
assert.match(toolboxStorage, /normalizeLinksData/);
|
||||||
assert.match(toolboxStorage, /export function normalizeCountersData/);
|
assert.match(toolboxStorage, /normalizeCountersData/);
|
||||||
assert.match(toolboxStorage, /export function normalizeCombosData/);
|
assert.match(toolboxStorage, /normalizeCombosData/);
|
||||||
assert.match(toolboxStorage, /export function normalizeCalculatorData/);
|
assert.match(toolboxStorage, /normalizeCalculatorData/);
|
||||||
assert.match(toolboxStorage, /export function normalizeTableData/);
|
assert.match(toolboxStorage, /normalizeTableData/);
|
||||||
assert.match(toolboxStorage, /export function normalizeTimerData/);
|
assert.match(toolboxStorage, /normalizeTimerData/);
|
||||||
assert.match(toolboxStorage, /export function normalizeTaskPlannerData/);
|
assert.match(toolboxStorage, /normalizeTaskPlannerData/);
|
||||||
assert.match(toolboxStorage, /export function normalizeEquipmentPlannerData/);
|
assert.match(toolboxStorage, /normalizeEquipmentPlannerData/);
|
||||||
assert.match(toolboxStorage, /export function summarizeEquipmentPlannerData/);
|
assert.match(toolboxStorage, /summarizeEquipmentPlannerData/);
|
||||||
assert.match(toolboxStorage, /export function normalizeImageAnnotationData/);
|
assert.match(toolboxStorage, /normalizeImageAnnotationData/);
|
||||||
assert.match(toolboxStorage, /export function compactModuleDataForStorage\(type, value\)/);
|
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, /DEFAULT_TOOLBOX_ICON/);
|
||||||
assert.match(toolboxCard, /export function ToolboxCard/);
|
assert.match(toolboxCard, /export function ToolboxCard/);
|
||||||
assert.match(toolboxCard, /export function ToolboxIconPicker/);
|
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 moduleMetadataRegistry = await readFile("website/src/features/toolboxes/toolboxModuleRegistry.js", "utf8");
|
||||||
const notepadModule = await readFile("website/src/features/toolboxes/modules/NotepadModule.jsx", "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 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 calculatorModule = await readFile("website/src/features/toolboxes/modules/CalculatorModule.jsx", "utf8");
|
||||||
const tableModule = await readFile("website/src/features/toolboxes/modules/TableModule.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 tableFormulaEngine = await readFile("website/src/features/toolboxes/modules/tableFormulaEngine.js", "utf8");
|
||||||
const timerModule = await readFile("website/src/features/toolboxes/modules/TimerModule.jsx", "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 taskPlannerModule = await readFile("website/src/features/toolboxes/modules/TaskPlannerModule.jsx", "utf8");
|
||||||
const taskPlannerItems = await readFile("website/src/features/toolboxes/modules/TaskPlannerItems.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 taskPlannerUtils = await readFile("website/src/features/toolboxes/modules/taskPlannerUtils.js", "utf8");
|
||||||
const equipmentPlannerModule = await readFile("website/src/features/toolboxes/modules/EquipmentPlannerModule.jsx", "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 equipmentPlannerOptions = await readFile("website/src/features/toolboxes/modules/equipmentPlannerOptions.js", "utf8");
|
||||||
const checklistModule = await readFile("website/src/features/toolboxes/modules/ChecklistModule.jsx", "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");
|
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, /TextExchangeActions/);
|
||||||
assert.match(notepadModule, /type="notepad"/);
|
assert.match(notepadModule, /type="notepad"/);
|
||||||
assert.match(combosModule, /export function CombosModule/);
|
assert.match(combosModule, /export function CombosModule/);
|
||||||
assert.match(combosModule, /keyboardMouse/);
|
assert.match(combosUtils, /keyboardMouse/);
|
||||||
assert.match(combosModule, /Nintendo 64/);
|
assert.match(combosUtils, /Nintendo 64/);
|
||||||
assert.match(combosModule, /ComboInputToken/);
|
assert.match(combosParts, /export function ComboInputToken/);
|
||||||
assert.match(combosModule, /ComboSequence/);
|
assert.match(combosParts, /export function ComboSequence/);
|
||||||
assert.match(combosModule, /keyboardLayout/);
|
assert.match(combosModule, /keyboardLayout/);
|
||||||
assert.match(combosModule, /simultaneousMode/);
|
assert.match(combosModule, /simultaneousMode/);
|
||||||
assert.match(combosModule, /SPECIAL_INPUT_GROUP/);
|
assert.match(combosUtils, /SPECIAL_INPUT_GROUP/);
|
||||||
assert.match(combosModule, /grab/);
|
assert.match(combosUtils, /grab/);
|
||||||
assert.match(combosModule, /protect/);
|
assert.match(combosUtils, /protect/);
|
||||||
assert.match(combosModule, /shoot/);
|
assert.match(combosUtils, /shoot/);
|
||||||
assert.match(combosModule, /holdMs/);
|
assert.match(combosModule, /holdMs/);
|
||||||
assert.match(combosModule, /toggleSelectedInputHold/);
|
assert.match(combosModule, /toggleSelectedInputHold/);
|
||||||
assert.match(combosModule, /combo-hold-field/);
|
assert.match(combosModule, /combo-hold-field/);
|
||||||
assert.match(combosModule, /useInlineEdit/);
|
assert.match(combosParts, /useInlineEdit/);
|
||||||
assert.match(combosModule, /useGroupedReorder/);
|
assert.match(combosModule, /useGroupedReorder/);
|
||||||
assert.match(combosModule, /reorderFeatures/);
|
assert.match(combosModule, /reorderFeatures/);
|
||||||
assert.match(combosModule, /namespace: `combos-\$\{moduleId\}`/);
|
assert.match(combosModule, /namespace: `combos-\$\{moduleId\}`/);
|
||||||
assert.match(combosModule, /getGroupBoundaryProps/);
|
assert.match(combosModule, /getGroupBoundaryProps/);
|
||||||
assert.match(combosModule, /combos-category-boundary-drop-zone/);
|
assert.match(combosModule, /combos-category-boundary-drop-zone/);
|
||||||
assert.match(combosModule, /function setComboItemCategory/);
|
assert.match(combosUtils, /function setComboItemCategory/);
|
||||||
assert.match(combosModule, /setItemGroup: setComboItemCategory/);
|
assert.match(combosModule, /setItemGroup: setComboItemCategory/);
|
||||||
assert.doesNotMatch(combosModule, /canMoveItem:/);
|
assert.doesNotMatch(combosModule, /canMoveItem:/);
|
||||||
assert.doesNotMatch(combosModule, /canMoveGroup:/);
|
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, /draggingSelectionRef/);
|
||||||
assert.match(tableModule, /movingSelectionRef/);
|
assert.match(tableModule, /movingSelectionRef/);
|
||||||
assert.match(tableModule, /copiedSelectionRef/);
|
assert.match(tableModule, /copiedSelectionRef/);
|
||||||
assert.match(tableModule, /moveSelectionCells/);
|
assert.match(tableUtils, /moveSelectionCells/);
|
||||||
assert.match(tableModule, /pasteCellBlock/);
|
assert.match(tableUtils, /pasteCellBlock/);
|
||||||
assert.match(tableModule, /parseTsvBlock/);
|
assert.match(tableUtils, /parseTsvBlock/);
|
||||||
assert.match(tableModule, /is-move-target/);
|
assert.match(tableGridParts, /is-move-target/);
|
||||||
assert.match(tableModule, /isMultiCellSelection/);
|
assert.match(tableUtils, /isMultiCellSelection/);
|
||||||
assert.match(tableModule, /handleCellKeyDown/);
|
assert.match(tableModule, /handleCellKeyDown/);
|
||||||
assert.match(tableModule, /clearSelection/);
|
assert.match(tableModule, /clearSelection/);
|
||||||
assert.match(tableModule, /event\.key === "Delete"/);
|
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, /ArrowDown/);
|
||||||
assert.match(tableModule, /ArrowLeft/);
|
assert.match(tableModule, /ArrowLeft/);
|
||||||
assert.match(tableModule, /ArrowRight/);
|
assert.match(tableModule, /ArrowRight/);
|
||||||
assert.match(tableModule, /function HeaderLabelInput/);
|
assert.match(tableGridParts, /export function HeaderLabelInput/);
|
||||||
assert.match(tableModule, /useInlineEdit/);
|
assert.match(tableGridParts, /useInlineEdit/);
|
||||||
assert.match(tableModule, /TextExchangeActions/);
|
assert.match(tableModule, /TextExchangeActions/);
|
||||||
assert.match(tableModule, /event\.target\.select/);
|
assert.match(tableGridParts, /event\.target\.select/);
|
||||||
assert.match(tableFormulaEngine, /export function evaluateTableCell/);
|
assert.match(tableFormulaEngine, /export function evaluateTableCell/);
|
||||||
assert.doesNotMatch(tableFormulaEngine, /Function\(/);
|
assert.doesNotMatch(tableFormulaEngine, /Function\(/);
|
||||||
assert.doesNotMatch(tableFormulaEngine, /\beval\(/);
|
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, /Tabs/);
|
||||||
assert.match(timerModule, /stopwatch/);
|
assert.match(timerModule, /stopwatch/);
|
||||||
assert.match(timerModule, /countdown/);
|
assert.match(timerModule, /countdown/);
|
||||||
assert.match(timerModule, /useInlineEdit/);
|
assert.match(timerParts, /useInlineEdit/);
|
||||||
assert.match(timerModule, /useDraftForm/);
|
assert.match(timerModule, /useDraftForm/);
|
||||||
assert.match(timerModule, /normalizeTimerData/);
|
assert.match(timerModule, /normalizeTimerData/);
|
||||||
assert.match(timerModule, /TextExchangeActions/);
|
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, /export function EquipmentPlannerModule/);
|
||||||
assert.match(equipmentPlannerModule, /normalizeEquipmentPlannerData/);
|
assert.match(equipmentPlannerModule, /normalizeEquipmentPlannerData/);
|
||||||
assert.match(equipmentPlannerModule, /summarizeEquipmentPlannerData/);
|
assert.match(equipmentPlannerModule, /summarizeEquipmentPlannerData/);
|
||||||
assert.match(equipmentPlannerModule, /socketItems/);
|
assert.match(equipmentPlannerModule, /EquipmentItem/);
|
||||||
assert.match(equipmentPlannerModule, /socketLinks/);
|
assert.match(equipmentPlannerModule, /EquipmentPlannerSummary/);
|
||||||
|
assert.match(equipmentPlannerEditors, /socketItems/);
|
||||||
|
assert.match(equipmentPlannerEditors, /socketLinks/);
|
||||||
assert.match(equipmentPlannerModule, /useGroupedReorder/);
|
assert.match(equipmentPlannerModule, /useGroupedReorder/);
|
||||||
assert.match(equipmentPlannerModule, /reorderFeatures/);
|
assert.match(equipmentPlannerEditors, /reorderFeatures/);
|
||||||
assert.match(equipmentPlannerModule, /TextExchangeActions/);
|
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 const EQUIPMENT_ICONS/);
|
||||||
assert.match(equipmentPlannerOptions, /export function formatValue/);
|
assert.match(equipmentPlannerOptions, /export function formatValue/);
|
||||||
assert.match(checklistModule, /export function ChecklistModule/);
|
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, /useGroupedReorder/);
|
||||||
assert.match(timerModule, /reorderEnabled={!data\.sortResults}/);
|
assert.match(timerModule, /reorderEnabled={!data\.sortResults}/);
|
||||||
assert.match(timerModule, /parentId=\{moduleId\}/);
|
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 parseColonImportLines/);
|
||||||
assert.match(textImport, /export function exportModuleText/);
|
assert.match(textImport, /export function exportModuleText/);
|
||||||
assert.match(textImport, /export function importModuleText/);
|
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.deepEqual(setItemCategory({ id: "item", category: "DPS" }, ""), { id: "item" });
|
||||||
assert.equal(getSocketItemName({ socketItems: [{ id: "gem", name: "Ruby" }] }, "gem"), "Ruby");
|
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.
|
// Rôle : fournit l'outil Combos avec palettes d'inputs, rendu visuel et échange texte.
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Icon } from "../../../components/Icon.jsx";
|
import { Icon } from "../../../components/Icon.jsx";
|
||||||
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
|
||||||
import {
|
import {
|
||||||
applyGroupedReorderOperation,
|
applyGroupedReorderOperation,
|
||||||
getGroupedEntries,
|
getGroupedEntries,
|
||||||
useGroupedReorder
|
useGroupedReorder
|
||||||
} from "../../../hooks/useGroupedReorder.js";
|
} from "../../../hooks/useGroupedReorder.js";
|
||||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
||||||
|
import { ComboInputToken, ComboSequence, InlineTextInput } from "./CombosParts.jsx";
|
||||||
const DEVICE_OPTIONS = [
|
import {
|
||||||
{ value: "playstation", label: "PlayStation" },
|
DEVICE_OPTIONS,
|
||||||
{ value: "xbox", label: "Xbox" },
|
createCombo,
|
||||||
{ value: "switch", label: "Switch" },
|
createInput,
|
||||||
{ value: "n64", label: "Nintendo 64" },
|
getComboCategory,
|
||||||
{ value: "keyboardMouse", label: "Clavier/Souris" }
|
getHoldSeconds,
|
||||||
];
|
getPaletteColumns,
|
||||||
|
getPaletteGroups,
|
||||||
const SPECIAL_INPUT_GROUP = { title: "Spéciale", column: "actions", inputs: [["button", "fist"], ["button", "foot"], ["button", "grab"], ["button", "protect"], ["button", "shoot"]] };
|
normalizeCustomKey,
|
||||||
|
parseHoldSeconds,
|
||||||
const DEVICE_GROUPS = {
|
setComboItemCategory
|
||||||
playstation: [
|
} from "./combosUtils.js";
|
||||||
{ 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
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CombosModule({ toolboxId, moduleId, context, editing }) {
|
export function CombosModule({ toolboxId, moduleId, context, editing }) {
|
||||||
const data = context.normalizeCombosData(context.getModuleData(toolboxId, moduleId, { combos: [] }));
|
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.
|
// Rôle : fournit l'outil de planification d'équipements, sertissages, résumé et échange texte.
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
|
||||||
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 { TextExchangeActions } from "./TextExchangeActions.jsx";
|
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
||||||
import {
|
import { EQUIPMENT_ICONS } from "./equipmentPlannerOptions.js";
|
||||||
DETAIL_TABS,
|
import { EquipmentItem } from "./EquipmentPlannerItem.jsx";
|
||||||
EQUIPMENT_ICONS,
|
import { IconChoiceDropdown } from "./EquipmentPlannerPickers.jsx";
|
||||||
SOCKET_COLORS,
|
import { EquipmentPlannerSummary } from "./EquipmentPlannerSummary.jsx";
|
||||||
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"} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EquipmentPlannerModule({ toolboxId, moduleId, context, editing }) {
|
export function EquipmentPlannerModule({ toolboxId, moduleId, context, editing }) {
|
||||||
const data = context.normalizeEquipmentPlannerData(context.getModuleData(toolboxId, moduleId, { types: [], equipments: [] }));
|
const data = context.normalizeEquipmentPlannerData(context.getModuleData(toolboxId, moduleId, { types: [], equipments: [] }));
|
||||||
|
|
@ -161,849 +135,3 @@ export function EquipmentPlannerModule({ toolboxId, moduleId, context, editing }
|
||||||
</div>
|
</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.
|
// Rôle : fournit l'outil tableau avec cellules libres, formules simples et échange TSV.
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Icon } from "../../../components/Icon.jsx";
|
import { Icon } from "../../../components/Icon.jsx";
|
||||||
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
|
||||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
||||||
import { cellAddress, columnIndexToName, evaluateTable, parseCellAddress } from "./tableFormulaEngine.js";
|
import { cellAddress, columnIndexToName, evaluateTable, parseCellAddress } from "./tableFormulaEngine.js";
|
||||||
|
import { HeaderLabelInput, TableRow } from "./TableGridParts.jsx";
|
||||||
const DEFAULT_ROWS = 10;
|
import {
|
||||||
const DEFAULT_COLUMNS = 6;
|
DEFAULT_COLUMNS,
|
||||||
const MAX_ROWS = 50;
|
DEFAULT_ROWS,
|
||||||
const MAX_COLUMNS = 20;
|
MAX_COLUMNS,
|
||||||
const MIN_COLUMN_WIDTH = 96;
|
MAX_ROWS,
|
||||||
const MAX_COLUMN_WIDTH = 220;
|
clampCellPosition,
|
||||||
const MIN_ROW_HEADER_WIDTH = 42;
|
cleanCells,
|
||||||
const MAX_ROW_HEADER_WIDTH = 180;
|
cleanLabels,
|
||||||
|
getBoundsFromStartAndSize,
|
||||||
function cleanCells(cells, rows, columns) {
|
getCellBlock,
|
||||||
const nextCells = {};
|
getColumnWidths,
|
||||||
for (let rowIndex = 0; rowIndex < rows; rowIndex += 1) {
|
getFormulaRangeText,
|
||||||
for (let columnIndex = 0; columnIndex < columns; columnIndex += 1) {
|
getRowHeaderWidth,
|
||||||
const address = cellAddress(rowIndex, columnIndex);
|
getSelectionBounds,
|
||||||
const value = String(cells?.[address] || "");
|
isCellSelected,
|
||||||
if (value.trim()) nextCells[address] = value;
|
isMultiCellSelection,
|
||||||
}
|
isSameBounds,
|
||||||
}
|
moveSelectionCells,
|
||||||
return nextCells;
|
parseTsvBlock,
|
||||||
}
|
pasteCellBlock
|
||||||
|
} from "./tableUtils.js";
|
||||||
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)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TableModule({ toolboxId, moduleId, context, editing }) {
|
export function TableModule({ toolboxId, moduleId, context, editing }) {
|
||||||
const data = context.normalizeTableData(context.getModuleData(toolboxId, moduleId, { rows: DEFAULT_ROWS, columns: DEFAULT_COLUMNS, cells: {} }));
|
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>
|
</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 { Icon } from "../../../components/Icon.jsx";
|
||||||
import { Tabs } from "../../../components/Tabs.jsx";
|
import { Tabs } from "../../../components/Tabs.jsx";
|
||||||
import { useDraftForm } from "../../../hooks/useDraftForm.js";
|
import { useDraftForm } from "../../../hooks/useDraftForm.js";
|
||||||
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
|
|
||||||
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
|
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
|
||||||
import {
|
import {
|
||||||
formatDuration,
|
|
||||||
formatDurationWithCentiseconds,
|
|
||||||
formatTargetTime,
|
|
||||||
getCentiseconds,
|
|
||||||
getCountdownTargetMs,
|
getCountdownTargetMs,
|
||||||
getDailyTargetMs,
|
getDailyTargetMs,
|
||||||
getIntervalAnchorMs,
|
getIntervalAnchorMs,
|
||||||
getNowMs,
|
getNowMs,
|
||||||
getTimePatternTargetMs,
|
getTimePatternTargetMs,
|
||||||
getTimePatternRecurrenceMs,
|
|
||||||
timePartsToDurationMs,
|
timePartsToDurationMs,
|
||||||
timePartsToString
|
timePartsToString
|
||||||
} from "./timerUtils.js";
|
} from "./timerUtils.js";
|
||||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
||||||
|
import { CountdownControls, CountdownList, StopwatchControls, StopwatchLapList } from "./TimerParts.jsx";
|
||||||
const TIMER_TABS = [
|
import {
|
||||||
{ id: "stopwatch", label: "Chronomètre", icon: "stopwatch" },
|
ALERT_MIN_DELAY_MS,
|
||||||
{ id: "countdown", label: "Compte à rebours", icon: "hourglass" }
|
AUTO_REFRESH_COUNTDOWN_TYPES,
|
||||||
];
|
COUNTDOWN_TYPES,
|
||||||
|
DEFAULT_COUNTDOWN_FORM,
|
||||||
const COUNTDOWN_TYPES = ["duration", "daily_time", "time_pattern", "interval"];
|
TIMER_TABS,
|
||||||
const EMPTY_TIME_PARTS = { hours: "", minutes: "", seconds: "" };
|
TOGGLE_REFRESH_COUNTDOWN_TYPES,
|
||||||
const DEFAULT_COUNTDOWN_FORM = {
|
canToggleAutoRefresh,
|
||||||
label: "",
|
getAlertGuardMessage,
|
||||||
type: "duration",
|
getCountdownValidationError,
|
||||||
duration: EMPTY_TIME_PARTS,
|
hasTimePartValue,
|
||||||
time: EMPTY_TIME_PARTS,
|
isAlertGuarded
|
||||||
pattern: EMPTY_TIME_PARTS,
|
} from "./timerOptions.js";
|
||||||
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 "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TimerModule({ toolboxId, moduleId, context, editing }) {
|
export function TimerModule({ toolboxId, moduleId, context, editing }) {
|
||||||
const data = context.normalizeTimerData(context.getModuleData(toolboxId, moduleId, { activeTab: "stopwatch" }));
|
const data = context.normalizeTimerData(context.getModuleData(toolboxId, moduleId, { activeTab: "stopwatch" }));
|
||||||
|
|
@ -411,326 +324,3 @@ export function TimerModule({ toolboxId, moduleId, context, editing }) {
|
||||||
</div>
|
</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) {
|
export function getSocketItemName(equipment, socketItemId) {
|
||||||
return equipment.socketItems.find((socketItem) => socketItem.id === socketItemId)?.name || "";
|
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