From 0acf5ecf21089c43922dd27465dbb42827fbc2ad Mon Sep 17 00:00:00 2001 From: Shinuwa Date: Tue, 29 Sep 2026 21:31:49 +0200 Subject: [PATCH] Add toolbox spaces and recover from stale asset bundles --- DESIGN_SYSTEM.md | 19 ++ docs/FEATURE_CHECKLIST.md | 4 +- docs/STORAGE_SCHEMA.md | 43 +++- e2e/fixtures/toolbox-tutorial-probe.js | 14 +- e2e/module-order.spec.js | 2 +- e2e/production/asset-recovery.spec.js | 37 ++++ e2e/text-exchange.spec.js | 7 + e2e/toolbox-spaces.spec.js | 148 ++++++++++++++ tests/helpers/data-validation.mjs | 39 +++- tests/persistence-queue.test.mjs | 26 +++ tests/static-app.test.mjs | 5 + tests/static-toolboxes.test.mjs | 4 +- tests/toolbox-modules.test.mjs | 68 ++++++- website/index.html | 27 +++ .../public/data/library-toolbox-export.json | 19 +- website/public/data/library.json | 18 +- website/public/data/site.json | 20 ++ .../src/features/toolboxes/ToolboxPages.jsx | 171 +++++++++++----- .../src/features/toolboxes/ToolboxSpaces.jsx | 134 ++++++++++++ .../toolboxes/modules/ModuleShell.jsx | 15 +- .../toolboxes/modules/ToolboxModules.jsx | 111 +++------- .../toolboxes/storage/modules/shared.js | 1 + .../toolboxes/storage/toolboxStorage.js | 85 +++++++- .../features/toolboxes/toolboxSpaceUtils.js | 163 +++++++++++++++ .../features/toolboxes/useToolboxActions.js | 42 +++- website/src/main.jsx | 10 + website/src/styles/_tokens.scss | 1 + website/src/styles/toolboxes/_layout.scss | 1 + .../src/styles/toolboxes/_module-shell.scss | 5 + .../src/styles/toolboxes/layout/_spaces.scss | 192 ++++++++++++++++++ 30 files changed, 1252 insertions(+), 179 deletions(-) create mode 100644 e2e/production/asset-recovery.spec.js create mode 100644 e2e/toolbox-spaces.spec.js create mode 100644 website/src/features/toolboxes/ToolboxSpaces.jsx create mode 100644 website/src/features/toolboxes/toolboxSpaceUtils.js create mode 100644 website/src/styles/toolboxes/layout/_spaces.scss diff --git a/DESIGN_SYSTEM.md b/DESIGN_SYSTEM.md index 42d4dd6..2e4ba2c 100644 --- a/DESIGN_SYSTEM.md +++ b/DESIGN_SYSTEM.md @@ -57,6 +57,18 @@ Rayons : - `--radius-xl: 18px` - `--radius-pill: 999px` +Espacements : + +- `--space-1: 0.25rem` +- `--space-2: 0.5rem` +- `--space-3: 0.75rem` +- `--space-4: 1rem` +- `--space-5: 1.25rem` +- `--space-6: 1.5rem` +- `--space-8: 2rem` +- `--space-10: 2.5rem` +- `--space-12: 3rem` + Animations : - `--duration-fast: 120ms` @@ -594,6 +606,13 @@ Outil Tableau : ## 14. Toolboxes +### Espaces d'outils + +- La barre d'espaces se place entre le hero de la toolbox et les contrôles de disposition, dans la page complète comme dans le drawer lié à un jeu. +- Quand seul l'espace par défaut existe, son onglet est masqué et le bouton `Nouvel espace` reste aligné à droite. À partir de deux espaces, la liste devient un `tablist` horizontal scrollable et l'ajout prend la forme d'un bouton `+` placé immédiatement après le dernier onglet. +- L'espace actif reprend les surfaces et focus des radio-button groups. Les onglets ciblés par un outil affichent un soulignement doré ; les espaces secondaires disposent d'une poignée de réordonnancement distincte avec un retrait à gauche. +- Le réordonnancement des onglets reprend l'indicateur d'insertion doré des outils, positionné à gauche ou à droite selon la destination. Les actions de renommage et suppression restent groupées à droite et passent sous les onglets sur mobile. + Les toolboxes sont locales et stockees dans le navigateur. Regles UI : diff --git a/docs/FEATURE_CHECKLIST.md b/docs/FEATURE_CHECKLIST.md index 3e5f2a8..36e4eca 100644 --- a/docs/FEATURE_CHECKLIST.md +++ b/docs/FEATURE_CHECKLIST.md @@ -38,7 +38,9 @@ Checklist à suivre lors de l'ajout ou de la modification d'un outil, d'une page - Vérifier qu'une notification ou une mise à jour sans rapport ne remplace pas un brouillon Mémos avant sa validation au blur. - Pour l'arbre d'objectifs, vérifier la sélection après suppression/import et le retour à Base quand le nœud sélectionné devient une étape sans onglet Contenu. - 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 que l'ajout, la suppression et la réorganisation respectent les `moduleOrder.one` et `moduleOrder.two` de l'espace actif. +- Pour les espaces toolbox, vérifier la conversion de l'ancien ordre vers « Défaut », la limite de cinq, l'espace actif partagé avec le panneau jeu, l'ajout dans l'espace actif, le renommage, le réordonnancement hors défaut et la suppression avec rapatriement des outils. +- Vérifier le déplacement d'un outil par sélecteur et par dépôt sur onglet : dépôt rapide sans changement d'espace, activation après 600 ms, dépôt précis après activation et `pointercancel` sans déplacement. - Pour les déplacements par pointeur, vérifier les dépôts avant/après, le rejet des cibles interdites et l'annulation navigateur (`pointercancel`) sans modification de l'ordre ; le démontage doit retirer les listeners actifs. - Vérifier le quota de stockage si l'outil manipule des données lourdes. - Pour la sauvegarde locale, vérifier la feature detection, les permissions sans popup au démarrage, le nommage en date locale, la déduplication quotidienne, le remplacement manuel, la rétention de dix fichiers et la conservation des fichiers étrangers. diff --git a/docs/STORAGE_SCHEMA.md b/docs/STORAGE_SCHEMA.md index e3d21b6..ed310fe 100644 --- a/docs/STORAGE_SCHEMA.md +++ b/docs/STORAGE_SCHEMA.md @@ -111,8 +111,8 @@ rechargement de l'application ; il n'y a pas d'éviction à la fermeture. Les exports attendent les écritures précédentes dans la même file et lisent les médias manquants depuis l'état confirmé, sans les ajouter au cache de l'interface. -Si cette lecture échoue, aucun fichier partiel n'est téléchargé. Les formats -IndexedDB et import/export sont inchangés. +Si cette lecture échoue, aucun fichier partiel n'est téléchargé. Les snapshots +IndexedDB et les exports utilisent le même registre d'espaces décrit ci-dessous. Cette coordination est locale à l'instance React ; elle ne synchronise pas l'édition entre plusieurs onglets. @@ -141,10 +141,18 @@ Les toolboxes sont stockées dans `kv/toolboxes`. } ], "moduleColumns": 2, - "moduleOrder": { - "one": ["mod1"], - "two": [["mod1"], []] - }, + "spaces": [ + { + "id": "space1", + "name": "Défaut", + "moduleOrder": { + "one": ["mod1"], + "two": [["mod1"], []] + } + } + ], + "defaultSpaceId": "space1", + "activeSpaceId": "space1", "tutorial": true, "updatedAt": "2026-07-25T12:00:00.000Z" } @@ -156,10 +164,12 @@ Notes : - `icon` est utilisé uniquement pour les toolboxes libres. - `customCover` est une image rectangulaire optionnelle utilisée comme cover de card pour les toolboxes libres. Elle est stockée en data URL image après recadrage, sans conserver le fichier original. - `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. +- `spaces` contient de un à cinq espaces dans l'ordre de leurs onglets. L'espace identifié par `defaultSpaceId` reste toujours premier et ne peut pas être supprimé, même après renommage. +- `activeSpaceId` conserve l'espace affiché dans la page complète et le panneau latéral. +- Chaque `spaces[].moduleOrder.one` stocke l'ordre du mode une colonne de cet espace ; `two` stocke ses colonnes gauche et droite. +- Chaque identifiant d'outil présent dans `modules` apparaît exactement une fois dans l'ordre `one` d'un espace et exactement une fois dans l'une de ses colonnes `two`. +- Les anciennes toolboxes sans `spaces` sont normalisées vers un espace « Défaut » qui reprend leur `moduleOrder`. Le format canonique compact ne conserve ensuite plus le champ racine `moduleOrder`. +- Les exports individuels et globaux utilisent la version 2. Un import v2 de plus de cinq espaces est rejeté avant toute écriture. - Les données lourdes des outils ne sont pas stockées dans la toolbox, mais dans `modules`. - `modules[].collapsed` vaut `true` lorsque seul l'en-tête de l'outil est affiché ; il est omis lorsque l'outil est déplié. - `tutorial` est un indicateur temporaire réservé aux toolboxes créées depuis la prise en main. Il déclenche les premiers repères à l'ouverture, puis il est retiré automatiquement du stockage. @@ -1152,6 +1162,7 @@ Exemple : ```json { + "version": 2, "toolbox": { "id": "t1", "name": "MHW", @@ -1162,6 +1173,18 @@ Exemple : "title": "Armures" } ], + "spaces": [ + { + "id": "sp1", + "name": "Défaut", + "moduleOrder": { + "one": ["m1"], + "two": [["m1"], []] + } + } + ], + "defaultSpaceId": "sp1", + "activeSpaceId": "sp1", "updatedAt": "2026-07-25T12:00:00.000Z" }, "modules": { diff --git a/e2e/fixtures/toolbox-tutorial-probe.js b/e2e/fixtures/toolbox-tutorial-probe.js index a43c3fe..b4f4793 100644 --- a/e2e/fixtures/toolbox-tutorial-probe.js +++ b/e2e/fixtures/toolbox-tutorial-probe.js @@ -9,10 +9,22 @@ export function mountToolboxTutorialProbe(element, siteContent) { const writes = []; const pendingChanges = []; let setInputs; + function createToolbox(id) { + const defaultSpaceId = `space-${id}`; + return { + id, + name: id, + modules: [], + spaces: [{ id: defaultSpaceId, name: "Défaut", moduleOrder: { one: [], two: [[], []] } }], + defaultSpaceId, + activeSpaceId: defaultSpaceId, + tutorial: id !== "c" + }; + } function Probe() { const [inputs, updateInputs] = useState({ toolboxId: "a", pendingTutorialToolboxId: "", - toolboxes: ["a", "b", "c"].map((id) => ({ id, name: id, modules: [], tutorial: id !== "c" })) + toolboxes: ["a", "b", "c"].map(createToolbox) }); setInputs = updateInputs; return createElement(ToolboxPage, { diff --git a/e2e/module-order.spec.js b/e2e/module-order.spec.js index 09a000d..b971a0b 100644 --- a/e2e/module-order.spec.js +++ b/e2e/module-order.spec.js @@ -20,7 +20,7 @@ async function setup(page, columns) { async function savedOrder(page) { return page.evaluate(async () => { const db = await import("/src/utils/indexedDbStorage.js"); - return (await db.getToolboxes())[0].moduleOrder; + return (await db.getToolboxes())[0].spaces[0].moduleOrder; }); } diff --git a/e2e/production/asset-recovery.spec.js b/e2e/production/asset-recovery.spec.js new file mode 100644 index 0000000..52fa57e --- /dev/null +++ b/e2e/production/asset-recovery.spec.js @@ -0,0 +1,37 @@ +// Rôle : vérifie qu'un ancien HTML rétablit l'application quand son bundle Vite a disparu. +import { test, expect } from "@playwright/test"; +import { readFile } from "node:fs/promises"; + +const site = JSON.parse(await readFile("website/public/data/site.json", "utf8")); + +test("a stale entry bundle triggers one fresh document load", async ({ page }) => { + let staleDocumentServed = false; + let documentRequestCount = 0; + + await page.route("**/*", async (route) => { + const request = route.request(); + const requestUrl = new globalThis.URL(request.url()); + const isRootDocument = request.resourceType() === "document" && requestUrl.pathname === "/"; + if (isRootDocument) documentRequestCount += 1; + + if (staleDocumentServed || !isRootDocument) { + await route.continue(); + return; + } + + staleDocumentServed = true; + const response = await route.fetch(); + const html = await response.text(); + await route.fulfill({ + response, + body: html.replace(/(
diff --git a/website/public/data/library-toolbox-export.json b/website/public/data/library-toolbox-export.json index 94fd6b7..5e867b7 100644 --- a/website/public/data/library-toolbox-export.json +++ b/website/public/data/library-toolbox-export.json @@ -1,4 +1,5 @@ { + "version": 2, "toolbox": { "id": "t1", "name": "Librairie", @@ -78,8 +79,12 @@ "type": "equipmentPlanner" } ], - "moduleOrder": { - "one": [ + "spaces": [ + { + "id": "library-space", + "name": "Défaut", + "moduleOrder": { + "one": [ "m1", "m2", "m3", @@ -99,7 +104,7 @@ "mh", "mi" ], - "two": [ + "two": [ [ "m1", "m2", @@ -122,8 +127,12 @@ "mh", "mi" ] - ] - }, + ] + } + } + ], + "defaultSpaceId": "library-space", + "activeSpaceId": "library-space", "updatedAt": "2026-08-09T07:34:31.425Z", "moduleColumns": 1 }, diff --git a/website/public/data/library.json b/website/public/data/library.json index aafbbf1..033fbc9 100644 --- a/website/public/data/library.json +++ b/website/public/data/library.json @@ -229,8 +229,12 @@ "type": "equipmentPlanner" } ], - "moduleOrder": { - "one": [ + "spaces": [ + { + "id": "library-space", + "name": "Défaut", + "moduleOrder": { + "one": [ "m0", "m1", "m2", @@ -250,7 +254,7 @@ "mb", "mc" ], - "two": [ + "two": [ [ "m0", "m1", @@ -273,8 +277,12 @@ "mb", "mc" ] - ] - }, + ] + } + } + ], + "defaultSpaceId": "library-space", + "activeSpaceId": "library-space", "updatedAt": "2026-08-09T07:34:31.425Z", "moduleColumns": 1 }, diff --git a/website/public/data/site.json b/website/public/data/site.json index 1c8b89b..d2d62c3 100644 --- a/website/public/data/site.json +++ b/website/public/data/site.json @@ -1803,6 +1803,26 @@ "retryLabel": "Réessayer", "writeErrorTitle": "Erreur de stockage local" }, + "spaces": { + "toolbarLabel": "Espaces de la toolbox", + "tabsLabel": "Changer d’espace", + "addLabel": "Nouvel espace", + "createTitle": "Créer un espace", + "renameTitle": "Renommer l’espace", + "renameLabel": "Renommer l’espace actif", + "deleteTitle": "Supprimer l’espace", + "deleteLabel": "Supprimer l’espace actif", + "deleteEmpty": "Supprimer cet espace vide ?", + "deleteWithTools": "Supprimer cet espace et déplacer ses {count} outil(s) vers l’espace par défaut ?", + "nameLabel": "Nom", + "cancelLabel": "Annuler", + "saveLabel": "Enregistrer", + "reorderLabel": "Réordonner l’espace", + "moveLabel": "Déplacer vers…", + "moveSuccess": "Outil déplacé vers {space}.", + "emptyText": "Cet espace est vide. Ajoutez-y un outil pour commencer.", + "limitReached": "Limite de 5 espaces atteinte" + }, "eyebrow": "Données locales", "title": "Toolboxes", "newButton": "Nouvelle toolbox", diff --git a/website/src/features/toolboxes/ToolboxPages.jsx b/website/src/features/toolboxes/ToolboxPages.jsx index 89c355e..056a612 100644 --- a/website/src/features/toolboxes/ToolboxPages.jsx +++ b/website/src/features/toolboxes/ToolboxPages.jsx @@ -1,5 +1,5 @@ // Rôle : affiche les pages toolbox, le panneau latéral et leurs contrôles. -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { GuidedTooltipTour } from "../../components/GuidedTooltipTour.jsx"; import { Icon } from "../../components/Icon.jsx"; import { ImportButton } from "../../components/ImportButton.jsx"; @@ -11,6 +11,16 @@ import { useInlineEdit } from "../../hooks/useInlineEdit.js"; import { compressImage } from "../../utils/imageCompression.js"; import { getGameCardCover, ToolboxCard, ToolboxGameIcon, ToolboxIconPicker } from "./ToolboxCard.jsx"; import { AddToolControls, ToolboxModules } from "./modules/index.jsx"; +import { ToolboxSpaces } from "./ToolboxSpaces.jsx"; +import { + addModuleToToolboxSpace, + decodeSpaceColumn, + encodeSpaceColumn, + getActiveToolboxSpace, + getModuleSpace, + moveToolboxModule, + removeModuleFromToolboxSpaces +} from "./toolboxSpaceUtils.js"; import { clampQty, hostnameFromUrl, @@ -24,7 +34,6 @@ import { normalizeImageAnnotationData, normalizeLinksData, normalizeMemosData, - normalizeModuleOrder, normalizeNotepadData, normalizeRandomizerData, normalizeRngSimulatorData, @@ -42,31 +51,6 @@ function getDrawerMaxWidth() { return Math.max(DRAWER_INITIAL_WIDTH, Math.floor(window.innerWidth * 0.94)); } -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); @@ -187,10 +171,16 @@ function shouldShowTutorial(toolbox, pendingTutorialToolboxId) { function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, storageUsage, storageReady = true, getModuleData, updateToolbox, updateModuleData, addImageFiles, pendingTutorialToolboxId = "", setPendingTutorialToolboxId, guideChecklist }) { const toolboxId = toolbox.id; const moduleColumns = embedded || Number(toolbox.moduleColumns) === 1 ? 1 : 2; + const activeSpace = getActiveToolboxSpace(toolbox); + const spacesText = siteContent.toolboxes.spaces; const toolboxGameCover = getGameCardCover(toolboxGame); const moduleText = siteContent.toolboxes.modules; const [showTutorial, setShowTutorial] = useState(() => shouldShowTutorial(toolbox, pendingTutorialToolboxId)); const [pendingModuleId, setPendingModuleId] = useState(""); + const dragOriginSpaceRef = useRef(""); + const dragSucceededRef = useRef(false); + const hoverActivatedRef = useRef(false); + const previousDraggingIdRef = useRef(""); const tutorialRequested = shouldShowTutorial(toolbox, pendingTutorialToolboxId); const [previousTutorial, setPreviousTutorial] = useState({ toolboxId, requested: tutorialRequested }); // Consommer la demande de lancement ne doit pas interrompre la visite en cours. @@ -237,15 +227,99 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto refresh: () => {} }; + const moduleLocations = useMemo(() => { + const locations = new Map(); + toolbox.spaces.forEach((space) => { + space.moduleOrder.one.forEach((moduleId) => { + const column = space.moduleOrder.two[1].includes(moduleId) ? "right" : "left"; + locations.set(moduleId, { spaceId: space.id, column }); + }); + }); + return locations; + }, [toolbox.spaces]); + + const rawModuleReorder = useGroupedReorder({ + namespace: `toolbox-modules-${toolbox.id}`, + items: toolbox.modules, + getItemId: (module) => module.id, + getItemGroup: (module) => { + const location = moduleLocations.get(module.id); + return encodeSpaceColumn(location?.spaceId || toolbox.defaultSpaceId, moduleColumns === 1 ? "one" : location?.column || "left"); + }, + getEffectiveGroup: (module) => { + const location = moduleLocations.get(module.id); + return encodeSpaceColumn(location?.spaceId || toolbox.defaultSpaceId, moduleColumns === 1 ? "one" : location?.column || "left"); + }, + getParentId: () => toolbox.id, + reorderFeatures: { item: { groupChange: true, boundaryDrop: true } }, + canMoveItem: (operation) => operation.targetType !== "group" || decodeSpaceColumn(operation.sourceGroup).spaceId !== operation.targetGroup, + onItemMove: (operation) => { + let targetSpaceId = ""; + let targetColumn = ""; + let targetModuleId = ""; + if (operation.targetType === "group") { + targetSpaceId = operation.targetGroup; + } else if (operation.targetType === "item") { + targetModuleId = operation.targetId; + targetSpaceId = getModuleSpace(toolbox, targetModuleId)?.id || ""; + } else if (operation.targetType === "boundary") { + const target = decodeSpaceColumn(operation.targetGroup); + targetSpaceId = target.spaceId; + targetColumn = target.column; + } + if (!targetSpaceId) return; + const sourceSpace = getModuleSpace(toolbox, operation.sourceId); + dragSucceededRef.current = true; + updateToolbox(moveToolboxModule(toolbox, operation.sourceId, { + targetSpaceId, + targetModuleId, + targetColumn, + placement: operation.placement, + moduleColumns + })); + if (sourceSpace?.id !== targetSpaceId) { + const destination = toolbox.spaces.find((space) => space.id === targetSpaceId); + actions.notify(spacesText.moveSuccess.replace("{space}", destination?.name || "")); + } + }, + hierarchy: { enabled: true, stickyParents: true } + }); + const moduleReorder = { + ...rawModuleReorder, + itemReorder: { + ...rawModuleReorder.itemReorder, + startDrag: (event, moduleId) => { + dragOriginSpaceRef.current = getModuleSpace(toolbox, moduleId)?.id || activeSpace.id; + dragSucceededRef.current = false; + hoverActivatedRef.current = false; + rawModuleReorder.itemReorder.startDrag(event, moduleId); + } + } + }; + + const activateHoveredSpace = useCallback((spaceId) => { + if (spaceId === toolbox.activeSpaceId) return; + hoverActivatedRef.current = true; + updateToolbox({ ...toolbox, activeSpaceId: spaceId }); + }, [toolbox, updateToolbox]); + + useEffect(() => { + const draggingId = rawModuleReorder.itemReorder.draggingId; + if (previousDraggingIdRef.current && !draggingId) { + if (!dragSucceededRef.current && hoverActivatedRef.current && toolbox.spaces.some((space) => space.id === dragOriginSpaceRef.current)) { + updateToolbox({ ...toolbox, activeSpaceId: dragOriginSpaceRef.current }); + } + dragOriginSpaceRef.current = ""; + dragSucceededRef.current = false; + hoverActivatedRef.current = false; + } + previousDraggingIdRef.current = draggingId; + }, [rawModuleReorder.itemReorder.draggingId, toolbox, updateToolbox]); + function addModule(type, initialData = null) { if (!type) return; const module = { id: uid("mod"), type }; - const modules = [...toolbox.modules, module]; - updateToolbox({ - ...toolbox, - modules, - moduleOrder: addModuleToOrder(toolbox.moduleOrder, modules, module.id) - }); + updateToolbox(addModuleToToolboxSpace(toolbox, module, activeSpace.id)); setPendingModuleId(module.id); if (initialData) updateModuleData(toolbox.id, module.id, initialData, type); } @@ -253,12 +327,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto function createImageAnnotationModule(dataUrl) { if (!dataUrl) return; const moduleId = uid("mod"); - const modules = [...toolbox.modules, { id: moduleId, type: "imageAnnotation" }]; - updateToolbox({ - ...toolbox, - modules, - moduleOrder: addModuleToOrder(toolbox.moduleOrder, modules, moduleId) - }); + updateToolbox(addModuleToToolboxSpace(toolbox, { id: moduleId, type: "imageAnnotation" }, activeSpace.id)); setPendingModuleId(moduleId); updateModuleData(toolbox.id, moduleId, { image: dataUrl, markers: [] }, "imageAnnotation"); } @@ -291,6 +360,14 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto + {!embedded && showTutorial && ( setPendingModuleId("")} 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 })} + onMoveToSpace={(moduleId, targetSpaceId) => { + const destination = toolbox.spaces.find((space) => space.id === targetSpaceId); + updateToolbox(moveToolboxModule(toolbox, moduleId, { targetSpaceId, moduleColumns })); + actions.notify(spacesText.moveSuccess.replace("{space}", destination?.name || "")); + }} onDelete={(moduleId) => actions.setConfirmModal({ title: "Retirer l'outil", message: "Retirer cet outil de la toolbox ?", @@ -336,12 +420,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto onResolve: (confirmed) => { if (!confirmed) return; actions.removeModuleData(toolbox.id, moduleId); - const modules = toolbox.modules.filter((module) => module.id !== moduleId); - updateToolbox({ - ...toolbox, - modules, - moduleOrder: removeModuleFromOrder(toolbox.moduleOrder, modules, moduleId) - }); + updateToolbox(removeModuleFromToolboxSpaces(toolbox, moduleId)); } })} /> diff --git a/website/src/features/toolboxes/ToolboxSpaces.jsx b/website/src/features/toolboxes/ToolboxSpaces.jsx new file mode 100644 index 0000000..dcce7d6 --- /dev/null +++ b/website/src/features/toolboxes/ToolboxSpaces.jsx @@ -0,0 +1,134 @@ +// Rôle : affiche et gère les onglets d'espaces persistants d'une toolbox. +import { useEffect, useState } from "react"; +import { Icon } from "../../components/Icon.jsx"; +import { useGroupedReorder } from "../../hooks/useGroupedReorder.js"; +import { lockBodyScroll } from "../../utils/bodyScrollLock.js"; +import { MAX_TOOLBOX_SPACES } from "./storage/toolboxStorage.js"; +import { createToolboxSpace, deleteToolboxSpace, reorderToolboxSpaces } from "./toolboxSpaceUtils.js"; + +const SPACE_HOVER_DELAY_MS = 600; + +function SpaceNameModal({ initialName = "", title, text, onClose }) { + const [name, setName] = useState(initialName); + useEffect(() => lockBodyScroll(), []); + return ( +
+
onClose("")} /> +
+

