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