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

@ -14,6 +14,7 @@ import {
hostnameFromUrl,
normalizeCalculatorData,
normalizeChecklistData,
normalizeCombosData,
normalizeCountersData,
normalizeImageAnnotationData,
normalizeLinksData,
@ -144,6 +145,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
getModuleData,
moduleText,
normalizeChecklistData,
normalizeCombosData,
normalizeLinksData,
normalizeCountersData,
normalizeCalculatorData,

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 },

View file

@ -1,5 +1,5 @@
// Rôle : normalise, compacte et sérialise les données toolbox persistées.
const ID_PREFIXES = { tbx: "t", mod: "m", item: "i", section: "g", image: "s", link: "l", counter: "c", calc: "r", marker: "k", timer: "z", task: "a", relation: "e", stroke: "d" };
const ID_PREFIXES = { tbx: "t", mod: "m", item: "i", section: "g", image: "s", link: "l", counter: "c", combo: "o", calc: "r", marker: "k", timer: "z", task: "a", relation: "e", stroke: "d" };
const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
const NOTEPAD_ALLOWED_COLORS = new Set(["#f5f7ff", "#b4bdd3", "#f6c453", "#22d3ee", "#8b5cf6", "#d946ef", "#101426", "#202745"]);
const NOTEPAD_ALLOWED_TAGS = new Set(["p", "div", "br", "strong", "b", "em", "i", "u", "s", "strike", "ul", "ol", "li", "h3", "h4", "span", "mark"]);
@ -17,6 +17,8 @@ const TIMER_TABS = new Set(["stopwatch", "countdown"]);
const COUNTDOWN_TYPES = new Set(["duration", "daily_time", "time_pattern", "interval"]);
const TIMER_ALERT_MODES = new Set(["off", "visible", "site"]);
const TASK_TYPES = new Set(["unique", "daily", "weekly"]);
const COMBO_DEVICES = new Set(["playstation", "xbox", "switch", "n64", "keyboardMouse"]);
const COMBO_INPUT_KINDS = new Set(["button", "direction", "key", "mouse"]);
const DEFAULT_TASK_PLANNER_WEEKLY_RESET_DAY = 1;
const DEFAULT_TASK_PLANNER_RESET_TIME = "00:00";
const TOOLBOX_ICON_BASE = "/static/img/toolbox-icons/";
@ -47,6 +49,7 @@ const DEFAULT_MODULE_TITLES = {
images: "Images",
links: "Liens",
counters: "Compteurs",
combos: "Combos",
calculator: "Calculateur",
table: "Tableau",
timer: "Timer",
@ -356,6 +359,56 @@ export function normalizeCountersData(data) {
};
}
function normalizeComboInput(input) {
const kind = COMBO_INPUT_KINDS.has(input?.kind) ? input.kind : "";
const value = String(input?.value || "").trim().slice(0, 24);
return kind && value ? { kind, value } : null;
}
function normalizeComboSteps(inputs) {
return (Array.isArray(inputs) ? inputs : [])
.slice(0, 64)
.map((step) => (Array.isArray(step) ? step : [])
.map(normalizeComboInput)
.filter(Boolean)
.slice(0, 8))
.filter((step) => step.length);
}
function normalizeCombo(combo) {
const name = String(combo?.name || "").trim().slice(0, 80);
const device = COMBO_DEVICES.has(combo?.device) ? combo.device : "";
const inputs = normalizeComboSteps(combo?.inputs);
if (!name && !inputs.length) return null;
const normalized = {
id: combo?.id || uid("combo"),
name: name || "Combo",
inputs
};
if (device) normalized.device = device;
return normalized;
}
function normalizeComboCategory(category) {
const title = String(category?.title || "").trim().slice(0, 80);
const combos = (Array.isArray(category?.combos) ? category.combos : []).map(normalizeCombo).filter(Boolean);
if (!combos.length) return null;
return {
id: category?.id || uid("section"),
title: title || "Catégorie",
collapsed: category?.collapsed === true,
combos
};
}
export function normalizeCombosData(data) {
return {
device: COMBO_DEVICES.has(data?.device) ? data.device : "playstation",
combos: (Array.isArray(data?.combos) ? data.combos : []).map(normalizeCombo).filter(Boolean),
categories: (Array.isArray(data?.categories) ? data.categories : []).map(normalizeComboCategory).filter(Boolean)
};
}
function normalizeCalculatorValue(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
@ -697,6 +750,41 @@ export function compactModuleDataForStorage(type, value) {
}));
return counters.length ? { counters } : null;
}
if (type === "combos") {
const normalized = normalizeCombosData(value);
const compact = {
combos: normalized.combos.map((combo) => {
const compactCombo = {
id: combo.id,
name: combo.name,
inputs: combo.inputs
};
if (combo.device) compactCombo.device = combo.device;
return compactCombo;
}),
categories: normalized.categories.map((category) => {
const compactCategory = {
id: category.id,
title: category.title,
combos: category.combos.map((combo) => {
const compactCombo = {
id: combo.id,
name: combo.name,
inputs: combo.inputs
};
if (combo.device) compactCombo.device = combo.device;
return compactCombo;
})
};
if (category.collapsed) compactCategory.collapsed = true;
return compactCategory;
})
};
if (normalized.device !== "playstation") compact.device = normalized.device;
if (!compact.combos.length) delete compact.combos;
if (!compact.categories.length) delete compact.categories;
return compact.combos || compact.categories ? compact : null;
}
if (type === "calculator") {
const normalized = normalizeCalculatorData(value);
const entries = normalized.entries.map((entry) => {
@ -837,6 +925,20 @@ function remapModuleDataForExport(type, data, nextId) {
return { counters: compact.counters.map((counter) => ({ ...counter, id: nextId("counter") })) };
}
if (type === "combos") {
const remapped = {};
if (compact.device) remapped.device = compact.device;
if (compact.combos) remapped.combos = compact.combos.map((combo) => ({ ...combo, id: nextId("combo") }));
if (compact.categories) {
remapped.categories = compact.categories.map((category) => ({
...category,
id: nextId("section"),
combos: category.combos.map((combo) => ({ ...combo, id: nextId("combo") }))
}));
}
return remapped;
}
if (type === "calculator") {
const entryIdMap = new Map();
compact.entries.forEach((entry) => entryIdMap.set(entry.id, nextId("calc")));

View file

@ -233,6 +233,25 @@
grid-row: auto;
}
.combos-device-row,
.combos-form-grid,
.combos-palette {
grid-template-columns: 1fr;
}
.combo-card {
grid-template-columns: 30px minmax(0, 1fr);
}
.combo-card-main {
grid-template-columns: 1fr;
}
.combo-card-actions {
grid-column: 2;
justify-content: flex-end;
}
.toolbox-actions-row {
align-items: stretch;
flex-direction: column;

View file

@ -742,6 +742,11 @@
-webkit-mask-image: url("/static/icons/table.svg");
}
.module-icon-controller {
mask-image: url("/static/icons/controller.svg");
-webkit-mask-image: url("/static/icons/controller.svg");
}
.tabs {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
@ -2271,6 +2276,663 @@ textarea:focus {
padding-inline: 10px;
}
.combos-module {
display: grid;
gap: 12px;
padding: var(--space-4);
}
.combos-editor {
display: grid;
gap: 12px;
}
.combos-device-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: end;
}
.combos-device-row label,
.combos-form-grid label {
display: grid;
gap: 6px;
min-width: 0;
color: var(--color-text-muted);
font-size: var(--font-size-xs);
font-weight: 800;
}
.combos-device-row select,
.combos-form-grid select,
.combos-form-grid input {
min-width: 0;
}
.combos-keyboard-layout-switch {
display: inline-flex;
width: fit-content;
align-items: center;
padding: 4px;
border: 1px solid rgba(165, 180, 252, 0.12);
border-radius: var(--radius-md);
background: rgba(5, 7, 17, 0.26);
}
.combos-keyboard-layout-switch button {
min-height: 34px;
padding: 0 10px;
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
font-weight: 900;
}
.combos-keyboard-layout-switch button.active {
border-color: rgba(246, 196, 83, 0.26);
background: rgba(246, 196, 83, 0.1);
color: var(--color-text-primary);
}
.combos-add-form {
display: grid;
gap: 12px;
}
.combos-form-grid {
display: grid;
grid-template-columns: minmax(120px, 0.8fr) minmax(0, 1.2fr);
gap: 10px;
}
.combo-draft-panel {
display: grid;
gap: 10px;
padding: 10px;
border: 1px solid rgba(165, 180, 252, 0.12);
border-radius: var(--radius-md);
background: rgba(5, 7, 17, 0.32);
}
.combo-draft-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.combos-simultaneous-toggle {
display: inline-flex;
width: auto;
min-width: 116px;
min-height: 34px;
padding: 3px;
border: 1px solid rgba(165, 180, 252, 0.12);
border-radius: var(--radius-md);
background: rgba(5, 7, 17, 0.26);
color: var(--color-text-secondary);
justify-content: stretch;
gap: 3px;
}
.combos-simultaneous-toggle span {
display: inline-grid;
min-width: 50px;
min-height: 26px;
place-items: center;
border-radius: var(--radius-sm);
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
font-weight: 900;
}
.combos-simultaneous-toggle span.active {
background: rgba(246, 196, 83, 0.12);
color: var(--color-text-primary);
}
.combo-draft-actions .primary {
min-width: 104px;
}
.combos-palette {
display: grid;
grid-template-columns: minmax(180px, 0.8fr) minmax(0, 1.2fr);
gap: 10px;
}
.combos-palette-column {
display: grid;
align-content: start;
gap: 10px;
min-width: 0;
}
.combos-palette-group {
display: grid;
gap: 8px;
min-width: 0;
padding: 9px;
border: 1px solid rgba(165, 180, 252, 0.12);
border-radius: var(--radius-md);
background: rgba(5, 7, 17, 0.24);
}
.combos-palette-group > span {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
font-weight: 900;
}
.combos-palette-group > div {
display: flex;
flex-wrap: wrap;
gap: 7px;
}
.combos-palette-group.is-direction-grid > div {
display: grid;
width: fit-content;
grid-template-columns: repeat(3, max-content);
gap: 7px;
}
.combo-palette-empty-cell {
display: block;
width: 34px;
height: 38px;
}
.combos-custom-key-form {
grid-template-columns: minmax(0, 1fr) auto;
}
.combos-category-list {
display: grid;
gap: 10px;
}
.combos-category-title {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 8px;
}
.combos-category-title h3 {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.combos-category-header > div {
gap: 6px;
}
.combos-root-list {
padding: 0;
}
.combos-root-drop-zone {
display: none;
padding: 9px 12px;
border: 1px dashed rgba(165, 180, 252, 0.22);
border-radius: var(--radius-md);
background: rgba(5, 7, 17, 0.2);
color: var(--color-text-muted);
font-size: var(--font-size-xs);
font-weight: 900;
}
.combos-root-drop-zone.is-visible,
.combos-root-drop-zone.is-drop-target {
display: block;
}
.combos-root-drop-zone.is-drop-target {
border-color: rgba(246, 196, 83, 0.72);
background: rgba(246, 196, 83, 0.08);
color: var(--color-text-primary);
}
.combos-list {
display: grid;
gap: 8px;
}
.combo-card {
position: relative;
display: grid;
grid-template-columns: 30px minmax(0, 1fr) auto;
gap: 8px;
align-items: start;
padding: 8px;
border: 1px solid rgba(165, 180, 252, 0.12);
border-radius: var(--radius-md);
background: rgba(5, 7, 17, 0.36);
}
.combo-card.is-editing {
grid-template-columns: 30px minmax(0, 1fr) auto;
}
.combo-card.is-dragging,
.combos-category.is-dragging {
cursor: grabbing;
opacity: 0.58;
transform: scale(0.99);
}
.combo-card.is-drop-target,
.combos-category.is-drop-target {
border-color: rgba(246, 196, 83, 0.72);
box-shadow: inset 0 3px 0 rgba(246, 196, 83, 0.8);
}
.combo-card.is-drop-target.drop-after,
.combos-category.is-drop-target.drop-after {
box-shadow: inset 0 -3px 0 rgba(246, 196, 83, 0.8);
}
.combo-drag-handle {
align-self: start;
}
.combo-card-main {
display: grid;
gap: 7px;
min-width: 0;
}
.combo-title-input {
color: var(--color-text-primary);
font-size: var(--font-size-sm);
font-weight: 900;
}
.combo-card-actions {
display: flex;
gap: 6px;
align-self: start;
}
.combo-save-actions {
display: inline-flex;
gap: 8px;
margin-left: auto;
}
.combo-cancel-button {
min-width: 104px;
min-height: 34px;
padding: 0 12px;
border: 1px solid rgba(165, 180, 252, 0.12);
border-radius: var(--radius-md);
background: rgba(21, 26, 48, 0.64);
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
font-weight: 900;
}
.combo-sequence {
display: flex;
flex-wrap: wrap;
row-gap: 6px;
column-gap: 5px;
align-items: center;
min-width: 0;
}
.combo-step,
.combo-step-inputs,
.combo-step-input {
display: inline-flex;
align-items: center;
gap: 5px;
}
.combo-step {
min-width: 0;
}
.combo-step-inputs {
min-height: 42px;
padding: 3px;
border: 1px solid transparent;
border-radius: var(--radius-pill);
background: transparent;
}
button.combo-step-inputs {
min-width: 0;
min-height: 42px;
color: inherit;
cursor: pointer;
}
.combo-step-inputs.is-single {
min-height: 38px;
padding: 0;
}
.combo-step-inputs.is-simultaneous {
padding: 5px;
border-color: rgba(246, 196, 83, 0.34);
border-radius: var(--radius-md);
background:
linear-gradient(135deg, rgba(246, 196, 83, 0.07), transparent 64%),
rgba(5, 7, 17, 0.28);
}
.combo-step-inputs.is-active {
border-color: rgba(139, 92, 246, 0.72);
background: rgba(139, 92, 246, 0.08);
box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.12);
}
.combo-step-inputs.is-single.is-active {
border-color: transparent;
background: transparent;
box-shadow: none;
}
.combo-step-inputs.is-single.is-active .combo-input-token {
box-shadow:
inset 0 -2px 0 rgba(0, 0, 0, 0.2),
0 0 0 2px rgba(139, 92, 246, 0.18);
}
.combo-step-separator,
.combo-plus {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
font-weight: 900;
}
.combo-step-separator {
margin-inline: 1px;
}
.combo-empty-sequence {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
font-weight: 700;
}
.combo-empty-step {
padding: 0 8px;
color: var(--color-text-muted);
font-size: var(--font-size-xs);
font-weight: 800;
}
.combo-input-token {
--combo-token-bg: rgba(21, 26, 48, 0.82);
--combo-token-border: rgba(165, 180, 252, 0.22);
--combo-token-color: var(--color-text-primary);
display: inline-flex;
box-sizing: border-box;
width: 34px;
min-width: 34px;
height: 34px;
min-height: 34px;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid var(--combo-token-border);
border-radius: 50%;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.08), transparent),
var(--combo-token-bg);
color: var(--combo-token-color);
font-size: 12px;
font-weight: 950;
line-height: 1;
box-shadow: inset 0 -2px 0 rgba(0, 0, 0, 0.2);
}
button.combo-input-token {
min-width: 36px;
min-height: 36px;
width: 36px;
height: 36px;
padding: 0;
cursor: pointer;
}
button.combo-input-token:hover {
border-color: var(--color-primary-border);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.1), transparent),
rgba(32, 39, 69, 0.92);
}
.combo-input-token span {
display: block;
overflow: hidden;
max-width: 100%;
text-overflow: ellipsis;
white-space: nowrap;
}
.combo-input-key,
.combo-input-mouse,
.combo-value-options,
.combo-value-share,
.combo-value-start,
.combo-value-menu,
.combo-value-view,
.combo-value-c-up,
.combo-value-c-down,
.combo-value-c-left,
.combo-value-c-right {
width: auto;
min-width: 48px;
padding: 0 10px;
border-radius: var(--radius-sm);
}
.combo-input-short {
width: 36px;
min-width: 36px;
padding: 0;
}
button.combo-input-token.combo-input-short {
width: 36px;
min-width: 36px;
padding: 0;
}
button.combo-input-token.combo-input-key,
button.combo-input-token.combo-input-mouse,
button.combo-input-token.combo-value-options,
button.combo-input-token.combo-value-share,
button.combo-input-token.combo-value-start,
button.combo-input-token.combo-value-menu,
button.combo-input-token.combo-value-view,
button.combo-input-token.combo-value-c-up,
button.combo-input-token.combo-value-c-down,
button.combo-input-token.combo-value-c-left,
button.combo-input-token.combo-value-c-right {
width: auto;
min-width: 48px;
padding: 0 10px;
}
button.combo-input-token.combo-input-short {
width: 36px;
min-width: 36px;
padding: 0;
}
.combo-input-key span,
.combo-input-mouse span,
.combo-value-options span,
.combo-value-share span,
.combo-value-start span,
.combo-value-menu span,
.combo-value-view span,
.combo-value-c-up span,
.combo-value-c-down span,
.combo-value-c-left span,
.combo-value-c-right span {
text-overflow: clip;
}
.combo-input-mouse,
button.combo-input-token.combo-input-mouse {
min-width: 62px;
}
.combo-value-l1,
.combo-value-r1,
.combo-value-l2,
.combo-value-r2,
.combo-value-l3,
.combo-value-r3,
.combo-value-lb,
.combo-value-rb,
.combo-value-lt,
.combo-value-rt,
.combo-value-ls,
.combo-value-rs,
.combo-value-zl,
.combo-value-zr {
width: 38px;
min-width: 38px;
height: 38px;
padding: 0;
font-size: 11px;
}
.combo-input-key {
height: 32px;
border-radius: 7px;
background:
linear-gradient(180deg, rgba(245, 247, 255, 0.11), rgba(5, 7, 17, 0.18)),
rgba(16, 20, 38, 0.94);
box-shadow:
inset 0 -3px 0 rgba(0, 0, 0, 0.28),
0 1px 0 rgba(245, 247, 255, 0.08);
}
.combo-value-ctrl,
.combo-value-alt,
.combo-value-shift,
.combo-value-enter,
.combo-value-tab {
min-width: 58px;
}
.combo-value-space {
min-width: 92px;
}
.combo-input-direction {
--combo-token-bg: rgba(34, 211, 238, 0.12);
--combo-token-border: rgba(34, 211, 238, 0.3);
--combo-token-color: var(--color-accent-cyan);
}
.combo-device-playstation.combo-value-triangle {
--combo-token-bg: rgba(55, 214, 122, 0.16);
--combo-token-border: rgba(55, 214, 122, 0.4);
--combo-token-color: #37d67a;
}
.combo-device-playstation.combo-value-circle {
--combo-token-bg: rgba(248, 113, 113, 0.16);
--combo-token-border: rgba(248, 113, 113, 0.4);
--combo-token-color: #f87171;
}
.combo-device-playstation.combo-value-cross {
--combo-token-bg: rgba(96, 165, 250, 0.16);
--combo-token-border: rgba(96, 165, 250, 0.4);
--combo-token-color: #60a5fa;
}
.combo-device-playstation.combo-value-square {
--combo-token-bg: rgba(244, 114, 182, 0.16);
--combo-token-border: rgba(244, 114, 182, 0.4);
--combo-token-color: #f472b6;
}
.combo-device-xbox.combo-value-a {
--combo-token-bg: rgba(101, 212, 110, 0.16);
--combo-token-border: rgba(101, 212, 110, 0.4);
--combo-token-color: #65d46e;
}
.combo-device-xbox.combo-value-b {
--combo-token-bg: rgba(239, 68, 68, 0.16);
--combo-token-border: rgba(239, 68, 68, 0.4);
--combo-token-color: #ef4444;
}
.combo-device-xbox.combo-value-x {
--combo-token-bg: rgba(59, 130, 246, 0.16);
--combo-token-border: rgba(59, 130, 246, 0.4);
--combo-token-color: #3b82f6;
}
.combo-device-xbox.combo-value-y {
--combo-token-bg: rgba(250, 204, 21, 0.16);
--combo-token-border: rgba(250, 204, 21, 0.42);
--combo-token-color: #facc15;
}
.combo-device-switch.combo-value-a,
.combo-device-switch.combo-value-x {
--combo-token-bg: rgba(239, 68, 68, 0.14);
--combo-token-border: rgba(239, 68, 68, 0.36);
--combo-token-color: #fca5a5;
}
.combo-device-switch.combo-value-b,
.combo-device-switch.combo-value-y {
--combo-token-bg: rgba(34, 211, 238, 0.14);
--combo-token-border: rgba(34, 211, 238, 0.34);
--combo-token-color: var(--color-accent-cyan);
}
.combo-device-switch.combo-value-l,
.combo-device-switch.combo-value-r,
.combo-device-switch.combo-value-zl,
.combo-device-switch.combo-value-zr {
--combo-token-bg: rgba(245, 247, 255, 0.1);
--combo-token-border: rgba(245, 247, 255, 0.28);
}
.combo-device-n64.combo-value-a {
--combo-token-bg: rgba(34, 197, 94, 0.18);
--combo-token-color: #86efac;
}
.combo-device-n64.combo-value-b {
--combo-token-bg: rgba(59, 130, 246, 0.18);
--combo-token-color: #93c5fd;
}
.combo-device-n64.combo-value-c-up,
.combo-device-n64.combo-value-c-down,
.combo-device-n64.combo-value-c-left,
.combo-device-n64.combo-value-c-right {
--combo-token-bg: rgba(246, 196, 83, 0.2);
--combo-token-border: rgba(246, 196, 83, 0.42);
--combo-token-color: var(--color-accent-gold);
}
.combo-device-n64.combo-value-start {
--combo-token-bg: rgba(239, 68, 68, 0.16);
--combo-token-color: #fca5a5;
}
.counters-add-form {
grid-template-columns: minmax(0, 1fr) auto;
}