From a6d35c6e6b8de544a9c8b97b8ee8b42ed2f0cc69 Mon Sep 17 00:00:00 2001 From: Shinuwa Date: Fri, 31 Jul 2026 16:47:21 +0200 Subject: [PATCH] fix drag&drop to reorganize combos --- docs/STORAGE_SCHEMA.md | 44 +- tests/helpers/data-validation.mjs | 1 - tests/toolbox-modules.test.mjs | 70 +- website/public/data/site.json | 1 - .../toolboxes/modules/CombosModule.jsx | 632 +++++++++++++----- .../toolboxes/modules/TaskPlannerModule.jsx | 156 ++++- .../toolboxes/storage/toolboxStorage.js | 62 +- website/src/styles/_toolboxes.scss | 40 +- 8 files changed, 724 insertions(+), 282 deletions(-) diff --git a/docs/STORAGE_SCHEMA.md b/docs/STORAGE_SCHEMA.md index cde4117..3aedd05 100644 --- a/docs/STORAGE_SCHEMA.md +++ b/docs/STORAGE_SCHEMA.md @@ -432,6 +432,8 @@ Type : `combos` ```json { "device": "playstation", + "categoryOrder": ["Neutral"], + "collapsedCategories": ["Advanced"], "combos": [ { "id": "combo0", @@ -443,27 +445,16 @@ Type : `combos` { "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" } - ] - ] - } + "id": "combo1", + "name": "Hadoken", + "category": "Neutral", + "device": "playstation", + "inputs": [ + [{ "kind": "direction", "value": "down" }], + [{ "kind": "direction", "value": "down-forward" }], + [{ "kind": "button", "value": "cross" }] ] } ] @@ -474,20 +465,23 @@ 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. +- `combos[].category` est facultatif ; les catégories sont déduites automatiquement de cette valeur. - `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. +- `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. 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. +- `category` est omise si vide. +- `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. -- `collapsed` est omis si `false`. -- les titres de catégorie et noms de combo sont limités à 80 caractères. +- les catégories et noms de combo sont limités à 80 caractères. - chaque combo conserve au maximum 64 étapes. ## Outil Calculateur diff --git a/tests/helpers/data-validation.mjs b/tests/helpers/data-validation.mjs index fb8d8b9..f367b4c 100644 --- a/tests/helpers/data-validation.mjs +++ b/tests/helpers/data-validation.mjs @@ -182,7 +182,6 @@ export function validateSiteContent(site) { "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", diff --git a/tests/toolbox-modules.test.mjs b/tests/toolbox-modules.test.mjs index a655677..c91d562 100644 --- a/tests/toolbox-modules.test.mjs +++ b/tests/toolbox-modules.test.mjs @@ -99,52 +99,43 @@ test("combos storage normalizes devices, steps and compact export ids", () => { const longText = "x".repeat(120); const normalized = normalizeCombosData({ device: "invalid", + categoryOrder: [`${longText} `, "Missing", `${longText} `], + collapsedCategories: [`${longText} `, "", `${longText} `], 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: "combo1", + device: "n64", + category: `${longText} `, + name: `${longText} `, + inputs: [ + [ + { kind: "direction", value: "down" }, + { kind: "button", value: "cross" }, + { kind: "bad", value: "ignored" } + ], + [], + [{ kind: "key", value: "space" }] ] }, - { id: "emptyCategory", title: " ", combos: [] }, - { id: "titledEmptyCategory", title: "Empty", combos: [] } + { id: "empty", name: " ", inputs: [] } ] }); 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.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, [ + assert.equal(normalized.combos[1].category.length, 80); + assert.equal(normalized.combos[1].name.length, 80); + assert.equal("note" in normalized.combos[1], false); + assert.deepEqual(normalized.categoryOrder, [longText.slice(0, 80)]); + assert.deepEqual(normalized.collapsedCategories, [longText.slice(0, 80)]); + assert.deepEqual(normalized.combos[1].inputs, [ [ { kind: "direction", value: "down" }, { 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" }); 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].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"); + assert.equal(compact.combos[1].device, "n64"); + assert.equal(compact.combos[1].id, "combo1"); + assert.equal(compact.combos[1].category.length, 80); const exported = createToolboxExportPayload( { 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].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"); + assert.deepEqual(exported.modules.m1.categoryOrder, [longText.slice(0, 80)]); + assert.deepEqual(exported.modules.m1.collapsedCategories, [longText.slice(0, 80)]); + 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", () => { diff --git a/website/public/data/site.json b/website/public/data/site.json index 859eeba..4ac81ce 100644 --- a/website/public/data/site.json +++ b/website/public/data/site.json @@ -312,7 +312,6 @@ "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", diff --git a/website/src/features/toolboxes/modules/CombosModule.jsx b/website/src/features/toolboxes/modules/CombosModule.jsx index d83d558..d12be41 100644 --- a/website/src/features/toolboxes/modules/CombosModule.jsx +++ b/website/src/features/toolboxes/modules/CombosModule.jsx @@ -3,6 +3,10 @@ import { useEffect, useMemo, useState } from "react"; import { Icon } from "../../../components/Icon.jsx"; 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 = [ { value: "playstation", label: "PlayStation" }, { value: "xbox", label: "Xbox" }, @@ -105,13 +109,15 @@ function createInput(kind, value) { return { kind, value }; } -function createCombo(context, id, name, inputs, fallbackName, device) { - return { +function createCombo(context, id, name, category, inputs, fallbackName, device) { + const combo = { id: id || context.uid("combo"), name: name.trim() || fallbackName, device, inputs: inputs.map((step) => step.map((input) => ({ ...input }))).filter((step) => step.length) }; + if (category.trim()) combo.category = category.trim(); + return combo; } function normalizeCustomKey(value) { @@ -132,43 +138,361 @@ function getPaletteColumns(groups) { ]; } -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 getComboCategory(combo) { + return String(combo?.category || "").trim(); } -function removeComboFromData(data, comboId) { - let movedCombo = null; - const combos = data.combos.filter((combo) => { - if (combo.id !== comboId) return true; - movedCombo = combo; - return false; +function getCategoryGroups(combos, categoryOrder) { + const categories = []; + combos.forEach((combo) => { + const category = getComboCategory(combo); + if (category && !categories.includes(category)) categories.push(category); }); - 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 } }; + const orderedCategories = [ + ...categoryOrder.filter((category) => categories.includes(category)), + ...categories.filter((category) => !categoryOrder.includes(category)) + ]; + const groups = orderedCategories.map((category) => ({ + category, + combos: combos.filter((combo) => getComboCategory(combo) === category) + })).filter((group) => group.combos.length); + 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") { - 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 getCategoryComboIds(combos, category) { + return combos.filter((combo) => getComboCategory(combo) === category).map((combo) => combo.id); +} + +function getAllCategories(combos) { + const categories = []; + 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 }) { @@ -292,7 +616,7 @@ function InlineTextInput({ value, onCommit, className, ariaLabel, placeholder = } 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 [comboCategory, setComboCategory] = 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 paletteColumns = useMemo(() => getPaletteColumns(paletteGroups), [paletteGroups]); const cleanDraftInputs = draftInputs.map((step) => step.filter(Boolean)).filter((step) => step.length); + const entries = getCategoryGroups(data.combos, data.categoryOrder); const { draggingId: draggingCategoryId, dropTarget: categoryDropTarget, startDrag: startCategoryDrag } = usePointerReorder({ - targetSelector: ".combos-category", - getTargetId: (target) => target.dataset.categoryId, + targetSelector: ".combos-category, .combo-card, .combos-root-drop-zone, .combos-category-boundary-drop-zone", + getTargetId: getCategoryTargetId, + canDropOn: canDropCategoryOnTarget, + getPlacement: getCategoryDropPlacement, onMove: moveCategory }); const { @@ -319,17 +646,10 @@ export function CombosModule({ toolboxId, moduleId, context, editing }) { 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; - }, + targetSelector: ".combo-card, .combos-category, .combos-root-drop-zone, .combos-category-boundary-drop-zone", + getTargetId: getComboTargetId, + canDropOn: (target, draggingId) => canDropComboOnTarget(target, draggingId, data.combos), + getPlacement: getComboDropPlacement, onMove: moveCombo }); @@ -342,64 +662,43 @@ export function CombosModule({ toolboxId, moduleId, context, editing }) { save({ ...data, device, - combos: data.combos.map(stampComboDevice), - categories: data.categories.map((category) => ({ - ...category, - combos: category.combos.map(stampComboDevice) - })) + combos: data.combos.map(stampComboDevice) }); } - function moveCategory(fromCategoryId, toCategoryId, placement) { - save({ ...data, categories: moveArrayItem(data.categories, fromCategoryId, toCategoryId, placement) }); + function moveCategory(fromId, toId, 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) { - const { movedCombo, data: dataWithoutCombo } = removeComboFromData(data, fromComboId); + const movedCombo = data.combos.find((combo) => combo.id === fromComboId); if (!movedCombo) return; - if (targetId === "root") { - save({ ...dataWithoutCombo, combos: [...dataWithoutCombo.combos, movedCombo] }); + if (isUncategorizedDropId(targetId)) { + save(removeComboCategory(data, fromComboId, placement)); 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) - }); + if (isCategoryBoundaryDropId(targetId)) { + save(moveComboByBoundary(data, fromComboId, targetId)); 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) }); + if (isCategorySectionId(targetId)) { + save(insertAfterCategory(data, fromComboId, parseCategorySectionId(targetId), 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] }; + save(moveComboAroundCombo(data, fromComboId, targetId, placement)); } function addInputToStep(kind, value, stepIndex) { @@ -534,26 +833,17 @@ export function CombosModule({ toolboxId, moduleId, context, editing }) { 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); + const combo = createCombo(context, editingComboId, comboName, comboCategory, cleanDraftInputs, textContent.defaultComboName || "Combo", data.device); + const nextCombos = data.combos.filter((item) => item.id !== editingComboId); + const nextCategoryOrder = combo.category && !data.categoryOrder.includes(combo.category) + ? [...data.categoryOrder, combo.category] + : data.categoryOrder; + save({ ...data, combos: [...nextCombos, combo], categoryOrder: nextCategoryOrder }); cancelComboEdit(); } - function startComboEdit(categoryId, combo) { - setComboCategory(data.categories.find((category) => category.id === categoryId)?.title || ""); + function startComboEdit(combo) { + setComboCategory(getComboCategory(combo)); setEditingComboId(combo.id); setComboName(combo.name); 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); } - 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 updateCombo(comboId, updater) { + save({ ...data, combos: data.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: [] })); + function deleteCombo(comboId) { + updateCombo(comboId, () => ({ name: "", inputs: [] })); 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 = [ "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" : "" + comboDropTarget.id === combo.id || categoryDropTarget.id === combo.id ? "is-drop-target" : "", + (comboDropTarget.id === combo.id && comboDropTarget.placement === "after") || (categoryDropTarget.id === combo.id && categoryDropTarget.placement === "after") ? "drop-after" : "" ].filter(Boolean).join(" "); return ( -
+
- @@ -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 = [ "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" : "" + isCollapsed ? "is-collapsed" : "", + draggingCategoryId === categoryId ? "is-dragging" : "", + categoryDropTarget.id === categoryId || comboDropTarget.id === categoryId ? "is-drop-target" : "", + (categoryDropTarget.id === categoryId && categoryDropTarget.placement === "after") || (comboDropTarget.id === categoryId && comboDropTarget.placement === "after") ? "drop-after" : "" ].filter(Boolean).join(" "); - return ( -
+ const showBoundaryDropZones = Boolean(draggingComboId || draggingCategoryId); + const beforeDropZone = showBoundaryDropZones ? ( +
+ ) : null; + const afterDropZone = showBoundaryDropZones ? ( +
+ ) : null; + return [ + beforeDropZone, +
-

{category.title}

+

{category}

- {category.combos.length} + {group.combos.length}
- {!category.collapsed && ( + {!isCollapsed && (
- {category.combos.map((combo) => renderCombo(combo, category.id))} - {!category.combos.length &&

{textContent.emptyCategory || "Aucun combo dans cette catégorie."}

} + {group.combos.map((combo) => renderCombo(combo))}
)} -
+
, + 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 ( +
+ {textContent.noCategoryLabel || "Sans catégorie"} +
); } @@ -787,20 +1110,13 @@ export function CombosModule({ toolboxId, moduleId, context, editing }) { )} - {!data.combos.length && !data.categories.length && ( + {!data.combos.length && (

{textContent.emptyCombos || "Aucun combo enregistré."}

)}
-
- {textContent.noCategoryLabel || "Sans catégorie"} -
- {data.combos.length > 0 && ( -
- {data.combos.map((combo) => renderCombo(combo))} -
- )} - {data.categories.map(renderCategory)} + {renderUncategorizedDropZone()} + {entries.map((entry) => entry.type === "category" ? renderCategory(entry.group) : renderCombo(entry.combo))}
); diff --git a/website/src/features/toolboxes/modules/TaskPlannerModule.jsx b/website/src/features/toolboxes/modules/TaskPlannerModule.jsx index 2516c35..bd4805b 100644 --- a/website/src/features/toolboxes/modules/TaskPlannerModule.jsx +++ b/website/src/features/toolboxes/modules/TaskPlannerModule.jsx @@ -6,6 +6,7 @@ import { usePointerReorder } from "../../../hooks/usePointerReorder.js"; const TASK_TYPES = ["daily", "weekly", "unique"]; const UNCATEGORIZED_DROP_ID = "task-planner:uncategorized"; +const CATEGORY_BOUNDARY_DROP_PREFIX = "task-planner:category-boundary"; const WEEK_DAYS = [ { value: 1, label: "Lundi" }, { value: 2, label: "Mardi" }, @@ -95,17 +96,38 @@ function getBoundaryTaskId(tasks, taskIds, placement) { return placement === "after" ? orderedTasks.at(-1)?.id || "" : orderedTasks[0]?.id || ""; } -function moveCategory(categoryOrder, fromCategory, toCategory, placement = "before") { - const index = categoryOrder.indexOf(fromCategory); - const targetIndex = categoryOrder.indexOf(toCategory); - if (index < 0 || targetIndex < 0 || fromCategory === toCategory) return categoryOrder; - const nextOrder = [...categoryOrder]; +function getRootCategories(tasks, parentMap) { + const categories = []; + tasks.forEach((task) => { + if (parentMap.get(task.id) || !getTaskCategory(task) || categories.includes(task.category)) return; + 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 nextTargetIndex = nextOrder.indexOf(toCategory); nextOrder.splice(placement === "after" ? nextTargetIndex + 1 : nextTargetIndex, 0, moved); 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) { return tasks.find((task) => task.id === taskId)?.title || "Tâche supprimée"; } @@ -257,12 +279,36 @@ function isUncategorizedDropId(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) { return tasks .filter((task) => !parentMap.get(task.id) && getTaskCategory(task) === category) .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 }) { const data = context.normalizeTaskPlannerData(context.getModuleData(toolboxId, moduleId, { tasks: [] })); const textContent = context.moduleText?.taskPlanner || {}; @@ -280,13 +326,17 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing }) { dropTarget, startDrag } = 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 || ""), canDropOn: (target, draggingTaskId) => { const draggingTask = data.tasks.find((task) => task.id === draggingTaskId); if (!draggingTask) return false; const draggingParentId = parentMap.get(draggingTaskId) || ""; 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")) { return !draggingParentId && Boolean(draggingCategory); } @@ -321,8 +371,25 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing }) { moveToCategory(""); 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)) { 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); return; } @@ -339,11 +406,17 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing }) { dropTarget: categoryDropTarget, startDrag: startCategoryDrag } = usePointerReorder({ - targetSelector: ".task-planner-category-section, .task-planner-item", - getTargetId: (target) => target.dataset.taskId || getCategorySectionId(target.dataset.parentId || "", target.dataset.category || ""), + targetSelector: ".task-planner-category-section, .task-planner-item, .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 || ""), canDropOn: (target, 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); return !target.dataset.category; }, @@ -351,6 +424,35 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing }) { const fromCategory = parseCategorySectionId(fromId).category; const movingIds = getRootTaskIdsForCategory(data.tasks, parentMap, fromCategory); 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)) { const toCategory = parseCategorySectionId(toId).category; const targetIds = getRootTaskIdsForCategory(data.tasks, parentMap, toCategory); @@ -359,7 +461,7 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing }) { save({ ...data, tasks: moveTaskGroup(data.tasks, movingIds, boundaryTaskId, placement), - categoryOrder: moveCategory(data.categoryOrder, fromCategory, toCategory, placement) + categoryOrder: moveCategory(data.categoryOrder, rootCategories, fromCategory, toCategory, placement) }); } return; @@ -643,7 +745,15 @@ function TaskPlannerBranch({ if (!children.length) return null; const nextVisited = new Set([...visited, ...children.map((task) => task.id)]); 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()) { if (seen.has(taskId)) return false; @@ -728,6 +838,8 @@ function TaskPlannerBranch({ const groupCount = countTaskTree(groupTasks.map((task) => task.id), tasks, parentMap); const groupCompletedCount = countCompletedTaskTree(groupTasks.map((task) => task.id), tasks, parentMap); 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 sectionClassName = [ "task-planner-category-section", @@ -737,7 +849,22 @@ function TaskPlannerBranch({ categoryDropTarget.id === categoryId || dropTarget.id === categoryId ? "is-drop-target" : "", (categoryDropTarget.id === categoryId && categoryDropTarget.placement === "after") || (dropTarget.id === categoryId && dropTarget.placement === "after") ? "drop-after" : "" ].filter(Boolean).join(" "); - return ( + const beforeDropZone = showCategoryBoundaryDropZones ? ( +
  • + ) : null; + const afterDropZone = showCategoryBoundaryDropZones ? ( +
  • + ) : null; + return [ + beforeDropZone,
  • @@ -771,8 +898,9 @@ function TaskPlannerBranch({ {groupTasks.map(renderTask).filter(Boolean)} )} -
  • - ); + , + afterDropZone + ]; } const entries = groupedTasks.entries.map((entry) => { diff --git a/website/src/features/toolboxes/storage/toolboxStorage.js b/website/src/features/toolboxes/storage/toolboxStorage.js index 30c19dc..e2031bd 100644 --- a/website/src/features/toolboxes/storage/toolboxStorage.js +++ b/website/src/features/toolboxes/storage/toolboxStorage.js @@ -377,6 +377,7 @@ function normalizeComboSteps(inputs) { function normalizeCombo(combo) { 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 inputs = normalizeComboSteps(combo?.inputs); if (!name && !inputs.length) return null; @@ -385,27 +386,32 @@ function normalizeCombo(combo) { name: name || "Combo", inputs }; + if (category) normalized.category = category; 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 - }; +function normalizeComboCategoryOrder(value, combos) { + const categories = combos.map((combo) => combo.category).filter(Boolean); + const orderedCategories = (Array.isArray(value) ? value : []) + .map((category) => String(category || "").trim().slice(0, 80)) + .filter((category, index, order) => category && categories.includes(category) && order.indexOf(category) === index); + return [ + ...orderedCategories, + ...categories.filter((category) => !orderedCategories.includes(category)) + ]; } export function normalizeCombosData(data) { + const combos = (Array.isArray(data?.combos) ? data.combos : []).map(normalizeCombo).filter(Boolean); + const categoryOrder = normalizeComboCategoryOrder(data?.categoryOrder, combos); 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) + combos, + 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, inputs: combo.inputs }; + if (combo.category) compactCombo.category = combo.category; 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 (normalized.collapsedCategories.length) compact.collapsedCategories = normalized.collapsedCategories; + if (normalized.categoryOrder.length) compact.categoryOrder = normalized.categoryOrder; if (!compact.combos.length) delete compact.combos; - if (!compact.categories.length) delete compact.categories; - return compact.combos || compact.categories ? compact : null; + return compact.combos ? compact : null; } if (type === "calculator") { const normalized = normalizeCalculatorData(value); @@ -929,13 +920,8 @@ function remapModuleDataForExport(type, data, nextId) { 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") })) - })); - } + if (compact.collapsedCategories) remapped.collapsedCategories = compact.collapsedCategories; + if (compact.categoryOrder) remapped.categoryOrder = compact.categoryOrder; return remapped; } diff --git a/website/src/styles/_toolboxes.scss b/website/src/styles/_toolboxes.scss index ba7bf2c..54f6e50 100644 --- a/website/src/styles/_toolboxes.scss +++ b/website/src/styles/_toolboxes.scss @@ -1882,6 +1882,19 @@ textarea:focus { 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 { display: grid; gap: 8px; @@ -2473,19 +2486,17 @@ textarea:focus { 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); + background: rgba(15, 23, 42, 0.26); color: var(--color-text-muted); font-size: var(--font-size-xs); font-weight: 900; + text-align: center; + text-transform: uppercase; } .combos-root-drop-zone.is-visible, @@ -2495,8 +2506,23 @@ textarea:focus { .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); + background: + 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 {