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

@ -48,6 +48,8 @@ Quand un outil est ajouté ou modifié :
- mettre à jour la structure IndexedDB dans `docs/STORAGE_SCHEMA.md` si nécessaire ; - mettre à jour la structure IndexedDB dans `docs/STORAGE_SCHEMA.md` si nécessaire ;
- vérifier l'affichage page toolbox et panneau latéral. - vérifier l'affichage page toolbox et panneau latéral.
Quand la structure de données d'un outil change, ne pas prévoir de compatibilité legacy, de migration ou de tolérance d'anciens formats, sauf demande explicite. Le format documenté dans `docs/STORAGE_SCHEMA.md` doit rester le format cible strict.
## Style ## Style
Le style est en Sass dans `website/src/styles/`. Le style est en Sass dans `website/src/styles/`.

View file

@ -179,6 +179,22 @@ Alertes timer :
- le garde-fou bloque uniquement les comptes a rebours recurrents (`Pattern horaire`, `Intervalle`) si la repetition est inferieure a 5 minutes ; - le garde-fou bloque uniquement les comptes a rebours recurrents (`Pattern horaire`, `Intervalle`) si la repetition est inferieure a 5 minutes ;
- un `Pattern horaire` comme `X:24:X` cible `hh:24:00` et se repete toutes les heures. - un `Pattern horaire` comme `X:24:X` cible `hh:24:00` et se repete toutes les heures.
### Combos
L'outil Combos affiche des inputs de manettes et clavier sous forme de tokens visuels compacts.
Regles UI :
- les touches simples sont rendues en HTML/CSS, pas en fichiers SVG distincts ;
- les formes specifiques peuvent utiliser de petits SVG inline ou des formes CSS si le HTML seul devient ambigu ;
- les couleurs de boutons restent des accents localises et ne doivent pas dominer le panneau ;
- les sequences longues doivent revenir a la ligne sans agrandir la largeur de l'outil ;
- l'edition utilise une palette guidee pour les manettes et une palette avec saisie courte pour clavier/souris ;
- l'ajout par clic cree des etapes successives par defaut, sans bouton dedie de nouvelle etape ;
- le clavier utilise AZERTY par defaut et propose un switch QWERTY ;
- le mode simultane doit etre volontaire, via switch ou depot d'une touche dans une etape existante ;
- les categories sont regroupees automatiquement depuis le champ categorie du combo.
### Bloc Notes Riche ### Bloc Notes Riche
Le bloc notes utilise une edition directe en `contentEditable`, sans mode preview separe. Le bloc notes utilise une edition directe en `contentEditable`, sans mode preview separe.

View file

@ -425,6 +425,71 @@ Notes :
- Les valeurs négatives sont acceptées. - Les valeurs négatives sont acceptées.
- Les compteurs sans libellé ne sont pas conservés. - Les compteurs sans libellé ne sont pas conservés.
## Outil Combos
Type : `combos`
```json
{
"device": "playstation",
"combos": [
{
"id": "combo0",
"name": "Anti air",
"device": "playstation",
"inputs": [
[
{ "kind": "direction", "value": "down" },
{ "kind": "button", "value": "triangle" }
]
]
}
],
"categories": [
{
"id": "category1",
"title": "Neutral",
"collapsed": false,
"combos": [
{
"id": "combo1",
"name": "Hadoken",
"device": "playstation",
"inputs": [
[{ "kind": "direction", "value": "down" }],
[{ "kind": "direction", "value": "down-forward" }],
[
{ "kind": "direction", "value": "forward" },
{ "kind": "button", "value": "cross" }
]
]
}
]
}
]
}
```
Notes :
- `device` vaut `playstation`, `xbox`, `switch`, `n64` ou `keyboardMouse`.
- `combos[].device` peut figer le périphérique de rendu d'un combo, afin qu'il conserve ses couleurs si l'outil change de périphérique.
- `inputs` est une liste d'étapes successives.
- chaque étape contient une ou plusieurs entrées simultanées.
- `kind` vaut `button`, `direction`, `key` ou `mouse`.
- `combos` à la racine contient les combos sans catégorie.
- les catégories restent locales à l'outil ; l'utilisateur peut ajouter plusieurs outils Combos pour séparer des usages.
Stockage compact :
- `device` est omis si la valeur vaut `playstation`.
- `combos` est omis si aucun combo sans catégorie n'existe.
- `categories` conserve uniquement les catégories avec au moins un combo valide.
- les combos sans nom et sans touche sont supprimés.
- `collapsed` est omis si `false`.
- les titres de catégorie et noms de combo sont limités à 80 caractères.
- chaque combo conserve au maximum 64 étapes.
## Outil Calculateur ## Outil Calculateur
Type : `calculator` Type : `calculator`

