diff --git a/docs/FEATURE_CHECKLIST.md b/docs/FEATURE_CHECKLIST.md index a22fbf0..c06eb46 100644 --- a/docs/FEATURE_CHECKLIST.md +++ b/docs/FEATURE_CHECKLIST.md @@ -16,6 +16,7 @@ Checklist à suivre lors de l'ajout ou de la modification d'un outil, d'une page - Vérifier l'affichage dans la page toolbox complète. - Vérifier l'affichage dans le panneau latéral. - Vérifier le mode une colonne et deux colonnes. +- Vérifier que l'ajout, la suppression et la réorganisation respectent `moduleOrder.one` et `moduleOrder.two`. - Vérifier le quota de stockage si l'outil manipule des données lourdes. ## Page Jeu diff --git a/docs/STORAGE_SCHEMA.md b/docs/STORAGE_SCHEMA.md index b535763..af63776 100644 --- a/docs/STORAGE_SCHEMA.md +++ b/docs/STORAGE_SCHEMA.md @@ -50,7 +50,11 @@ Les toolboxes sont stockées dans `kv/toolboxes`. "title": "Armures" } ], - "layout": "two", + "moduleColumns": 2, + "moduleOrder": { + "one": ["mod1"], + "two": [["mod1"], []] + }, "updatedAt": "2026-07-25T12:00:00.000Z" } ``` @@ -59,7 +63,11 @@ Notes : - `gameId` est vide pour une toolbox libre. - `icon` est utilisé uniquement pour les toolboxes libres. -- `layout` vaut généralement `one` ou `two`. +- `moduleColumns` vaut `1` ou `2` et contrôle l'affichage de la page toolbox complète ; le panneau latéral utilise toujours l'ordre une colonne. +- `moduleOrder.one` stocke l'ordre du mode une colonne. +- `moduleOrder.two` stocke l'ordre explicite des colonnes gauche et droite en mode deux colonnes. +- Chaque identifiant d'outil présent dans `modules` doit apparaître exactement une fois dans `moduleOrder.one` et exactement une fois dans l'une des deux colonnes de `moduleOrder.two`. +- Si une ancienne toolbox n'a pas encore `moduleOrder.two`, les outils sont répartis automatiquement depuis l'ordre une colonne avec une moitié en colonne gauche et une moitié en colonne droite. - Les données lourdes des outils ne sont pas stockées dans la toolbox, mais dans `modules`. ## Liens Jeu / Toolbox diff --git a/tests/helpers/data-validation.mjs b/tests/helpers/data-validation.mjs index 556c246..78d8e80 100644 --- a/tests/helpers/data-validation.mjs +++ b/tests/helpers/data-validation.mjs @@ -552,6 +552,15 @@ export function validateLibraryData(payload) { moduleIds.add(module.id); assert.equal(typeof payload.modules[module.id], "object", `library.modules.${module.id} must be an object`); }); + assert.deepEqual(payload.toolbox.moduleOrder?.one, [...moduleIds], "library.toolbox.moduleOrder.one must match toolbox modules"); + assert.ok(Array.isArray(payload.toolbox.moduleOrder?.two), "library.toolbox.moduleOrder.two must be an array"); + assert.equal(payload.toolbox.moduleOrder.two.length, 2, "library.toolbox.moduleOrder.two must contain 2 columns"); + payload.toolbox.moduleOrder.two.forEach((column, index) => { + assert.ok(Array.isArray(column), `library.toolbox.moduleOrder.two[${index}] must be an array`); + }); + const twoColumnIds = payload.toolbox.moduleOrder.two.flat(); + assert.deepEqual(new Set(twoColumnIds), moduleIds, "library.toolbox.moduleOrder.two must contain every module exactly once"); + assert.equal(twoColumnIds.length, moduleIds.size, "library.toolbox.moduleOrder.two must not contain duplicate modules"); ["notepad", "checklist", "images", "links", "counters", "combos", "calculator", "table", "timer", "taskPlanner", "equipmentPlanner", "imageAnnotation"].forEach((type) => { assert.ok(payload.toolbox.modules.some((module) => module.type === type), `library must include a ${type} example`); diff --git a/tests/toolbox-modules.test.mjs b/tests/toolbox-modules.test.mjs index 7bd05c0..b35bad0 100644 --- a/tests/toolbox-modules.test.mjs +++ b/tests/toolbox-modules.test.mjs @@ -4,7 +4,7 @@ import assert from "node:assert/strict"; import { evaluateTableCell } from "../website/src/features/toolboxes/modules/tableFormulaEngine.js"; import { exportModuleText, importModuleText, parseColonImportLines } from "../website/src/features/toolboxes/modules/textImport.js"; import { getTimePatternRecurrenceMs, getTimePatternTargetMs } from "../website/src/features/toolboxes/modules/timerUtils.js"; -import { compactModuleDataForStorage, createToolboxExportPayload, normalizeCalculatorData, normalizeChecklistData, normalizeCombosData, normalizeCountersData, normalizeEquipmentPlannerData, normalizeImageAnnotationData, normalizeLinksData, normalizeNotepadData, normalizeTableData, normalizeTaskPlannerData, normalizeTimerData, normalizeUrl, summarizeEquipmentPlannerData } from "../website/src/features/toolboxes/storage/toolboxStorage.js"; +import { compactModuleDataForStorage, createToolboxExportPayload, normalizeCalculatorData, normalizeChecklistData, normalizeCombosData, normalizeCountersData, normalizeEquipmentPlannerData, normalizeImageAnnotationData, normalizeLinksData, normalizeModuleOrder, normalizeNotepadData, normalizeTableData, normalizeTaskPlannerData, normalizeTimerData, normalizeToolbox, normalizeUrl, summarizeEquipmentPlannerData } from "../website/src/features/toolboxes/storage/toolboxStorage.js"; import { applyGroupedReorderOperation, completeGroupOrder, getBoundaryItemId, getGroupedEntries, moveGroupOrder, moveGroupOrderToEnd, moveGroupOrderToStart, moveItem, moveItemGroup } from "../website/src/hooks/useGroupedReorder.js"; function createTextImportContext() { @@ -470,6 +470,48 @@ test("equipment planner export remaps nested ids and socket links", () => { assert.equal(exported.equipments[0].socketLinks[0].toSocketItemId, exported.equipments[0].socketItems[1].id); }); +test("toolbox module order normalizes one and two column layouts", () => { + const modules = [{ id: "a" }, { id: "b" }, { id: "c" }, { id: "d" }]; + + assert.deepEqual(normalizeModuleOrder(null, modules), { + one: ["a", "b", "c", "d"], + two: [["a", "b"], ["c", "d"]] + }); + assert.deepEqual(normalizeModuleOrder({ + one: ["c", "x", "c"], + two: [["d", "d", "x"], ["b"]] + }, modules), { + one: ["c", "a", "b", "d"], + two: [["d", "a"], ["b", "c"]] + }); + assert.deepEqual(normalizeModuleOrder({ + one: ["a", "b", "c", "d"], + two: [["a", "b", "c", "d"], []] + }, modules).two, [["a", "b", "c", "d"], []]); +}); + +test("toolbox export remaps module order ids", () => { + const payload = createToolboxExportPayload( + { + id: "toolbox1", + name: "Order", + modules: [{ id: "module1", type: "notepad" }, { id: "module2", type: "links" }, { id: "module3", type: "counters" }], + moduleOrder: { + one: ["module3", "module1", "module2"], + two: [["module2"], ["module3", "module1"]] + } + }, + {} + ); + + assert.deepEqual(payload.toolbox.modules.map((module) => module.id), ["m1", "m2", "m3"]); + assert.deepEqual(payload.toolbox.moduleOrder, { + one: ["m3", "m1", "m2"], + two: [["m2"], ["m3", "m1"]] + }); + assert.deepEqual(normalizeToolbox(payload.toolbox).moduleOrder, payload.toolbox.moduleOrder); +}); + test("grouped reorder helpers group flat items and complete group order", () => { const items = [ { id: "a", category: "" }, diff --git a/website/public/data/library.json b/website/public/data/library.json index 25a53c0..bfef493 100644 --- a/website/public/data/library.json +++ b/website/public/data/library.json @@ -53,6 +53,10 @@ "type": "equipmentPlanner" } ], + "moduleOrder": { + "one": ["m1", "m2", "m3", "m4", "m5", "m6", "m7", "m8", "m9", "ma", "mb", "mc"], + "two": [["m1", "m2", "m3", "m4", "m5", "m6"], ["m7", "m8", "m9", "ma", "mb", "mc"]] + }, "updatedAt": "2026-08-09T07:34:31.425Z", "moduleColumns": 1 }, diff --git a/website/src/features/toolboxes/ToolboxPages.jsx b/website/src/features/toolboxes/ToolboxPages.jsx index 406c619..82a7fa0 100644 --- a/website/src/features/toolboxes/ToolboxPages.jsx +++ b/website/src/features/toolboxes/ToolboxPages.jsx @@ -20,6 +20,7 @@ import { normalizeEquipmentPlannerData, normalizeImageAnnotationData, normalizeLinksData, + normalizeModuleOrder, normalizeNotepadData, normalizeTableData, normalizeTaskPlannerData, @@ -31,6 +32,31 @@ import { const DRAWER_WIDTH_SETTING = "drawerWidth"; +function addModuleToOrder(moduleOrder, modules, moduleId) { + const order = normalizeModuleOrder(moduleOrder, modules); + const two = [ + order.two[0].filter((id) => id !== moduleId), + order.two[1].filter((id) => id !== moduleId) + ]; + const targetColumnIndex = two[0].length <= two[1].length ? 0 : 1; + two[targetColumnIndex].push(moduleId); + return { + one: [...order.one.filter((id) => id !== moduleId), moduleId], + two + }; +} + +function removeModuleFromOrder(moduleOrder, modules, moduleId) { + const order = normalizeModuleOrder(moduleOrder, modules); + return { + one: order.one.filter((id) => id !== moduleId), + two: [ + order.two[0].filter((id) => id !== moduleId), + order.two[1].filter((id) => id !== moduleId) + ] + }; +} + async function copyText(value) { try { await navigator.clipboard.writeText(value); @@ -144,7 +170,7 @@ export function ToolboxPage(props) { } function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, storageUsage, getModuleData, updateToolbox, updateModuleData, addImageFiles }) { - const moduleColumns = Number(toolbox.moduleColumns) === 1 ? 1 : 2; + const moduleColumns = embedded || Number(toolbox.moduleColumns) === 1 ? 1 : 2; const toolboxGameCover = getGameCardCover(toolboxGame); const moduleText = siteContent.toolboxes.modules; const moduleContext = { @@ -178,28 +204,27 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto function addModule(type) { if (!type) return; - updateToolbox({ ...toolbox, modules: [...toolbox.modules, { id: uid("mod"), type }] }); + const module = { id: uid("mod"), type }; + const modules = [...toolbox.modules, module]; + updateToolbox({ + ...toolbox, + modules, + moduleOrder: addModuleToOrder(toolbox.moduleOrder, modules, module.id) + }); } function createImageAnnotationModule(dataUrl) { if (!dataUrl) return; const moduleId = uid("mod"); - updateToolbox({ ...toolbox, modules: [...toolbox.modules, { id: moduleId, type: "imageAnnotation" }] }); + const modules = [...toolbox.modules, { id: moduleId, type: "imageAnnotation" }]; + updateToolbox({ + ...toolbox, + modules, + moduleOrder: addModuleToOrder(toolbox.moduleOrder, modules, moduleId) + }); updateModuleData(toolbox.id, moduleId, { image: dataUrl, markers: [] }, "imageAnnotation"); } - function moveModule(fromModuleId, toModuleId, placement = "before") { - if (!fromModuleId || !toModuleId || fromModuleId === toModuleId) return; - const modules = [...toolbox.modules]; - const fromIndex = modules.findIndex((module) => module.id === fromModuleId); - const toIndex = modules.findIndex((module) => module.id === toModuleId); - if (fromIndex < 0 || toIndex < 0) return; - const [moved] = modules.splice(fromIndex, 1); - const targetIndex = modules.findIndex((module) => module.id === toModuleId); - modules.splice(placement === "after" ? targetIndex + 1 : targetIndex, 0, moved); - updateToolbox({ ...toolbox, modules }); - } - return (
@@ -241,6 +266,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto context={moduleContext} onRename={(moduleId, title) => updateToolbox({ ...toolbox, modules: toolbox.modules.map((module) => module.id === moduleId ? { ...module, title } : module) })} onUpdateModule={(moduleId, updater) => updateToolbox({ ...toolbox, modules: toolbox.modules.map((module) => module.id === moduleId ? updater(module) : module) })} + onModuleOrderChange={(moduleOrder) => updateToolbox({ ...toolbox, moduleOrder })} onDelete={(moduleId) => actions.setConfirmModal({ title: "Retirer l'outil", message: "Retirer cet outil de la toolbox ?", @@ -249,10 +275,14 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto onResolve: (confirmed) => { if (!confirmed) return; actions.removeModuleData(toolbox.id, moduleId); - updateToolbox({ ...toolbox, modules: toolbox.modules.filter((module) => module.id !== moduleId) }); + const modules = toolbox.modules.filter((module) => module.id !== moduleId); + updateToolbox({ + ...toolbox, + modules, + moduleOrder: removeModuleFromOrder(toolbox.moduleOrder, modules, moduleId) + }); } })} - onMove={moveModule} /> {!embedded && }
diff --git a/website/src/features/toolboxes/modules/index.jsx b/website/src/features/toolboxes/modules/index.jsx index d229897..de5a9a4 100644 --- a/website/src/features/toolboxes/modules/index.jsx +++ b/website/src/features/toolboxes/modules/index.jsx @@ -1,5 +1,5 @@ // Rôle : registre des outils toolbox, rendu commun et contrôles d'ajout/réorganisation. -import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { createPortal } from "react-dom"; import { CompactDropdown } from "../../../components/CompactDropdown.jsx"; import { Icon } from "../../../components/Icon.jsx"; @@ -34,51 +34,63 @@ const MODULE_COMPONENTS = { imageAnnotation: { label: "Annotation d'images", icon: "map", Component: ImageAnnotationModule, editable: true } }; -const MODULE_COLUMN_GAP_PX = 16; +const MODULE_COLUMN_IDS = ["left", "right"]; export const TOOLBOX_MODULES = Object.fromEntries(Object.entries(MODULE_COMPONENTS).map(([type, module]) => [ type, { label: module.label, icon: module.icon } ])); -function getSequentialModuleColumns(modules, splitIndex) { - return [ - modules.slice(0, splitIndex), - modules.slice(splitIndex) +function getModuleMap(modules) { + return new Map(modules.map((module) => [module.id, module])); +} + +function getModulesByIds(modules, ids) { + const moduleMap = getModuleMap(modules); + return (Array.isArray(ids) ? ids : []).map((id) => moduleMap.get(id)).filter(Boolean); +} + +function getModuleColumnId(moduleId, twoColumnOrder) { + return twoColumnOrder?.[1]?.includes(moduleId) ? MODULE_COLUMN_IDS[1] : MODULE_COLUMN_IDS[0]; +} + +function moveId(ids, sourceId, targetId, placement = "before") { + const nextIds = ids.filter((id) => id !== sourceId); + const targetIndex = nextIds.indexOf(targetId); + if (targetIndex < 0) return ids; + nextIds.splice(placement === "after" ? targetIndex + 1 : targetIndex, 0, sourceId); + return nextIds; +} + +function moveModuleInOneColumnOrder(order, operation) { + if (!operation.sourceId || !operation.targetId || operation.sourceId === operation.targetId) return order; + return { + ...order, + one: moveId(order.one, operation.sourceId, operation.targetId, operation.placement) + }; +} + +function moveModuleInTwoColumnOrder(order, operation) { + const columns = [ + [...(order.two?.[0] || [])], + [...(order.two?.[1] || [])] ]; -} + const sourceColumnIndex = columns.findIndex((column) => column.includes(operation.sourceId)); + if (sourceColumnIndex < 0) return order; + const targetColumnIndex = operation.targetType === "item" + ? columns.findIndex((column) => column.includes(operation.targetId)) + : MODULE_COLUMN_IDS.indexOf(operation.targetGroup); + if (targetColumnIndex < 0) return order; -function getColumnHeight(heights) { - if (!heights.length) return 0; - return heights.reduce((total, height) => total + height, 0) + MODULE_COLUMN_GAP_PX * (heights.length - 1); -} - -function chooseMeasuredSplitIndex(modules, moduleHeights, fallbackSplitIndex) { - if (modules.length <= 1) return modules.length; - if (modules.some((module) => !moduleHeights.get(module.id))) return fallbackSplitIndex; - - let bestSplitIndex = fallbackSplitIndex; - let bestDiff = Number.POSITIVE_INFINITY; - let bestLeftDominantSplitIndex = 0; - let bestLeftDominantDiff = Number.POSITIVE_INFINITY; - - for (let splitIndex = 1; splitIndex < modules.length; splitIndex += 1) { - const leftHeight = getColumnHeight(modules.slice(0, splitIndex).map((module) => moduleHeights.get(module.id))); - const rightHeight = getColumnHeight(modules.slice(splitIndex).map((module) => moduleHeights.get(module.id))); - const diff = Math.abs(leftHeight - rightHeight); - - if (leftHeight >= rightHeight && diff < bestLeftDominantDiff) { - bestLeftDominantDiff = diff; - bestLeftDominantSplitIndex = splitIndex; - } - - if (diff < bestDiff) { - bestDiff = diff; - bestSplitIndex = splitIndex; - } + const [movedId] = columns[sourceColumnIndex].splice(columns[sourceColumnIndex].indexOf(operation.sourceId), 1); + if (operation.targetType === "item") { + const targetIndex = columns[targetColumnIndex].indexOf(operation.targetId); + if (targetIndex < 0) return order; + columns[targetColumnIndex].splice(operation.placement === "after" ? targetIndex + 1 : targetIndex, 0, movedId); + } else { + columns[targetColumnIndex].splice(operation.placement === "after" ? columns[targetColumnIndex].length : 0, 0, movedId); } - - return bestLeftDominantSplitIndex || bestSplitIndex; + return { ...order, two: columns }; } export function AddToolControls({ onAdd }) { @@ -178,100 +190,74 @@ export function AddToolControls({ onAdd }) { ); } -export function ToolboxModules({ toolbox, moduleColumns, context, onRename, onUpdateModule, onDelete, onMove }) { - const [measuredSplitIndex, setMeasuredSplitIndex] = useState(() => Math.ceil(toolbox.modules.length / 2)); - const moduleElementsRef = useRef(new Map()); +export function ToolboxModules({ toolbox, moduleColumns, context, onRename, onUpdateModule, onDelete, onModuleOrderChange }) { const moduleIdSignature = useMemo(() => toolbox.modules.map((module) => module.id).join("|"), [toolbox.modules]); + const twoColumnOrder = toolbox.moduleOrder?.two || [[], []]; + const orderedModules = useMemo(() => getModulesByIds(toolbox.modules, toolbox.moduleOrder?.one || []), [toolbox.modules, toolbox.moduleOrder?.one, moduleIdSignature]); + const columns = useMemo(() => [ + getModulesByIds(toolbox.modules, twoColumnOrder[0]), + getModulesByIds(toolbox.modules, twoColumnOrder[1]) + ], [toolbox.modules, twoColumnOrder, moduleIdSignature]); const { itemReorder, getItemProps, + getGroupBoundaryProps, isItemDragging, isItemDropTarget, + isBoundaryDropTarget, getDropPlacement } = useGroupedReorder({ namespace: "toolbox-modules", items: toolbox.modules, getItemId: (module) => module.id, + getItemGroup: (module) => moduleColumns === 2 ? getModuleColumnId(module.id, twoColumnOrder) : "", + getEffectiveGroup: (module) => moduleColumns === 2 ? getModuleColumnId(module.id, twoColumnOrder) : "", getParentId: () => toolbox.id, - onItemMove: (operation) => onMove(operation.sourceId, operation.targetId, operation.placement), + reorderFeatures: { + item: { + groupChange: moduleColumns === 2, + boundaryDrop: moduleColumns === 2 + } + }, + onItemMove: (operation) => { + const nextOrder = moduleColumns === 2 + ? moveModuleInTwoColumnOrder(toolbox.moduleOrder, operation) + : moveModuleInOneColumnOrder(toolbox.moduleOrder, operation); + onModuleOrderChange(nextOrder); + }, hierarchy: { enabled: true, stickyParents: true } }); - useEffect(() => { - setMeasuredSplitIndex(Math.ceil(toolbox.modules.length / 2)); - }, [toolbox.id, moduleIdSignature, toolbox.modules.length]); - - useLayoutEffect(() => { - if (moduleColumns !== 2 || toolbox.modules.length <= 1) return undefined; - - let frameId = 0; - function rebalanceColumns() { - cancelAnimationFrame(frameId); - frameId = requestAnimationFrame(() => { - const moduleHeights = new Map(); - toolbox.modules.forEach((module) => { - const element = moduleElementsRef.current.get(module.id); - if (element) moduleHeights.set(module.id, element.getBoundingClientRect().height); - }); - - setMeasuredSplitIndex((currentSplitIndex) => { - const fallbackSplitIndex = Math.min(Math.max(currentSplitIndex, 1), toolbox.modules.length - 1); - const nextSplitIndex = chooseMeasuredSplitIndex(toolbox.modules, moduleHeights, fallbackSplitIndex); - return nextSplitIndex === currentSplitIndex ? currentSplitIndex : nextSplitIndex; - }); - }); - } - - const observer = new ResizeObserver(rebalanceColumns); - - toolbox.modules.forEach((module) => { - const element = moduleElementsRef.current.get(module.id); - if (element) observer.observe(element); - }); - rebalanceColumns(); - - return () => { - cancelAnimationFrame(frameId); - observer.disconnect(); - }; - }, [moduleColumns, toolbox.id, moduleIdSignature, toolbox.modules.length]); - - function registerModuleElement(moduleId, element) { - if (element) { - moduleElementsRef.current.set(moduleId, element); - } else { - moduleElementsRef.current.delete(moduleId); - } - } - if (moduleColumns === 1) { return (
- {toolbox.modules.map((module) => ( + {orderedModules.map((module) => ( ))}
); } - const splitIndex = Math.min(Math.max(measuredSplitIndex, 1), Math.max(1, toolbox.modules.length - 1)); - const columns = getSequentialModuleColumns(toolbox.modules, splitIndex); + const hasEmptyColumn = toolbox.modules.length > 1 && columns.some((modules) => modules.length === 0); return (
{columns.map((modules, index) => ( -
+
+
{modules.map((module) => ( ))} +
))}
); } -function ModuleShell({ toolbox, module, context, reorder, onRename, onUpdateModule, onDelete, registerModuleElement }) { +function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRename, onUpdateModule, onDelete }) { const definition = MODULE_COMPONENTS[module.type] || MODULE_COMPONENTS.notepad; const Component = definition.Component; const label = definition.label || module.type; @@ -309,8 +298,7 @@ function ModuleShell({ toolbox, module, context, reorder, onRename, onUpdateModu return (
registerModuleElement?.(module.id, element)} + {...reorder.getItemProps({ itemId: module.id, groupId: itemGroup, parentId: toolbox.id })} >
diff --git a/website/src/features/toolboxes/storage/toolboxStorage.js b/website/src/features/toolboxes/storage/toolboxStorage.js index 7ebb7e7..681648b 100644 --- a/website/src/features/toolboxes/storage/toolboxStorage.js +++ b/website/src/features/toolboxes/storage/toolboxStorage.js @@ -121,14 +121,70 @@ function normalizeToolboxModule(module) { return normalized; } +function uniqueKnownIds(ids, knownIds) { + const known = new Set(knownIds); + const seen = new Set(); + return (Array.isArray(ids) ? ids : []) + .map((id) => String(id || "")) + .filter((id) => { + if (!known.has(id) || seen.has(id)) return false; + seen.add(id); + return true; + }); +} + +function completeModuleOrder(ids, knownIds) { + const ordered = uniqueKnownIds(ids, knownIds); + const used = new Set(ordered); + return [...ordered, ...knownIds.filter((id) => !used.has(id))]; +} + +function splitModuleOrder(ids) { + const splitIndex = Math.ceil(ids.length / 2); + return [ids.slice(0, splitIndex), ids.slice(splitIndex)]; +} + +export function normalizeModuleOrder(moduleOrder, modules) { + const moduleIds = modules.map((module) => module.id); + const one = completeModuleOrder(moduleOrder?.one, moduleIds); + const hasTwoColumnOrder = Array.isArray(moduleOrder?.two) && Array.isArray(moduleOrder.two[0]) && Array.isArray(moduleOrder.two[1]); + if (!hasTwoColumnOrder) return { one, two: splitModuleOrder(one) }; + + const firstColumn = uniqueKnownIds(moduleOrder.two[0], moduleIds); + const firstColumnIds = new Set(firstColumn); + const secondColumn = uniqueKnownIds(moduleOrder.two[1], moduleIds).filter((id) => !firstColumnIds.has(id)); + const used = new Set([...firstColumn, ...secondColumn]); + const missing = moduleIds.filter((id) => !used.has(id)); + const two = [firstColumn, secondColumn]; + missing.forEach((id) => { + const targetIndex = two[0].length <= two[1].length ? 0 : 1; + two[targetIndex].push(id); + }); + return { one, two }; +} + +export function remapModuleOrderIds(moduleOrder, moduleIdMap) { + if (!moduleOrder) return null; + const remapIds = (ids) => (Array.isArray(ids) ? ids.map((id) => moduleIdMap.get(id)).filter(Boolean) : []); + return { + one: remapIds(moduleOrder.one), + two: [ + remapIds(moduleOrder.two?.[0]), + remapIds(moduleOrder.two?.[1]) + ] + }; +} + export function normalizeToolbox(toolbox) { if (!toolbox || typeof toolbox !== "object") return null; + const modules = (Array.isArray(toolbox.modules) ? toolbox.modules : []).map(normalizeToolboxModule).filter(Boolean); return { id: toolbox.id || uid("tbx"), name: String(toolbox.name || "Nouvelle toolbox").trim() || "Nouvelle toolbox", icon: normalizeToolboxIcon(toolbox.icon), moduleColumns: Number(toolbox.moduleColumns) === 1 ? 1 : 2, - modules: (Array.isArray(toolbox.modules) ? toolbox.modules : []).map(normalizeToolboxModule).filter(Boolean), + modules, + moduleOrder: normalizeModuleOrder(toolbox.moduleOrder, modules), updatedAt: toolbox.updatedAt || new Date().toISOString() }; } @@ -140,6 +196,7 @@ export function compactToolboxForStorage(toolbox) { id: normalized.id, name: normalized.name, modules: normalized.modules, + moduleOrder: normalized.moduleOrder, updatedAt: normalized.updatedAt }; if (normalized.moduleColumns === 1) compact.moduleColumns = 1; @@ -1413,7 +1470,8 @@ export function createToolboxExportPayload(toolbox, moduleData) { const id = nextId("mod"); moduleIdMap.set(module.id, id); return { ...module, id }; - }) + }), + moduleOrder: remapModuleOrderIds(source.moduleOrder, moduleIdMap) }); const modules = Object.fromEntries(source.modules .map((module) => [ diff --git a/website/src/features/toolboxes/useToolboxActions.js b/website/src/features/toolboxes/useToolboxActions.js index cc55944..fc418de 100644 --- a/website/src/features/toolboxes/useToolboxActions.js +++ b/website/src/features/toolboxes/useToolboxActions.js @@ -6,6 +6,7 @@ import { globalModuleKey, labelFromFileName, normalizeToolbox, + remapModuleOrderIds, uid } from "./storage/toolboxStorage.js"; import { compressImage } from "../../utils/imageCompression.js"; @@ -123,14 +124,16 @@ export function useToolboxActions({ store, notify, setConfirmModal, setCreateMod moduleIdMap.set(module.id, nextId); return { ...module, id: nextId }; }); - if (!store.setToolboxes([imported, ...store.toolboxes])) return null; + imported.moduleOrder = remapModuleOrderIds(imported.moduleOrder, moduleIdMap); + const normalizedImport = normalizeToolbox(imported); + if (!store.setToolboxes([normalizedImport, ...store.toolboxes])) return null; Object.entries(payload.modules || {}).forEach(([oldId, data]) => { const nextId = moduleIdMap.get(oldId); - const module = imported.modules.find((item) => item.id === nextId); - if (nextId) store.updateModuleData(imported.id, nextId, data, module?.type); + const module = normalizedImport.modules.find((item) => item.id === nextId); + if (nextId) store.updateModuleData(normalizedImport.id, nextId, data, module?.type); }); - if (gameId) linkToolboxToGame(gameId, imported.id); - return imported; + if (gameId) linkToolboxToGame(gameId, normalizedImport.id); + return normalizedImport; } async function importAllToolboxesPayload(file) { @@ -143,12 +146,15 @@ export function useToolboxActions({ store, notify, setConfirmModal, setCreateMod const importedToolboxes = payload.toolboxes.map((toolbox) => { const nextToolboxId = uid("tbx"); toolboxIdMap.set(toolbox.id, nextToolboxId); + const localModuleIdMap = new Map(); const modules = (toolbox.modules || []).map((module) => { const nextModuleId = uid("mod"); moduleIdMap.set(globalModuleKey(toolbox.id, module.id), nextModuleId); + localModuleIdMap.set(module.id, nextModuleId); return { ...module, id: nextModuleId }; }); - return normalizeToolbox({ ...toolbox, id: nextToolboxId, name: `${toolbox.name || "Toolbox"} (import)`, modules, updatedAt: new Date().toISOString() }); + const moduleOrder = remapModuleOrderIds(toolbox.moduleOrder, localModuleIdMap); + return normalizeToolbox({ ...toolbox, id: nextToolboxId, name: `${toolbox.name || "Toolbox"} (import)`, modules, moduleOrder, updatedAt: new Date().toISOString() }); }); const nextLinks = { ...store.links }; Object.entries(payload.links || {}).forEach(([gameId, oldToolboxId]) => { diff --git a/website/src/styles/_toolboxes.scss b/website/src/styles/_toolboxes.scss index 9fcc4b7..82f8ebd 100644 --- a/website/src/styles/_toolboxes.scss +++ b/website/src/styles/_toolboxes.scss @@ -418,8 +418,36 @@ .module-column { display: grid; - gap: var(--space-4); + gap: var(--space-3); align-content: start; + min-width: 0; +} + +.module-column.is-empty-warning { + min-height: 132px; + border: 1px dashed rgba(246, 196, 83, 0.26); + border-radius: var(--radius-md); + background: rgba(246, 196, 83, 0.04); +} + +.module-column-boundary-drop-zone { + position: relative; + min-height: 10px; + border-radius: var(--radius-pill); +} + +.module-column-boundary-drop-zone.is-drop-target::before { + position: absolute; + top: 50%; + right: 0; + left: 0; + height: 3px; + border-radius: var(--radius-pill); + background: rgba(246, 196, 83, 0.9); + box-shadow: 0 0 12px rgba(246, 196, 83, 0.24); + content: ""; + pointer-events: none; + transform: translateY(-50%); } .module { @@ -512,6 +540,25 @@ box-shadow: var(--reorder-drop-shadow, none), inset -3px 0 0 rgba(246, 196, 83, 0.86); } +.module[data-reorder-target="item"][data-reorder-orientation="vertical"].is-drop-target { + border-color: transparent; + box-shadow: + var(--shadow-sm), + inset 0 3px 0 rgba(246, 196, 83, 0.86), + 0 0 14px rgba(246, 196, 83, 0.12); +} + +.module[data-reorder-target="item"][data-reorder-orientation="vertical"].is-drop-target.drop-after { + box-shadow: + var(--shadow-sm), + inset 0 -3px 0 rgba(246, 196, 83, 0.86), + 0 0 14px rgba(246, 196, 83, 0.12); +} + +.module[data-reorder-target="item"][data-reorder-orientation="vertical"].is-drop-target::before { + display: none; +} + .module header { display: flex; align-items: center;