diff --git a/tests/static-toolboxes.test.mjs b/tests/static-toolboxes.test.mjs index 402a384..8d1f133 100644 --- a/tests/static-toolboxes.test.mjs +++ b/tests/static-toolboxes.test.mjs @@ -4,6 +4,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { SOCKET_SHAPES, formatValue, getSocketItemName, setItemCategory } from "../website/src/features/toolboxes/modules/equipmentPlannerOptions.js"; import { applyDueResets, getTaskParentMap, getTaskTreeIds, moveTask } from "../website/src/features/toolboxes/modules/taskPlannerUtils.js"; +import { createInput, getInputLabel, normalizeCustomKey, parseHoldSeconds } from "../website/src/features/toolboxes/modules/combosUtils.js"; test("toolbox storage, cards and pages are wired", async () => { const source = await readFile("website/src/main.jsx", "utf8"); @@ -114,6 +115,8 @@ test("toolbox module registry and modules expose expected behavior", async () => const moduleMetadataRegistry = await readFile("website/src/features/toolboxes/toolboxModuleRegistry.js", "utf8"); const notepadModule = await readFile("website/src/features/toolboxes/modules/NotepadModule.jsx", "utf8"); const combosModule = await readFile("website/src/features/toolboxes/modules/CombosModule.jsx", "utf8"); + const combosParts = await readFile("website/src/features/toolboxes/modules/CombosParts.jsx", "utf8"); + const combosUtils = await readFile("website/src/features/toolboxes/modules/combosUtils.js", "utf8"); const calculatorModule = await readFile("website/src/features/toolboxes/modules/CalculatorModule.jsx", "utf8"); const tableModule = await readFile("website/src/features/toolboxes/modules/TableModule.jsx", "utf8"); const tableFormulaEngine = await readFile("website/src/features/toolboxes/modules/tableFormulaEngine.js", "utf8"); @@ -180,26 +183,26 @@ test("toolbox module registry and modules expose expected behavior", async () => assert.match(notepadModule, /TextExchangeActions/); assert.match(notepadModule, /type="notepad"/); assert.match(combosModule, /export function CombosModule/); - assert.match(combosModule, /keyboardMouse/); - assert.match(combosModule, /Nintendo 64/); - assert.match(combosModule, /ComboInputToken/); - assert.match(combosModule, /ComboSequence/); + assert.match(combosUtils, /keyboardMouse/); + assert.match(combosUtils, /Nintendo 64/); + assert.match(combosParts, /export function ComboInputToken/); + assert.match(combosParts, /export function ComboSequence/); assert.match(combosModule, /keyboardLayout/); assert.match(combosModule, /simultaneousMode/); - assert.match(combosModule, /SPECIAL_INPUT_GROUP/); - assert.match(combosModule, /grab/); - assert.match(combosModule, /protect/); - assert.match(combosModule, /shoot/); + assert.match(combosUtils, /SPECIAL_INPUT_GROUP/); + assert.match(combosUtils, /grab/); + assert.match(combosUtils, /protect/); + assert.match(combosUtils, /shoot/); assert.match(combosModule, /holdMs/); assert.match(combosModule, /toggleSelectedInputHold/); assert.match(combosModule, /combo-hold-field/); - assert.match(combosModule, /useInlineEdit/); + assert.match(combosParts, /useInlineEdit/); assert.match(combosModule, /useGroupedReorder/); assert.match(combosModule, /reorderFeatures/); assert.match(combosModule, /namespace: `combos-\$\{moduleId\}`/); assert.match(combosModule, /getGroupBoundaryProps/); assert.match(combosModule, /combos-category-boundary-drop-zone/); - assert.match(combosModule, /function setComboItemCategory/); + assert.match(combosUtils, /function setComboItemCategory/); assert.match(combosModule, /setItemGroup: setComboItemCategory/); assert.doesNotMatch(combosModule, /canMoveItem:/); assert.doesNotMatch(combosModule, /canMoveGroup:/); @@ -411,3 +414,13 @@ test("equipment planner options expose stable formatting and socket helpers", () assert.deepEqual(setItemCategory({ id: "item", category: "DPS" }, ""), { id: "item" }); assert.equal(getSocketItemName({ socketItems: [{ id: "gem", name: "Ruby" }] }, "gem"), "Ruby"); }); + +test("combos helpers keep labels, hold parsing and custom keys stable", () => { + assert.deepEqual(createInput("button", "a", 1500), { kind: "button", value: "a", holdMs: 1500 }); + assert.deepEqual(createInput("button", "a"), { kind: "button", value: "a" }); + assert.equal(getInputLabel({ kind: "key", value: "q" }), "Q"); + assert.equal(getInputLabel({ kind: "mouse", value: "left" }), "Clic G"); + assert.equal(parseHoldSeconds("120s"), 99); + assert.equal(parseHoldSeconds("abc"), 0); + assert.equal(normalizeCustomKey(" Ctrl Alt Shift "), "Ctrl Alt Shift"); +}); diff --git a/website/src/features/toolboxes/modules/CombosModule.jsx b/website/src/features/toolboxes/modules/CombosModule.jsx index 47f2644..89d7498 100644 --- a/website/src/features/toolboxes/modules/CombosModule.jsx +++ b/website/src/features/toolboxes/modules/CombosModule.jsx @@ -1,333 +1,25 @@ // Rôle : fournit l'outil Combos avec palettes d'inputs, rendu visuel et échange texte. import { useMemo, useState } from "react"; import { Icon } from "../../../components/Icon.jsx"; -import { useInlineEdit } from "../../../hooks/useInlineEdit.js"; import { applyGroupedReorderOperation, getGroupedEntries, useGroupedReorder } from "../../../hooks/useGroupedReorder.js"; import { TextExchangeActions } from "./TextExchangeActions.jsx"; - -const DEVICE_OPTIONS = [ - { value: "playstation", label: "PlayStation" }, - { value: "xbox", label: "Xbox" }, - { value: "switch", label: "Switch" }, - { value: "n64", label: "Nintendo 64" }, - { value: "keyboardMouse", label: "Clavier/Souris" } -]; - -const SPECIAL_INPUT_GROUP = { title: "Spéciale", column: "actions", inputs: [["button", "fist"], ["button", "foot"], ["button", "grab"], ["button", "protect"], ["button", "shoot"]] }; - -const DEVICE_GROUPS = { - playstation: [ - { title: "Directions", kind: "directions", column: "movement", inputs: [["direction", "up-left"], ["direction", "up"], ["direction", "up-right"], ["direction", "left"], null, ["direction", "right"], ["direction", "down-left"], ["direction", "down"], ["direction", "down-right"]] }, - { title: "Actions", column: "actions", inputs: [["button", "triangle"], ["button", "circle"], ["button", "cross"], ["button", "square"], ["button", "l1"], ["button", "r1"], ["button", "l2"], ["button", "r2"]] }, - SPECIAL_INPUT_GROUP, - { title: "Système", inputs: [["button", "options"], ["button", "share"], ["button", "l3"], ["button", "r3"]] } - ], - xbox: [ - { title: "Directions", kind: "directions", column: "movement", inputs: [["direction", "up-left"], ["direction", "up"], ["direction", "up-right"], ["direction", "left"], null, ["direction", "right"], ["direction", "down-left"], ["direction", "down"], ["direction", "down-right"]] }, - { title: "Actions", column: "actions", inputs: [["button", "a"], ["button", "b"], ["button", "x"], ["button", "y"], ["button", "lb"], ["button", "rb"], ["button", "lt"], ["button", "rt"]] }, - SPECIAL_INPUT_GROUP, - { title: "Système", inputs: [["button", "menu"], ["button", "view"], ["button", "ls"], ["button", "rs"]] } - ], - switch: [ - { title: "Directions", kind: "directions", column: "movement", inputs: [["direction", "up-left"], ["direction", "up"], ["direction", "up-right"], ["direction", "left"], null, ["direction", "right"], ["direction", "down-left"], ["direction", "down"], ["direction", "down-right"]] }, - { title: "Actions", column: "actions", inputs: [["button", "a"], ["button", "b"], ["button", "x"], ["button", "y"], ["button", "l"], ["button", "r"], ["button", "zl"], ["button", "zr"]] }, - SPECIAL_INPUT_GROUP, - { title: "Système", inputs: [["button", "plus"], ["button", "minus"], ["button", "ls"], ["button", "rs"]] } - ], - n64: [ - { title: "Stick", kind: "directions", column: "movement", inputs: [["direction", "stick-up-left"], ["direction", "stick-up"], ["direction", "stick-up-right"], ["direction", "stick-left"], null, ["direction", "stick-right"], ["direction", "stick-down-left"], ["direction", "stick-down"], ["direction", "stick-down-right"]] }, - { title: "D-Pad", kind: "directions", column: "movement", inputs: [["direction", "up-left"], ["direction", "up"], ["direction", "up-right"], ["direction", "left"], null, ["direction", "right"], ["direction", "down-left"], ["direction", "down"], ["direction", "down-right"]] }, - { title: "Actions", column: "actions", inputs: [["button", "a"], ["button", "b"], ["button", "z"], ["button", "start"], ["button", "l"], ["button", "r"]] }, - SPECIAL_INPUT_GROUP, - { title: "C-buttons", kind: "directions", inputs: [["button", "c-up"], ["button", "c-down"], ["button", "c-left"], ["button", "c-right"]] } - ] -}; - -const KEYBOARD_GROUPS = { - azerty: [ - { title: "ZQSD", kind: "directions", column: "movement", inputs: [null, ["key", "z"], null, ["key", "q"], ["key", "s"], ["key", "d"], null, null, null] }, - { title: "Flèches", kind: "directions", column: "movement", inputs: [null, ["direction", "up"], null, ["direction", "left"], ["direction", "down"], ["direction", "right"], null, null, null] }, - { title: "Touches", column: "actions", inputs: [["key", "ctrl"], ["key", "alt"], ["key", "shift"], ["key", "space"], ["key", "enter"], ["key", "tab"], ["key", "e"], ["key", "a"], ["key", "r"], ["key", "f"]] }, - SPECIAL_INPUT_GROUP, - { title: "Souris", inputs: [["mouse", "left"], ["mouse", "right"], ["mouse", "middle"], ["mouse", "wheel-up"], ["mouse", "wheel-down"]] } - ], - qwerty: [ - { title: "WASD", kind: "directions", column: "movement", inputs: [null, ["key", "w"], null, ["key", "a"], ["key", "s"], ["key", "d"], null, null, null] }, - { title: "Flèches", kind: "directions", column: "movement", inputs: [null, ["direction", "up"], null, ["direction", "left"], ["direction", "down"], ["direction", "right"], null, null, null] }, - { title: "Touches", column: "actions", inputs: [["key", "ctrl"], ["key", "alt"], ["key", "shift"], ["key", "space"], ["key", "enter"], ["key", "tab"], ["key", "e"], ["key", "q"], ["key", "r"], ["key", "f"]] }, - SPECIAL_INPUT_GROUP, - { title: "Souris", inputs: [["mouse", "left"], ["mouse", "right"], ["mouse", "middle"], ["mouse", "wheel-up"], ["mouse", "wheel-down"]] } - ] -}; - -const INPUT_LABELS = { - triangle: "△", - circle: "○", - cross: "×", - square: "□", - options: "Options", - share: "Share", - up: "↑", - down: "↓", - left: "←", - right: "→", - "up-left": "↖", - "up-right": "↗", - "down-left": "↙", - "down-right": "↘", - "stick-up": "↑", - "stick-down": "↓", - "stick-left": "←", - "stick-right": "→", - "stick-up-left": "↖", - "stick-up-right": "↗", - "stick-down-left": "↙", - "stick-down-right": "↘", - plus: "+", - minus: "-", - "c-up": "C↑", - "c-down": "C↓", - "c-left": "C←", - "c-right": "C→", - fist: "Poing", - foot: "Pied", - grab: "Grab", - protect: "Protect", - shoot: "Shoot", - space: "Espace" -}; - -const INPUT_ICONS = { - fist: "fist", - foot: "foot", - grab: "grab", - protect: "shield", - shoot: "gun" -}; - -const MOUSE_LABELS = { - left: "Clic G", - right: "Clic D", - middle: "Molette", - "wheel-up": "Molette ↑", - "wheel-down": "Molette ↓" -}; - -function getInputLabel(input) { - const value = String(input?.value || ""); - if (input?.kind === "key" && value.length === 1) return value.toUpperCase(); - if (input?.kind === "mouse") return MOUSE_LABELS[value] || value; - return INPUT_LABELS[value] || value.toUpperCase(); -} - -function inputKey(input) { - return `${input.kind}:${input.value}:${input.hold ? "hold" : ""}:${input.holdMs || 0}`; -} - -function createInput(kind, value, holdMs = 0) { - const input = { kind, value }; - if (holdMs > 0) input.holdMs = holdMs; - return input; -} - -function getHoldSeconds(input) { - return Math.round((Number(input?.holdMs) || 0) / 1000); -} - -function formatHoldLabel(input) { - const seconds = getHoldSeconds(input); - if (!seconds) return input?.hold ? "hold" : ""; - return `${seconds}s`; -} - -function parseHoldSeconds(value) { - const seconds = Number.parseInt(String(value || "").replace(/[^\d]/g, ""), 10); - if (!Number.isInteger(seconds) || seconds <= 0) return 0; - return Math.min(99, seconds); -} - -function isDirectionInput(input) { - return input?.kind === "direction"; -} - -function createCombo(context, id, name, category, inputs, fallbackName, device) { - const combo = { - id: id || context.uid("combo"), - name: name.trim() || fallbackName, - device, - inputs: inputs.map((step) => step.map((input) => ({ ...input }))).filter((step) => step.length) - }; - if (category.trim()) combo.category = category.trim(); - return combo; -} - -function normalizeCustomKey(value) { - return String(value || "") - .trim() - .replace(/\s+/g, " ") - .slice(0, 16); -} - -function getPaletteGroups(device, keyboardLayout) { - return device === "keyboardMouse" ? KEYBOARD_GROUPS[keyboardLayout] : DEVICE_GROUPS[device] || DEVICE_GROUPS.playstation; -} - -function getPaletteColumns(groups) { - return [ - groups.filter((group) => group.column === "movement"), - groups.filter((group) => group.column !== "movement") - ]; -} - -function getComboCategory(combo) { - return String(combo?.category || "").trim(); -} - -function setComboItemCategory(combo, category) { - const nextCombo = { ...combo }; - if (category) nextCombo.category = category; - else delete nextCombo.category; - return nextCombo; -} - - -function ComboInputToken({ input, device, palette = false, onClick, onDragStart }) { - const label = getInputLabel(input); - const iconName = INPUT_ICONS[input.value]; - const holdLabel = !palette ? formatHoldLabel(input) : ""; - const classNames = [ - "combo-input-token", - `combo-input-${input.kind}`, - iconName ? "combo-input-icon" : "", - holdLabel ? "has-hold" : "", - input.kind === "key" && label.length === 1 ? "combo-input-short" : "", - `combo-device-${device}`, - `combo-value-${String(input.value || "").replace(/[^a-z0-9]+/gi, "-").toLowerCase()}` - ].filter(Boolean).join(" "); - - if (palette) { - return ( - - ); - } - - return ( - - {iconName ? : label} - {holdLabel && {holdLabel}} - - ); -} - -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) => ( - - {inputIndex > 0 && } - { - event.stopPropagation(); - onSelectInput(stepIndex, inputIndex); - } : null} - onDragStart={onDragInput ? (event) => onDragInput(event, stepIndex, inputIndex, input) : null} - /> - - )) : Étape vide; - - if (onClick) { - return ( - - ); - } - - return {content}; -} - -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 {emptyLabel}; - return ( -
{ - 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 ( - - onSelectStep(stepIndex) : null} - onSelectInput={onSelectInput} - onDropInput={onDropInput ? (event) => onDropInput(event, stepIndex) : null} - onDragInput={onDragInput} - /> - {visibleIndex < visibleInputs.length - 1 && !hideSeparator && } - - ); - })} -
- ); -} - -function InlineTextInput({ value, onCommit, className, ariaLabel, placeholder = "" }) { - const inlineEdit = useInlineEdit({ - value, - onCommit, - blurOnEscape: true - }); - - return ( - - ); -} +import { ComboInputToken, ComboSequence, InlineTextInput } from "./CombosParts.jsx"; +import { + DEVICE_OPTIONS, + createCombo, + createInput, + getComboCategory, + getHoldSeconds, + getPaletteColumns, + getPaletteGroups, + normalizeCustomKey, + parseHoldSeconds, + setComboItemCategory +} from "./combosUtils.js"; export function CombosModule({ toolboxId, moduleId, context, editing }) { const data = context.normalizeCombosData(context.getModuleData(toolboxId, moduleId, { combos: [] })); diff --git a/website/src/features/toolboxes/modules/CombosParts.jsx b/website/src/features/toolboxes/modules/CombosParts.jsx new file mode 100644 index 0000000..24691f7 --- /dev/null +++ b/website/src/features/toolboxes/modules/CombosParts.jsx @@ -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 ( + + ); + } + + return ( + + {iconName ? : label} + {holdLabel && {holdLabel}} + + ); +} + +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 {emptyLabel}; + return ( +
{ + 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 ( + + onSelectStep(stepIndex) : null} + onSelectInput={onSelectInput} + onDropInput={onDropInput ? (event) => onDropInput(event, stepIndex) : null} + onDragInput={onDragInput} + /> + {visibleIndex < visibleInputs.length - 1 && !hideSeparator && } + + ); + })} +
+ ); +} + +export function InlineTextInput({ value, onCommit, className, ariaLabel, placeholder = "" }) { + const inlineEdit = useInlineEdit({ + value, + onCommit, + blurOnEscape: true + }); + + return ( + + ); +} + +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) => ( + + {inputIndex > 0 && } + { + event.stopPropagation(); + onSelectInput(stepIndex, inputIndex); + } : null} + onDragStart={onDragInput ? (event) => onDragInput(event, stepIndex, inputIndex, input) : null} + /> + + )) : Étape vide; + + if (onClick) { + return ( + + ); + } + + return {content}; +} diff --git a/website/src/features/toolboxes/modules/combosUtils.js b/website/src/features/toolboxes/modules/combosUtils.js new file mode 100644 index 0000000..9a2bee3 --- /dev/null +++ b/website/src/features/toolboxes/modules/combosUtils.js @@ -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; +}