add combos tool
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s

This commit is contained in:
Shinuwa 2026-07-31 14:46:28 +02:00
parent 025cd4bf22
commit 7b4cfb7dc2
13 changed files with 1829 additions and 2 deletions

View file

@ -0,0 +1,807 @@
// Rôle : fournit l'outil Combos avec palettes d'inputs et rendu visuel par périphérique.
import { useEffect, useMemo, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { usePointerReorder } from "../../../hooks/usePointerReorder.js";
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 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"]] },
{ 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"]] },
{ 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"]] },
{ 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"]] },
{ 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"]] },
{ 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"]] },
{ 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→",
space: "Espace"
};
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}`;
}
function createInput(kind, value) {
return { kind, value };
}
function createCombo(context, id, name, inputs, fallbackName, device) {
return {
id: id || context.uid("combo"),
name: name.trim() || fallbackName,
device,
inputs: inputs.map((step) => step.map((input) => ({ ...input }))).filter((step) => step.length)
};
}
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 moveArrayItem(items, fromId, toId, placement, getId = (item) => item.id) {
if (!fromId || !toId || fromId === toId) return items;
const nextItems = [...items];
const fromIndex = nextItems.findIndex((item) => getId(item) === fromId);
const toIndex = nextItems.findIndex((item) => getId(item) === toId);
if (fromIndex < 0 || toIndex < 0) return items;
const [moved] = nextItems.splice(fromIndex, 1);
const targetIndex = nextItems.findIndex((item) => getId(item) === toId);
nextItems.splice(placement === "after" ? targetIndex + 1 : targetIndex, 0, moved);
return nextItems;
}
function removeComboFromData(data, comboId) {
let movedCombo = null;
const combos = data.combos.filter((combo) => {
if (combo.id !== comboId) return true;
movedCombo = combo;
return false;
});
const categories = data.categories.map((category) => ({
...category,
combos: category.combos.filter((combo) => {
if (combo.id !== comboId) return true;
movedCombo = combo;
return false;
})
}));
return { movedCombo, data: { ...data, combos, categories } };
}
function insertComboInList(list, combo, targetComboId = "", placement = "after") {
if (!targetComboId) return [...list, combo];
const targetIndex = list.findIndex((item) => item.id === targetComboId);
if (targetIndex < 0) return [...list, combo];
const nextList = [...list];
nextList.splice(placement === "after" ? targetIndex + 1 : targetIndex, 0, combo);
return nextList;
}
function ComboInputToken({ input, device, palette = false, onClick, onDragStart }) {
const label = getInputLabel(input);
const classNames = [
"combo-input-token",
`combo-input-${input.kind}`,
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>{label}</span>
</button>
);
}
return (
<span className={classNames} draggable={Boolean(onDragStart)} onDragStart={onDragStart} title={label}>
<span>{label}</span>
</span>
);
}
function ComboStep({ step, stepIndex, device, active = false, onClick, 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" key={`${inputKey(input)}-${inputIndex}`}>
{inputIndex > 0 && <span className="combo-plus" aria-hidden="true">+</span>}
<ComboInputToken input={input} device={device} 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, onSelectStep, 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) => (
<span className="combo-step" key={`${stepIndex}-${step.map(inputKey).join("+")}`}>
<ComboStep step={step} stepIndex={stepIndex} device={device} active={activeStepIndex === stepIndex} onClick={onSelectStep ? () => onSelectStep(stepIndex) : null} onDropInput={onDropInput ? (event) => onDropInput(event, stepIndex) : null} onDragInput={onDragInput} />
{visibleIndex < visibleInputs.length - 1 && <span className="combo-step-separator" aria-hidden="true"></span>}
</span>
))}
</div>
);
}
function InlineTextInput({ value, onCommit, className, ariaLabel, placeholder = "" }) {
const [draft, setDraft] = useState(value);
useEffect(() => {
setDraft(value);
}, [value]);
function commit() {
const cleanDraft = draft.trim();
if (cleanDraft !== value) onCommit(cleanDraft);
}
return (
<input
className={className}
value={draft}
placeholder={placeholder}
onChange={(event) => setDraft(event.target.value)}
onBlur={commit}
onKeyDown={(event) => {
if (event.key === "Enter") event.currentTarget.blur();
if (event.key === "Escape") {
setDraft(value);
event.currentTarget.blur();
}
}}
aria-label={ariaLabel}
/>
);
}
export function CombosModule({ toolboxId, moduleId, context, editing }) {
const data = context.normalizeCombosData(context.getModuleData(toolboxId, moduleId, { combos: [], categories: [] }));
const textContent = context.moduleText?.combos || {};
const [comboCategory, setComboCategory] = useState("");
const [comboName, setComboName] = useState("");
const [draftInputs, setDraftInputs] = useState([[]]);
const [activeStepIndex, setActiveStepIndex] = useState(0);
const [simultaneousMode, setSimultaneousMode] = useState(false);
const [keyboardLayout, setKeyboardLayout] = useState("azerty");
const [customKey, setCustomKey] = useState("");
const [editingComboId, setEditingComboId] = useState("");
const paletteGroups = useMemo(() => getPaletteGroups(data.device, keyboardLayout), [data.device, keyboardLayout]);
const paletteColumns = useMemo(() => getPaletteColumns(paletteGroups), [paletteGroups]);
const cleanDraftInputs = draftInputs.map((step) => step.filter(Boolean)).filter((step) => step.length);
const {
draggingId: draggingCategoryId,
dropTarget: categoryDropTarget,
startDrag: startCategoryDrag
} = usePointerReorder({
targetSelector: ".combos-category",
getTargetId: (target) => target.dataset.categoryId,
onMove: moveCategory
});
const {
draggingId: draggingComboId,
dropTarget: comboDropTarget,
startDrag: startComboDrag
} = usePointerReorder({
targetSelector: ".combo-card, .combos-root-drop-zone, .combos-category",
getTargetId: (target) => {
if (target.classList.contains("combo-card")) return `combo:${target.dataset.comboId}`;
if (target.classList.contains("combos-category")) return `category:${target.dataset.categoryId}`;
return "root";
},
canDropOn: (target, draggingId) => {
if (target.classList.contains("combo-card")) return target.dataset.comboId !== draggingId;
if (target.classList.contains("combos-category")) return !target.querySelector(`[data-combo-id="${draggingId}"]`);
return true;
},
onMove: moveCombo
});
function save(nextData) {
context.setModuleData(toolboxId, moduleId, nextData, "combos");
}
function updateDevice(device) {
const stampComboDevice = (combo) => combo.device ? combo : { ...combo, device: data.device };
save({
...data,
device,
combos: data.combos.map(stampComboDevice),
categories: data.categories.map((category) => ({
...category,
combos: category.combos.map(stampComboDevice)
}))
});
}
function moveCategory(fromCategoryId, toCategoryId, placement) {
save({ ...data, categories: moveArrayItem(data.categories, fromCategoryId, toCategoryId, placement) });
}
function moveCombo(fromComboId, targetId, placement) {
const { movedCombo, data: dataWithoutCombo } = removeComboFromData(data, fromComboId);
if (!movedCombo) return;
if (targetId === "root") {
save({ ...dataWithoutCombo, combos: [...dataWithoutCombo.combos, movedCombo] });
return;
}
if (targetId.startsWith("category:")) {
const categoryId = targetId.replace("category:", "");
save({
...dataWithoutCombo,
categories: dataWithoutCombo.categories.map((category) => category.id === categoryId
? { ...category, combos: [...category.combos, movedCombo], collapsed: false }
: category)
});
return;
}
const targetComboId = targetId.replace("combo:", "");
const targetElement = document.querySelector(`[data-combo-id="${targetComboId}"]`);
const targetList = targetElement?.dataset.comboList || "";
if (!targetList) {
save({ ...dataWithoutCombo, combos: insertComboInList(dataWithoutCombo.combos, movedCombo, targetComboId, placement) });
return;
}
save({
...dataWithoutCombo,
categories: dataWithoutCombo.categories.map((category) => category.id === targetList
? { ...category, combos: insertComboInList(category.combos, movedCombo, targetComboId, placement), collapsed: false }
: category)
});
}
function updateCategory(categoryId, updater) {
save({
...data,
categories: data.categories.map((category) => category.id === categoryId ? updater(category) : category)
});
}
function getCategoryTarget(categories, title) {
const cleanTitle = title.trim();
if (!cleanTitle) return { categoryId: "", categories };
const existing = categories.find((category) => category.title.toLowerCase() === cleanTitle.toLowerCase());
if (existing) return { categoryId: existing.id, categories };
const category = { id: context.uid("section"), title: cleanTitle, collapsed: false, combos: [] };
return { categoryId: category.id, categories: [...categories, category] };
}
function addInputToStep(kind, value, stepIndex) {
const input = createInput(kind, value);
setDraftInputs((steps) => {
const nextSteps = steps.length ? steps.map((step) => [...step]) : [[]];
const targetIndex = Math.min(Math.max(0, stepIndex), nextSteps.length - 1);
nextSteps[targetIndex] = [...nextSteps[targetIndex], input];
return nextSteps;
});
}
function addDraftInput(kind, value) {
const input = createInput(kind, value);
if (simultaneousMode) {
addInputToStep(kind, value, activeStepIndex);
return;
}
setDraftInputs((steps) => {
const filledSteps = steps.filter((step) => step.length);
const nextSteps = [...filledSteps, [input]];
setActiveStepIndex(nextSteps.length - 1);
return nextSteps;
});
}
function dragPaletteInput(event, kind, value) {
event.dataTransfer.effectAllowed = "copy";
event.dataTransfer.setData("application/x-sokkog-combo-input", JSON.stringify(createInput(kind, value)));
}
function dragDraftInput(event, stepIndex, inputIndex, input) {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("application/x-sokkog-combo-input", JSON.stringify({
...input,
sourceStepIndex: stepIndex,
sourceInputIndex: inputIndex
}));
}
function dropInputOnStep(event, stepIndex) {
const rawInput = event.dataTransfer.getData("application/x-sokkog-combo-input");
if (!rawInput) return;
try {
const input = JSON.parse(rawInput);
if (!input?.kind || !input?.value) return;
if (Number.isInteger(input.sourceStepIndex) && Number.isInteger(input.sourceInputIndex)) {
moveDraftInput(input.sourceStepIndex, input.sourceInputIndex, stepIndex);
} else {
addInputToStep(input.kind, input.value, stepIndex);
}
setActiveStepIndex(stepIndex);
} catch {
// Ignore invalid drag payloads from outside the app.
}
}
function dropInputAsNewStep(event) {
const rawInput = event.dataTransfer.getData("application/x-sokkog-combo-input");
if (!rawInput) return;
try {
const input = JSON.parse(rawInput);
if (!input?.kind || !input?.value) return;
if (Number.isInteger(input.sourceStepIndex) && Number.isInteger(input.sourceInputIndex)) {
moveDraftInputToNewStep(input.sourceStepIndex, input.sourceInputIndex);
} else {
addDraftInput(input.kind, input.value);
}
} catch {
// Ignore invalid drag payloads from outside the app.
}
}
function moveDraftInput(sourceStepIndex, sourceInputIndex, targetStepIndex) {
setDraftInputs((steps) => {
if (sourceStepIndex === targetStepIndex) return steps;
const nextSteps = steps.map((step) => [...step]);
const [input] = nextSteps[sourceStepIndex]?.splice(sourceInputIndex, 1) || [];
if (!input || !nextSteps[targetStepIndex]) return steps;
nextSteps[targetStepIndex].push(input);
return nextSteps.filter((step, index) => step.length || index === targetStepIndex);
});
}
function moveDraftInputToNewStep(sourceStepIndex, sourceInputIndex) {
setDraftInputs((steps) => {
const nextSteps = steps.map((step) => [...step]);
const [input] = nextSteps[sourceStepIndex]?.splice(sourceInputIndex, 1) || [];
if (!input) return steps;
const safeSteps = nextSteps.filter((step) => step.length);
setActiveStepIndex(safeSteps.length);
return [...safeSteps, [input]];
});
}
function removeActiveStep() {
setDraftInputs((steps) => {
const nextSteps = steps.filter((_, index) => index !== activeStepIndex);
const safeSteps = nextSteps.length ? nextSteps : [[]];
setActiveStepIndex(Math.min(activeStepIndex, safeSteps.length - 1));
return safeSteps;
});
}
function removeLastInput() {
setDraftInputs((steps) => {
const nextSteps = steps.length ? steps.map((step) => [...step]) : [[]];
let targetIndex = -1;
for (let index = nextSteps.length - 1; index >= 0; index -= 1) {
if (nextSteps[index].length) {
targetIndex = index;
break;
}
}
if (targetIndex < 0) return [[]];
nextSteps[targetIndex].pop();
const safeSteps = nextSteps.filter((step) => step.length);
setActiveStepIndex(Math.max(0, safeSteps.length - 1));
return safeSteps.length ? safeSteps : [[]];
});
}
function addCustomKey(event) {
event.preventDefault();
const value = normalizeCustomKey(customKey);
if (!value || data.device !== "keyboardMouse") return;
addDraftInput("key", value.toLowerCase());
setCustomKey("");
}
function saveCombo(event) {
event.preventDefault();
if (!comboName.trim() && !cleanDraftInputs.length) return;
const combo = createCombo(context, editingComboId, comboName, cleanDraftInputs, textContent.defaultComboName || "Combo", data.device);
const nextRootCombos = data.combos.filter((item) => item.id !== editingComboId);
const nextCategories = data.categories.map((category) => ({
...category,
combos: category.combos.filter((item) => item.id !== editingComboId)
}));
const target = getCategoryTarget(nextCategories, comboCategory);
const nextData = target.categoryId
? {
...data,
combos: nextRootCombos,
categories: target.categories.map((category) => category.id === target.categoryId ? { ...category, combos: [...category.combos, combo], collapsed: false } : category)
}
: { ...data, combos: [...nextRootCombos, combo], categories: nextCategories };
save(nextData);
cancelComboEdit();
}
function startComboEdit(categoryId, combo) {
setComboCategory(data.categories.find((category) => category.id === categoryId)?.title || "");
setEditingComboId(combo.id);
setComboName(combo.name);
setDraftInputs(combo.inputs.length ? combo.inputs.map((step) => step.map((input) => ({ ...input }))) : [[]]);
setActiveStepIndex(0);
}
function cancelComboEdit() {
setEditingComboId("");
setComboName("");
setComboCategory("");
setDraftInputs([[]]);
setActiveStepIndex(0);
setSimultaneousMode(false);
}
function updateCombo(categoryId, comboId, updater) {
if (!categoryId) {
save({ ...data, combos: data.combos.map((combo) => combo.id === comboId ? updater(combo) : combo).filter((combo) => combo.name || combo.inputs.length) });
return;
}
updateCategory(categoryId, (category) => ({
...category,
combos: category.combos.map((combo) => combo.id === comboId ? updater(combo) : combo).filter((combo) => combo.name || combo.inputs.length)
}));
}
function deleteCombo(categoryId, comboId) {
updateCombo(categoryId, comboId, () => ({ name: "", inputs: [] }));
if (editingComboId === comboId) cancelComboEdit();
}
function renderCombo(combo, categoryId = "") {
const comboClassName = [
"combo-card",
"is-editing",
draggingComboId === combo.id ? "is-dragging" : "",
comboDropTarget.id === `combo:${combo.id}` ? "is-drop-target" : "",
comboDropTarget.id === `combo:${combo.id}` && comboDropTarget.placement === "after" ? "drop-after" : ""
].filter(Boolean).join(" ");
return (
<article className={comboClassName} key={combo.id} data-combo-id={combo.id} data-combo-list={categoryId}>
<button
className="task-planner-drag-handle combo-drag-handle"
type="button"
onPointerDown={(event) => startComboDrag(event, combo.id)}
aria-label={`${textContent.reorderComboTitle || "Déplacer le combo"} ${combo.name}`}
title={textContent.reorderComboTitle || "Déplacer le combo"}
>
<Icon name="drag" />
</button>
<div className="combo-card-main">
<InlineTextInput
className="tool-split-entry-label combo-title-input"
value={combo.name}
onCommit={(name) => updateCombo(categoryId, combo.id, (current) => ({ ...current, name: name || current.name }))}
ariaLabel={textContent.comboNameLabel || "Nom"}
/>
<ComboSequence inputs={combo.inputs} device={combo.device || data.device} emptyLabel={textContent.emptySequence || "Combo sans touches"} />
</div>
<div className="combo-card-actions">
<button type="button" className="checklist-delete-button" onClick={() => startComboEdit(categoryId, combo)} aria-label={`${textContent.editComboTitle || "Modifier le combo"} ${combo.name}`} title={textContent.editComboTitle || "Modifier le combo"}>
<Icon name="edit" />
</button>
<button type="button" className="checklist-delete-button danger" onClick={() => deleteCombo(categoryId, combo.id)} aria-label={`${textContent.deleteComboTitle || "Supprimer le combo"} ${combo.name}`} title={textContent.deleteComboTitle || "Supprimer le combo"}>
<Icon name="trash" />
</button>
</div>
</article>
);
}
function renderCategory(category) {
const categoryClassName = [
"combos-category checklist-section is-grouped",
category.collapsed ? "is-collapsed" : "",
draggingCategoryId === category.id ? "is-dragging" : "",
categoryDropTarget.id === category.id || comboDropTarget.id === `category:${category.id}` ? "is-drop-target" : "",
(categoryDropTarget.id === category.id && categoryDropTarget.placement === "after") ? "drop-after" : ""
].filter(Boolean).join(" ");
return (
<section className={categoryClassName} key={category.id} data-category-id={category.id}>
<div className="checklist-section-header combos-category-header">
<div className="combos-category-title">
<button
className="task-planner-category-drag-handle"
type="button"
onPointerDown={(event) => startCategoryDrag(event, category.id)}
aria-label={`${textContent.reorderCategoryTitle || "Déplacer la catégorie"} ${category.title}`}
title={textContent.reorderCategoryTitle || "Déplacer la catégorie"}
>
<Icon name="drag" />
</button>
<h3>{category.title}</h3>
</div>
<div>
<span>{category.combos.length}</span>
<button
className="checklist-section-collapse-button"
type="button"
onClick={() => updateCategory(category.id, (item) => ({ ...item, collapsed: !item.collapsed }))}
aria-expanded={!category.collapsed}
aria-label={category.collapsed ? textContent.showCategoryTitle || "Afficher la catégorie" : textContent.hideCategoryTitle || "Réduire la catégorie"}
title={category.collapsed ? textContent.showCategoryTitle || "Afficher la catégorie" : textContent.hideCategoryTitle || "Réduire la catégorie"}
>
<Icon name={category.collapsed ? "chevron-down" : "chevron-up"} />
</button>
</div>
</div>
{!category.collapsed && (
<div className="combos-list">
{category.combos.map((combo) => renderCombo(combo, category.id))}
{!category.combos.length && <p className="empty-state">{textContent.emptyCategory || "Aucun combo dans cette catégorie."}</p>}
</div>
)}
</section>
);
}
return (
<div className="combos-module">
{(editing || editingComboId) && (
<div className="module-add-panel combos-editor">
<div className="combos-device-row">
<label>
<span>{textContent.deviceLabel || "Périphérique"}</span>
<select value={data.device} onChange={(event) => updateDevice(event.target.value)}>
{DEVICE_OPTIONS.map((device) => (
<option key={device.value} value={device.value}>{device.label}</option>
))}
</select>
</label>
{data.device === "keyboardMouse" && (
<div className="combos-keyboard-layout-switch" role="group" aria-label={textContent.keyboardLayoutLabel || "Disposition clavier"}>
{[
{ value: "azerty", label: "AZERTY" },
{ value: "qwerty", label: "QWERTY" }
].map((layout) => (
<button
key={layout.value}
type="button"
className={keyboardLayout === layout.value ? "active" : ""}
onClick={() => setKeyboardLayout(layout.value)}
aria-pressed={keyboardLayout === layout.value}
>
{layout.label}
</button>
))}
</div>
)}
</div>
<form className="combos-add-form" onSubmit={saveCombo}>
<div className="combos-form-grid">
<label>
<span>{textContent.targetCategoryLabel || "Catégorie"}</span>
<input value={comboCategory} onChange={(event) => setComboCategory(event.target.value)} placeholder={textContent.noCategoryLabel || "Sans catégorie"} />
</label>
<label>
<span>{textContent.comboNameLabel || "Nom"}</span>
<input value={comboName} onChange={(event) => setComboName(event.target.value)} placeholder={textContent.comboNamePlaceholder || "Nom du combo"} />
</label>
</div>
<div className="combos-palette" aria-label={textContent.paletteLabel || "Palette de touches"}>
{paletteColumns.map((columnGroups, columnIndex) => (
<div className="combos-palette-column" key={columnIndex}>
{columnGroups.map((group) => (
<div className={`combos-palette-group ${group.kind === "directions" ? "is-direction-grid" : ""}`} key={group.title}>
<span>{group.title}</span>
<div>
{group.inputs.map((input, inputIndex) => {
if (!input) return <span className="combo-palette-empty-cell" key={`empty-${inputIndex}`} aria-hidden="true" />;
const [kind, value] = input;
return (
<ComboInputToken
key={`${kind}-${value}`}
input={createInput(kind, value)}
device={data.device}
palette
onClick={() => addDraftInput(kind, value)}
onDragStart={(event) => dragPaletteInput(event, kind, value)}
/>
);
})}
</div>
</div>
))}
</div>
))}
</div>
{data.device === "keyboardMouse" && (
<div className="inline-form combos-custom-key-form">
<input value={customKey} onChange={(event) => setCustomKey(event.target.value)} placeholder={textContent.customKeyPlaceholder || "Touche personnalisée"} />
<button type="button" onClick={addCustomKey}>{textContent.addCustomKeyButton || "Ajouter touche"}</button>
</div>
)}
<div className="combo-draft-panel">
<ComboSequence inputs={draftInputs} device={data.device} emptyLabel={textContent.emptyDraft || "Aucune touche sélectionnée."} activeStepIndex={activeStepIndex} onSelectStep={setActiveStepIndex} onDropInput={dropInputOnStep} onDropNewStep={dropInputAsNewStep} onDragInput={dragDraftInput} />
<div className="combo-draft-actions">
<button
type="button"
className={`combos-simultaneous-toggle ${simultaneousMode ? "active" : ""}`}
onClick={() => setSimultaneousMode((enabled) => !enabled)}
aria-pressed={simultaneousMode}
title={textContent.simultaneousModeTitle || "Ajout simultané"}
aria-label={textContent.simultaneousModeTitle || "Ajout simultané"}
>
<span className={!simultaneousMode ? "active" : ""}>{textContent.successiveModeLabel || "A B"}</span>
<span className={simultaneousMode ? "active" : ""}>{textContent.simultaneousModeLabel || "A + B"}</span>
</button>
<button type="button" className="notepad-toolbar-button" onClick={removeLastInput} disabled={!cleanDraftInputs.length} title={textContent.removeLastInputTitle || "Retirer la dernière touche"} aria-label={textContent.removeLastInputTitle || "Retirer la dernière touche"}>
<Icon name="back" />
</button>
<button type="button" className="notepad-toolbar-button danger" onClick={removeActiveStep} disabled={draftInputs.length <= 1} title={textContent.removeStepTitle || "Supprimer l'étape"} aria-label={textContent.removeStepTitle || "Supprimer l'étape"}>
<Icon name="trash" />
</button>
<div className="combo-save-actions">
{editingComboId && (
<button type="button" className="combo-cancel-button" onClick={cancelComboEdit}>
{textContent.cancelEditButton || "Annuler"}
</button>
)}
<button className="primary" disabled={!comboName.trim() && !cleanDraftInputs.length}>
{editingComboId ? textContent.saveComboButton || "Enregistrer" : textContent.addComboButton || "Ajouter le combo"}
</button>
</div>
</div>
</div>
</form>
</div>
)}
{!data.combos.length && !data.categories.length && (
<p className="empty-state">{textContent.emptyCombos || "Aucun combo enregistré."}</p>
)}
<div className="combos-category-list">
<div className={`combos-root-drop-zone ${draggingComboId ? "is-visible" : ""} ${comboDropTarget.id === "root" ? "is-drop-target" : ""}`} data-combo-root-drop="true">
{textContent.noCategoryLabel || "Sans catégorie"}
</div>
{data.combos.length > 0 && (
<div className="combos-list combos-root-list">
{data.combos.map((combo) => renderCombo(combo))}
</div>
)}
{data.categories.map(renderCategory)}
</div>
</div>
);
}

View file

@ -7,6 +7,7 @@ import { usePointerReorder } from "../../../hooks/usePointerReorder.js";
import { lockBodyScroll } from "../../../utils/bodyScrollLock.js";
import { CalculatorModule } from "./CalculatorModule.jsx";
import { ChecklistModule } from "./ChecklistModule.jsx";
import { CombosModule } from "./CombosModule.jsx";
import { CountersModule } from "./CountersModule.jsx";
import { ImageAnnotationModule } from "./ImageAnnotationModule.jsx";
import { LinksModule } from "./LinksModule.jsx";
@ -22,6 +23,7 @@ const MODULE_COMPONENTS = {
images: { label: "Images", icon: "picture", Component: ImagesModule, editable: true, scrollable: true },
links: { label: "Liens", icon: "link", Component: LinksModule, editable: true, scrollable: true },
counters: { label: "Compteurs", icon: "abacus", Component: CountersModule, editable: true, scrollable: true },
combos: { label: "Combos", icon: "controller", Component: CombosModule, editable: true, scrollable: true },
calculator: { label: "Calculateur", icon: "calculator", Component: CalculatorModule, editable: false },
table: { label: "Tableau", icon: "table", Component: TableModule, editable: false, scrollable: true },
timer: { label: "Timer", icon: "clock", Component: TimerModule, editable: false },