This commit is contained in:
parent
025cd4bf22
commit
7b4cfb7dc2
13 changed files with 1829 additions and 2 deletions
|
|
@ -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/`.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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/);
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
807
website/src/features/toolboxes/modules/CombosModule.jsx
Normal file
807
website/src/features/toolboxes/modules/CombosModule.jsx
Normal file
|
|
@ -0,0 +1,807 @@
|
||||||
|
// Rôle : fournit l'outil Combos avec palettes d'inputs et rendu visuel par périphérique.
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Icon } from "../../../components/Icon.jsx";
|
||||||
|
import { usePointerReorder } from "../../../hooks/usePointerReorder.js";
|
||||||
|
|
||||||
|
const DEVICE_OPTIONS = [
|
||||||
|
{ value: "playstation", label: "PlayStation" },
|
||||||
|
{ value: "xbox", label: "Xbox" },
|
||||||
|
{ value: "switch", label: "Switch" },
|
||||||
|
{ value: "n64", label: "Nintendo 64" },
|
||||||
|
{ value: "keyboardMouse", label: "Clavier/Souris" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEVICE_GROUPS = {
|
||||||
|
playstation: [
|
||||||
|
{ title: "Directions", kind: "directions", column: "movement", inputs: [["direction", "up-left"], ["direction", "up"], ["direction", "up-right"], ["direction", "left"], null, ["direction", "right"], ["direction", "down-left"], ["direction", "down"], ["direction", "down-right"]] },
|
||||||
|
{ title: "Actions", column: "actions", inputs: [["button", "triangle"], ["button", "circle"], ["button", "cross"], ["button", "square"], ["button", "l1"], ["button", "r1"], ["button", "l2"], ["button", "r2"]] },
|
||||||
|
{ title: "Système", inputs: [["button", "options"], ["button", "share"], ["button", "l3"], ["button", "r3"]] }
|
||||||
|
],
|
||||||
|
xbox: [
|
||||||
|
{ title: "Directions", kind: "directions", column: "movement", inputs: [["direction", "up-left"], ["direction", "up"], ["direction", "up-right"], ["direction", "left"], null, ["direction", "right"], ["direction", "down-left"], ["direction", "down"], ["direction", "down-right"]] },
|
||||||
|
{ title: "Actions", column: "actions", inputs: [["button", "a"], ["button", "b"], ["button", "x"], ["button", "y"], ["button", "lb"], ["button", "rb"], ["button", "lt"], ["button", "rt"]] },
|
||||||
|
{ title: "Système", inputs: [["button", "menu"], ["button", "view"], ["button", "ls"], ["button", "rs"]] }
|
||||||
|
],
|
||||||
|
switch: [
|
||||||
|
{ title: "Directions", kind: "directions", column: "movement", inputs: [["direction", "up-left"], ["direction", "up"], ["direction", "up-right"], ["direction", "left"], null, ["direction", "right"], ["direction", "down-left"], ["direction", "down"], ["direction", "down-right"]] },
|
||||||
|
{ title: "Actions", column: "actions", inputs: [["button", "a"], ["button", "b"], ["button", "x"], ["button", "y"], ["button", "l"], ["button", "r"], ["button", "zl"], ["button", "zr"]] },
|
||||||
|
{ title: "Système", inputs: [["button", "plus"], ["button", "minus"], ["button", "ls"], ["button", "rs"]] }
|
||||||
|
],
|
||||||
|
n64: [
|
||||||
|
{ title: "Stick", kind: "directions", column: "movement", inputs: [["direction", "stick-up-left"], ["direction", "stick-up"], ["direction", "stick-up-right"], ["direction", "stick-left"], null, ["direction", "stick-right"], ["direction", "stick-down-left"], ["direction", "stick-down"], ["direction", "stick-down-right"]] },
|
||||||
|
{ title: "D-Pad", kind: "directions", column: "movement", inputs: [["direction", "up-left"], ["direction", "up"], ["direction", "up-right"], ["direction", "left"], null, ["direction", "right"], ["direction", "down-left"], ["direction", "down"], ["direction", "down-right"]] },
|
||||||
|
{ title: "Actions", column: "actions", inputs: [["button", "a"], ["button", "b"], ["button", "z"], ["button", "start"], ["button", "l"], ["button", "r"]] },
|
||||||
|
{ title: "C-buttons", kind: "directions", inputs: [["button", "c-up"], ["button", "c-down"], ["button", "c-left"], ["button", "c-right"]] }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const KEYBOARD_GROUPS = {
|
||||||
|
azerty: [
|
||||||
|
{ title: "ZQSD", kind: "directions", column: "movement", inputs: [null, ["key", "z"], null, ["key", "q"], ["key", "s"], ["key", "d"], null, null, null] },
|
||||||
|
{ title: "Flèches", kind: "directions", column: "movement", inputs: [null, ["direction", "up"], null, ["direction", "left"], ["direction", "down"], ["direction", "right"], null, null, null] },
|
||||||
|
{ title: "Touches", column: "actions", inputs: [["key", "ctrl"], ["key", "alt"], ["key", "shift"], ["key", "space"], ["key", "enter"], ["key", "tab"], ["key", "e"], ["key", "a"], ["key", "r"], ["key", "f"]] },
|
||||||
|
{ title: "Souris", inputs: [["mouse", "left"], ["mouse", "right"], ["mouse", "middle"], ["mouse", "wheel-up"], ["mouse", "wheel-down"]] }
|
||||||
|
],
|
||||||
|
qwerty: [
|
||||||
|
{ title: "WASD", kind: "directions", column: "movement", inputs: [null, ["key", "w"], null, ["key", "a"], ["key", "s"], ["key", "d"], null, null, null] },
|
||||||
|
{ title: "Flèches", kind: "directions", column: "movement", inputs: [null, ["direction", "up"], null, ["direction", "left"], ["direction", "down"], ["direction", "right"], null, null, null] },
|
||||||
|
{ title: "Touches", column: "actions", inputs: [["key", "ctrl"], ["key", "alt"], ["key", "shift"], ["key", "space"], ["key", "enter"], ["key", "tab"], ["key", "e"], ["key", "q"], ["key", "r"], ["key", "f"]] },
|
||||||
|
{ title: "Souris", inputs: [["mouse", "left"], ["mouse", "right"], ["mouse", "middle"], ["mouse", "wheel-up"], ["mouse", "wheel-down"]] }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const INPUT_LABELS = {
|
||||||
|
triangle: "△",
|
||||||
|
circle: "○",
|
||||||
|
cross: "×",
|
||||||
|
square: "□",
|
||||||
|
options: "Options",
|
||||||
|
share: "Share",
|
||||||
|
up: "↑",
|
||||||
|
down: "↓",
|
||||||
|
left: "←",
|
||||||
|
right: "→",
|
||||||
|
"up-left": "↖",
|
||||||
|
"up-right": "↗",
|
||||||
|
"down-left": "↙",
|
||||||
|
"down-right": "↘",
|
||||||
|
"stick-up": "↑",
|
||||||
|
"stick-down": "↓",
|
||||||
|
"stick-left": "←",
|
||||||
|
"stick-right": "→",
|
||||||
|
"stick-up-left": "↖",
|
||||||
|
"stick-up-right": "↗",
|
||||||
|
"stick-down-left": "↙",
|
||||||
|
"stick-down-right": "↘",
|
||||||
|
plus: "+",
|
||||||
|
minus: "-",
|
||||||
|
"c-up": "C↑",
|
||||||
|
"c-down": "C↓",
|
||||||
|
"c-left": "C←",
|
||||||
|
"c-right": "C→",
|
||||||
|
space: "Espace"
|
||||||
|
};
|
||||||
|
|
||||||
|
const MOUSE_LABELS = {
|
||||||
|
left: "Clic G",
|
||||||
|
right: "Clic D",
|
||||||
|
middle: "Molette",
|
||||||
|
"wheel-up": "Molette ↑",
|
||||||
|
"wheel-down": "Molette ↓"
|
||||||
|
};
|
||||||
|
|
||||||
|
function getInputLabel(input) {
|
||||||
|
const value = String(input?.value || "");
|
||||||
|
if (input?.kind === "key" && value.length === 1) return value.toUpperCase();
|
||||||
|
if (input?.kind === "mouse") return MOUSE_LABELS[value] || value;
|
||||||
|
return INPUT_LABELS[value] || value.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function inputKey(input) {
|
||||||
|
return `${input.kind}:${input.value}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createInput(kind, value) {
|
||||||
|
return { kind, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCombo(context, id, name, inputs, fallbackName, device) {
|
||||||
|
return {
|
||||||
|
id: id || context.uid("combo"),
|
||||||
|
name: name.trim() || fallbackName,
|
||||||
|
device,
|
||||||
|
inputs: inputs.map((step) => step.map((input) => ({ ...input }))).filter((step) => step.length)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCustomKey(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPaletteGroups(device, keyboardLayout) {
|
||||||
|
return device === "keyboardMouse" ? KEYBOARD_GROUPS[keyboardLayout] : DEVICE_GROUPS[device] || DEVICE_GROUPS.playstation;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPaletteColumns(groups) {
|
||||||
|
return [
|
||||||
|
groups.filter((group) => group.column === "movement"),
|
||||||
|
groups.filter((group) => group.column !== "movement")
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveArrayItem(items, fromId, toId, placement, getId = (item) => item.id) {
|
||||||
|
if (!fromId || !toId || fromId === toId) return items;
|
||||||
|
const nextItems = [...items];
|
||||||
|
const fromIndex = nextItems.findIndex((item) => getId(item) === fromId);
|
||||||
|
const toIndex = nextItems.findIndex((item) => getId(item) === toId);
|
||||||
|
if (fromIndex < 0 || toIndex < 0) return items;
|
||||||
|
const [moved] = nextItems.splice(fromIndex, 1);
|
||||||
|
const targetIndex = nextItems.findIndex((item) => getId(item) === toId);
|
||||||
|
nextItems.splice(placement === "after" ? targetIndex + 1 : targetIndex, 0, moved);
|
||||||
|
return nextItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeComboFromData(data, comboId) {
|
||||||
|
let movedCombo = null;
|
||||||
|
const combos = data.combos.filter((combo) => {
|
||||||
|
if (combo.id !== comboId) return true;
|
||||||
|
movedCombo = combo;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
const categories = data.categories.map((category) => ({
|
||||||
|
...category,
|
||||||
|
combos: category.combos.filter((combo) => {
|
||||||
|
if (combo.id !== comboId) return true;
|
||||||
|
movedCombo = combo;
|
||||||
|
return false;
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
return { movedCombo, data: { ...data, combos, categories } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertComboInList(list, combo, targetComboId = "", placement = "after") {
|
||||||
|
if (!targetComboId) return [...list, combo];
|
||||||
|
const targetIndex = list.findIndex((item) => item.id === targetComboId);
|
||||||
|
if (targetIndex < 0) return [...list, combo];
|
||||||
|
const nextList = [...list];
|
||||||
|
nextList.splice(placement === "after" ? targetIndex + 1 : targetIndex, 0, combo);
|
||||||
|
return nextList;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboInputToken({ input, device, palette = false, onClick, onDragStart }) {
|
||||||
|
const label = getInputLabel(input);
|
||||||
|
const classNames = [
|
||||||
|
"combo-input-token",
|
||||||
|
`combo-input-${input.kind}`,
|
||||||
|
input.kind === "key" && label.length === 1 ? "combo-input-short" : "",
|
||||||
|
`combo-device-${device}`,
|
||||||
|
`combo-value-${String(input.value || "").replace(/[^a-z0-9]+/gi, "-").toLowerCase()}`
|
||||||
|
].filter(Boolean).join(" ");
|
||||||
|
|
||||||
|
if (palette) {
|
||||||
|
return (
|
||||||
|
<button className={classNames} type="button" draggable onClick={onClick} onDragStart={onDragStart} title={label} aria-label={label}>
|
||||||
|
<span>{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={classNames} draggable={Boolean(onDragStart)} onDragStart={onDragStart} title={label}>
|
||||||
|
<span>{label}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboStep({ step, stepIndex, device, active = false, onClick, onDropInput, onDragInput }) {
|
||||||
|
const className = [
|
||||||
|
"combo-step-inputs",
|
||||||
|
step.length > 1 ? "is-simultaneous" : "",
|
||||||
|
step.length <= 1 ? "is-single" : "",
|
||||||
|
active ? "is-active" : ""
|
||||||
|
].filter(Boolean).join(" ");
|
||||||
|
const content = step.length ? step.map((input, inputIndex) => (
|
||||||
|
<span className="combo-step-input" key={`${inputKey(input)}-${inputIndex}`}>
|
||||||
|
{inputIndex > 0 && <span className="combo-plus" aria-hidden="true">+</span>}
|
||||||
|
<ComboInputToken input={input} device={device} onDragStart={onDragInput ? (event) => onDragInput(event, stepIndex, inputIndex, input) : null} />
|
||||||
|
</span>
|
||||||
|
)) : <span className="combo-empty-step">Étape vide</span>;
|
||||||
|
|
||||||
|
if (onClick) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={className}
|
||||||
|
onClick={onClick}
|
||||||
|
onDragOver={(event) => {
|
||||||
|
if (!onDropInput) return;
|
||||||
|
event.preventDefault();
|
||||||
|
}}
|
||||||
|
onDrop={(event) => {
|
||||||
|
if (!onDropInput) return;
|
||||||
|
event.preventDefault();
|
||||||
|
onDropInput(event);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span className={className}>{content}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboSequence({ inputs, device, emptyLabel, activeStepIndex = -1, onSelectStep, onDropInput, onDropNewStep, onDragInput }) {
|
||||||
|
const visibleInputs = inputs.map((step, index) => ({ step, index })).filter(({ step }) => step.length || onSelectStep);
|
||||||
|
if (!visibleInputs.length) return <span className="combo-empty-sequence">{emptyLabel}</span>;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="combo-sequence"
|
||||||
|
onDragOver={(event) => {
|
||||||
|
if (!onDropNewStep) return;
|
||||||
|
event.preventDefault();
|
||||||
|
}}
|
||||||
|
onDrop={(event) => {
|
||||||
|
if (!onDropNewStep || event.target.closest(".combo-step-inputs")) return;
|
||||||
|
event.preventDefault();
|
||||||
|
onDropNewStep(event);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{visibleInputs.map(({ step, index: stepIndex }, visibleIndex) => (
|
||||||
|
<span className="combo-step" key={`${stepIndex}-${step.map(inputKey).join("+")}`}>
|
||||||
|
<ComboStep step={step} stepIndex={stepIndex} device={device} active={activeStepIndex === stepIndex} onClick={onSelectStep ? () => onSelectStep(stepIndex) : null} onDropInput={onDropInput ? (event) => onDropInput(event, stepIndex) : null} onDragInput={onDragInput} />
|
||||||
|
{visibleIndex < visibleInputs.length - 1 && <span className="combo-step-separator" aria-hidden="true">›</span>}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InlineTextInput({ value, onCommit, className, ariaLabel, placeholder = "" }) {
|
||||||
|
const [draft, setDraft] = useState(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDraft(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
function commit() {
|
||||||
|
const cleanDraft = draft.trim();
|
||||||
|
if (cleanDraft !== value) onCommit(cleanDraft);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
className={className}
|
||||||
|
value={draft}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChange={(event) => setDraft(event.target.value)}
|
||||||
|
onBlur={commit}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") event.currentTarget.blur();
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
setDraft(value);
|
||||||
|
event.currentTarget.blur();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CombosModule({ toolboxId, moduleId, context, editing }) {
|
||||||
|
const data = context.normalizeCombosData(context.getModuleData(toolboxId, moduleId, { combos: [], categories: [] }));
|
||||||
|
const textContent = context.moduleText?.combos || {};
|
||||||
|
const [comboCategory, setComboCategory] = useState("");
|
||||||
|
const [comboName, setComboName] = useState("");
|
||||||
|
const [draftInputs, setDraftInputs] = useState([[]]);
|
||||||
|
const [activeStepIndex, setActiveStepIndex] = useState(0);
|
||||||
|
const [simultaneousMode, setSimultaneousMode] = useState(false);
|
||||||
|
const [keyboardLayout, setKeyboardLayout] = useState("azerty");
|
||||||
|
const [customKey, setCustomKey] = useState("");
|
||||||
|
const [editingComboId, setEditingComboId] = useState("");
|
||||||
|
const paletteGroups = useMemo(() => getPaletteGroups(data.device, keyboardLayout), [data.device, keyboardLayout]);
|
||||||
|
const paletteColumns = useMemo(() => getPaletteColumns(paletteGroups), [paletteGroups]);
|
||||||
|
const cleanDraftInputs = draftInputs.map((step) => step.filter(Boolean)).filter((step) => step.length);
|
||||||
|
const {
|
||||||
|
draggingId: draggingCategoryId,
|
||||||
|
dropTarget: categoryDropTarget,
|
||||||
|
startDrag: startCategoryDrag
|
||||||
|
} = usePointerReorder({
|
||||||
|
targetSelector: ".combos-category",
|
||||||
|
getTargetId: (target) => target.dataset.categoryId,
|
||||||
|
onMove: moveCategory
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
draggingId: draggingComboId,
|
||||||
|
dropTarget: comboDropTarget,
|
||||||
|
startDrag: startComboDrag
|
||||||
|
} = usePointerReorder({
|
||||||
|
targetSelector: ".combo-card, .combos-root-drop-zone, .combos-category",
|
||||||
|
getTargetId: (target) => {
|
||||||
|
if (target.classList.contains("combo-card")) return `combo:${target.dataset.comboId}`;
|
||||||
|
if (target.classList.contains("combos-category")) return `category:${target.dataset.categoryId}`;
|
||||||
|
return "root";
|
||||||
|
},
|
||||||
|
canDropOn: (target, draggingId) => {
|
||||||
|
if (target.classList.contains("combo-card")) return target.dataset.comboId !== draggingId;
|
||||||
|
if (target.classList.contains("combos-category")) return !target.querySelector(`[data-combo-id="${draggingId}"]`);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
onMove: moveCombo
|
||||||
|
});
|
||||||
|
|
||||||
|
function save(nextData) {
|
||||||
|
context.setModuleData(toolboxId, moduleId, nextData, "combos");
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDevice(device) {
|
||||||
|
const stampComboDevice = (combo) => combo.device ? combo : { ...combo, device: data.device };
|
||||||
|
save({
|
||||||
|
...data,
|
||||||
|
device,
|
||||||
|
combos: data.combos.map(stampComboDevice),
|
||||||
|
categories: data.categories.map((category) => ({
|
||||||
|
...category,
|
||||||
|
combos: category.combos.map(stampComboDevice)
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveCategory(fromCategoryId, toCategoryId, placement) {
|
||||||
|
save({ ...data, categories: moveArrayItem(data.categories, fromCategoryId, toCategoryId, placement) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveCombo(fromComboId, targetId, placement) {
|
||||||
|
const { movedCombo, data: dataWithoutCombo } = removeComboFromData(data, fromComboId);
|
||||||
|
if (!movedCombo) return;
|
||||||
|
if (targetId === "root") {
|
||||||
|
save({ ...dataWithoutCombo, combos: [...dataWithoutCombo.combos, movedCombo] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (targetId.startsWith("category:")) {
|
||||||
|
const categoryId = targetId.replace("category:", "");
|
||||||
|
save({
|
||||||
|
...dataWithoutCombo,
|
||||||
|
categories: dataWithoutCombo.categories.map((category) => category.id === categoryId
|
||||||
|
? { ...category, combos: [...category.combos, movedCombo], collapsed: false }
|
||||||
|
: category)
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targetComboId = targetId.replace("combo:", "");
|
||||||
|
const targetElement = document.querySelector(`[data-combo-id="${targetComboId}"]`);
|
||||||
|
const targetList = targetElement?.dataset.comboList || "";
|
||||||
|
if (!targetList) {
|
||||||
|
save({ ...dataWithoutCombo, combos: insertComboInList(dataWithoutCombo.combos, movedCombo, targetComboId, placement) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
save({
|
||||||
|
...dataWithoutCombo,
|
||||||
|
categories: dataWithoutCombo.categories.map((category) => category.id === targetList
|
||||||
|
? { ...category, combos: insertComboInList(category.combos, movedCombo, targetComboId, placement), collapsed: false }
|
||||||
|
: category)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCategory(categoryId, updater) {
|
||||||
|
save({
|
||||||
|
...data,
|
||||||
|
categories: data.categories.map((category) => category.id === categoryId ? updater(category) : category)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCategoryTarget(categories, title) {
|
||||||
|
const cleanTitle = title.trim();
|
||||||
|
if (!cleanTitle) return { categoryId: "", categories };
|
||||||
|
const existing = categories.find((category) => category.title.toLowerCase() === cleanTitle.toLowerCase());
|
||||||
|
if (existing) return { categoryId: existing.id, categories };
|
||||||
|
const category = { id: context.uid("section"), title: cleanTitle, collapsed: false, combos: [] };
|
||||||
|
return { categoryId: category.id, categories: [...categories, category] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function addInputToStep(kind, value, stepIndex) {
|
||||||
|
const input = createInput(kind, value);
|
||||||
|
setDraftInputs((steps) => {
|
||||||
|
const nextSteps = steps.length ? steps.map((step) => [...step]) : [[]];
|
||||||
|
const targetIndex = Math.min(Math.max(0, stepIndex), nextSteps.length - 1);
|
||||||
|
nextSteps[targetIndex] = [...nextSteps[targetIndex], input];
|
||||||
|
return nextSteps;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDraftInput(kind, value) {
|
||||||
|
const input = createInput(kind, value);
|
||||||
|
if (simultaneousMode) {
|
||||||
|
addInputToStep(kind, value, activeStepIndex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDraftInputs((steps) => {
|
||||||
|
const filledSteps = steps.filter((step) => step.length);
|
||||||
|
const nextSteps = [...filledSteps, [input]];
|
||||||
|
setActiveStepIndex(nextSteps.length - 1);
|
||||||
|
return nextSteps;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragPaletteInput(event, kind, value) {
|
||||||
|
event.dataTransfer.effectAllowed = "copy";
|
||||||
|
event.dataTransfer.setData("application/x-sokkog-combo-input", JSON.stringify(createInput(kind, value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragDraftInput(event, stepIndex, inputIndex, input) {
|
||||||
|
event.dataTransfer.effectAllowed = "move";
|
||||||
|
event.dataTransfer.setData("application/x-sokkog-combo-input", JSON.stringify({
|
||||||
|
...input,
|
||||||
|
sourceStepIndex: stepIndex,
|
||||||
|
sourceInputIndex: inputIndex
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropInputOnStep(event, stepIndex) {
|
||||||
|
const rawInput = event.dataTransfer.getData("application/x-sokkog-combo-input");
|
||||||
|
if (!rawInput) return;
|
||||||
|
try {
|
||||||
|
const input = JSON.parse(rawInput);
|
||||||
|
if (!input?.kind || !input?.value) return;
|
||||||
|
if (Number.isInteger(input.sourceStepIndex) && Number.isInteger(input.sourceInputIndex)) {
|
||||||
|
moveDraftInput(input.sourceStepIndex, input.sourceInputIndex, stepIndex);
|
||||||
|
} else {
|
||||||
|
addInputToStep(input.kind, input.value, stepIndex);
|
||||||
|
}
|
||||||
|
setActiveStepIndex(stepIndex);
|
||||||
|
} catch {
|
||||||
|
// Ignore invalid drag payloads from outside the app.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropInputAsNewStep(event) {
|
||||||
|
const rawInput = event.dataTransfer.getData("application/x-sokkog-combo-input");
|
||||||
|
if (!rawInput) return;
|
||||||
|
try {
|
||||||
|
const input = JSON.parse(rawInput);
|
||||||
|
if (!input?.kind || !input?.value) return;
|
||||||
|
if (Number.isInteger(input.sourceStepIndex) && Number.isInteger(input.sourceInputIndex)) {
|
||||||
|
moveDraftInputToNewStep(input.sourceStepIndex, input.sourceInputIndex);
|
||||||
|
} else {
|
||||||
|
addDraftInput(input.kind, input.value);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore invalid drag payloads from outside the app.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveDraftInput(sourceStepIndex, sourceInputIndex, targetStepIndex) {
|
||||||
|
setDraftInputs((steps) => {
|
||||||
|
if (sourceStepIndex === targetStepIndex) return steps;
|
||||||
|
const nextSteps = steps.map((step) => [...step]);
|
||||||
|
const [input] = nextSteps[sourceStepIndex]?.splice(sourceInputIndex, 1) || [];
|
||||||
|
if (!input || !nextSteps[targetStepIndex]) return steps;
|
||||||
|
nextSteps[targetStepIndex].push(input);
|
||||||
|
return nextSteps.filter((step, index) => step.length || index === targetStepIndex);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveDraftInputToNewStep(sourceStepIndex, sourceInputIndex) {
|
||||||
|
setDraftInputs((steps) => {
|
||||||
|
const nextSteps = steps.map((step) => [...step]);
|
||||||
|
const [input] = nextSteps[sourceStepIndex]?.splice(sourceInputIndex, 1) || [];
|
||||||
|
if (!input) return steps;
|
||||||
|
const safeSteps = nextSteps.filter((step) => step.length);
|
||||||
|
setActiveStepIndex(safeSteps.length);
|
||||||
|
return [...safeSteps, [input]];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeActiveStep() {
|
||||||
|
setDraftInputs((steps) => {
|
||||||
|
const nextSteps = steps.filter((_, index) => index !== activeStepIndex);
|
||||||
|
const safeSteps = nextSteps.length ? nextSteps : [[]];
|
||||||
|
setActiveStepIndex(Math.min(activeStepIndex, safeSteps.length - 1));
|
||||||
|
return safeSteps;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLastInput() {
|
||||||
|
setDraftInputs((steps) => {
|
||||||
|
const nextSteps = steps.length ? steps.map((step) => [...step]) : [[]];
|
||||||
|
let targetIndex = -1;
|
||||||
|
for (let index = nextSteps.length - 1; index >= 0; index -= 1) {
|
||||||
|
if (nextSteps[index].length) {
|
||||||
|
targetIndex = index;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targetIndex < 0) return [[]];
|
||||||
|
nextSteps[targetIndex].pop();
|
||||||
|
const safeSteps = nextSteps.filter((step) => step.length);
|
||||||
|
setActiveStepIndex(Math.max(0, safeSteps.length - 1));
|
||||||
|
return safeSteps.length ? safeSteps : [[]];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCustomKey(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const value = normalizeCustomKey(customKey);
|
||||||
|
if (!value || data.device !== "keyboardMouse") return;
|
||||||
|
addDraftInput("key", value.toLowerCase());
|
||||||
|
setCustomKey("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveCombo(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!comboName.trim() && !cleanDraftInputs.length) return;
|
||||||
|
const combo = createCombo(context, editingComboId, comboName, cleanDraftInputs, textContent.defaultComboName || "Combo", data.device);
|
||||||
|
const nextRootCombos = data.combos.filter((item) => item.id !== editingComboId);
|
||||||
|
const nextCategories = data.categories.map((category) => ({
|
||||||
|
...category,
|
||||||
|
combos: category.combos.filter((item) => item.id !== editingComboId)
|
||||||
|
}));
|
||||||
|
const target = getCategoryTarget(nextCategories, comboCategory);
|
||||||
|
const nextData = target.categoryId
|
||||||
|
? {
|
||||||
|
...data,
|
||||||
|
combos: nextRootCombos,
|
||||||
|
categories: target.categories.map((category) => category.id === target.categoryId ? { ...category, combos: [...category.combos, combo], collapsed: false } : category)
|
||||||
|
}
|
||||||
|
: { ...data, combos: [...nextRootCombos, combo], categories: nextCategories };
|
||||||
|
save(nextData);
|
||||||
|
cancelComboEdit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startComboEdit(categoryId, combo) {
|
||||||
|
setComboCategory(data.categories.find((category) => category.id === categoryId)?.title || "");
|
||||||
|
setEditingComboId(combo.id);
|
||||||
|
setComboName(combo.name);
|
||||||
|
setDraftInputs(combo.inputs.length ? combo.inputs.map((step) => step.map((input) => ({ ...input }))) : [[]]);
|
||||||
|
setActiveStepIndex(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelComboEdit() {
|
||||||
|
setEditingComboId("");
|
||||||
|
setComboName("");
|
||||||
|
setComboCategory("");
|
||||||
|
setDraftInputs([[]]);
|
||||||
|
setActiveStepIndex(0);
|
||||||
|
setSimultaneousMode(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCombo(categoryId, comboId, updater) {
|
||||||
|
if (!categoryId) {
|
||||||
|
save({ ...data, combos: data.combos.map((combo) => combo.id === comboId ? updater(combo) : combo).filter((combo) => combo.name || combo.inputs.length) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateCategory(categoryId, (category) => ({
|
||||||
|
...category,
|
||||||
|
combos: category.combos.map((combo) => combo.id === comboId ? updater(combo) : combo).filter((combo) => combo.name || combo.inputs.length)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteCombo(categoryId, comboId) {
|
||||||
|
updateCombo(categoryId, comboId, () => ({ name: "", inputs: [] }));
|
||||||
|
if (editingComboId === comboId) cancelComboEdit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCombo(combo, categoryId = "") {
|
||||||
|
const comboClassName = [
|
||||||
|
"combo-card",
|
||||||
|
"is-editing",
|
||||||
|
draggingComboId === combo.id ? "is-dragging" : "",
|
||||||
|
comboDropTarget.id === `combo:${combo.id}` ? "is-drop-target" : "",
|
||||||
|
comboDropTarget.id === `combo:${combo.id}` && comboDropTarget.placement === "after" ? "drop-after" : ""
|
||||||
|
].filter(Boolean).join(" ");
|
||||||
|
return (
|
||||||
|
<article className={comboClassName} key={combo.id} data-combo-id={combo.id} data-combo-list={categoryId}>
|
||||||
|
<button
|
||||||
|
className="task-planner-drag-handle combo-drag-handle"
|
||||||
|
type="button"
|
||||||
|
onPointerDown={(event) => startComboDrag(event, combo.id)}
|
||||||
|
aria-label={`${textContent.reorderComboTitle || "Déplacer le combo"} ${combo.name}`}
|
||||||
|
title={textContent.reorderComboTitle || "Déplacer le combo"}
|
||||||
|
>
|
||||||
|
<Icon name="drag" />
|
||||||
|
</button>
|
||||||
|
<div className="combo-card-main">
|
||||||
|
<InlineTextInput
|
||||||
|
className="tool-split-entry-label combo-title-input"
|
||||||
|
value={combo.name}
|
||||||
|
onCommit={(name) => updateCombo(categoryId, combo.id, (current) => ({ ...current, name: name || current.name }))}
|
||||||
|
ariaLabel={textContent.comboNameLabel || "Nom"}
|
||||||
|
/>
|
||||||
|
<ComboSequence inputs={combo.inputs} device={combo.device || data.device} emptyLabel={textContent.emptySequence || "Combo sans touches"} />
|
||||||
|
</div>
|
||||||
|
<div className="combo-card-actions">
|
||||||
|
<button type="button" className="checklist-delete-button" onClick={() => startComboEdit(categoryId, combo)} aria-label={`${textContent.editComboTitle || "Modifier le combo"} ${combo.name}`} title={textContent.editComboTitle || "Modifier le combo"}>
|
||||||
|
<Icon name="edit" />
|
||||||
|
</button>
|
||||||
|
<button type="button" className="checklist-delete-button danger" onClick={() => deleteCombo(categoryId, combo.id)} aria-label={`${textContent.deleteComboTitle || "Supprimer le combo"} ${combo.name}`} title={textContent.deleteComboTitle || "Supprimer le combo"}>
|
||||||
|
<Icon name="trash" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCategory(category) {
|
||||||
|
const categoryClassName = [
|
||||||
|
"combos-category checklist-section is-grouped",
|
||||||
|
category.collapsed ? "is-collapsed" : "",
|
||||||
|
draggingCategoryId === category.id ? "is-dragging" : "",
|
||||||
|
categoryDropTarget.id === category.id || comboDropTarget.id === `category:${category.id}` ? "is-drop-target" : "",
|
||||||
|
(categoryDropTarget.id === category.id && categoryDropTarget.placement === "after") ? "drop-after" : ""
|
||||||
|
].filter(Boolean).join(" ");
|
||||||
|
return (
|
||||||
|
<section className={categoryClassName} key={category.id} data-category-id={category.id}>
|
||||||
|
<div className="checklist-section-header combos-category-header">
|
||||||
|
<div className="combos-category-title">
|
||||||
|
<button
|
||||||
|
className="task-planner-category-drag-handle"
|
||||||
|
type="button"
|
||||||
|
onPointerDown={(event) => startCategoryDrag(event, category.id)}
|
||||||
|
aria-label={`${textContent.reorderCategoryTitle || "Déplacer la catégorie"} ${category.title}`}
|
||||||
|
title={textContent.reorderCategoryTitle || "Déplacer la catégorie"}
|
||||||
|
>
|
||||||
|
<Icon name="drag" />
|
||||||
|
</button>
|
||||||
|
<h3>{category.title}</h3>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>{category.combos.length}</span>
|
||||||
|
<button
|
||||||
|
className="checklist-section-collapse-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateCategory(category.id, (item) => ({ ...item, collapsed: !item.collapsed }))}
|
||||||
|
aria-expanded={!category.collapsed}
|
||||||
|
aria-label={category.collapsed ? textContent.showCategoryTitle || "Afficher la catégorie" : textContent.hideCategoryTitle || "Réduire la catégorie"}
|
||||||
|
title={category.collapsed ? textContent.showCategoryTitle || "Afficher la catégorie" : textContent.hideCategoryTitle || "Réduire la catégorie"}
|
||||||
|
>
|
||||||
|
<Icon name={category.collapsed ? "chevron-down" : "chevron-up"} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!category.collapsed && (
|
||||||
|
<div className="combos-list">
|
||||||
|
{category.combos.map((combo) => renderCombo(combo, category.id))}
|
||||||
|
{!category.combos.length && <p className="empty-state">{textContent.emptyCategory || "Aucun combo dans cette catégorie."}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="combos-module">
|
||||||
|
{(editing || editingComboId) && (
|
||||||
|
<div className="module-add-panel combos-editor">
|
||||||
|
<div className="combos-device-row">
|
||||||
|
<label>
|
||||||
|
<span>{textContent.deviceLabel || "Périphérique"}</span>
|
||||||
|
<select value={data.device} onChange={(event) => updateDevice(event.target.value)}>
|
||||||
|
{DEVICE_OPTIONS.map((device) => (
|
||||||
|
<option key={device.value} value={device.value}>{device.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{data.device === "keyboardMouse" && (
|
||||||
|
<div className="combos-keyboard-layout-switch" role="group" aria-label={textContent.keyboardLayoutLabel || "Disposition clavier"}>
|
||||||
|
{[
|
||||||
|
{ value: "azerty", label: "AZERTY" },
|
||||||
|
{ value: "qwerty", label: "QWERTY" }
|
||||||
|
].map((layout) => (
|
||||||
|
<button
|
||||||
|
key={layout.value}
|
||||||
|
type="button"
|
||||||
|
className={keyboardLayout === layout.value ? "active" : ""}
|
||||||
|
onClick={() => setKeyboardLayout(layout.value)}
|
||||||
|
aria-pressed={keyboardLayout === layout.value}
|
||||||
|
>
|
||||||
|
{layout.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="combos-add-form" onSubmit={saveCombo}>
|
||||||
|
<div className="combos-form-grid">
|
||||||
|
<label>
|
||||||
|
<span>{textContent.targetCategoryLabel || "Catégorie"}</span>
|
||||||
|
<input value={comboCategory} onChange={(event) => setComboCategory(event.target.value)} placeholder={textContent.noCategoryLabel || "Sans catégorie"} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>{textContent.comboNameLabel || "Nom"}</span>
|
||||||
|
<input value={comboName} onChange={(event) => setComboName(event.target.value)} placeholder={textContent.comboNamePlaceholder || "Nom du combo"} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="combos-palette" aria-label={textContent.paletteLabel || "Palette de touches"}>
|
||||||
|
{paletteColumns.map((columnGroups, columnIndex) => (
|
||||||
|
<div className="combos-palette-column" key={columnIndex}>
|
||||||
|
{columnGroups.map((group) => (
|
||||||
|
<div className={`combos-palette-group ${group.kind === "directions" ? "is-direction-grid" : ""}`} key={group.title}>
|
||||||
|
<span>{group.title}</span>
|
||||||
|
<div>
|
||||||
|
{group.inputs.map((input, inputIndex) => {
|
||||||
|
if (!input) return <span className="combo-palette-empty-cell" key={`empty-${inputIndex}`} aria-hidden="true" />;
|
||||||
|
const [kind, value] = input;
|
||||||
|
return (
|
||||||
|
<ComboInputToken
|
||||||
|
key={`${kind}-${value}`}
|
||||||
|
input={createInput(kind, value)}
|
||||||
|
device={data.device}
|
||||||
|
palette
|
||||||
|
onClick={() => addDraftInput(kind, value)}
|
||||||
|
onDragStart={(event) => dragPaletteInput(event, kind, value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.device === "keyboardMouse" && (
|
||||||
|
<div className="inline-form combos-custom-key-form">
|
||||||
|
<input value={customKey} onChange={(event) => setCustomKey(event.target.value)} placeholder={textContent.customKeyPlaceholder || "Touche personnalisée"} />
|
||||||
|
<button type="button" onClick={addCustomKey}>{textContent.addCustomKeyButton || "Ajouter touche"}</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="combo-draft-panel">
|
||||||
|
<ComboSequence inputs={draftInputs} device={data.device} emptyLabel={textContent.emptyDraft || "Aucune touche sélectionnée."} activeStepIndex={activeStepIndex} onSelectStep={setActiveStepIndex} onDropInput={dropInputOnStep} onDropNewStep={dropInputAsNewStep} onDragInput={dragDraftInput} />
|
||||||
|
<div className="combo-draft-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`combos-simultaneous-toggle ${simultaneousMode ? "active" : ""}`}
|
||||||
|
onClick={() => setSimultaneousMode((enabled) => !enabled)}
|
||||||
|
aria-pressed={simultaneousMode}
|
||||||
|
title={textContent.simultaneousModeTitle || "Ajout simultané"}
|
||||||
|
aria-label={textContent.simultaneousModeTitle || "Ajout simultané"}
|
||||||
|
>
|
||||||
|
<span className={!simultaneousMode ? "active" : ""}>{textContent.successiveModeLabel || "A › B"}</span>
|
||||||
|
<span className={simultaneousMode ? "active" : ""}>{textContent.simultaneousModeLabel || "A + B"}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" className="notepad-toolbar-button" onClick={removeLastInput} disabled={!cleanDraftInputs.length} title={textContent.removeLastInputTitle || "Retirer la dernière touche"} aria-label={textContent.removeLastInputTitle || "Retirer la dernière touche"}>
|
||||||
|
<Icon name="back" />
|
||||||
|
</button>
|
||||||
|
<button type="button" className="notepad-toolbar-button danger" onClick={removeActiveStep} disabled={draftInputs.length <= 1} title={textContent.removeStepTitle || "Supprimer l'étape"} aria-label={textContent.removeStepTitle || "Supprimer l'étape"}>
|
||||||
|
<Icon name="trash" />
|
||||||
|
</button>
|
||||||
|
<div className="combo-save-actions">
|
||||||
|
{editingComboId && (
|
||||||
|
<button type="button" className="combo-cancel-button" onClick={cancelComboEdit}>
|
||||||
|
{textContent.cancelEditButton || "Annuler"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="primary" disabled={!comboName.trim() && !cleanDraftInputs.length}>
|
||||||
|
{editingComboId ? textContent.saveComboButton || "Enregistrer" : textContent.addComboButton || "Ajouter le combo"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!data.combos.length && !data.categories.length && (
|
||||||
|
<p className="empty-state">{textContent.emptyCombos || "Aucun combo enregistré."}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="combos-category-list">
|
||||||
|
<div className={`combos-root-drop-zone ${draggingComboId ? "is-visible" : ""} ${comboDropTarget.id === "root" ? "is-drop-target" : ""}`} data-combo-root-drop="true">
|
||||||
|
{textContent.noCategoryLabel || "Sans catégorie"}
|
||||||
|
</div>
|
||||||
|
{data.combos.length > 0 && (
|
||||||
|
<div className="combos-list combos-root-list">
|
||||||
|
{data.combos.map((combo) => renderCombo(combo))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data.categories.map(renderCategory)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import { usePointerReorder } from "../../../hooks/usePointerReorder.js";
|
||||||
import { lockBodyScroll } from "../../../utils/bodyScrollLock.js";
|
import { 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 },
|
||||||
|
|
|
||||||
|
|
@ -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")));
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue