fix drag&drop to reorganize combos
All checks were successful
Deploy Sokko G / deploy (push) Successful in 8s

This commit is contained in:
Shinuwa 2026-07-31 16:47:21 +02:00
parent 7b4cfb7dc2
commit a6d35c6e6b
8 changed files with 724 additions and 282 deletions

View file

@ -432,6 +432,8 @@ Type : `combos`
```json ```json
{ {
"device": "playstation", "device": "playstation",
"categoryOrder": ["Neutral"],
"collapsedCategories": ["Advanced"],
"combos": [ "combos": [
{ {
"id": "combo0", "id": "combo0",
@ -443,27 +445,16 @@ Type : `combos`
{ "kind": "button", "value": "triangle" } { "kind": "button", "value": "triangle" }
] ]
] ]
} },
],
"categories": [
{
"id": "category1",
"title": "Neutral",
"collapsed": false,
"combos": [
{ {
"id": "combo1", "id": "combo1",
"name": "Hadoken", "name": "Hadoken",
"category": "Neutral",
"device": "playstation", "device": "playstation",
"inputs": [ "inputs": [
[{ "kind": "direction", "value": "down" }], [{ "kind": "direction", "value": "down" }],
[{ "kind": "direction", "value": "down-forward" }], [{ "kind": "direction", "value": "down-forward" }],
[ [{ "kind": "button", "value": "cross" }]
{ "kind": "direction", "value": "forward" },
{ "kind": "button", "value": "cross" }
]
]
}
] ]
} }
] ]
@ -474,20 +465,23 @@ Notes :
- `device` vaut `playstation`, `xbox`, `switch`, `n64` ou `keyboardMouse`. - `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. - `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.
- `combos[].category` est facultatif ; les catégories sont déduites automatiquement de cette valeur.
- `inputs` est une liste d'étapes successives. - `inputs` est une liste d'étapes successives.
- chaque étape contient une ou plusieurs entrées simultanées. - chaque étape contient une ou plusieurs entrées simultanées.
- `kind` vaut `button`, `direction`, `key` ou `mouse`. - `kind` vaut `button`, `direction`, `key` ou `mouse`.
- `combos` à la racine contient les combos sans catégorie. - `categoryOrder` stocke l'ordre d'affichage des catégories utilisées.
- `collapsedCategories` stocke les noms des catégories repliées.
- les catégories restent locales à l'outil ; l'utilisateur peut ajouter plusieurs outils Combos pour séparer des usages. - les catégories restent locales à l'outil ; l'utilisateur peut ajouter plusieurs outils Combos pour séparer des usages.
Stockage compact : Stockage compact :
- `device` est omis si la valeur vaut `playstation`. - `device` est omis si la valeur vaut `playstation`.
- `combos` est omis si aucun combo sans catégorie n'existe. - `category` est omise si vide.
- `categories` conserve uniquement les catégories avec au moins un combo valide. - `categoryOrder` est omis si aucune catégorie n'est utilisée.
- `collapsedCategories` est omis si aucune catégorie n'est repliée.
- `combos` est omis si aucun combo valide n'existe.
- les combos sans nom et sans touche sont supprimés. - les combos sans nom et sans touche sont supprimés.
- `collapsed` est omis si `false`. - les catégories et noms de combo sont limités à 80 caractères.
- les titres de catégorie et noms de combo sont limités à 80 caractères.
- chaque combo conserve au maximum 64 étapes. - chaque combo conserve au maximum 64 étapes.
## Outil Calculateur ## Outil Calculateur

View file

@ -182,7 +182,6 @@ export function validateSiteContent(site) {
"toolboxes.modules.combos.customKeyPlaceholder", "toolboxes.modules.combos.customKeyPlaceholder",
"toolboxes.modules.combos.addCustomKeyButton", "toolboxes.modules.combos.addCustomKeyButton",
"toolboxes.modules.combos.emptyCombos", "toolboxes.modules.combos.emptyCombos",
"toolboxes.modules.combos.emptyCategory",
"toolboxes.modules.combos.emptySequence", "toolboxes.modules.combos.emptySequence",
"toolboxes.modules.combos.showCategoryTitle", "toolboxes.modules.combos.showCategoryTitle",
"toolboxes.modules.combos.hideCategoryTitle", "toolboxes.modules.combos.hideCategoryTitle",

View file

@ -99,23 +99,19 @@ test("combos storage normalizes devices, steps and compact export ids", () => {
const longText = "x".repeat(120); const longText = "x".repeat(120);
const normalized = normalizeCombosData({ const normalized = normalizeCombosData({
device: "invalid", device: "invalid",
categoryOrder: [`${longText} `, "Missing", `${longText} `],
collapsedCategories: [`${longText} `, "", `${longText} `],
combos: [ combos: [
{ {
id: "rootCombo", id: "rootCombo",
name: "Root", name: "Root",
device: "switch", device: "switch",
inputs: [[{ kind: "button", value: "triangle" }]] inputs: [[{ kind: "button", value: "triangle" }]]
} },
],
categories: [
{
id: "category1",
title: `${longText} `,
collapsed: true,
combos: [
{ {
id: "combo1", id: "combo1",
device: "n64", device: "n64",
category: `${longText} `,
name: `${longText} `, name: `${longText} `,
inputs: [ inputs: [
[ [
@ -129,22 +125,17 @@ test("combos storage normalizes devices, steps and compact export ids", () => {
}, },
{ id: "empty", name: " ", inputs: [] } { id: "empty", name: " ", inputs: [] }
] ]
},
{ id: "emptyCategory", title: " ", combos: [] },
{ id: "titledEmptyCategory", title: "Empty", combos: [] }
]
}); });
assert.equal(normalized.device, "playstation"); assert.equal(normalized.device, "playstation");
assert.equal(normalized.combos.length, 1); assert.equal(normalized.combos.length, 2);
assert.equal(normalized.combos[0].device, "switch"); assert.equal(normalized.combos[0].device, "switch");
assert.equal(normalized.categories.length, 1); assert.equal(normalized.combos[1].category.length, 80);
assert.equal(normalized.categories[0].title.length, 80); assert.equal(normalized.combos[1].name.length, 80);
assert.equal(normalized.categories[0].collapsed, true); assert.equal("note" in normalized.combos[1], false);
assert.equal(normalized.categories[0].combos.length, 1); assert.deepEqual(normalized.categoryOrder, [longText.slice(0, 80)]);
assert.equal(normalized.categories[0].combos[0].name.length, 80); assert.deepEqual(normalized.collapsedCategories, [longText.slice(0, 80)]);
assert.equal("note" in normalized.categories[0].combos[0], false); assert.deepEqual(normalized.combos[1].inputs, [
assert.deepEqual(normalized.categories[0].combos[0].inputs, [
[ [
{ kind: "direction", value: "down" }, { kind: "direction", value: "down" },
{ kind: "button", value: "cross" } { kind: "button", value: "cross" }
@ -154,11 +145,13 @@ test("combos storage normalizes devices, steps and compact export ids", () => {
const compact = compactModuleDataForStorage("combos", { ...normalized, device: "xbox" }); const compact = compactModuleDataForStorage("combos", { ...normalized, device: "xbox" });
assert.equal(compact.device, "xbox"); assert.equal(compact.device, "xbox");
assert.deepEqual(compact.categoryOrder, [longText.slice(0, 80)]);
assert.deepEqual(compact.collapsedCategories, [longText.slice(0, 80)]);
assert.equal(compact.combos[0].id, "rootCombo"); assert.equal(compact.combos[0].id, "rootCombo");
assert.equal(compact.combos[0].device, "switch"); assert.equal(compact.combos[0].device, "switch");
assert.equal(compact.categories[0].id, "category1"); assert.equal(compact.combos[1].device, "n64");
assert.equal(compact.categories[0].combos[0].device, "n64"); assert.equal(compact.combos[1].id, "combo1");
assert.equal(compact.categories[0].combos[0].id, "combo1"); assert.equal(compact.combos[1].category.length, 80);
const exported = createToolboxExportPayload( const exported = createToolboxExportPayload(
{ id: "toolbox1", name: "Combos", modules: [{ id: "module1", type: "combos" }], updatedAt: "2026-01-01T00:00:00.000Z" }, { id: "toolbox1", name: "Combos", modules: [{ id: "module1", type: "combos" }], updatedAt: "2026-01-01T00:00:00.000Z" },
@ -166,9 +159,10 @@ test("combos storage normalizes devices, steps and compact export ids", () => {
); );
assert.equal(exported.modules.m1.combos[0].id, "o1"); assert.equal(exported.modules.m1.combos[0].id, "o1");
assert.equal(exported.modules.m1.combos[0].device, "switch"); assert.equal(exported.modules.m1.combos[0].device, "switch");
assert.equal(exported.modules.m1.categories[0].id, "g1"); assert.deepEqual(exported.modules.m1.categoryOrder, [longText.slice(0, 80)]);
assert.equal(exported.modules.m1.categories[0].combos[0].id, "o2"); assert.deepEqual(exported.modules.m1.collapsedCategories, [longText.slice(0, 80)]);
assert.equal(exported.modules.m1.categories[0].combos[0].device, "n64"); assert.equal(exported.modules.m1.combos[1].id, "o2");
assert.equal(exported.modules.m1.combos[1].device, "n64");
}); });
test("notepad data migrates text, sanitizes html and compacts drawings", () => { test("notepad data migrates text, sanitizes html and compacts drawings", () => {

View file

@ -312,7 +312,6 @@
"customKeyPlaceholder": "Touche personnalisée", "customKeyPlaceholder": "Touche personnalisée",
"addCustomKeyButton": "Ajouter touche", "addCustomKeyButton": "Ajouter touche",
"emptyCombos": "Aucun combo enregistré.", "emptyCombos": "Aucun combo enregistré.",
"emptyCategory": "Aucun combo dans cette catégorie.",
"emptySequence": "Combo sans touches", "emptySequence": "Combo sans touches",
"showCategoryTitle": "Afficher la catégorie", "showCategoryTitle": "Afficher la catégorie",
"hideCategoryTitle": "Réduire la catégorie", "hideCategoryTitle": "Réduire la catégorie",

View file

@ -3,6 +3,10 @@ import { useEffect, useMemo, useState } from "react";
import { Icon } from "../../../components/Icon.jsx"; import { Icon } from "../../../components/Icon.jsx";
import { usePointerReorder } from "../../../hooks/usePointerReorder.js"; import { usePointerReorder } from "../../../hooks/usePointerReorder.js";
const UNCATEGORIZED_DROP_ID = "combos:uncategorized";
const CATEGORY_SECTION_PREFIX = "combos:category";
const CATEGORY_BOUNDARY_DROP_PREFIX = "combos:category-boundary";
const DEVICE_OPTIONS = [ const DEVICE_OPTIONS = [
{ value: "playstation", label: "PlayStation" }, { value: "playstation", label: "PlayStation" },
{ value: "xbox", label: "Xbox" }, { value: "xbox", label: "Xbox" },
@ -105,13 +109,15 @@ function createInput(kind, value) {
return { kind, value }; return { kind, value };
} }
function createCombo(context, id, name, inputs, fallbackName, device) { function createCombo(context, id, name, category, inputs, fallbackName, device) {
return { const combo = {
id: id || context.uid("combo"), id: id || context.uid("combo"),
name: name.trim() || fallbackName, name: name.trim() || fallbackName,
device, device,
inputs: inputs.map((step) => step.map((input) => ({ ...input }))).filter((step) => step.length) inputs: inputs.map((step) => step.map((input) => ({ ...input }))).filter((step) => step.length)
}; };
if (category.trim()) combo.category = category.trim();
return combo;
} }
function normalizeCustomKey(value) { function normalizeCustomKey(value) {
@ -132,43 +138,361 @@ function getPaletteColumns(groups) {
]; ];
} }
function moveArrayItem(items, fromId, toId, placement, getId = (item) => item.id) { function getComboCategory(combo) {
if (!fromId || !toId || fromId === toId) return items; return String(combo?.category || "").trim();
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) { function getCategoryGroups(combos, categoryOrder) {
let movedCombo = null; const categories = [];
const combos = data.combos.filter((combo) => { combos.forEach((combo) => {
if (combo.id !== comboId) return true; const category = getComboCategory(combo);
movedCombo = combo; if (category && !categories.includes(category)) categories.push(category);
return false;
}); });
const categories = data.categories.map((category) => ({ const orderedCategories = [
...category, ...categoryOrder.filter((category) => categories.includes(category)),
combos: category.combos.filter((combo) => { ...categories.filter((category) => !categoryOrder.includes(category))
if (combo.id !== comboId) return true; ];
movedCombo = combo; const groups = orderedCategories.map((category) => ({
return false; category,
}) combos: combos.filter((combo) => getComboCategory(combo) === category)
})); })).filter((group) => group.combos.length);
return { movedCombo, data: { ...data, combos, categories } }; const renderedCategories = new Set();
return combos.map((combo) => {
const category = getComboCategory(combo);
if (!category) return { type: "combo", combo };
if (renderedCategories.has(category)) return null;
renderedCategories.add(category);
return { type: "category", group: groups.find((group) => group.category === category) };
}).filter(Boolean);
} }
function insertComboInList(list, combo, targetComboId = "", placement = "after") { function getCategoryComboIds(combos, category) {
if (!targetComboId) return [...list, combo]; return combos.filter((combo) => getComboCategory(combo) === category).map((combo) => combo.id);
const targetIndex = list.findIndex((item) => item.id === targetComboId); }
if (targetIndex < 0) return [...list, combo];
const nextList = [...list]; function getAllCategories(combos) {
nextList.splice(placement === "after" ? targetIndex + 1 : targetIndex, 0, combo); const categories = [];
return nextList; combos.forEach((combo) => {
const category = getComboCategory(combo);
if (category && !categories.includes(category)) categories.push(category);
});
return categories;
}
function getCompleteCategoryOrder(categoryOrder, categories) {
return [
...categoryOrder.filter((category) => categories.includes(category)),
...categories.filter((category) => !categoryOrder.includes(category))
];
}
function getCategorySectionId(category) {
return `${CATEGORY_SECTION_PREFIX}\n${category}`;
}
function parseCategorySectionId(id) {
const parts = String(id || "").split("\n");
if (parts[0] !== CATEGORY_SECTION_PREFIX) return "";
return parts.slice(1).join("\n");
}
function isCategorySectionId(id) {
return String(id || "").startsWith(`${CATEGORY_SECTION_PREFIX}\n`);
}
function getCategoryBoundaryDropId(category, placement) {
return `${CATEGORY_BOUNDARY_DROP_PREFIX}\n${category}\n${placement}`;
}
function parseCategoryBoundaryDropId(id) {
const parts = String(id || "").split("\n");
if (parts[0] !== CATEGORY_BOUNDARY_DROP_PREFIX) return { category: "", placement: "" };
return {
category: parts[1] || "",
placement: parts[2] === "after" ? "after" : "before"
};
}
function isCategoryBoundaryDropId(id) {
return String(id || "").startsWith(`${CATEGORY_BOUNDARY_DROP_PREFIX}\n`);
}
function isUncategorizedDropId(id) {
return id === UNCATEGORIZED_DROP_ID;
}
function moveComboGroup(combos, movingIds, targetComboId, placement = "before") {
const movingSet = new Set(movingIds);
if (!movingSet.size || movingSet.has(targetComboId)) return combos;
const movingCombos = combos.filter((combo) => movingSet.has(combo.id));
const remainingCombos = combos.filter((combo) => !movingSet.has(combo.id));
const targetIndex = remainingCombos.findIndex((combo) => combo.id === targetComboId);
if (targetIndex < 0) return combos;
remainingCombos.splice(placement === "after" ? targetIndex + 1 : targetIndex, 0, ...movingCombos);
return remainingCombos;
}
function getBoundaryComboId(combos, comboIds, placement) {
const ids = new Set(comboIds);
const orderedCombos = combos.filter((combo) => ids.has(combo.id));
return placement === "after" ? orderedCombos.at(-1)?.id || "" : orderedCombos[0]?.id || "";
}
function moveCategoryOrder(categoryOrder, categories, fromCategory, toCategory, placement = "before") {
const nextOrder = getCompleteCategoryOrder(categoryOrder, categories);
const index = nextOrder.indexOf(fromCategory);
const targetIndex = nextOrder.indexOf(toCategory);
if (index < 0 || targetIndex < 0 || fromCategory === toCategory) return nextOrder;
const [moved] = nextOrder.splice(index, 1);
const nextTargetIndex = nextOrder.indexOf(toCategory);
nextOrder.splice(placement === "after" ? nextTargetIndex + 1 : nextTargetIndex, 0, moved);
return nextOrder;
}
function moveCategoryOrderToStart(categoryOrder, categories, fromCategory) {
const nextOrder = getCompleteCategoryOrder(categoryOrder, categories).filter((category) => category !== fromCategory);
return [fromCategory, ...nextOrder];
}
function moveCategoryOrderToEnd(categoryOrder, categories, fromCategory) {
const nextOrder = getCompleteCategoryOrder(categoryOrder, categories).filter((category) => category !== fromCategory);
return [...nextOrder, fromCategory];
}
function moveComboToCategory(combos, comboId, category) {
return combos.map((combo) => {
if (combo.id !== comboId) return combo;
const nextCombo = { ...combo };
if (category) nextCombo.category = category;
else delete nextCombo.category;
return nextCombo;
});
}
function getFirstComboIdOutsideCategory(combos, category, excludedIds = []) {
const excluded = new Set(excludedIds);
return combos.find((combo) => !excluded.has(combo.id) && getComboCategory(combo) !== category)?.id || "";
}
function getLastComboIdOutsideCategory(combos, category, excludedIds = []) {
const excluded = new Set(excludedIds);
return [...combos].reverse().find((combo) => !excluded.has(combo.id) && getComboCategory(combo) !== category)?.id || "";
}
function insertCategoryGroupAtBoundary(data, fromCategory, boundary, movingIds) {
const allCategories = getAllCategories(data.combos);
const targetIds = getCategoryComboIds(data.combos, boundary.category);
const boundaryComboId = getBoundaryComboId(data.combos, targetIds, boundary.placement);
if (!boundaryComboId) return data;
return {
...data,
combos: moveComboGroup(data.combos, movingIds, boundaryComboId, boundary.placement),
categoryOrder: moveCategoryOrder(data.categoryOrder, allCategories, fromCategory, boundary.category, boundary.placement)
};
}
function moveCategoryGroupToEdge(data, fromCategory, edge) {
const movingIds = getCategoryComboIds(data.combos, fromCategory);
if (!movingIds.length) return data;
const allCategories = getAllCategories(data.combos);
const boundaryComboId = edge === "start"
? getFirstComboIdOutsideCategory(data.combos, fromCategory, movingIds)
: getLastComboIdOutsideCategory(data.combos, fromCategory, movingIds);
const combos = boundaryComboId
? moveComboGroup(data.combos, movingIds, boundaryComboId, edge === "start" ? "before" : "after")
: data.combos;
return {
...data,
combos,
categoryOrder: edge === "start"
? moveCategoryOrderToStart(data.categoryOrder, allCategories, fromCategory)
: moveCategoryOrderToEnd(data.categoryOrder, allCategories, fromCategory)
};
}
function moveComboToEdge(data, comboId, edge, category = "") {
const nextCombosWithCategory = moveComboToCategory(data.combos, comboId, category);
const boundaryComboId = edge === "start"
? nextCombosWithCategory.find((combo) => combo.id !== comboId)?.id || ""
: [...nextCombosWithCategory].reverse().find((combo) => combo.id !== comboId)?.id || "";
const combos = boundaryComboId
? moveComboGroup(nextCombosWithCategory, [comboId], boundaryComboId, edge === "start" ? "before" : "after")
: nextCombosWithCategory;
return { ...data, combos };
}
function moveComboBeforeCategoryBoundary(data, comboId, boundary) {
const nextCombosWithCategory = moveComboToCategory(data.combos, comboId, "");
const targetIds = getCategoryComboIds(data.combos, boundary.category).filter((id) => id !== comboId);
const boundaryComboId = getBoundaryComboId(data.combos, targetIds, boundary.placement);
return {
...data,
combos: boundaryComboId ? moveComboGroup(nextCombosWithCategory, [comboId], boundaryComboId, boundary.placement) : nextCombosWithCategory
};
}
function addCategoryToOrder(categoryOrder, category) {
return category && !categoryOrder.includes(category) ? [...categoryOrder, category] : categoryOrder;
}
function cleanCategoryOrder(categoryOrder, combos) {
const categories = getAllCategories(combos);
return getCompleteCategoryOrder(categoryOrder, categories);
}
function getCategoryEdge(categoryDropTarget, comboDropTarget, category, edge) {
const dropId = getCategoryBoundaryDropId(category, edge === "start" ? "before" : "after");
return categoryDropTarget.id === dropId || comboDropTarget.id === dropId;
}
function getDropEdgeClass(categoryDropTarget, comboDropTarget, category, edge) {
return getCategoryEdge(categoryDropTarget, comboDropTarget, category, edge) ? "is-drop-target" : "";
}
function getEdgeFromPlacement(placement) {
return placement === "after" ? "end" : "start";
}
function insertComboIntoCategory(data, comboId, category, placement) {
const nextCombos = moveComboToCategory(data.combos, comboId, category);
const targetIds = getCategoryComboIds(data.combos, category).filter((id) => id !== comboId);
const boundaryComboId = getBoundaryComboId(data.combos, targetIds, placement);
return {
...data,
combos: boundaryComboId ? moveComboGroup(nextCombos, [comboId], boundaryComboId, placement) : nextCombos,
categoryOrder: addCategoryToOrder(data.categoryOrder, category),
collapsedCategories: data.collapsedCategories.filter((item) => item !== category)
};
}
function moveCategoryGroupAroundCombo(data, fromCategory, targetComboId, placement, movingIds) {
return {
...data,
combos: moveComboGroup(data.combos, movingIds, targetComboId, placement)
};
}
function getCategoryOrderAfterComboMove(categoryOrder, combos) {
return cleanCategoryOrder(categoryOrder, combos);
}
function moveComboAroundCombo(data, fromComboId, targetComboId, placement) {
const targetCombo = data.combos.find((combo) => combo.id === targetComboId);
if (!targetCombo) return data;
const targetCategory = getComboCategory(targetCombo);
const nextCombos = moveComboToCategory(data.combos, fromComboId, targetCategory);
const movedCombos = moveComboGroup(nextCombos, [fromComboId], targetComboId, placement);
return {
...data,
combos: movedCombos,
categoryOrder: getCategoryOrderAfterComboMove(data.categoryOrder, movedCombos)
};
}
function isBoundaryEdgeTarget(toId, edge) {
if (!isCategoryBoundaryDropId(toId)) return false;
return parseCategoryBoundaryDropId(toId).placement === (edge === "start" ? "before" : "after");
}
function moveCategoryBoundary(data, fromCategory, toId) {
if (isUncategorizedDropId(toId)) return moveCategoryGroupToEdge(data, fromCategory, "start");
if (!isCategoryBoundaryDropId(toId)) return data;
const boundary = parseCategoryBoundaryDropId(toId);
if (!boundary.category || boundary.category === fromCategory) return data;
if (isBoundaryEdgeTarget(toId, "start") && getCompleteCategoryOrder(data.categoryOrder, getAllCategories(data.combos))[0] === boundary.category) {
return moveCategoryGroupToEdge(data, fromCategory, "start");
}
if (isBoundaryEdgeTarget(toId, "end")) {
const order = getCompleteCategoryOrder(data.categoryOrder, getAllCategories(data.combos));
if (order.at(-1) === boundary.category) return moveCategoryGroupToEdge(data, fromCategory, "end");
}
const movingIds = getCategoryComboIds(data.combos, fromCategory);
return insertCategoryGroupAtBoundary(data, fromCategory, boundary, movingIds);
}
function moveCategoryOrderAroundTarget(categoryOrder, combos, fromCategory, toCategory, placement) {
return moveCategoryOrder(categoryOrder, getAllCategories(combos), fromCategory, toCategory, placement);
}
function moveCategoryAroundCategory(data, fromCategory, toCategory, placement) {
const movingIds = getCategoryComboIds(data.combos, fromCategory);
const targetIds = getCategoryComboIds(data.combos, toCategory);
const boundaryComboId = getBoundaryComboId(data.combos, targetIds, placement);
if (!boundaryComboId) return data;
return {
...data,
combos: moveComboGroup(data.combos, movingIds, boundaryComboId, placement),
categoryOrder: moveCategoryOrderAroundTarget(data.categoryOrder, data.combos, fromCategory, toCategory, placement)
};
}
function removeComboCategoryAtBoundary(data, comboId, boundary) {
const nextData = moveComboBeforeCategoryBoundary(data, comboId, boundary);
return {
...nextData,
categoryOrder: cleanCategoryOrder(nextData.categoryOrder, nextData.combos)
};
}
function moveComboByBoundary(data, fromComboId, toId) {
const boundary = parseCategoryBoundaryDropId(toId);
if (!boundary.category) return data;
return removeComboCategoryAtBoundary(data, fromComboId, boundary);
}
function removeComboCategory(data, comboId, placement) {
return moveComboToEdge(data, comboId, getEdgeFromPlacement(placement), "");
}
function insertAfterCategory(data, comboId, category, placement) {
return insertComboIntoCategory(data, comboId, category, placement);
}
function getCategoryTargetId(target) {
if (target.dataset.dropId) return target.dataset.dropId;
if (target.classList.contains("combos-category")) return getCategorySectionId(target.dataset.category || "");
return target.dataset.comboId || "";
}
function getComboTargetId(target) {
if (target.dataset.dropId) return target.dataset.dropId;
if (target.classList.contains("combo-card")) return target.dataset.comboId || "";
return getCategorySectionId(target.dataset.category || "");
}
function canDropComboOnTarget(target, draggingId, combos) {
const draggingCombo = combos.find((combo) => combo.id === draggingId);
if (!draggingCombo) return false;
const draggingCategory = getComboCategory(draggingCombo);
if (target.classList.contains("combos-root-drop-zone")) return Boolean(draggingCategory);
if (target.classList.contains("combos-category-boundary-drop-zone")) return true;
if (target.classList.contains("combos-category")) return target.dataset.category !== draggingCategory;
if (target.classList.contains("combo-card")) return target.dataset.comboId !== draggingId;
return false;
}
function canDropCategoryOnTarget(target, draggingId) {
const draggingCategory = parseCategorySectionId(draggingId);
if (!draggingCategory) return false;
if (target.classList.contains("combos-root-drop-zone")) return true;
if (target.classList.contains("combos-category-boundary-drop-zone")) {
const boundary = parseCategoryBoundaryDropId(target.dataset.dropId);
return boundary.category && boundary.category !== draggingCategory;
}
if (target.classList.contains("combos-category")) return target.dataset.category && target.dataset.category !== draggingCategory;
return !target.dataset.comboCategory;
}
function getCategoryDropPlacement(event, target) {
if (target.classList.contains("combos-category-boundary-drop-zone") || target.classList.contains("combos-root-drop-zone")) return "before";
const rect = target.getBoundingClientRect();
return event.clientY > rect.top + rect.height / 2 ? "after" : "before";
}
function getComboDropPlacement(event, target) {
if (target.classList.contains("combos-category-boundary-drop-zone") || target.classList.contains("combos-root-drop-zone")) return "before";
const rect = target.getBoundingClientRect();
return event.clientY > rect.top + rect.height / 2 ? "after" : "before";
} }
function ComboInputToken({ input, device, palette = false, onClick, onDragStart }) { function ComboInputToken({ input, device, palette = false, onClick, onDragStart }) {
@ -292,7 +616,7 @@ function InlineTextInput({ value, onCommit, className, ariaLabel, placeholder =
} }
export function CombosModule({ toolboxId, moduleId, context, editing }) { export function CombosModule({ toolboxId, moduleId, context, editing }) {
const data = context.normalizeCombosData(context.getModuleData(toolboxId, moduleId, { combos: [], categories: [] })); const data = context.normalizeCombosData(context.getModuleData(toolboxId, moduleId, { combos: [] }));
const textContent = context.moduleText?.combos || {}; const textContent = context.moduleText?.combos || {};
const [comboCategory, setComboCategory] = useState(""); const [comboCategory, setComboCategory] = useState("");
const [comboName, setComboName] = useState(""); const [comboName, setComboName] = useState("");
@ -305,13 +629,16 @@ export function CombosModule({ toolboxId, moduleId, context, editing }) {
const paletteGroups = useMemo(() => getPaletteGroups(data.device, keyboardLayout), [data.device, keyboardLayout]); const paletteGroups = useMemo(() => getPaletteGroups(data.device, keyboardLayout), [data.device, keyboardLayout]);
const paletteColumns = useMemo(() => getPaletteColumns(paletteGroups), [paletteGroups]); const paletteColumns = useMemo(() => getPaletteColumns(paletteGroups), [paletteGroups]);
const cleanDraftInputs = draftInputs.map((step) => step.filter(Boolean)).filter((step) => step.length); const cleanDraftInputs = draftInputs.map((step) => step.filter(Boolean)).filter((step) => step.length);
const entries = getCategoryGroups(data.combos, data.categoryOrder);
const { const {
draggingId: draggingCategoryId, draggingId: draggingCategoryId,
dropTarget: categoryDropTarget, dropTarget: categoryDropTarget,
startDrag: startCategoryDrag startDrag: startCategoryDrag
} = usePointerReorder({ } = usePointerReorder({
targetSelector: ".combos-category", targetSelector: ".combos-category, .combo-card, .combos-root-drop-zone, .combos-category-boundary-drop-zone",
getTargetId: (target) => target.dataset.categoryId, getTargetId: getCategoryTargetId,
canDropOn: canDropCategoryOnTarget,
getPlacement: getCategoryDropPlacement,
onMove: moveCategory onMove: moveCategory
}); });
const { const {
@ -319,17 +646,10 @@ export function CombosModule({ toolboxId, moduleId, context, editing }) {
dropTarget: comboDropTarget, dropTarget: comboDropTarget,
startDrag: startComboDrag startDrag: startComboDrag
} = usePointerReorder({ } = usePointerReorder({
targetSelector: ".combo-card, .combos-root-drop-zone, .combos-category", targetSelector: ".combo-card, .combos-category, .combos-root-drop-zone, .combos-category-boundary-drop-zone",
getTargetId: (target) => { getTargetId: getComboTargetId,
if (target.classList.contains("combo-card")) return `combo:${target.dataset.comboId}`; canDropOn: (target, draggingId) => canDropComboOnTarget(target, draggingId, data.combos),
if (target.classList.contains("combos-category")) return `category:${target.dataset.categoryId}`; getPlacement: getComboDropPlacement,
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 onMove: moveCombo
}); });
@ -342,64 +662,43 @@ export function CombosModule({ toolboxId, moduleId, context, editing }) {
save({ save({
...data, ...data,
device, device,
combos: data.combos.map(stampComboDevice), combos: data.combos.map(stampComboDevice)
categories: data.categories.map((category) => ({
...category,
combos: category.combos.map(stampComboDevice)
}))
}); });
} }
function moveCategory(fromCategoryId, toCategoryId, placement) { function moveCategory(fromId, toId, placement) {
save({ ...data, categories: moveArrayItem(data.categories, fromCategoryId, toCategoryId, placement) }); const fromCategory = parseCategorySectionId(fromId);
const movingIds = getCategoryComboIds(data.combos, fromCategory);
if (!movingIds.length) return;
if (isUncategorizedDropId(toId) || isCategoryBoundaryDropId(toId)) {
save(moveCategoryBoundary(data, fromCategory, toId));
return;
}
if (isCategorySectionId(toId)) {
save(moveCategoryAroundCategory(data, fromCategory, parseCategorySectionId(toId), placement));
return;
}
if (data.combos.some((combo) => combo.id === toId)) {
save(moveCategoryGroupAroundCombo(data, fromCategory, toId, placement, movingIds));
}
} }
function moveCombo(fromComboId, targetId, placement) { function moveCombo(fromComboId, targetId, placement) {
const { movedCombo, data: dataWithoutCombo } = removeComboFromData(data, fromComboId); const movedCombo = data.combos.find((combo) => combo.id === fromComboId);
if (!movedCombo) return; if (!movedCombo) return;
if (targetId === "root") { if (isUncategorizedDropId(targetId)) {
save({ ...dataWithoutCombo, combos: [...dataWithoutCombo.combos, movedCombo] }); save(removeComboCategory(data, fromComboId, placement));
return; return;
} }
if (targetId.startsWith("category:")) { if (isCategoryBoundaryDropId(targetId)) {
const categoryId = targetId.replace("category:", ""); save(moveComboByBoundary(data, fromComboId, targetId));
save({
...dataWithoutCombo,
categories: dataWithoutCombo.categories.map((category) => category.id === categoryId
? { ...category, combos: [...category.combos, movedCombo], collapsed: false }
: category)
});
return; return;
} }
const targetComboId = targetId.replace("combo:", ""); if (isCategorySectionId(targetId)) {
const targetElement = document.querySelector(`[data-combo-id="${targetComboId}"]`); save(insertAfterCategory(data, fromComboId, parseCategorySectionId(targetId), placement));
const targetList = targetElement?.dataset.comboList || "";
if (!targetList) {
save({ ...dataWithoutCombo, combos: insertComboInList(dataWithoutCombo.combos, movedCombo, targetComboId, placement) });
return; return;
} }
save({ save(moveComboAroundCombo(data, fromComboId, targetId, placement));
...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) { function addInputToStep(kind, value, stepIndex) {
@ -534,26 +833,17 @@ export function CombosModule({ toolboxId, moduleId, context, editing }) {
function saveCombo(event) { function saveCombo(event) {
event.preventDefault(); event.preventDefault();
if (!comboName.trim() && !cleanDraftInputs.length) return; if (!comboName.trim() && !cleanDraftInputs.length) return;
const combo = createCombo(context, editingComboId, comboName, cleanDraftInputs, textContent.defaultComboName || "Combo", data.device); const combo = createCombo(context, editingComboId, comboName, comboCategory, cleanDraftInputs, textContent.defaultComboName || "Combo", data.device);
const nextRootCombos = data.combos.filter((item) => item.id !== editingComboId); const nextCombos = data.combos.filter((item) => item.id !== editingComboId);
const nextCategories = data.categories.map((category) => ({ const nextCategoryOrder = combo.category && !data.categoryOrder.includes(combo.category)
...category, ? [...data.categoryOrder, combo.category]
combos: category.combos.filter((item) => item.id !== editingComboId) : data.categoryOrder;
})); save({ ...data, combos: [...nextCombos, combo], categoryOrder: nextCategoryOrder });
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(); cancelComboEdit();
} }
function startComboEdit(categoryId, combo) { function startComboEdit(combo) {
setComboCategory(data.categories.find((category) => category.id === categoryId)?.title || ""); setComboCategory(getComboCategory(combo));
setEditingComboId(combo.id); setEditingComboId(combo.id);
setComboName(combo.name); setComboName(combo.name);
setDraftInputs(combo.inputs.length ? combo.inputs.map((step) => step.map((input) => ({ ...input }))) : [[]]); setDraftInputs(combo.inputs.length ? combo.inputs.map((step) => step.map((input) => ({ ...input }))) : [[]]);
@ -569,32 +859,33 @@ export function CombosModule({ toolboxId, moduleId, context, editing }) {
setSimultaneousMode(false); setSimultaneousMode(false);
} }
function updateCombo(categoryId, comboId, updater) { function updateCombo(comboId, updater) {
if (!categoryId) {
save({ ...data, combos: data.combos.map((combo) => combo.id === comboId ? updater(combo) : combo).filter((combo) => combo.name || combo.inputs.length) }); 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) { function deleteCombo(comboId) {
updateCombo(categoryId, comboId, () => ({ name: "", inputs: [] })); updateCombo(comboId, () => ({ name: "", inputs: [] }));
if (editingComboId === comboId) cancelComboEdit(); if (editingComboId === comboId) cancelComboEdit();
} }
function renderCombo(combo, categoryId = "") { function toggleCategoryCollapsed(category) {
const collapsedCategories = data.collapsedCategories.includes(category)
? data.collapsedCategories.filter((item) => item !== category)
: [...data.collapsedCategories, category];
save({ ...data, collapsedCategories });
}
function renderCombo(combo) {
const category = getComboCategory(combo);
const comboClassName = [ const comboClassName = [
"combo-card", "combo-card",
"is-editing", "is-editing",
draggingComboId === combo.id ? "is-dragging" : "", draggingComboId === combo.id ? "is-dragging" : "",
comboDropTarget.id === `combo:${combo.id}` ? "is-drop-target" : "", comboDropTarget.id === combo.id || categoryDropTarget.id === combo.id ? "is-drop-target" : "",
comboDropTarget.id === `combo:${combo.id}` && comboDropTarget.placement === "after" ? "drop-after" : "" (comboDropTarget.id === combo.id && comboDropTarget.placement === "after") || (categoryDropTarget.id === combo.id && categoryDropTarget.placement === "after") ? "drop-after" : ""
].filter(Boolean).join(" "); ].filter(Boolean).join(" ");
return ( return (
<article className={comboClassName} key={combo.id} data-combo-id={combo.id} data-combo-list={categoryId}> <article className={comboClassName} key={combo.id} data-combo-id={combo.id} data-combo-category={category}>
<button <button
className="task-planner-drag-handle combo-drag-handle" className="task-planner-drag-handle combo-drag-handle"
type="button" type="button"
@ -608,16 +899,16 @@ export function CombosModule({ toolboxId, moduleId, context, editing }) {
<InlineTextInput <InlineTextInput
className="tool-split-entry-label combo-title-input" className="tool-split-entry-label combo-title-input"
value={combo.name} value={combo.name}
onCommit={(name) => updateCombo(categoryId, combo.id, (current) => ({ ...current, name: name || current.name }))} onCommit={(name) => updateCombo(combo.id, (current) => ({ ...current, name: name || current.name }))}
ariaLabel={textContent.comboNameLabel || "Nom"} ariaLabel={textContent.comboNameLabel || "Nom"}
/> />
<ComboSequence inputs={combo.inputs} device={combo.device || data.device} emptyLabel={textContent.emptySequence || "Combo sans touches"} /> <ComboSequence inputs={combo.inputs} device={combo.device || data.device} emptyLabel={textContent.emptySequence || "Combo sans touches"} />
</div> </div>
<div className="combo-card-actions"> <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"}> <button type="button" className="checklist-delete-button" onClick={() => startComboEdit(combo)} aria-label={`${textContent.editComboTitle || "Modifier le combo"} ${combo.name}`} title={textContent.editComboTitle || "Modifier le combo"}>
<Icon name="edit" /> <Icon name="edit" />
</button> </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"}> <button type="button" className="checklist-delete-button danger" onClick={() => deleteCombo(combo.id)} aria-label={`${textContent.deleteComboTitle || "Supprimer le combo"} ${combo.name}`} title={textContent.deleteComboTitle || "Supprimer le combo"}>
<Icon name="trash" /> <Icon name="trash" />
</button> </button>
</div> </div>
@ -625,50 +916,82 @@ export function CombosModule({ toolboxId, moduleId, context, editing }) {
); );
} }
function renderCategory(category) { function renderCategory(group) {
const category = group.category;
const categoryId = getCategorySectionId(category);
const beforeDropId = getCategoryBoundaryDropId(category, "before");
const afterDropId = getCategoryBoundaryDropId(category, "after");
const isCollapsed = data.collapsedCategories.includes(category);
const categoryClassName = [ const categoryClassName = [
"combos-category checklist-section is-grouped", "combos-category checklist-section is-grouped",
category.collapsed ? "is-collapsed" : "", isCollapsed ? "is-collapsed" : "",
draggingCategoryId === category.id ? "is-dragging" : "", draggingCategoryId === categoryId ? "is-dragging" : "",
categoryDropTarget.id === category.id || comboDropTarget.id === `category:${category.id}` ? "is-drop-target" : "", categoryDropTarget.id === categoryId || comboDropTarget.id === categoryId ? "is-drop-target" : "",
(categoryDropTarget.id === category.id && categoryDropTarget.placement === "after") ? "drop-after" : "" (categoryDropTarget.id === categoryId && categoryDropTarget.placement === "after") || (comboDropTarget.id === categoryId && comboDropTarget.placement === "after") ? "drop-after" : ""
].filter(Boolean).join(" "); ].filter(Boolean).join(" ");
return ( const showBoundaryDropZones = Boolean(draggingComboId || draggingCategoryId);
<section className={categoryClassName} key={category.id} data-category-id={category.id}> const beforeDropZone = showBoundaryDropZones ? (
<div
className={`combos-category-boundary-drop-zone ${getDropEdgeClass(categoryDropTarget, comboDropTarget, category, "start")}`}
key={`${category}:before-drop`}
data-drop-id={beforeDropId}
/>
) : null;
const afterDropZone = showBoundaryDropZones ? (
<div
className={`combos-category-boundary-drop-zone ${getDropEdgeClass(categoryDropTarget, comboDropTarget, category, "end")}`}
key={`${category}:after-drop`}
data-drop-id={afterDropId}
/>
) : null;
return [
beforeDropZone,
<section className={categoryClassName} key={category} data-category={category}>
<div className="checklist-section-header combos-category-header"> <div className="checklist-section-header combos-category-header">
<div className="combos-category-title"> <div className="combos-category-title">
<button <button
className="task-planner-category-drag-handle" className="task-planner-category-drag-handle"
type="button" type="button"
onPointerDown={(event) => startCategoryDrag(event, category.id)} onPointerDown={(event) => startCategoryDrag(event, categoryId)}
aria-label={`${textContent.reorderCategoryTitle || "Déplacer la catégorie"} ${category.title}`} aria-label={`${textContent.reorderCategoryTitle || "Déplacer la catégorie"} ${category}`}
title={textContent.reorderCategoryTitle || "Déplacer la catégorie"} title={textContent.reorderCategoryTitle || "Déplacer la catégorie"}
> >
<Icon name="drag" /> <Icon name="drag" />
</button> </button>
<h3>{category.title}</h3> <h3>{category}</h3>
</div> </div>
<div> <div>
<span>{category.combos.length}</span> <span>{group.combos.length}</span>
<button <button
className="checklist-section-collapse-button" className="checklist-section-collapse-button"
type="button" type="button"
onClick={() => updateCategory(category.id, (item) => ({ ...item, collapsed: !item.collapsed }))} onClick={() => toggleCategoryCollapsed(category)}
aria-expanded={!category.collapsed} aria-expanded={!isCollapsed}
aria-label={category.collapsed ? textContent.showCategoryTitle || "Afficher la catégorie" : textContent.hideCategoryTitle || "Réduire la catégorie"} aria-label={isCollapsed ? 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"} title={isCollapsed ? textContent.showCategoryTitle || "Afficher la catégorie" : textContent.hideCategoryTitle || "Réduire la catégorie"}
> >
<Icon name={category.collapsed ? "chevron-down" : "chevron-up"} /> <Icon name={isCollapsed ? "chevron-down" : "chevron-up"} />
</button> </button>
</div> </div>
</div> </div>
{!category.collapsed && ( {!isCollapsed && (
<div className="combos-list"> <div className="combos-list">
{category.combos.map((combo) => renderCombo(combo, category.id))} {group.combos.map((combo) => renderCombo(combo))}
{!category.combos.length && <p className="empty-state">{textContent.emptyCategory || "Aucun combo dans cette catégorie."}</p>}
</div> </div>
)} )}
</section> </section>,
afterDropZone
];
}
function renderUncategorizedDropZone() {
const draggingCombo = draggingComboId ? data.combos.find((combo) => combo.id === draggingComboId) : null;
const draggingCategory = parseCategorySectionId(draggingCategoryId);
if ((!draggingCombo || !getComboCategory(draggingCombo)) && !draggingCategory) return null;
return (
<div className={`combos-root-drop-zone is-visible ${comboDropTarget.id === UNCATEGORIZED_DROP_ID || categoryDropTarget.id === UNCATEGORIZED_DROP_ID ? "is-drop-target" : ""}`} data-drop-id={UNCATEGORIZED_DROP_ID}>
{textContent.noCategoryLabel || "Sans catégorie"}
</div>
); );
} }
@ -787,20 +1110,13 @@ export function CombosModule({ toolboxId, moduleId, context, editing }) {
</div> </div>
)} )}
{!data.combos.length && !data.categories.length && ( {!data.combos.length && (
<p className="empty-state">{textContent.emptyCombos || "Aucun combo enregistré."}</p> <p className="empty-state">{textContent.emptyCombos || "Aucun combo enregistré."}</p>
)} )}
<div className="combos-category-list"> <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"> {renderUncategorizedDropZone()}
{textContent.noCategoryLabel || "Sans catégorie"} {entries.map((entry) => entry.type === "category" ? renderCategory(entry.group) : renderCombo(entry.combo))}
</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>
</div> </div>
); );

View file

@ -6,6 +6,7 @@ import { usePointerReorder } from "../../../hooks/usePointerReorder.js";
const TASK_TYPES = ["daily", "weekly", "unique"]; const TASK_TYPES = ["daily", "weekly", "unique"];
const UNCATEGORIZED_DROP_ID = "task-planner:uncategorized"; const UNCATEGORIZED_DROP_ID = "task-planner:uncategorized";
const CATEGORY_BOUNDARY_DROP_PREFIX = "task-planner:category-boundary";
const WEEK_DAYS = [ const WEEK_DAYS = [
{ value: 1, label: "Lundi" }, { value: 1, label: "Lundi" },
{ value: 2, label: "Mardi" }, { value: 2, label: "Mardi" },
@ -95,17 +96,38 @@ function getBoundaryTaskId(tasks, taskIds, placement) {
return placement === "after" ? orderedTasks.at(-1)?.id || "" : orderedTasks[0]?.id || ""; return placement === "after" ? orderedTasks.at(-1)?.id || "" : orderedTasks[0]?.id || "";
} }
function moveCategory(categoryOrder, fromCategory, toCategory, placement = "before") { function getRootCategories(tasks, parentMap) {
const index = categoryOrder.indexOf(fromCategory); const categories = [];
const targetIndex = categoryOrder.indexOf(toCategory); tasks.forEach((task) => {
if (index < 0 || targetIndex < 0 || fromCategory === toCategory) return categoryOrder; if (parentMap.get(task.id) || !getTaskCategory(task) || categories.includes(task.category)) return;
const nextOrder = [...categoryOrder]; categories.push(task.category);
});
return categories;
}
function getCompleteCategoryOrder(categoryOrder, categories) {
return [
...categoryOrder.filter((category) => categories.includes(category)),
...categories.filter((category) => !categoryOrder.includes(category))
];
}
function moveCategory(categoryOrder, categories, fromCategory, toCategory, placement = "before") {
const nextOrder = getCompleteCategoryOrder(categoryOrder, categories);
const index = nextOrder.indexOf(fromCategory);
const targetIndex = nextOrder.indexOf(toCategory);
if (index < 0 || targetIndex < 0 || fromCategory === toCategory) return nextOrder;
const [moved] = nextOrder.splice(index, 1); const [moved] = nextOrder.splice(index, 1);
const nextTargetIndex = nextOrder.indexOf(toCategory); const nextTargetIndex = nextOrder.indexOf(toCategory);
nextOrder.splice(placement === "after" ? nextTargetIndex + 1 : nextTargetIndex, 0, moved); nextOrder.splice(placement === "after" ? nextTargetIndex + 1 : nextTargetIndex, 0, moved);
return nextOrder; return nextOrder;
} }
function moveCategoryToStart(categoryOrder, categories, fromCategory) {
const nextOrder = getCompleteCategoryOrder(categoryOrder, categories).filter((category) => category !== fromCategory);
return [fromCategory, ...nextOrder.filter(Boolean)];
}
function getTaskTitle(tasks, taskId) { function getTaskTitle(tasks, taskId) {
return tasks.find((task) => task.id === taskId)?.title || "Tâche supprimée"; return tasks.find((task) => task.id === taskId)?.title || "Tâche supprimée";
} }
@ -257,12 +279,36 @@ function isUncategorizedDropId(id) {
return id === UNCATEGORIZED_DROP_ID; return id === UNCATEGORIZED_DROP_ID;
} }
function getCategoryBoundaryDropId(parentId, category, placement) {
return `${CATEGORY_BOUNDARY_DROP_PREFIX}\n${parentId || ""}\n${category}\n${placement}`;
}
function parseCategoryBoundaryDropId(id) {
const parts = String(id || "").split("\n");
if (parts[0] !== CATEGORY_BOUNDARY_DROP_PREFIX) return { parentId: "", category: "", placement: "" };
return {
parentId: parts[1] || "",
category: parts[2] || "",
placement: parts[3] === "after" ? "after" : "before"
};
}
function isCategoryBoundaryDropId(id) {
return String(id || "").startsWith(`${CATEGORY_BOUNDARY_DROP_PREFIX}\n`);
}
function getRootTaskIdsForCategory(tasks, parentMap, category) { function getRootTaskIdsForCategory(tasks, parentMap, category) {
return tasks return tasks
.filter((task) => !parentMap.get(task.id) && getTaskCategory(task) === category) .filter((task) => !parentMap.get(task.id) && getTaskCategory(task) === category)
.map((task) => task.id); .map((task) => task.id);
} }
function getRootTaskIds(tasks, parentMap) {
return tasks
.filter((task) => !parentMap.get(task.id))
.map((task) => task.id);
}
export function TaskPlannerModule({ toolboxId, moduleId, context, editing }) { export function TaskPlannerModule({ toolboxId, moduleId, context, editing }) {
const data = context.normalizeTaskPlannerData(context.getModuleData(toolboxId, moduleId, { tasks: [] })); const data = context.normalizeTaskPlannerData(context.getModuleData(toolboxId, moduleId, { tasks: [] }));
const textContent = context.moduleText?.taskPlanner || {}; const textContent = context.moduleText?.taskPlanner || {};
@ -280,13 +326,17 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing }) {
dropTarget, dropTarget,
startDrag startDrag
} = usePointerReorder({ } = usePointerReorder({
targetSelector: ".task-planner-item, .task-planner-category-section, .task-planner-uncategorized-drop-zone", targetSelector: ".task-planner-item, .task-planner-category-section, .task-planner-uncategorized-drop-zone, .task-planner-category-boundary-drop-zone",
getTargetId: (target) => target.dataset.dropId || target.dataset.taskId || getCategorySectionId(target.dataset.parentId || "", target.dataset.category || ""), getTargetId: (target) => target.dataset.dropId || target.dataset.taskId || getCategorySectionId(target.dataset.parentId || "", target.dataset.category || ""),
canDropOn: (target, draggingTaskId) => { canDropOn: (target, draggingTaskId) => {
const draggingTask = data.tasks.find((task) => task.id === draggingTaskId); const draggingTask = data.tasks.find((task) => task.id === draggingTaskId);
if (!draggingTask) return false; if (!draggingTask) return false;
const draggingParentId = parentMap.get(draggingTaskId) || ""; const draggingParentId = parentMap.get(draggingTaskId) || "";
const draggingCategory = getTaskCategoryDataset(draggingTask, data.tasks, parentMap); const draggingCategory = getTaskCategoryDataset(draggingTask, data.tasks, parentMap);
if (target.classList.contains("task-planner-category-boundary-drop-zone")) {
const boundary = parseCategoryBoundaryDropId(target.dataset.dropId);
return !draggingParentId && !boundary.parentId && Boolean(boundary.category);
}
if (target.classList.contains("task-planner-uncategorized-drop-zone")) { if (target.classList.contains("task-planner-uncategorized-drop-zone")) {
return !draggingParentId && Boolean(draggingCategory); return !draggingParentId && Boolean(draggingCategory);
} }
@ -321,8 +371,25 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing }) {
moveToCategory(""); moveToCategory("");
return; return;
} }
if (isCategoryBoundaryDropId(toId)) {
const boundary = parseCategoryBoundaryDropId(toId);
const targetIds = getRootTaskIdsForCategory(data.tasks, parentMap, boundary.category);
const boundaryTaskId = getBoundaryTaskId(data.tasks, targetIds, boundary.placement);
if (!boundaryTaskId) return;
const sourceCategory = getTaskCategoryDataset(fromTask, data.tasks, parentMap);
const tasks = sourceCategory ? setRootTaskCategory(data.tasks, "") : data.tasks;
save({ ...data, tasks: moveTask(tasks, fromTaskId, boundaryTaskId, boundary.placement) });
return;
}
if (isCategorySectionId(toId)) { if (isCategorySectionId(toId)) {
const targetCategory = parseCategorySectionId(toId).category; const targetCategory = parseCategorySectionId(toId).category;
const sourceCategory = getTaskCategoryDataset(fromTask, data.tasks, parentMap);
if (!sourceCategory) {
const targetIds = getRootTaskIdsForCategory(data.tasks, parentMap, targetCategory);
const boundaryTaskId = getBoundaryTaskId(data.tasks, targetIds, placement);
if (boundaryTaskId) save({ ...data, tasks: moveTask(data.tasks, fromTaskId, boundaryTaskId, placement) });
return;
}
moveToCategory(targetCategory); moveToCategory(targetCategory);
return; return;
} }
@ -339,11 +406,17 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing }) {
dropTarget: categoryDropTarget, dropTarget: categoryDropTarget,
startDrag: startCategoryDrag startDrag: startCategoryDrag
} = usePointerReorder({ } = usePointerReorder({
targetSelector: ".task-planner-category-section, .task-planner-item", targetSelector: ".task-planner-category-section, .task-planner-item, .task-planner-uncategorized-drop-zone, .task-planner-category-boundary-drop-zone",
getTargetId: (target) => target.dataset.taskId || getCategorySectionId(target.dataset.parentId || "", target.dataset.category || ""), getTargetId: (target) => target.dataset.dropId || target.dataset.taskId || getCategorySectionId(target.dataset.parentId || "", target.dataset.category || ""),
canDropOn: (target, draggingIdValue) => { canDropOn: (target, draggingIdValue) => {
const draggingCategory = parseCategorySectionId(draggingIdValue); const draggingCategory = parseCategorySectionId(draggingIdValue);
if (!draggingCategory.category || target.dataset.parentId !== draggingCategory.parentId) return false; if (!draggingCategory.category) return false;
if (target.classList.contains("task-planner-uncategorized-drop-zone")) return !draggingCategory.parentId;
if (target.classList.contains("task-planner-category-boundary-drop-zone")) {
const boundary = parseCategoryBoundaryDropId(target.dataset.dropId);
return boundary.parentId === draggingCategory.parentId && boundary.category && boundary.category !== draggingCategory.category;
}
if (target.dataset.parentId !== draggingCategory.parentId) return false;
if (target.classList.contains("task-planner-category-section")) return Boolean(target.dataset.category); if (target.classList.contains("task-planner-category-section")) return Boolean(target.dataset.category);
return !target.dataset.category; return !target.dataset.category;
}, },
@ -351,6 +424,35 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing }) {
const fromCategory = parseCategorySectionId(fromId).category; const fromCategory = parseCategorySectionId(fromId).category;
const movingIds = getRootTaskIdsForCategory(data.tasks, parentMap, fromCategory); const movingIds = getRootTaskIdsForCategory(data.tasks, parentMap, fromCategory);
if (!movingIds.length) return; if (!movingIds.length) return;
const rootCategories = getRootCategories(data.tasks, parentMap);
if (isUncategorizedDropId(toId)) {
const targetIds = data.tasks
.filter((task) => !parentMap.get(task.id) && !getTaskCategory(task) && !movingIds.includes(task.id))
.map((task) => task.id);
const rootIds = getRootTaskIds(data.tasks, parentMap).filter((taskId) => !movingIds.includes(taskId));
const boundaryTaskId = getBoundaryTaskId(data.tasks, targetIds, placement) || getBoundaryTaskId(data.tasks, rootIds, "before");
if (boundaryTaskId) {
save({
...data,
tasks: moveTaskGroup(data.tasks, movingIds, boundaryTaskId, "before"),
categoryOrder: moveCategoryToStart(data.categoryOrder, rootCategories, fromCategory)
});
}
return;
}
if (isCategoryBoundaryDropId(toId)) {
const boundary = parseCategoryBoundaryDropId(toId);
const targetIds = getRootTaskIdsForCategory(data.tasks, parentMap, boundary.category);
const boundaryTaskId = getBoundaryTaskId(data.tasks, targetIds, boundary.placement);
if (boundaryTaskId) {
save({
...data,
tasks: moveTaskGroup(data.tasks, movingIds, boundaryTaskId, boundary.placement),
categoryOrder: moveCategory(data.categoryOrder, rootCategories, fromCategory, boundary.category, boundary.placement)
});
}
return;
}
if (isCategorySectionId(toId)) { if (isCategorySectionId(toId)) {
const toCategory = parseCategorySectionId(toId).category; const toCategory = parseCategorySectionId(toId).category;
const targetIds = getRootTaskIdsForCategory(data.tasks, parentMap, toCategory); const targetIds = getRootTaskIdsForCategory(data.tasks, parentMap, toCategory);
@ -359,7 +461,7 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing }) {
save({ save({
...data, ...data,
tasks: moveTaskGroup(data.tasks, movingIds, boundaryTaskId, placement), tasks: moveTaskGroup(data.tasks, movingIds, boundaryTaskId, placement),
categoryOrder: moveCategory(data.categoryOrder, fromCategory, toCategory, placement) categoryOrder: moveCategory(data.categoryOrder, rootCategories, fromCategory, toCategory, placement)
}); });
} }
return; return;
@ -643,7 +745,15 @@ function TaskPlannerBranch({
if (!children.length) return null; if (!children.length) return null;
const nextVisited = new Set([...visited, ...children.map((task) => task.id)]); const nextVisited = new Set([...visited, ...children.map((task) => task.id)]);
const draggingTask = draggingId ? getTaskById(tasks, draggingId) : null; const draggingTask = draggingId ? getTaskById(tasks, draggingId) : null;
const showUncategorizedDropZone = !parentId && draggingTask && !parentMap.get(draggingTask.id) && Boolean(getTaskCategoryDataset(draggingTask, tasks, parentMap)); const draggingCategory = parseCategorySectionId(draggingCategoryId);
const showUncategorizedDropZone = !parentId && (
(draggingTask && !parentMap.get(draggingTask.id) && Boolean(getTaskCategoryDataset(draggingTask, tasks, parentMap)))
|| Boolean(draggingCategory.category && !draggingCategory.parentId)
);
const showCategoryBoundaryDropZones = !parentId && (
(draggingTask && !parentMap.get(draggingTask.id))
|| Boolean(draggingCategory.category && !draggingCategory.parentId)
);
function hasVisibleDescendants(taskId, seen = new Set()) { function hasVisibleDescendants(taskId, seen = new Set()) {
if (seen.has(taskId)) return false; if (seen.has(taskId)) return false;
@ -728,6 +838,8 @@ function TaskPlannerBranch({
const groupCount = countTaskTree(groupTasks.map((task) => task.id), tasks, parentMap); const groupCount = countTaskTree(groupTasks.map((task) => task.id), tasks, parentMap);
const groupCompletedCount = countCompletedTaskTree(groupTasks.map((task) => task.id), tasks, parentMap); const groupCompletedCount = countCompletedTaskTree(groupTasks.map((task) => task.id), tasks, parentMap);
const categoryId = getCategorySectionId(parentId, group.category); const categoryId = getCategorySectionId(parentId, group.category);
const beforeDropId = getCategoryBoundaryDropId(parentId, group.category, "before");
const afterDropId = getCategoryBoundaryDropId(parentId, group.category, "after");
const isCollapsed = data.collapsedCategories.includes(group.category); const isCollapsed = data.collapsedCategories.includes(group.category);
const sectionClassName = [ const sectionClassName = [
"task-planner-category-section", "task-planner-category-section",
@ -737,7 +849,22 @@ function TaskPlannerBranch({
categoryDropTarget.id === categoryId || dropTarget.id === categoryId ? "is-drop-target" : "", categoryDropTarget.id === categoryId || dropTarget.id === categoryId ? "is-drop-target" : "",
(categoryDropTarget.id === categoryId && categoryDropTarget.placement === "after") || (dropTarget.id === categoryId && dropTarget.placement === "after") ? "drop-after" : "" (categoryDropTarget.id === categoryId && categoryDropTarget.placement === "after") || (dropTarget.id === categoryId && dropTarget.placement === "after") ? "drop-after" : ""
].filter(Boolean).join(" "); ].filter(Boolean).join(" ");
return ( const beforeDropZone = showCategoryBoundaryDropZones ? (
<li
className={`task-planner-category-boundary-drop-zone ${dropTarget.id === beforeDropId || categoryDropTarget.id === beforeDropId ? "is-drop-target" : ""}`}
key={`${parentId || "root"}:${group.category}:before-drop`}
data-drop-id={beforeDropId}
/>
) : null;
const afterDropZone = showCategoryBoundaryDropZones ? (
<li
className={`task-planner-category-boundary-drop-zone ${dropTarget.id === afterDropId || categoryDropTarget.id === afterDropId ? "is-drop-target" : ""}`}
key={`${parentId || "root"}:${group.category}:after-drop`}
data-drop-id={afterDropId}
/>
) : null;
return [
beforeDropZone,
<li className={sectionClassName} key={`${parentId || "root"}:${group.category}`} data-parent-id={parentId} data-category={group.category}> <li className={sectionClassName} key={`${parentId || "root"}:${group.category}`} data-parent-id={parentId} data-category={group.category}>
<div className="checklist-section-header task-planner-category-header"> <div className="checklist-section-header task-planner-category-header">
<div className="task-planner-category-title"> <div className="task-planner-category-title">
@ -771,8 +898,9 @@ function TaskPlannerBranch({
{groupTasks.map(renderTask).filter(Boolean)} {groupTasks.map(renderTask).filter(Boolean)}
</ul> </ul>
)} )}
</li> </li>,
); afterDropZone
];
} }
const entries = groupedTasks.entries.map((entry) => { const entries = groupedTasks.entries.map((entry) => {

View file

@ -377,6 +377,7 @@ function normalizeComboSteps(inputs) {
function normalizeCombo(combo) { function normalizeCombo(combo) {
const name = String(combo?.name || "").trim().slice(0, 80); const name = String(combo?.name || "").trim().slice(0, 80);
const category = String(combo?.category || "").trim().slice(0, 80);
const device = COMBO_DEVICES.has(combo?.device) ? combo.device : ""; const device = COMBO_DEVICES.has(combo?.device) ? combo.device : "";
const inputs = normalizeComboSteps(combo?.inputs); const inputs = normalizeComboSteps(combo?.inputs);
if (!name && !inputs.length) return null; if (!name && !inputs.length) return null;
@ -385,27 +386,32 @@ function normalizeCombo(combo) {
name: name || "Combo", name: name || "Combo",
inputs inputs
}; };
if (category) normalized.category = category;
if (device) normalized.device = device; if (device) normalized.device = device;
return normalized; return normalized;
} }
function normalizeComboCategory(category) { function normalizeComboCategoryOrder(value, combos) {
const title = String(category?.title || "").trim().slice(0, 80); const categories = combos.map((combo) => combo.category).filter(Boolean);
const combos = (Array.isArray(category?.combos) ? category.combos : []).map(normalizeCombo).filter(Boolean); const orderedCategories = (Array.isArray(value) ? value : [])
if (!combos.length) return null; .map((category) => String(category || "").trim().slice(0, 80))
return { .filter((category, index, order) => category && categories.includes(category) && order.indexOf(category) === index);
id: category?.id || uid("section"), return [
title: title || "Catégorie", ...orderedCategories,
collapsed: category?.collapsed === true, ...categories.filter((category) => !orderedCategories.includes(category))
combos ];
};
} }
export function normalizeCombosData(data) { export function normalizeCombosData(data) {
const combos = (Array.isArray(data?.combos) ? data.combos : []).map(normalizeCombo).filter(Boolean);
const categoryOrder = normalizeComboCategoryOrder(data?.categoryOrder, combos);
return { return {
device: COMBO_DEVICES.has(data?.device) ? data.device : "playstation", device: COMBO_DEVICES.has(data?.device) ? data.device : "playstation",
combos: (Array.isArray(data?.combos) ? data.combos : []).map(normalizeCombo).filter(Boolean), combos,
categories: (Array.isArray(data?.categories) ? data.categories : []).map(normalizeComboCategory).filter(Boolean) collapsedCategories: (Array.isArray(data?.collapsedCategories) ? data.collapsedCategories : [])
.map((category) => String(category || "").trim().slice(0, 80))
.filter((category, index, categories) => category && categoryOrder.includes(category) && categories.indexOf(category) === index),
categoryOrder
}; };
} }
@ -759,31 +765,16 @@ export function compactModuleDataForStorage(type, value) {
name: combo.name, name: combo.name,
inputs: combo.inputs inputs: combo.inputs
}; };
if (combo.category) compactCombo.category = combo.category;
if (combo.device) compactCombo.device = combo.device; if (combo.device) compactCombo.device = combo.device;
return compactCombo; 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 (normalized.device !== "playstation") compact.device = normalized.device;
if (normalized.collapsedCategories.length) compact.collapsedCategories = normalized.collapsedCategories;
if (normalized.categoryOrder.length) compact.categoryOrder = normalized.categoryOrder;
if (!compact.combos.length) delete compact.combos; if (!compact.combos.length) delete compact.combos;
if (!compact.categories.length) delete compact.categories; return compact.combos ? compact : null;
return compact.combos || compact.categories ? compact : null;
} }
if (type === "calculator") { if (type === "calculator") {
const normalized = normalizeCalculatorData(value); const normalized = normalizeCalculatorData(value);
@ -929,13 +920,8 @@ function remapModuleDataForExport(type, data, nextId) {
const remapped = {}; const remapped = {};
if (compact.device) remapped.device = compact.device; if (compact.device) remapped.device = compact.device;
if (compact.combos) remapped.combos = compact.combos.map((combo) => ({ ...combo, id: nextId("combo") })); if (compact.combos) remapped.combos = compact.combos.map((combo) => ({ ...combo, id: nextId("combo") }));
if (compact.categories) { if (compact.collapsedCategories) remapped.collapsedCategories = compact.collapsedCategories;
remapped.categories = compact.categories.map((category) => ({ if (compact.categoryOrder) remapped.categoryOrder = compact.categoryOrder;
...category,
id: nextId("section"),
combos: category.combos.map((combo) => ({ ...combo, id: nextId("combo") }))
}));
}
return remapped; return remapped;
} }

View file

@ -1882,6 +1882,19 @@ textarea:focus {
box-shadow: inset 0 -3px 0 rgba(246, 196, 83, 0.8); box-shadow: inset 0 -3px 0 rgba(246, 196, 83, 0.8);
} }
.task-planner-category-boundary-drop-zone {
min-height: 10px;
border: 1px dashed rgba(165, 180, 252, 0.16);
border-radius: var(--radius-sm);
background: rgba(15, 23, 42, 0.12);
}
.task-planner-category-boundary-drop-zone.is-drop-target {
border-color: rgba(246, 196, 83, 0.72);
background: rgba(246, 196, 83, 0.08);
box-shadow: inset 0 0 0 1px rgba(246, 196, 83, 0.24);
}
.task-planner-category-section { .task-planner-category-section {
display: grid; display: grid;
gap: 8px; gap: 8px;
@ -2473,19 +2486,17 @@ textarea:focus {
gap: 6px; gap: 6px;
} }
.combos-root-list {
padding: 0;
}
.combos-root-drop-zone { .combos-root-drop-zone {
display: none; display: none;
padding: 9px 12px; padding: 9px 12px;
border: 1px dashed rgba(165, 180, 252, 0.22); border: 1px dashed rgba(165, 180, 252, 0.22);
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: rgba(5, 7, 17, 0.2); background: rgba(15, 23, 42, 0.26);
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: var(--font-size-xs); font-size: var(--font-size-xs);
font-weight: 900; font-weight: 900;
text-align: center;
text-transform: uppercase;
} }
.combos-root-drop-zone.is-visible, .combos-root-drop-zone.is-visible,
@ -2495,8 +2506,23 @@ textarea:focus {
.combos-root-drop-zone.is-drop-target { .combos-root-drop-zone.is-drop-target {
border-color: rgba(246, 196, 83, 0.72); border-color: rgba(246, 196, 83, 0.72);
background: rgba(246, 196, 83, 0.08); background:
color: var(--color-text-primary); linear-gradient(135deg, rgba(246, 196, 83, 0.08), transparent 56%),
rgba(15, 23, 42, 0.34);
color: var(--color-accent-gold);
box-shadow: inset 0 3px 0 rgba(246, 196, 83, 0.8);
}
.combos-category-boundary-drop-zone {
min-height: 6px;
border-radius: 999px;
}
.combos-category-boundary-drop-zone.is-drop-target {
min-height: 12px;
border: 1px dashed rgba(246, 196, 83, 0.72);
background: rgba(246, 196, 83, 0.12);
box-shadow: inset 0 2px 0 rgba(246, 196, 83, 0.74);
} }
.combos-list { .combos-list {