{title}

+
{ + event.preventDefault(); + if (name.trim()) onClose(name.trim()); + }}> + +
+ + +
+
+
+
+ ); +} + +export function ToolboxSpaces({ toolbox, text, moduleReorder, onUpdateToolbox, onHoverActivate, onConfirm }) { + const [nameModal, setNameModal] = useState(null); + const activeSpace = toolbox.spaces.find((space) => space.id === toolbox.activeSpaceId) || toolbox.spaces[0]; + const atLimit = toolbox.spaces.length >= MAX_TOOLBOX_SPACES; + const hoveredSpaceId = toolbox.spaces.find((space) => space.id !== activeSpace.id && moduleReorder.isGroupDropTarget(space.id, toolbox.id))?.id || ""; + const spaceReorder = useGroupedReorder({ + namespace: `toolbox-spaces-${toolbox.id}`, + items: toolbox.spaces, + getItemId: (space) => space.id, + orientation: "horizontal", + canMoveItem: (operation) => operation.sourceId !== toolbox.defaultSpaceId, + onItemMove: (operation) => onUpdateToolbox(reorderToolboxSpaces(toolbox, operation.sourceId, operation.targetId, operation.placement)) + }); + + useEffect(() => { + if (!hoveredSpaceId || !moduleReorder.itemReorder.draggingId) return undefined; + const timeoutId = window.setTimeout(() => onHoverActivate(hoveredSpaceId), SPACE_HOVER_DELAY_MS); + return () => window.clearTimeout(timeoutId); + }, [hoveredSpaceId, moduleReorder.itemReorder.draggingId, onHoverActivate]); + + function saveName(name) { + if (!name || !nameModal) return setNameModal(null); + if (nameModal.mode === "create") onUpdateToolbox(createToolboxSpace(toolbox, name)); + else onUpdateToolbox({ + ...toolbox, + spaces: toolbox.spaces.map((space) => space.id === nameModal.spaceId ? { ...space, name } : space) + }); + setNameModal(null); + } + + function requestDelete() { + const count = activeSpace.moduleOrder.one.length; + onConfirm({ + title: text.deleteTitle, + message: count ? text.deleteWithTools.replace("{count}", count) : text.deleteEmpty, + confirmLabel: text.deleteLabel, + danger: true, + onResolve: (confirmed) => { + if (confirmed) onUpdateToolbox(deleteToolboxSpace(toolbox, activeSpace.id)); + } + }); + } + + return ( + <> +
+ {toolbox.spaces.length > 1 && ( +
+
+ {toolbox.spaces.map((space) => { + const active = space.id === activeSpace.id; + const isDefault = space.id === toolbox.defaultSpaceId; + const isModuleDropTarget = moduleReorder.isGroupDropTarget(space.id, toolbox.id); + const dropAfter = spaceReorder.getDropPlacement("item", space.id) === "after"; + return ( +
+ {!isDefault && } + +
+ ); + })} +
+ +
+ )} +
+ {toolbox.spaces.length > 1 && } + {toolbox.spaces.length > 1 && activeSpace.id !== toolbox.defaultSpaceId && } + {toolbox.spaces.length === 1 && } +
+
+ {nameModal && } + + ); +} diff --git a/website/src/features/toolboxes/modules/ModuleShell.jsx b/website/src/features/toolboxes/modules/ModuleShell.jsx index 245c858..e0b6c8b 100644 --- a/website/src/features/toolboxes/modules/ModuleShell.jsx +++ b/website/src/features/toolboxes/modules/ModuleShell.jsx @@ -8,7 +8,7 @@ import { ModuleDataGate, ModuleLoadingFallback } from "./ModuleDataGate.jsx"; import { supportsTextExchange } from "./textImport.js"; import { MODULE_COMPONENTS } from "./toolboxModuleComponents.jsx"; -export function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", scrollIntoView = false, onScrolledIntoView, onRename, onUpdateModule, onDelete }) { +export function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", spaces = [], activeSpaceId = "", moveText = "Déplacer vers", scrollIntoView = false, onScrolledIntoView, onRename, onUpdateModule, onDelete, onMoveToSpace }) { const definition = MODULE_COMPONENTS[module.type] || MODULE_COMPONENTS.notepad; const Component = definition.Component; const label = definition.label || module.type; @@ -93,6 +93,19 @@ export function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRename(module.id, title)} />
+ {spaces.length > 1 && ( + + )} {definition.scrollable && (