View file

@ -160,6 +160,36 @@ export function validateSiteContent(site) {
"toolboxes.modules.counters.incrementLabel", "toolboxes.modules.counters.incrementLabel",
"toolboxes.modules.counters.resetTitle", "toolboxes.modules.counters.resetTitle",
"toolboxes.modules.counters.deleteTitle", "toolboxes.modules.counters.deleteTitle",
"toolboxes.modules.combos.deviceLabel",
"toolboxes.modules.combos.keyboardLayoutLabel",
"toolboxes.modules.combos.categoryPlaceholder",
"toolboxes.modules.combos.targetCategoryLabel",
"toolboxes.modules.combos.noCategoryLabel",
"toolboxes.modules.combos.comboNameLabel",
"toolboxes.modules.combos.comboNamePlaceholder",
"toolboxes.modules.combos.defaultComboName",
"toolboxes.modules.combos.emptyDraft",
"toolboxes.modules.combos.simultaneousModeTitle",
"toolboxes.modules.combos.successiveModeLabel",
"toolboxes.modules.combos.simultaneousModeLabel",
"toolboxes.modules.combos.removeStepTitle",
"toolboxes.modules.combos.removeLastInputTitle",
"toolboxes.modules.combos.addComboButton",
"toolboxes.modules.combos.saveComboButton",
"toolboxes.modules.combos.cancelEditTitle",
"toolboxes.modules.combos.cancelEditButton",
"toolboxes.modules.combos.paletteLabel",
"toolboxes.modules.combos.customKeyPlaceholder",
"toolboxes.modules.combos.addCustomKeyButton",
"toolboxes.modules.combos.emptyCombos",
"toolboxes.modules.combos.emptyCategory",
"toolboxes.modules.combos.emptySequence",
"toolboxes.modules.combos.showCategoryTitle",
"toolboxes.modules.combos.hideCategoryTitle",
"toolboxes.modules.combos.reorderCategoryTitle",
"toolboxes.modules.combos.reorderComboTitle",
"toolboxes.modules.combos.editComboTitle",
"toolboxes.modules.combos.deleteComboTitle",
"toolboxes.modules.calculator.expressionLabel", "toolboxes.modules.calculator.expressionLabel",
"toolboxes.modules.calculator.expressionPlaceholder", "toolboxes.modules.calculator.expressionPlaceholder",
"toolboxes.modules.calculator.resultLabel", "toolboxes.modules.calculator.resultLabel",

View file

@ -35,6 +35,7 @@ test("toolbox storage, cards and pages are wired", async () => {
assert.match(toolboxStorage, /module\.scrollable/); assert.match(toolboxStorage, /module\.scrollable/);
assert.match(toolboxStorage, /export function normalizeLinksData/); assert.match(toolboxStorage, /export function normalizeLinksData/);
assert.match(toolboxStorage, /export function normalizeCountersData/); assert.match(toolboxStorage, /export function normalizeCountersData/);
assert.match(toolboxStorage, /export function normalizeCombosData/);
assert.match(toolboxStorage, /export function normalizeCalculatorData/); assert.match(toolboxStorage, /export function normalizeCalculatorData/);
assert.match(toolboxStorage, /export function normalizeTableData/); assert.match(toolboxStorage, /export function normalizeTableData/);
assert.match(toolboxStorage, /export function normalizeTimerData/); assert.match(toolboxStorage, /export function normalizeTimerData/);
@ -62,6 +63,7 @@ test("toolbox storage, cards and pages are wired", async () => {
assert.match(toolboxPages, /#\/toolbox\/\$\{toolbox\.id\}/); assert.match(toolboxPages, /#\/toolbox\/\$\{toolbox\.id\}/);
assert.match(toolboxPages, /setDrawerGameId\(""\)/); assert.match(toolboxPages, /setDrawerGameId\(""\)/);
assert.match(toolboxPages, /moduleColumns/); assert.match(toolboxPages, /moduleColumns/);
assert.match(toolboxPages, /normalizeCombosData/);
assert.match(toolboxPages, /function ToolboxView/); assert.match(toolboxPages, /function ToolboxView/);
assert.match(toolboxPages, /usePointerReorder/); assert.match(toolboxPages, /usePointerReorder/);
assert.match(storageQuota, /export function StorageQuota/); assert.match(storageQuota, /export function StorageQuota/);
@ -77,6 +79,7 @@ test("toolbox module registry and modules expose expected behavior", async () =>
const source = await readFile("website/src/main.jsx", "utf8"); const source = await readFile("website/src/main.jsx", "utf8");
const moduleRegistry = await readFile("website/src/features/toolboxes/modules/index.jsx", "utf8"); const moduleRegistry = await readFile("website/src/features/toolboxes/modules/index.jsx", "utf8");
const notepadModule = await readFile("website/src/features/toolboxes/modules/NotepadModule.jsx", "utf8"); const notepadModule = await readFile("website/src/features/toolboxes/modules/NotepadModule.jsx", "utf8");
const combosModule = await readFile("website/src/features/toolboxes/modules/CombosModule.jsx", "utf8");
const calculatorModule = await readFile("website/src/features/toolboxes/modules/CalculatorModule.jsx", "utf8"); const calculatorModule = await readFile("website/src/features/toolboxes/modules/CalculatorModule.jsx", "utf8");
const tableModule = await readFile("website/src/features/toolboxes/modules/TableModule.jsx", "utf8"); const tableModule = await readFile("website/src/features/toolboxes/modules/TableModule.jsx", "utf8");
const tableFormulaEngine = await readFile("website/src/features/toolboxes/modules/tableFormulaEngine.js", "utf8"); const tableFormulaEngine = await readFile("website/src/features/toolboxes/modules/tableFormulaEngine.js", "utf8");
@ -100,6 +103,8 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(moduleRegistry, /images:/); assert.match(moduleRegistry, /images:/);
assert.match(moduleRegistry, /links:/); assert.match(moduleRegistry, /links:/);
assert.match(moduleRegistry, /counters:/); assert.match(moduleRegistry, /counters:/);
assert.match(moduleRegistry, /combos:/);
assert.match(moduleRegistry, /CombosModule/);
assert.match(moduleRegistry, /calculator:/); assert.match(moduleRegistry, /calculator:/);
assert.match(moduleRegistry, /table:/); assert.match(moduleRegistry, /table:/);
assert.match(moduleRegistry, /table: \{ label: "Tableau", icon: "table", Component: TableModule, editable: false, scrollable: true \}/); assert.match(moduleRegistry, /table: \{ label: "Tableau", icon: "table", Component: TableModule, editable: false, scrollable: true \}/);
@ -122,6 +127,13 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(moduleRegistry, /CompactDropdown/); assert.match(moduleRegistry, /CompactDropdown/);
assert.doesNotMatch(source, /<select value="" onChange=\{\(event\) => addModule/); assert.doesNotMatch(source, /<select value="" onChange=\{\(event\) => addModule/);
assert.match(notepadModule, /export function NotepadModule/); assert.match(notepadModule, /export function NotepadModule/);
assert.match(combosModule, /export function CombosModule/);
assert.match(combosModule, /keyboardMouse/);
assert.match(combosModule, /Nintendo 64/);
assert.match(combosModule, /ComboInputToken/);
assert.match(combosModule, /ComboSequence/);
assert.match(combosModule, /keyboardLayout/);
assert.match(combosModule, /simultaneousMode/);
assert.match(calculatorModule, /export function CalculatorModule/); assert.match(calculatorModule, /export function CalculatorModule/);
assert.match(calculatorModule, /calculateExpression/); assert.match(calculatorModule, /calculateExpression/);
assert.match(calculatorModule, /activeParentId/); assert.match(calculatorModule, /activeParentId/);

View file

@ -4,7 +4,7 @@ import assert from "node:assert/strict";
import { evaluateTableCell } from "../website/src/features/toolboxes/modules/tableFormulaEngine.js"; import { evaluateTableCell } from "../website/src/features/toolboxes/modules/tableFormulaEngine.js";
import { parseColonImportLines } from "../website/src/features/toolboxes/modules/textImport.js"; import { parseColonImportLines } from "../website/src/features/toolboxes/modules/textImport.js";
import { getTimePatternRecurrenceMs, getTimePatternTargetMs } from "../website/src/features/toolboxes/modules/timerUtils.js"; import { getTimePatternRecurrenceMs, getTimePatternTargetMs } from "../website/src/features/toolboxes/modules/timerUtils.js";
import { compactModuleDataForStorage, createToolboxExportPayload, normalizeNotepadData, normalizeTableData, normalizeTaskPlannerData } from "../website/src/features/toolboxes/storage/toolboxStorage.js"; import { compactModuleDataForStorage, createToolboxExportPayload, normalizeCombosData, normalizeNotepadData, normalizeTableData, normalizeTaskPlannerData } from "../website/src/features/toolboxes/storage/toolboxStorage.js";
test("colon text import keeps urls intact after the first separator", () => { test("colon text import keeps urls intact after the first separator", () => {
assert.deepEqual(parseColonImportLines("Build:https://example.com/build?class=rogue\nPotion:10"), [ assert.deepEqual(parseColonImportLines("Build:https://example.com/build?class=rogue\nPotion:10"), [
@ -95,6 +95,82 @@ test("table storage clamps dimensions and compacts non-empty cells", () => {
assert.equal(compactModuleDataForStorage("table", { rows: 10, columns: 6, cells: {} }), null); assert.equal(compactModuleDataForStorage("table", { rows: 10, columns: 6, cells: {} }), null);
}); });
test("combos storage normalizes devices, steps and compact export ids", () => {
const longText = "x".repeat(120);
const normalized = normalizeCombosData({
device: "invalid",
combos: [
{
id: "rootCombo",
name: "Root",
device: "switch",
inputs: [[{ kind: "button", value: "triangle" }]]
}
],
categories: [
{
id: "category1",
title: `${longText} `,
collapsed: true,
combos: [
{
id: "combo1",
device: "n64",
name: `${longText} `,
inputs: [
[
{ kind: "direction", value: "down" },
{ kind: "button", value: "cross" },
{ kind: "bad", value: "ignored" }
],
[],
[{ kind: "key", value: "space" }]
]
},
{ id: "empty", name: " ", inputs: [] }
]
},
{ id: "emptyCategory", title: " ", combos: [] },
{ id: "titledEmptyCategory", title: "Empty", combos: [] }
]
});
assert.equal(normalized.device, "playstation");
assert.equal(normalized.combos.length, 1);
assert.equal(normalized.combos[0].device, "switch");
assert.equal(normalized.categories.length, 1);
assert.equal(normalized.categories[0].title.length, 80);
assert.equal(normalized.categories[0].collapsed, true);
assert.equal(normalized.categories[0].combos.length, 1);
assert.equal(normalized.categories[0].combos[0].name.length, 80);
assert.equal("note" in normalized.categories[0].combos[0], false);
assert.deepEqual(normalized.categories[0].combos[0].inputs, [
[
{ kind: "direction", value: "down" },
{ kind: "button", value: "cross" }
],
[{ kind: "key", value: "space" }]
]);
const compact = compactModuleDataForStorage("combos", { ...normalized, device: "xbox" });
assert.equal(compact.device, "xbox");
assert.equal(compact.combos[0].id, "rootCombo");
assert.equal(compact.combos[0].device, "switch");
assert.equal(compact.categories[0].id, "category1");
assert.equal(compact.categories[0].combos[0].device, "n64");
assert.equal(compact.categories[0].combos[0].id, "combo1");
const exported = createToolboxExportPayload(
{ id: "toolbox1", name: "Combos", modules: [{ id: "module1", type: "combos" }], updatedAt: "2026-01-01T00:00:00.000Z" },
{ "toolbox1:module1": compact }
);
assert.equal(exported.modules.m1.combos[0].id, "o1");
assert.equal(exported.modules.m1.combos[0].device, "switch");
assert.equal(exported.modules.m1.categories[0].id, "g1");
assert.equal(exported.modules.m1.categories[0].combos[0].id, "o2");
assert.equal(exported.modules.m1.categories[0].combos[0].device, "n64");
});
test("notepad data migrates text, sanitizes html and compacts drawings", () => { test("notepad data migrates text, sanitizes html and compacts drawings", () => {
const migrated = normalizeNotepadData({ text: "Boss\nPhase 2" }); const migrated = normalizeNotepadData({ text: "Boss\nPhase 2" });
assert.equal(migrated.text, "Boss\nPhase 2"); assert.equal(migrated.text, "Boss\nPhase 2");

View file

@ -289,6 +289,38 @@
"resetTitle": "Réinitialiser", "resetTitle": "Réinitialiser",
"deleteTitle": "Supprimer" "deleteTitle": "Supprimer"
}, },
"combos": {
"deviceLabel": "Périphérique",
"keyboardLayoutLabel": "Disposition clavier",
"categoryPlaceholder": "Catégorie",
"targetCategoryLabel": "Catégorie",
"noCategoryLabel": "Sans catégorie",
"comboNameLabel": "Nom",
"comboNamePlaceholder": "Nom du combo",
"defaultComboName": "Combo",
"emptyDraft": "Aucune touche sélectionnée.",
"simultaneousModeTitle": "Ajouter dans l'étape active",
"successiveModeLabel": "A B",
"simultaneousModeLabel": "A + B",
"removeStepTitle": "Supprimer l'étape",
"removeLastInputTitle": "Retirer la dernière touche",
"addComboButton": "Ajouter le combo",
"saveComboButton": "Enregistrer",
"cancelEditTitle": "Annuler l'édition",
"cancelEditButton": "Annuler",
"paletteLabel": "Palette de touches",
"customKeyPlaceholder": "Touche personnalisée",
"addCustomKeyButton": "Ajouter touche",
"emptyCombos": "Aucun combo enregistré.",
"emptyCategory": "Aucun combo dans cette catégorie.",
"emptySequence": "Combo sans touches",
"showCategoryTitle": "Afficher la catégorie",
"hideCategoryTitle": "Réduire la catégorie",
"reorderCategoryTitle": "Déplacer la catégorie",
"reorderComboTitle": "Déplacer le combo",
"editComboTitle": "Modifier le combo",
"deleteComboTitle": "Supprimer le combo"
},
"calculator": { "calculator": {
"expressionLabel": "Calcul", "expressionLabel": "Calcul",
"expressionPlaceholder": "10*10", "expressionPlaceholder": "10*10",

View file

@ -14,6 +14,7 @@ import {
hostnameFromUrl, hostnameFromUrl,
normalizeCalculatorData, normalizeCalculatorData,
normalizeChecklistData, normalizeChecklistData,
normalizeCombosData,
normalizeCountersData, normalizeCountersData,
normalizeImageAnnotationData, normalizeImageAnnotationData,
normalizeLinksData, normalizeLinksData,
@ -144,6 +145,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
getModuleData, getModuleData,
moduleText, moduleText,
normalizeChecklistData, normalizeChecklistData,
normalizeCombosData,
normalizeLinksData, normalizeLinksData,
normalizeCountersData, normalizeCountersData,
normalizeCalculatorData, 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 { lockBodyScroll } from "../../../utils/bodyScrollLock.js";
import { CalculatorModule } from "./CalculatorModule.jsx"; import { CalculatorModule } from "./CalculatorModule.jsx";
import { ChecklistModule } from "./ChecklistModule.jsx"; import { ChecklistModule } from "./ChecklistModule.jsx";
import { CombosModule } from "./CombosModule.jsx";
import { CountersModule } from "./CountersModule.jsx"; import { CountersModule } from "./CountersModule.jsx";
import { ImageAnnotationModule } from "./ImageAnnotationModule.jsx"; import { ImageAnnotationModule } from "./ImageAnnotationModule.jsx";
import { LinksModule } from "./LinksModule.jsx"; import { LinksModule } from "./LinksModule.jsx";
@ -22,6 +23,7 @@ const MODULE_COMPONENTS = {
images: { label: "Images", icon: "picture", Component: ImagesModule, editable: true, scrollable: true }, images: { label: "Images", icon: "picture", Component: ImagesModule, editable: true, scrollable: true },
links: { label: "Liens", icon: "link", Component: LinksModule, 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 }, 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 }, calculator: { label: "Calculateur", icon: "calculator", Component: CalculatorModule, editable: false },
table: { label: "Tableau", icon: "table", Component: TableModule, editable: false, scrollable: true }, table: { label: "Tableau", icon: "table", Component: TableModule, editable: false, scrollable: true },
timer: { label: "Timer", icon: "clock", Component: TimerModule, editable: false }, 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. // 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 ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
const NOTEPAD_ALLOWED_COLORS = new Set(["#f5f7ff", "#b4bdd3", "#f6c453", "#22d3ee", "#8b5cf6", "#d946ef", "#101426", "#202745"]); 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"]); 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 COUNTDOWN_TYPES = new Set(["duration", "daily_time", "time_pattern", "interval"]);
const TIMER_ALERT_MODES = new Set(["off", "visible", "site"]); const TIMER_ALERT_MODES = new Set(["off", "visible", "site"]);
const TASK_TYPES = new Set(["unique", "daily", "weekly"]); 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_WEEKLY_RESET_DAY = 1;
const DEFAULT_TASK_PLANNER_RESET_TIME = "00:00"; const DEFAULT_TASK_PLANNER_RESET_TIME = "00:00";
const TOOLBOX_ICON_BASE = "/static/img/toolbox-icons/"; const TOOLBOX_ICON_BASE = "/static/img/toolbox-icons/";
@ -47,6 +49,7 @@ const DEFAULT_MODULE_TITLES = {
images: "Images", images: "Images",
links: "Liens", links: "Liens",
counters: "Compteurs", counters: "Compteurs",
combos: "Combos",
calculator: "Calculateur", calculator: "Calculateur",
table: "Tableau", table: "Tableau",
timer: "Timer", 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) { function normalizeCalculatorValue(value) {
const parsed = Number(value); const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0; return Number.isFinite(parsed) ? parsed : 0;
@ -697,6 +750,41 @@ export function compactModuleDataForStorage(type, value) {
})); }));
return counters.length ? { counters } : null; 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") { if (type === "calculator") {
const normalized = normalizeCalculatorData(value); const normalized = normalizeCalculatorData(value);
const entries = normalized.entries.map((entry) => { 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") })) }; 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") { if (type === "calculator") {
const entryIdMap = new Map(); const entryIdMap = new Map();
compact.entries.forEach((entry) => entryIdMap.set(entry.id, nextId("calc"))); compact.entries.forEach((entry) => entryIdMap.set(entry.id, nextId("calc")));

View file

@ -233,6 +233,25 @@
grid-row: auto; 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 { .toolbox-actions-row {
align-items: stretch; align-items: stretch;
flex-direction: column; flex-direction: column;

View file

@ -742,6 +742,11 @@
-webkit-mask-image: url("/static/icons/table.svg"); -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 { .tabs {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(0, 1fr)); grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
@ -2271,6 +2276,663 @@ textarea:focus {
padding-inline: 10px; 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 { .counters-add-form {
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
} }