This commit is contained in:
parent
025cd4bf22
commit
7b4cfb7dc2
13 changed files with 1829 additions and 2 deletions
807
website/src/features/toolboxes/modules/CombosModule.jsx
Normal file
807
website/src/features/toolboxes/modules/CombosModule.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue