Add toolbox spaces and recover from stale asset bundles
All checks were successful
Deploy Sokko G / deploy (push) Successful in 18s

This commit is contained in:
Shinuwa 2026-09-29 21:31:49 +02:00
parent 0959c6740b
commit 0acf5ecf21
30 changed files with 1252 additions and 179 deletions

View file

@ -57,6 +57,18 @@ Rayons :
- `--radius-xl: 18px` - `--radius-xl: 18px`
- `--radius-pill: 999px` - `--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 : Animations :
- `--duration-fast: 120ms` - `--duration-fast: 120ms`
@ -594,6 +606,13 @@ Outil Tableau :
## 14. Toolboxes ## 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. Les toolboxes sont locales et stockees dans le navigateur.
Regles UI : Regles UI :

View file

@ -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. - 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. - 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 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. - 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. - 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. - 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.

View file

@ -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 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. 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 Si cette lecture échoue, aucun fichier partiel n'est téléchargé. Les snapshots
IndexedDB et import/export sont inchangés. 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 Cette coordination est locale à l'instance React ; elle ne synchronise pas
l'édition entre plusieurs onglets. l'édition entre plusieurs onglets.
@ -141,10 +141,18 @@ Les toolboxes sont stockées dans `kv/toolboxes`.
} }
], ],
"moduleColumns": 2, "moduleColumns": 2,
"moduleOrder": { "spaces": [
"one": ["mod1"], {
"two": [["mod1"], []] "id": "space1",
}, "name": "Défaut",
"moduleOrder": {
"one": ["mod1"],
"two": [["mod1"], []]
}
}
],
"defaultSpaceId": "space1",
"activeSpaceId": "space1",
"tutorial": true, "tutorial": true,
"updatedAt": "2026-07-25T12:00:00.000Z" "updatedAt": "2026-07-25T12:00:00.000Z"
} }
@ -156,10 +164,12 @@ Notes :
- `icon` est utilisé uniquement pour les toolboxes libres. - `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. - `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. - `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. - `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.
- `moduleOrder.two` stocke l'ordre explicite des colonnes gauche et droite en mode deux colonnes. - `activeSpaceId` conserve l'espace affiché dans la page complète et le panneau latéral.
- 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`. - Chaque `spaces[].moduleOrder.one` stocke l'ordre du mode une colonne de cet espace ; `two` stocke ses colonnes gauche et droite.
- 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. - 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`. - 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é. - `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. - `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 ```json
{ {
"version": 2,
"toolbox": { "toolbox": {
"id": "t1", "id": "t1",
"name": "MHW", "name": "MHW",
@ -1162,6 +1173,18 @@ Exemple :
"title": "Armures" "title": "Armures"
} }
], ],
"spaces": [
{
"id": "sp1",
"name": "Défaut",
"moduleOrder": {
"one": ["m1"],
"two": [["m1"], []]
}
}
],
"defaultSpaceId": "sp1",
"activeSpaceId": "sp1",
"updatedAt": "2026-07-25T12:00:00.000Z" "updatedAt": "2026-07-25T12:00:00.000Z"
}, },
"modules": { "modules": {

View file

@ -9,10 +9,22 @@ export function mountToolboxTutorialProbe(element, siteContent) {
const writes = []; const writes = [];
const pendingChanges = []; const pendingChanges = [];
let setInputs; 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() { function Probe() {
const [inputs, updateInputs] = useState({ const [inputs, updateInputs] = useState({
toolboxId: "a", pendingTutorialToolboxId: "", toolboxId: "a", pendingTutorialToolboxId: "",
toolboxes: ["a", "b", "c"].map((id) => ({ id, name: id, modules: [], tutorial: id !== "c" })) toolboxes: ["a", "b", "c"].map(createToolbox)
}); });
setInputs = updateInputs; setInputs = updateInputs;
return createElement(ToolboxPage, { return createElement(ToolboxPage, {

View file

@ -20,7 +20,7 @@ async function setup(page, columns) {
async function savedOrder(page) { async function savedOrder(page) {
return page.evaluate(async () => { return page.evaluate(async () => {
const db = await import("/src/utils/indexedDbStorage.js"); const db = await import("/src/utils/indexedDbStorage.js");
return (await db.getToolboxes())[0].moduleOrder; return (await db.getToolboxes())[0].spaces[0].moduleOrder;
}); });
} }

View file

@ -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(/(<script type="module"[^>]*src=")[^"]+/, "$1/assets/index-stale.js")
});
});
await page.goto("/").catch(() => {});
await expect(page.getByRole("heading", { name: site.home.hero.title, exact: true })).toBeVisible();
await expect(page).toHaveURL(/\/$|\/#\/$/);
expect(staleDocumentServed).toBe(true);
expect(documentRequestCount).toBe(2);
});

View file

@ -84,9 +84,16 @@ test("invalid calculator imports preserve existing results and a corrected impor
await expect(dialog.getByRole("alert")).toHaveCount(0); await expect(dialog.getByRole("alert")).toHaveCount(0);
const text = "@scrollResults: true\nOre: 10 | formula=10\n Bars: 20 | formula=base*2"; const text = "@scrollResults: true\nOre: 10 | formula=10\n Bars: 20 | formula=base*2";
await dialog.getByRole("textbox").fill(text); await dialog.getByRole("textbox").fill(text);
await page.evaluate(() => {
globalThis.__sokkogModuleWriteComplete = false;
globalThis.addEventListener("sokkog:auto-backup-data-changed", () => {
globalThis.__sokkogModuleWriteComplete = true;
}, { once: true });
});
await dialog.getByRole("button", { name: "Importer le texte", exact: true }).click(); await dialog.getByRole("button", { name: "Importer le texte", exact: true }).click();
await expect(dialog).toBeHidden(); await expect(dialog).toBeHidden();
await expect(calc.locator(".calculator-entry em")).toHaveText(["Ore", "Bars"]); await expect(calc.locator(".calculator-entry em")).toHaveText(["Ore", "Bars"]);
await expect.poll(() => page.evaluate(() => globalThis.__sokkogModuleWriteComplete)).toBe(true);
await page.reload(); await page.reload();
await expect(calc.locator(".calculator-entry em")).toHaveText(["Ore", "Bars"]); await expect(calc.locator(".calculator-entry em")).toHaveText(["Ore", "Bars"]);
await expect(calc.getByRole("button", { name: "Liste scrollable", exact: true })).toHaveAttribute("aria-pressed", "true"); await expect(calc.getByRole("button", { name: "Liste scrollable", exact: true })).toHaveAttribute("aria-pressed", "true");

148
e2e/toolbox-spaces.spec.js Normal file
View file

@ -0,0 +1,148 @@
// Rôle : vérifie la gestion, la limite et les déplacements entre espaces toolbox.
import { expect, test } from "@playwright/test";
import { resetSokkoStorage } from "./helpers/storage.js";
async function seedToolbox(page, toolbox, links = {}) {
await resetSokkoStorage(page);
await page.evaluate(async ({ value, links }) => {
const db = await import("/src/utils/indexedDbStorage.js");
await db.setToolboxSnapshot([value], links, {});
}, { value: toolbox, links });
await page.goto(`/#/toolbox/${toolbox.id}`);
await page.reload();
}
async function addSpace(page, name) {
await page.getByRole("button", { name: "Nouvel espace", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Créer un espace" });
await dialog.getByLabel("Nom").fill(name);
await dialog.getByRole("button", { name: "Enregistrer" }).click();
}
async function dragModuleToTab(page, title, tab, hoverMs = 0) {
await page.getByRole("button", { name: `Déplacer ${title}`, exact: true }).hover();
await page.mouse.down();
const box = await tab.boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2, { steps: 4 });
if (hoverMs) await page.waitForTimeout(hoverMs);
await page.mouse.up();
}
test("creates at most five spaces and moves tools with the accessible selector", async ({ page }) => {
await seedToolbox(page, {
id: "spaces",
name: "Spaces",
modules: [{ id: "a", type: "counters", title: "Alpha" }],
moduleOrder: { one: ["a"], two: [["a"], []] }
});
await expect(page.getByRole("tab")).toHaveCount(0);
await addSpace(page, "Boss");
const compactAddButton = page.getByRole("button", { name: "Nouvel espace", exact: true });
await expect(compactAddButton).toHaveClass(/toolbox-space-add-compact/);
await expect(compactAddButton).not.toContainText("Nouvel espace");
for (const name of ["Farm", "Build", "Notes"]) await addSpace(page, name);
await expect(page.getByRole("tab")).toHaveCount(5);
await expect(page.getByRole("button", { name: "Limite de 5 espaces atteinte" })).toBeDisabled();
await page.getByRole("button", { name: "Renommer l’espace actif" }).click();
const renameDialog = page.getByRole("dialog", { name: "Renommer l’espace" });
await renameDialog.getByLabel("Nom").fill("Divers");
await renameDialog.getByRole("button", { name: "Enregistrer" }).click();
await expect(page.getByRole("tab", { name: "Divers" })).toHaveAttribute("aria-selected", "true");
await page.getByRole("tab", { name: "Défaut" }).click();
await page.getByRole("combobox", { name: "Déplacer vers… — Alpha" }).selectOption({ label: "Divers" });
await expect(page.locator("[data-module-id='a']")).toHaveCount(0);
await page.getByRole("tab", { name: "Divers" }).click();
await expect(page.locator("[data-module-id='a']")).toBeVisible();
await page.getByRole("button", { name: "Supprimer l’espace actif" }).click();
await page.getByRole("dialog", { name: "Supprimer l’espace" }).getByRole("button", { name: "Supprimer l’espace actif" }).click();
await expect(page.getByRole("button", { name: "Nouvel espace" })).toBeEnabled();
await expect(page.getByRole("tab", { name: "Défaut" })).toHaveAttribute("aria-selected", "true");
await expect(page.locator("[data-module-id='a']")).toBeVisible();
await page.reload();
await expect(page.getByRole("tab", { name: "Défaut" })).toHaveAttribute("aria-selected", "true");
});
test("shows the tab insertion indicator on the correct side while reordering", async ({ page }) => {
await seedToolbox(page, {
id: "reorder-spaces",
name: "Reorder spaces",
modules: [],
spaces: [
{ id: "default", name: "Défaut", moduleOrder: { one: [], two: [[], []] } },
{ id: "boss", name: "Boss", moduleOrder: { one: [], two: [[], []] } },
{ id: "farm", name: "Farm", moduleOrder: { one: [], two: [[], []] } }
],
defaultSpaceId: "default",
activeSpaceId: "default"
});
const farmTabWrap = page.getByRole("tab", { name: "Farm" }).locator("..");
const farmBox = await farmTabWrap.boundingBox();
const bossDragHandle = page.getByRole("button", { name: "Réordonner l’espace Boss" });
await expect.poll(() => bossDragHandle.evaluate((element) => globalThis.getComputedStyle(element).marginLeft)).toBe("4px");
await bossDragHandle.hover();
await page.mouse.down();
await page.mouse.move(farmBox.x + farmBox.width - 2, farmBox.y + farmBox.height / 2, { steps: 4 });
await expect(farmTabWrap).toHaveClass(/is-drop-target/);
await expect(farmTabWrap).toHaveClass(/drop-after/);
await expect.poll(() => farmTabWrap.evaluate((element) => globalThis.getComputedStyle(element, "::before").right)).toBe("0px");
await page.mouse.up();
await expect(page.getByRole("tab")).toHaveText(["Défaut", "Farm", "Boss"]);
});
test("quick drop stays on the source while prolonged hover opens the destination", async ({ page }) => {
await seedToolbox(page, {
id: "drag-spaces",
name: "Drag spaces",
modules: [
{ id: "a", type: "counters", title: "Alpha" },
{ id: "b", type: "counters", title: "Beta" }
],
spaces: [
{ id: "default", name: "Défaut", moduleOrder: { one: ["a", "b"], two: [["a"], ["b"]] } },
{ id: "boss", name: "Boss", moduleOrder: { one: [], two: [[], []] } }
],
defaultSpaceId: "default",
activeSpaceId: "default"
});
const bossTab = page.getByRole("tab", { name: "Boss" });
await dragModuleToTab(page, "Alpha", bossTab);
await expect(page.getByRole("tab", { name: "Défaut" })).toHaveAttribute("aria-selected", "true");
await expect(page.locator("[data-module-id='a']")).toHaveCount(0);
await dragModuleToTab(page, "Beta", bossTab, 700);
await expect(bossTab).toHaveAttribute("aria-selected", "true");
await expect(page.locator("[data-module-id='a']")).toBeVisible();
await expect(page.locator("[data-module-id='b']")).toBeVisible();
});
test("the linked game drawer shares the persisted active space", async ({ page }) => {
await seedToolbox(page, {
id: "drawer-spaces",
name: "Drawer spaces",
modules: [{ id: "a", type: "counters", title: "Alpha" }],
spaces: [
{ id: "default", name: "Défaut", moduleOrder: { one: [], two: [[], []] } },
{ id: "boss", name: "Boss", moduleOrder: { one: ["a"], two: [["a"], []] } }
],
defaultSpaceId: "default",
activeSpaceId: "boss"
}, { mhwilds: "drawer-spaces" });
await page.goto("/#/games/mhwilds");
await page.getByRole("button", { name: "Afficher la toolbox", exact: true }).click();
const drawer = page.locator("#toolbox-drawer");
await expect(drawer.getByRole("tab", { name: "Boss" })).toHaveAttribute("aria-selected", "true");
await expect(drawer.locator("[data-module-id='a']")).toBeVisible();
await drawer.getByRole("tab", { name: "Défaut" }).click();
await expect(drawer.locator("[data-module-id='a']")).toHaveCount(0);
await page.reload();
await page.getByRole("button", { name: "Afficher la toolbox", exact: true }).click();
await expect(page.locator("#toolbox-drawer").getByRole("tab", { name: "Défaut" })).toHaveAttribute("aria-selected", "true");
});

View file

@ -217,6 +217,24 @@ export function validateSiteContent(site) {
"toolboxes.storageStatus.errorMessage", "toolboxes.storageStatus.errorMessage",
"toolboxes.storageStatus.retryLabel", "toolboxes.storageStatus.retryLabel",
"toolboxes.storageStatus.writeErrorTitle", "toolboxes.storageStatus.writeErrorTitle",
"toolboxes.spaces.toolbarLabel",
"toolboxes.spaces.tabsLabel",
"toolboxes.spaces.addLabel",
"toolboxes.spaces.createTitle",
"toolboxes.spaces.renameTitle",
"toolboxes.spaces.renameLabel",
"toolboxes.spaces.deleteTitle",
"toolboxes.spaces.deleteLabel",
"toolboxes.spaces.deleteEmpty",
"toolboxes.spaces.deleteWithTools",
"toolboxes.spaces.nameLabel",
"toolboxes.spaces.cancelLabel",
"toolboxes.spaces.saveLabel",
"toolboxes.spaces.reorderLabel",
"toolboxes.spaces.moveLabel",
"toolboxes.spaces.moveSuccess",
"toolboxes.spaces.emptyText",
"toolboxes.spaces.limitReached",
"toolboxes.title", "toolboxes.title",
"toolboxes.newButton", "toolboxes.newButton",
"toolboxes.libraryLink", "toolboxes.libraryLink",
@ -1150,15 +1168,20 @@ export function validateLibraryData(payload) {
moduleIds.add(module.id); moduleIds.add(module.id);
assert.equal(typeof payload.modules[module.id], "object", `library.modules.${module.id} must be an object`); 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.spaces), "library.toolbox.spaces must be an array");
assert.ok(Array.isArray(payload.toolbox.moduleOrder?.two), "library.toolbox.moduleOrder.two must be an array"); assert.equal(payload.toolbox.spaces.length, 1, "library.toolbox must declare one default space");
assert.equal(payload.toolbox.moduleOrder.two.length, 2, "library.toolbox.moduleOrder.two must contain 2 columns"); const defaultSpace = payload.toolbox.spaces[0];
payload.toolbox.moduleOrder.two.forEach((column, index) => { assert.equal(payload.toolbox.defaultSpaceId, defaultSpace.id, "library.toolbox.defaultSpaceId must target the default space");
assert.ok(Array.isArray(column), `library.toolbox.moduleOrder.two[${index}] must be an array`); assert.equal(payload.toolbox.activeSpaceId, defaultSpace.id, "library.toolbox.activeSpaceId must target the default space");
assert.deepEqual(defaultSpace.moduleOrder?.one, [...moduleIds], "library default space order must match toolbox modules");
assert.ok(Array.isArray(defaultSpace.moduleOrder?.two), "library default space two-column order must be an array");
assert.equal(defaultSpace.moduleOrder.two.length, 2, "library default space must contain 2 columns");
defaultSpace.moduleOrder.two.forEach((column, index) => {
assert.ok(Array.isArray(column), `library default space column ${index} must be an array`);
}); });
const twoColumnIds = payload.toolbox.moduleOrder.two.flat(); const twoColumnIds = defaultSpace.moduleOrder.two.flat();
assert.deepEqual(new Set(twoColumnIds), moduleIds, "library.toolbox.moduleOrder.two must contain every module exactly once"); assert.deepEqual(new Set(twoColumnIds), moduleIds, "library default space must contain every module exactly once");
assert.equal(twoColumnIds.length, moduleIds.size, "library.toolbox.moduleOrder.two must not contain duplicate modules"); assert.equal(twoColumnIds.length, moduleIds.size, "library default space must not contain duplicate modules");
["memos", "notepad", "checklist", "images", "links", "counters", "combos", "calculator", "table", "goalTree", "timer", "calendar", "taskPlanner", "equipmentPlanner", "imageAnnotation"].forEach((type) => { ["memos", "notepad", "checklist", "images", "links", "counters", "combos", "calculator", "table", "goalTree", "timer", "calendar", "taskPlanner", "equipmentPlanner", "imageAnnotation"].forEach((type) => {
assert.ok(payload.toolbox.modules.some((module) => module.type === type), `library must include a ${type} example`); assert.ok(payload.toolbox.modules.some((module) => module.type === type), `library must include a ${type} example`);

View file

@ -137,6 +137,32 @@ test("global imports started from the same render both survive", async () => {
assert.deepEqual(queue.getCurrent(), disk); assert.deepEqual(queue.getCurrent(), disk);
}); });
test("v2 imports reject more than five spaces before writing", async () => {
let writes = 0;
let errorMessage = "";
const actions = useToolboxActions({
store: {
ready: true,
persistToolboxSnapshot: () => { writes += 1; return Promise.resolve(true); }
},
notify() {},
setConfirmModal: (value) => { errorMessage = value.message; }
});
const result = await actions.importToolbox({ text: async () => JSON.stringify({
version: 2,
toolbox: {
id: "overflow",
name: "Overflow",
modules: [],
spaces: Array.from({ length: 6 }, (_, index) => ({ id: `space-${index}`, name: `Space ${index}`, moduleOrder: { one: [], two: [[], []] } }))
},
modules: {}
}) });
assert.equal(result, null);
assert.equal(writes, 0);
assert.match(errorMessage, /plus de 5 espaces/);
});
test("failed toolbox deletion preserves registry links and modules together", async () => { test("failed toolbox deletion preserves registry links and modules together", async () => {
const initial = { const initial = {
toolboxes: [{ id: "existing", modules: [{ id: "note", type: "notepad" }] }], toolboxes: [{ id: "existing", modules: [{ id: "note", type: "notepad" }] }],

View file

@ -19,7 +19,12 @@ test("vite entrypoint and app shell are wired", async () => {
assert.match(html, /<div id="app"><\/div>/); assert.match(html, /<div id="app"><\/div>/);
assert.match(html, /\/favicon\.ico/); assert.match(html, /\/favicon\.ico/);
assert.match(html, /\/src\/main\.jsx/); assert.match(html, /\/src\/main\.jsx/);
assert.match(html, /_asset-recovery/);
assert.match(html, /vite:preloadError/);
assert.match(html, /HTMLScriptElement/);
assert.match(source, /styles\/main\.scss/); assert.match(source, /styles\/main\.scss/);
assert.match(source, /searchParams\.has\("_asset-recovery"\)/);
assert.match(source, /searchParams\.delete\("_asset-recovery"\)/);
assert.match(source, /hooks\/useGameFilters\.js/); assert.match(source, /hooks\/useGameFilters\.js/);
assert.doesNotMatch(source, /DEFAULT_SITE_CONTENT/); assert.doesNotMatch(source, /DEFAULT_SITE_CONTENT/);
assert.match(gameFiltersHook, /export function useGameFilters/); assert.match(gameFiltersHook, /export function useGameFilters/);

View file

@ -91,6 +91,7 @@ test("toolbox module registry and modules expose expected behavior", async () =>
const moduleRegistry = await readFile("website/src/features/toolboxes/modules/index.jsx", "utf8"); const moduleRegistry = await readFile("website/src/features/toolboxes/modules/index.jsx", "utf8");
const moduleComponents = await readFile("website/src/features/toolboxes/modules/toolboxModuleComponents.jsx", "utf8"); const moduleComponents = await readFile("website/src/features/toolboxes/modules/toolboxModuleComponents.jsx", "utf8");
const addToolControls = await readFile("website/src/features/toolboxes/modules/AddToolControls.jsx", "utf8"); const addToolControls = await readFile("website/src/features/toolboxes/modules/AddToolControls.jsx", "utf8");
const toolboxPages = await readFile("website/src/features/toolboxes/ToolboxPages.jsx", "utf8");
const toolboxModules = await readFile("website/src/features/toolboxes/modules/ToolboxModules.jsx", "utf8"); const toolboxModules = await readFile("website/src/features/toolboxes/modules/ToolboxModules.jsx", "utf8");
const moduleShell = await readFile("website/src/features/toolboxes/modules/ModuleShell.jsx", "utf8"); const moduleShell = await readFile("website/src/features/toolboxes/modules/ModuleShell.jsx", "utf8");
const moduleRegistryBundle = `${moduleRegistry}\n${moduleComponents}\n${addToolControls}\n${toolboxModules}\n${moduleShell}`; const moduleRegistryBundle = `${moduleRegistry}\n${moduleComponents}\n${addToolControls}\n${toolboxModules}\n${moduleShell}`;
@ -222,7 +223,8 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(moduleShell, /module-edit-button/); assert.match(moduleShell, /module-edit-button/);
assert.match(moduleShell, /itemReorder\.startDrag/); assert.match(moduleShell, /itemReorder\.startDrag/);
assert.match(moduleRegistryBundle, /onPointerDown/); assert.match(moduleRegistryBundle, /onPointerDown/);
assert.match(toolboxModules, /useGroupedReorder/); assert.match(toolboxPages, /useGroupedReorder/);
assert.match(toolboxModules, /reorder=\{reorder\}/);
assert.match(moduleShell, /useInlineEdit/); assert.match(moduleShell, /useInlineEdit/);
assert.match(moduleShell, /getContentEditableProps/); assert.match(moduleShell, /getContentEditableProps/);
assert.match(addToolControls, /tool-add-card/); assert.match(addToolControls, /tool-add-card/);

View file

@ -15,6 +15,7 @@ import { createBalancedTeams, drawWeightedEntries, filterExclusionsForPlayers, g
import { calculateAtLeastOneProbability, calculateDrawsForChance, calculateRngCostSummary, calculateRngRealDrawsSummary, calculateRngSummary, getAveragePositionStatus, getDurationPresetsForAverageHours, getEffectiveRate, normalizeCostString, normalizeRateString, simulateRngDraws } from "../website/src/features/toolboxes/modules/rngSimulatorUtils.js"; import { calculateAtLeastOneProbability, calculateDrawsForChance, calculateRngCostSummary, calculateRngRealDrawsSummary, calculateRngSummary, getAveragePositionStatus, getDurationPresetsForAverageHours, getEffectiveRate, normalizeCostString, normalizeRateString, simulateRngDraws } from "../website/src/features/toolboxes/modules/rngSimulatorUtils.js";
import { getTimePatternRecurrenceMs, getTimePatternTargetMs } from "../website/src/features/toolboxes/modules/timerUtils.js"; import { getTimePatternRecurrenceMs, getTimePatternTargetMs } from "../website/src/features/toolboxes/modules/timerUtils.js";
import { compactModuleDataForStorage, createToolboxExportPayload, moduleStorageKey, normalizeCalculatorData, normalizeChecklistData, normalizeCombosData, normalizeCountersData, normalizeEquipmentPlannerData, normalizeGoalTreeData, normalizeImageAnnotationData, normalizeLinksData, normalizeMemosData, normalizeModuleOrder, normalizeNotepadData, normalizeRandomizerData, normalizeRngSimulatorData, normalizeTableData, normalizeTaskPlannerData, normalizeTimerData, normalizeToolbox, normalizeUrl, summarizeEquipmentPlannerData } from "../website/src/features/toolboxes/storage/toolboxStorage.js"; import { compactModuleDataForStorage, createToolboxExportPayload, moduleStorageKey, normalizeCalculatorData, normalizeChecklistData, normalizeCombosData, normalizeCountersData, normalizeEquipmentPlannerData, normalizeGoalTreeData, normalizeImageAnnotationData, normalizeLinksData, normalizeMemosData, normalizeModuleOrder, normalizeNotepadData, normalizeRandomizerData, normalizeRngSimulatorData, normalizeTableData, normalizeTaskPlannerData, normalizeTimerData, normalizeToolbox, normalizeUrl, summarizeEquipmentPlannerData } from "../website/src/features/toolboxes/storage/toolboxStorage.js";
import { createToolboxSpace, deleteToolboxSpace, moveToolboxModule, reorderToolboxSpaces } from "../website/src/features/toolboxes/toolboxSpaceUtils.js";
import { applyGroupedReorderOperation, completeGroupOrder, getBoundaryItemId, getGroupedEntries, moveGroupOrder, moveGroupOrderToEnd, moveGroupOrderToStart, moveItem, moveItemGroup } from "../website/src/hooks/useGroupedReorder.js"; import { applyGroupedReorderOperation, completeGroupOrder, getBoundaryItemId, getGroupedEntries, moveGroupOrder, moveGroupOrderToEnd, moveGroupOrderToStart, moveItem, moveItemGroup } from "../website/src/hooks/useGroupedReorder.js";
function createTextImportContext() { function createTextImportContext() {
@ -989,6 +990,48 @@ test("toolbox modules preserve only active shell display options", () => {
assert.equal("collapsed" in toolbox.modules[1], false); assert.equal("collapsed" in toolbox.modules[1], false);
}); });
test("legacy toolbox order becomes a persistent default space", () => {
const toolbox = normalizeToolbox({
id: "legacy",
modules: [{ id: "a", type: "notepad" }, { id: "b", type: "links" }],
moduleOrder: { one: ["b", "a"], two: [["a"], ["b"]] }
});
assert.equal(toolbox.spaces.length, 1);
assert.equal(toolbox.spaces[0].name, "Défaut");
assert.equal(toolbox.defaultSpaceId, toolbox.spaces[0].id);
assert.equal(toolbox.activeSpaceId, toolbox.spaces[0].id);
assert.deepEqual(toolbox.spaces[0].moduleOrder, { one: ["b", "a"], two: [["a"], ["b"]] });
});
test("spaces move modules, preserve inactive orders and merge deleted spaces into default", () => {
let toolbox = normalizeToolbox({
id: "spaces",
modules: ["a", "b", "c"].map((id) => ({ id, type: "counters" })),
moduleOrder: { one: ["a", "b", "c"], two: [["a", "b"], ["c"]] }
});
toolbox = createToolboxSpace(toolbox, "Boss");
const bossId = toolbox.activeSpaceId;
toolbox = moveToolboxModule(toolbox, "b", { targetSpaceId: bossId, moduleColumns: 1 });
assert.deepEqual(toolbox.spaces[0].moduleOrder, { one: ["a", "c"], two: [["a"], ["c"]] });
assert.deepEqual(toolbox.spaces[1].moduleOrder, { one: ["b"], two: [["b"], []] });
toolbox = deleteToolboxSpace(toolbox, bossId);
assert.deepEqual(toolbox.spaces[0].moduleOrder, { one: ["a", "c", "b"], two: [["a", "b"], ["c"]] });
});
test("the default space stays first when secondary spaces are reordered", () => {
let toolbox = normalizeToolbox({ modules: [] });
toolbox = createToolboxSpace(toolbox, "Deux");
const secondId = toolbox.activeSpaceId;
toolbox = createToolboxSpace(toolbox, "Trois");
const thirdId = toolbox.activeSpaceId;
toolbox = reorderToolboxSpaces(toolbox, thirdId, secondId, "before");
assert.equal(toolbox.spaces[0].id, toolbox.defaultSpaceId);
assert.deepEqual(toolbox.spaces.slice(1).map((space) => space.id), [thirdId, secondId]);
toolbox = createToolboxSpace(createToolboxSpace(toolbox, "Quatre"), "Cinq");
assert.equal(toolbox.spaces.length, 5);
assert.equal(createToolboxSpace(toolbox, "Six").spaces.length, 5);
});
test("toolbox export remaps module order ids", () => { test("toolbox export remaps module order ids", () => {
const payload = createToolboxExportPayload( const payload = createToolboxExportPayload(
{ {
@ -1004,11 +1047,32 @@ test("toolbox export remaps module order ids", () => {
); );
assert.deepEqual(payload.toolbox.modules.map((module) => module.id), ["m1", "m2", "m3"]); assert.deepEqual(payload.toolbox.modules.map((module) => module.id), ["m1", "m2", "m3"]);
assert.deepEqual(payload.toolbox.moduleOrder, { assert.equal(payload.version, 2);
assert.deepEqual(payload.toolbox.spaces[0].moduleOrder, {
one: ["m3", "m1", "m2"], one: ["m3", "m1", "m2"],
two: [["m2"], ["m3", "m1"]] two: [["m2"], ["m3", "m1"]]
}); });
assert.deepEqual(normalizeToolbox(payload.toolbox).moduleOrder, payload.toolbox.moduleOrder); assert.deepEqual(normalizeToolbox(payload.toolbox).spaces[0].moduleOrder, payload.toolbox.spaces[0].moduleOrder);
});
test("toolbox export remaps space ids and preserves the active space", () => {
const payload = createToolboxExportPayload({
id: "toolbox-spaces",
name: "Spaces",
modules: [{ id: "module1", type: "notepad" }, { id: "module2", type: "links" }],
spaces: [
{ id: "default", name: "Défaut", moduleOrder: { one: ["module1"], two: [["module1"], []] } },
{ id: "boss", name: "Boss", moduleOrder: { one: ["module2"], two: [[], ["module2"]] } }
],
defaultSpaceId: "default",
activeSpaceId: "boss"
}, {});
assert.equal(payload.toolbox.spaces.length, 2);
assert.notEqual(payload.toolbox.defaultSpaceId, "default");
assert.notEqual(payload.toolbox.activeSpaceId, "boss");
assert.equal(payload.toolbox.defaultSpaceId, payload.toolbox.spaces[0].id);
assert.equal(payload.toolbox.activeSpaceId, payload.toolbox.spaces[1].id);
assert.deepEqual(payload.toolbox.spaces.map((space) => space.moduleOrder.one), [["m1"], ["m2"]]);
}); });
test("toolbox export remaps randomizer multi-target exclusions", () => { test("toolbox export remaps randomizer multi-target exclusions", () => {

View file

@ -11,6 +11,33 @@
<title>Sokko G</title> <title>Sokko G</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" /> <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="alternate icon" href="/favicon.ico" sizes="any" /> <link rel="alternate icon" href="/favicon.ico" sizes="any" />
<script>
(() => {
const recoveryParameter = "_asset-recovery";
function reloadWithFreshAssets() {
const recoveryUrl = new URL(window.location.href);
if (recoveryUrl.searchParams.has(recoveryParameter)) return;
recoveryUrl.searchParams.set(recoveryParameter, Date.now().toString());
window.location.replace(recoveryUrl);
}
window.addEventListener("error", (event) => {
const resource = event.target;
if (!(resource instanceof HTMLScriptElement) || resource.type !== "module") return;
const resourceUrl = new URL(resource.src, window.location.href);
if (resourceUrl.origin === window.location.origin && resourceUrl.pathname.startsWith("/assets/")) {
reloadWithFreshAssets();
}
}, true);
window.addEventListener("vite:preloadError", (event) => {
event.preventDefault();
reloadWithFreshAssets();
});
})();
</script>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View file

@ -1,4 +1,5 @@
{ {
"version": 2,
"toolbox": { "toolbox": {
"id": "t1", "id": "t1",
"name": "Librairie", "name": "Librairie",
@ -78,8 +79,12 @@
"type": "equipmentPlanner" "type": "equipmentPlanner"
} }
], ],
"moduleOrder": { "spaces": [
"one": [ {
"id": "library-space",
"name": "Défaut",
"moduleOrder": {
"one": [
"m1", "m1",
"m2", "m2",
"m3", "m3",
@ -99,7 +104,7 @@
"mh", "mh",
"mi" "mi"
], ],
"two": [ "two": [
[ [
"m1", "m1",
"m2", "m2",
@ -122,8 +127,12 @@
"mh", "mh",
"mi" "mi"
] ]
] ]
}, }
}
],
"defaultSpaceId": "library-space",
"activeSpaceId": "library-space",
"updatedAt": "2026-08-09T07:34:31.425Z", "updatedAt": "2026-08-09T07:34:31.425Z",
"moduleColumns": 1 "moduleColumns": 1
}, },

View file

@ -229,8 +229,12 @@
"type": "equipmentPlanner" "type": "equipmentPlanner"
} }
], ],
"moduleOrder": { "spaces": [
"one": [ {
"id": "library-space",
"name": "Défaut",
"moduleOrder": {
"one": [
"m0", "m0",
"m1", "m1",
"m2", "m2",
@ -250,7 +254,7 @@
"mb", "mb",
"mc" "mc"
], ],
"two": [ "two": [
[ [
"m0", "m0",
"m1", "m1",
@ -273,8 +277,12 @@
"mb", "mb",
"mc" "mc"
] ]
] ]
}, }
}
],
"defaultSpaceId": "library-space",
"activeSpaceId": "library-space",
"updatedAt": "2026-08-09T07:34:31.425Z", "updatedAt": "2026-08-09T07:34:31.425Z",
"moduleColumns": 1 "moduleColumns": 1
}, },

View file

@ -1803,6 +1803,26 @@
"retryLabel": "Réessayer", "retryLabel": "Réessayer",
"writeErrorTitle": "Erreur de stockage local" "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", "eyebrow": "Données locales",
"title": "Toolboxes", "title": "Toolboxes",
"newButton": "Nouvelle toolbox", "newButton": "Nouvelle toolbox",

View file

@ -1,5 +1,5 @@
// Rôle : affiche les pages toolbox, le panneau latéral et leurs contrôles. // 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 { GuidedTooltipTour } from "../../components/GuidedTooltipTour.jsx";
import { Icon } from "../../components/Icon.jsx"; import { Icon } from "../../components/Icon.jsx";
import { ImportButton } from "../../components/ImportButton.jsx"; import { ImportButton } from "../../components/ImportButton.jsx";
@ -11,6 +11,16 @@ import { useInlineEdit } from "../../hooks/useInlineEdit.js";
import { compressImage } from "../../utils/imageCompression.js"; import { compressImage } from "../../utils/imageCompression.js";
import { getGameCardCover, ToolboxCard, ToolboxGameIcon, ToolboxIconPicker } from "./ToolboxCard.jsx"; import { getGameCardCover, ToolboxCard, ToolboxGameIcon, ToolboxIconPicker } from "./ToolboxCard.jsx";
import { AddToolControls, ToolboxModules } from "./modules/index.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 { import {
clampQty, clampQty,
hostnameFromUrl, hostnameFromUrl,
@ -24,7 +34,6 @@ import {
normalizeImageAnnotationData, normalizeImageAnnotationData,
normalizeLinksData, normalizeLinksData,
normalizeMemosData, normalizeMemosData,
normalizeModuleOrder,
normalizeNotepadData, normalizeNotepadData,
normalizeRandomizerData, normalizeRandomizerData,
normalizeRngSimulatorData, normalizeRngSimulatorData,
@ -42,31 +51,6 @@ function getDrawerMaxWidth() {
return Math.max(DRAWER_INITIAL_WIDTH, Math.floor(window.innerWidth * 0.94)); 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) { async function copyText(value) {
try { try {
await navigator.clipboard.writeText(value); 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 }) { function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, storageUsage, storageReady = true, getModuleData, updateToolbox, updateModuleData, addImageFiles, pendingTutorialToolboxId = "", setPendingTutorialToolboxId, guideChecklist }) {
const toolboxId = toolbox.id; const toolboxId = toolbox.id;
const moduleColumns = embedded || Number(toolbox.moduleColumns) === 1 ? 1 : 2; const moduleColumns = embedded || Number(toolbox.moduleColumns) === 1 ? 1 : 2;
const activeSpace = getActiveToolboxSpace(toolbox);
const spacesText = siteContent.toolboxes.spaces;
const toolboxGameCover = getGameCardCover(toolboxGame); const toolboxGameCover = getGameCardCover(toolboxGame);
const moduleText = siteContent.toolboxes.modules; const moduleText = siteContent.toolboxes.modules;
const [showTutorial, setShowTutorial] = useState(() => shouldShowTutorial(toolbox, pendingTutorialToolboxId)); const [showTutorial, setShowTutorial] = useState(() => shouldShowTutorial(toolbox, pendingTutorialToolboxId));
const [pendingModuleId, setPendingModuleId] = useState(""); const [pendingModuleId, setPendingModuleId] = useState("");
const dragOriginSpaceRef = useRef("");
const dragSucceededRef = useRef(false);
const hoverActivatedRef = useRef(false);
const previousDraggingIdRef = useRef("");
const tutorialRequested = shouldShowTutorial(toolbox, pendingTutorialToolboxId); const tutorialRequested = shouldShowTutorial(toolbox, pendingTutorialToolboxId);
const [previousTutorial, setPreviousTutorial] = useState({ toolboxId, requested: tutorialRequested }); const [previousTutorial, setPreviousTutorial] = useState({ toolboxId, requested: tutorialRequested });
// Consommer la demande de lancement ne doit pas interrompre la visite en cours. // 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: () => {} 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) { function addModule(type, initialData = null) {
if (!type) return; if (!type) return;
const module = { id: uid("mod"), type }; const module = { id: uid("mod"), type };
const modules = [...toolbox.modules, module]; updateToolbox(addModuleToToolboxSpace(toolbox, module, activeSpace.id));
updateToolbox({
...toolbox,
modules,
moduleOrder: addModuleToOrder(toolbox.moduleOrder, modules, module.id)
});
setPendingModuleId(module.id); setPendingModuleId(module.id);
if (initialData) updateModuleData(toolbox.id, module.id, initialData, type); if (initialData) updateModuleData(toolbox.id, module.id, initialData, type);
} }
@ -253,12 +327,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
function createImageAnnotationModule(dataUrl) { function createImageAnnotationModule(dataUrl) {
if (!dataUrl) return; if (!dataUrl) return;
const moduleId = uid("mod"); const moduleId = uid("mod");
const modules = [...toolbox.modules, { id: moduleId, type: "imageAnnotation" }]; updateToolbox(addModuleToToolboxSpace(toolbox, { id: moduleId, type: "imageAnnotation" }, activeSpace.id));
updateToolbox({
...toolbox,
modules,
moduleOrder: addModuleToOrder(toolbox.moduleOrder, modules, moduleId)
});
setPendingModuleId(moduleId); setPendingModuleId(moduleId);
updateModuleData(toolbox.id, moduleId, { image: dataUrl, markers: [] }, "imageAnnotation"); updateModuleData(toolbox.id, moduleId, { image: dataUrl, markers: [] }, "imageAnnotation");
} }
@ -291,6 +360,14 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
<AddToolControls onAdd={addModule} /> <AddToolControls onAdd={addModule} />
</div> </div>
</section> </section>
<ToolboxSpaces
toolbox={toolbox}
text={spacesText}
moduleReorder={moduleReorder}
onUpdateToolbox={updateToolbox}
onHoverActivate={activateHoveredSpace}
onConfirm={actions.setConfirmModal}
/>
{!embedded && showTutorial && ( {!embedded && showTutorial && (
<GuidedTooltipTour <GuidedTooltipTour
key={toolboxId} key={toolboxId}
@ -321,13 +398,20 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
)} )}
<ToolboxModules <ToolboxModules
toolbox={toolbox} toolbox={toolbox}
space={activeSpace}
moduleColumns={moduleColumns} moduleColumns={moduleColumns}
context={moduleContext} context={moduleContext}
reorder={moduleReorder}
spacesText={spacesText}
scrollToModuleId={pendingModuleId} scrollToModuleId={pendingModuleId}
onModuleScrolled={() => setPendingModuleId("")} onModuleScrolled={() => setPendingModuleId("")}
onRename={(moduleId, title) => updateToolbox({ ...toolbox, modules: toolbox.modules.map((module) => module.id === moduleId ? { ...module, title } : module) })} 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) })} 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({ onDelete={(moduleId) => actions.setConfirmModal({
title: "Retirer l'outil", title: "Retirer l'outil",
message: "Retirer cet outil de la toolbox ?", message: "Retirer cet outil de la toolbox ?",
@ -336,12 +420,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
onResolve: (confirmed) => { onResolve: (confirmed) => {
if (!confirmed) return; if (!confirmed) return;
actions.removeModuleData(toolbox.id, moduleId); actions.removeModuleData(toolbox.id, moduleId);
const modules = toolbox.modules.filter((module) => module.id !== moduleId); updateToolbox(removeModuleFromToolboxSpaces(toolbox, moduleId));
updateToolbox({
...toolbox,
modules,
moduleOrder: removeModuleFromOrder(toolbox.moduleOrder, modules, moduleId)
});
} }
})} })}
/> />

View file

@ -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 (
<div className="confirm-modal-root">
<div className="confirm-backdrop nds-modal-backdrop" onClick={() => onClose("")} />
<section className="confirm-modal nds-modal toolbox-space-modal" role="dialog" aria-modal="true" aria-labelledby="toolbox-space-modal-title">
<header><h2 id="toolbox-space-modal-title">{title}</h2></header>
<form onSubmit={(event) => {
event.preventDefault();
if (name.trim()) onClose(name.trim());
}}>
<label className="field"><span>{text.nameLabel}</span><input className="nds-input" data-size="sm" value={name} onChange={(event) => setName(event.target.value)} autoComplete="off" autoFocus required /></label>
<footer className="nds-modal-footer">
<button className="nds-button" data-size="sm" type="button" onClick={() => onClose("")}>{text.cancelLabel}</button>
<button className="nds-button" data-size="sm" data-variant="primary" type="submit">{text.saveLabel}</button>
</footer>
</form>
</section>
</div>
);
}
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 (
<>
<section className={`toolbox-spaces-toolbar ${toolbox.spaces.length === 1 ? "is-single" : ""}`} aria-label={text.toolbarLabel}>
{toolbox.spaces.length > 1 && (
<div className="toolbox-space-tabs-row legacy-scrollbar">
<div className="toolbox-space-tabs" role="tablist" aria-label={text.tabsLabel}>
{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 (
<div
className={`toolbox-space-tab-wrap ${active ? "active" : ""} ${isModuleDropTarget ? "is-module-drop-target" : ""} ${spaceReorder.isItemDragging(space.id) ? "is-dragging" : ""} ${spaceReorder.isItemDropTarget(space.id) ? "is-drop-target" : ""} ${dropAfter ? "drop-after" : ""}`}
key={space.id}
role="presentation"
{...spaceReorder.getItemProps({ itemId: space.id })}
>
{!isDefault && <button className="toolbox-space-drag-handle nds-icon-button" data-size="xs" type="button" onPointerDown={(event) => spaceReorder.itemReorder.startDrag(event, space.id)} aria-label={`${text.reorderLabel} ${space.name}`} title={text.reorderLabel}><Icon name="drag" /></button>}
<button
className={`toolbox-space-tab ${active ? "active" : ""}`}
type="button"
role="tab"
aria-selected={active}
tabIndex={active ? 0 : -1}
onClick={() => onUpdateToolbox({ ...toolbox, activeSpaceId: space.id })}
onKeyDown={(event) => {
const index = toolbox.spaces.findIndex((item) => item.id === space.id);
let nextIndex;
if (event.key === "ArrowRight") nextIndex = (index + 1) % toolbox.spaces.length;
else if (event.key === "ArrowLeft") nextIndex = (index - 1 + toolbox.spaces.length) % toolbox.spaces.length;
else if (event.key === "Home") nextIndex = 0;
else if (event.key === "End") nextIndex = toolbox.spaces.length - 1;
else return;
event.preventDefault();
const nextSpace = toolbox.spaces[nextIndex];
onUpdateToolbox({ ...toolbox, activeSpaceId: nextSpace.id });
event.currentTarget.parentElement?.parentElement?.querySelectorAll('[role="tab"]')[nextIndex]?.focus();
}}
{...moduleReorder.getGroupProps({ groupId: space.id, parentId: toolbox.id })}
>{space.name}</button>
</div>
);
})}
</div>
<button className="toolbox-space-add-compact nds-icon-button" data-size="sm" type="button" disabled={atLimit} onClick={() => !atLimit && setNameModal({ mode: "create" })} aria-label={atLimit ? text.limitReached : text.addLabel} title={atLimit ? text.limitReached : text.addLabel}><Icon name="add" /></button>
</div>
)}
<div className="toolbox-space-actions nds-toolbar">
{toolbox.spaces.length > 1 && <button className="nds-icon-button" data-size="sm" type="button" onClick={() => setNameModal({ mode: "rename", spaceId: activeSpace.id })} aria-label={text.renameLabel} title={text.renameLabel}><Icon name="edit" /></button>}
{toolbox.spaces.length > 1 && activeSpace.id !== toolbox.defaultSpaceId && <button className="nds-icon-button" data-size="sm" data-variant="danger" type="button" onClick={requestDelete} aria-label={text.deleteLabel} title={text.deleteLabel}><span className="ui-icon ui-icon-trash" aria-hidden="true" /></button>}
{toolbox.spaces.length === 1 && <button className="nds-button toolbox-space-add" data-size="sm" data-variant="dashed" type="button" disabled={atLimit} onClick={() => !atLimit && setNameModal({ mode: "create" })} aria-label={atLimit ? text.limitReached : text.addLabel} title={atLimit ? text.limitReached : text.addLabel}><Icon name="add" />{text.addLabel}</button>}
</div>
</section>
{nameModal && <SpaceNameModal initialName={nameModal.mode === "rename" ? activeSpace.name : ""} title={nameModal.mode === "create" ? text.createTitle : text.renameTitle} text={text} onClose={saveName} />}
</>
);
}

View file

@ -8,7 +8,7 @@ import { ModuleDataGate, ModuleLoadingFallback } from "./ModuleDataGate.jsx";
import { supportsTextExchange } from "./textImport.js"; import { supportsTextExchange } from "./textImport.js";
import { MODULE_COMPONENTS } from "./toolboxModuleComponents.jsx"; 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 definition = MODULE_COMPONENTS[module.type] || MODULE_COMPONENTS.notepad;
const Component = definition.Component; const Component = definition.Component;
const label = definition.label || module.type; const label = definition.label || module.type;
@ -93,6 +93,19 @@ export function ModuleShell({ toolbox, module, context, reorder, itemGroup = "",
<EditableModuleTitle value={module.title || label} fallback={label} tutorialTarget={module.type === "memos" ? "moduleTitle" : ""} onSave={(title) => onRename(module.id, title)} /> <EditableModuleTitle value={module.title || label} fallback={label} tutorialTarget={module.type === "memos" ? "moduleTitle" : ""} onSave={(title) => onRename(module.id, title)} />
</div> </div>
<div> <div>
{spaces.length > 1 && (
<select
className="module-space-select nds-select"
data-size="sm"
value=""
aria-label={`${moveText} — ${module.title || label}`}
title={moveText}
onChange={(event) => onMoveToSpace(module.id, event.target.value)}
>
<option value="" disabled>{moveText}</option>
{spaces.filter((space) => space.id !== activeSpaceId).map((space) => <option key={space.id} value={space.id}>{space.name}</option>)}
</select>
)}
{definition.scrollable && ( {definition.scrollable && (
<button <button
className={`module-scroll-button nds-switch ${scrollable ? "active" : ""}`} className={`module-scroll-button nds-switch ${scrollable ? "active" : ""}`}

View file

@ -1,6 +1,6 @@
// Rôle : répartit et réorganise les modules d'une toolbox dans leurs colonnes. // Rôle : répartit les modules de l'espace actif dans leurs colonnes et cibles de dépôt.
import { useMemo } from "react"; import { useMemo } from "react";
import { useGroupedReorder } from "../../../hooks/useGroupedReorder.js"; import { encodeSpaceColumn } from "../toolboxSpaceUtils.js";
import { ModuleShell } from "./ModuleShell.jsx"; import { ModuleShell } from "./ModuleShell.jsx";
const MODULE_COLUMN_IDS = ["left", "right"]; const MODULE_COLUMN_IDS = ["left", "right"];
@ -10,97 +10,29 @@ function getModulesByIds(modules, ids) {
return (Array.isArray(ids) ? ids : []).map((id) => moduleMap.get(id)).filter(Boolean); return (Array.isArray(ids) ? ids : []).map((id) => moduleMap.get(id)).filter(Boolean);
} }
function getModuleColumnId(moduleId, twoColumnOrder) { export function ToolboxModules({ toolbox, space, moduleColumns, context, reorder, spacesText, scrollToModuleId = "", onModuleScrolled, onRename, onUpdateModule, onDelete, onMoveToSpace }) {
return twoColumnOrder?.[1]?.includes(moduleId) ? MODULE_COLUMN_IDS[1] : MODULE_COLUMN_IDS[0]; const twoColumnOrder = useMemo(() => space.moduleOrder?.two || [[], []], [space.moduleOrder?.two]);
} const orderedModules = useMemo(() => getModulesByIds(toolbox.modules, space.moduleOrder?.one || []), [toolbox.modules, space.moduleOrder?.one]);
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;
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 { ...order, two: columns };
}
export function ToolboxModules({ toolbox, moduleColumns, context, scrollToModuleId = "", onModuleScrolled, onRename, onUpdateModule, onDelete, onModuleOrderChange }) {
const twoColumnOrder = useMemo(() => toolbox.moduleOrder?.two || [[], []], [toolbox.moduleOrder?.two]);
const orderedModules = useMemo(() => getModulesByIds(toolbox.modules, toolbox.moduleOrder?.one || []), [toolbox.modules, toolbox.moduleOrder?.one]);
const columns = useMemo(() => [ const columns = useMemo(() => [
getModulesByIds(toolbox.modules, twoColumnOrder[0]), getModulesByIds(toolbox.modules, twoColumnOrder[0]),
getModulesByIds(toolbox.modules, twoColumnOrder[1]) getModulesByIds(toolbox.modules, twoColumnOrder[1])
], [toolbox.modules, twoColumnOrder]); ], [toolbox.modules, twoColumnOrder]);
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,
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 }
});
if (moduleColumns === 1) { if (moduleColumns === 1) {
return ( return (
<section className="modules" data-toolbox-id={toolbox.id} data-layout-cols={moduleColumns}> <section className="modules" data-toolbox-id={toolbox.id} data-layout-cols={moduleColumns}>
{!orderedModules.length && <div className="empty toolbox-space-empty nds-empty-state"><p>{spacesText.emptyText}</p></div>}
{orderedModules.map((module) => ( {orderedModules.map((module) => (
<ModuleShell <ModuleShell
key={module.id} key={module.id}
toolbox={toolbox} toolbox={toolbox}
module={module} module={module}
context={context} context={context}
reorder={{ itemReorder, getItemProps, isItemDragging, isItemDropTarget, getDropPlacement }} reorder={reorder}
itemGroup="" itemGroup={encodeSpaceColumn(space.id, "one")}
spaces={toolbox.spaces}
activeSpaceId={space.id}
moveText={spacesText.moveLabel}
onMoveToSpace={onMoveToSpace}
scrollIntoView={module.id === scrollToModuleId} scrollIntoView={module.id === scrollToModuleId}
onScrolledIntoView={onModuleScrolled} onScrolledIntoView={onModuleScrolled}
onRename={onRename} onRename={onRename}
@ -112,15 +44,15 @@ export function ToolboxModules({ toolbox, moduleColumns, context, scrollToModule
); );
} }
const hasEmptyColumn = toolbox.modules.length > 1 && columns.some((modules) => modules.length === 0); const hasEmptyColumn = orderedModules.length > 1 && columns.some((modules) => modules.length === 0);
return ( return (
<section className="modules" data-toolbox-id={toolbox.id} data-layout-cols={moduleColumns}> <section className="modules" data-toolbox-id={toolbox.id} data-layout-cols={moduleColumns}>
{columns.map((modules, index) => ( {columns.map((modules, index) => (
<div className={`module-column ${hasEmptyColumn && !modules.length ? "is-empty-warning" : ""}`} key={MODULE_COLUMN_IDS[index]}> <div className={`module-column ${hasEmptyColumn && !modules.length ? "is-empty-warning" : ""}`} key={MODULE_COLUMN_IDS[index]}>
<div <div
className={`module-column-boundary-drop-zone ${isBoundaryDropTarget({ groupId: MODULE_COLUMN_IDS[index], parentId: toolbox.id, placement: "before" }) ? "is-drop-target" : ""}`} className={`module-column-boundary-drop-zone ${reorder.isBoundaryDropTarget({ groupId: encodeSpaceColumn(space.id, MODULE_COLUMN_IDS[index]), parentId: toolbox.id, placement: "before" }) ? "is-drop-target" : ""}`}
{...getGroupBoundaryProps({ groupId: MODULE_COLUMN_IDS[index], parentId: toolbox.id, placement: "before" })} {...reorder.getGroupBoundaryProps({ groupId: encodeSpaceColumn(space.id, MODULE_COLUMN_IDS[index]), parentId: toolbox.id, placement: "before" })}
/> />
{modules.map((module) => ( {modules.map((module) => (
<ModuleShell <ModuleShell
@ -128,8 +60,12 @@ export function ToolboxModules({ toolbox, moduleColumns, context, scrollToModule
toolbox={toolbox} toolbox={toolbox}
module={module} module={module}
context={context} context={context}
reorder={{ itemReorder, getItemProps, isItemDragging, isItemDropTarget, getDropPlacement }} reorder={reorder}
itemGroup={MODULE_COLUMN_IDS[index]} itemGroup={encodeSpaceColumn(space.id, MODULE_COLUMN_IDS[index])}
spaces={toolbox.spaces}
activeSpaceId={space.id}
moveText={spacesText.moveLabel}
onMoveToSpace={onMoveToSpace}
scrollIntoView={module.id === scrollToModuleId} scrollIntoView={module.id === scrollToModuleId}
onScrolledIntoView={onModuleScrolled} onScrolledIntoView={onModuleScrolled}
onRename={onRename} onRename={onRename}
@ -138,11 +74,12 @@ export function ToolboxModules({ toolbox, moduleColumns, context, scrollToModule
/> />
))} ))}
<div <div
className={`module-column-boundary-drop-zone ${isBoundaryDropTarget({ groupId: MODULE_COLUMN_IDS[index], parentId: toolbox.id, placement: "after" }) ? "is-drop-target" : ""}`} className={`module-column-boundary-drop-zone ${reorder.isBoundaryDropTarget({ groupId: encodeSpaceColumn(space.id, MODULE_COLUMN_IDS[index]), parentId: toolbox.id, placement: "after" }) ? "is-drop-target" : ""}`}
{...getGroupBoundaryProps({ groupId: MODULE_COLUMN_IDS[index], parentId: toolbox.id, placement: "after" })} {...reorder.getGroupBoundaryProps({ groupId: encodeSpaceColumn(space.id, MODULE_COLUMN_IDS[index]), parentId: toolbox.id, placement: "after" })}
/> />
</div> </div>
))} ))}
{!orderedModules.length && <div className="empty toolbox-space-empty nds-empty-state"><p>{spacesText.emptyText}</p></div>}
</section> </section>
); );
} }

View file

@ -1,6 +1,7 @@
// Rôle : fournit les primitives communes de normalisation et d'identifiants du stockage toolbox. // Rôle : fournit les primitives communes de normalisation et d'identifiants du stockage toolbox.
export const ID_PREFIXES = { export const ID_PREFIXES = {
tbx: "t", tbx: "t",
space: "sp",
mod: "m", mod: "m",
item: "i", item: "i",
section: "g", section: "g",

View file

@ -47,6 +47,7 @@ const TOOLBOX_ICON_FILES = [
]; ];
export const TOOLBOX_ICONS = TOOLBOX_ICON_FILES.map((file) => `${TOOLBOX_ICON_BASE}${file}`); export const TOOLBOX_ICONS = TOOLBOX_ICON_FILES.map((file) => `${TOOLBOX_ICON_BASE}${file}`);
export const DEFAULT_TOOLBOX_ICON = `${TOOLBOX_ICON_BASE}toolbox.png`; export const DEFAULT_TOOLBOX_ICON = `${TOOLBOX_ICON_BASE}toolbox.png`;
export const MAX_TOOLBOX_SPACES = 5;
export function normalizeToolboxIcon(icon) { export function normalizeToolboxIcon(icon) {
const value = String(icon || "").trim(); const value = String(icon || "").trim();
@ -123,9 +124,74 @@ export function remapModuleOrderIds(moduleOrder, moduleIdMap) {
}; };
} }
function getSpaceModuleIds(space, knownModuleIds) {
const candidates = [
...(Array.isArray(space?.moduleOrder?.one) ? space.moduleOrder.one : []),
...(Array.isArray(space?.moduleOrder?.two?.[0]) ? space.moduleOrder.two[0] : []),
...(Array.isArray(space?.moduleOrder?.two?.[1]) ? space.moduleOrder.two[1] : [])
];
return uniqueKnownIds(candidates, knownModuleIds);
}
export function normalizeToolboxSpaces(toolbox, modules) {
const moduleIds = modules.map((module) => module.id);
const rawSpaces = Array.isArray(toolbox?.spaces) ? toolbox.spaces.filter((space) => space && typeof space === "object") : [];
if (!rawSpaces.length) {
const id = uid("space");
return {
spaces: [{ id, name: "Défaut", moduleOrder: normalizeModuleOrder(toolbox?.moduleOrder, modules) }],
defaultSpaceId: id,
activeSpaceId: id
};
}
const seenSpaceIds = new Set();
const prepared = rawSpaces.slice(0, MAX_TOOLBOX_SPACES).map((space, index) => {
let id = String(space.id || "").trim() || uid("space");
while (seenSpaceIds.has(id)) id = uid("space");
seenSpaceIds.add(id);
return {
id,
name: String(space.name || "").trim() || (index === 0 ? "Défaut" : `Espace ${index + 1}`),
source: space
};
});
const requestedDefaultId = String(toolbox?.defaultSpaceId || "");
const defaultIndex = prepared.findIndex((space) => space.id === requestedDefaultId);
if (defaultIndex > 0) prepared.unshift(prepared.splice(defaultIndex, 1)[0]);
const assigned = new Set();
const memberships = new Map();
prepared.forEach((space) => {
const ids = getSpaceModuleIds(space.source, moduleIds).filter((id) => {
if (assigned.has(id)) return false;
assigned.add(id);
return true;
});
memberships.set(space.id, ids);
});
const defaultId = prepared[0].id;
memberships.set(defaultId, [...memberships.get(defaultId), ...moduleIds.filter((id) => !assigned.has(id))]);
const spaces = prepared.map((space) => {
const memberIds = memberships.get(space.id);
const memberSet = new Set(memberIds);
const spaceModules = modules.filter((module) => memberSet.has(module.id));
return {
id: space.id,
name: space.name,
moduleOrder: normalizeModuleOrder(space.source.moduleOrder, spaceModules)
};
});
const requestedActiveId = String(toolbox?.activeSpaceId || "");
const activeSpaceId = spaces.some((space) => space.id === requestedActiveId) ? requestedActiveId : defaultId;
return { spaces, defaultSpaceId: defaultId, activeSpaceId };
}
export function normalizeToolbox(toolbox) { export function normalizeToolbox(toolbox) {
if (!toolbox || typeof toolbox !== "object") return null; if (!toolbox || typeof toolbox !== "object") return null;
const modules = (Array.isArray(toolbox.modules) ? toolbox.modules : []).map(normalizeToolboxModule).filter(Boolean); const modules = (Array.isArray(toolbox.modules) ? toolbox.modules : []).map(normalizeToolboxModule).filter(Boolean);
const spaceState = normalizeToolboxSpaces(toolbox, modules);
const normalized = { const normalized = {
id: toolbox.id || uid("tbx"), id: toolbox.id || uid("tbx"),
name: String(toolbox.name || "Nouvelle toolbox").trim() || "Nouvelle toolbox", name: String(toolbox.name || "Nouvelle toolbox").trim() || "Nouvelle toolbox",
@ -133,7 +199,7 @@ export function normalizeToolbox(toolbox) {
customCover: normalizeToolboxCustomCover(toolbox.customCover), customCover: normalizeToolboxCustomCover(toolbox.customCover),
moduleColumns: Number(toolbox.moduleColumns) === 1 ? 1 : 2, moduleColumns: Number(toolbox.moduleColumns) === 1 ? 1 : 2,
modules, modules,
moduleOrder: normalizeModuleOrder(toolbox.moduleOrder, modules), ...spaceState,
updatedAt: toolbox.updatedAt || new Date().toISOString() updatedAt: toolbox.updatedAt || new Date().toISOString()
}; };
if (toolbox.tutorial === true) normalized.tutorial = true; if (toolbox.tutorial === true) normalized.tutorial = true;
@ -147,7 +213,9 @@ export function compactToolboxForStorage(toolbox) {
id: normalized.id, id: normalized.id,
name: normalized.name, name: normalized.name,
modules: normalized.modules, modules: normalized.modules,
moduleOrder: normalized.moduleOrder, spaces: normalized.spaces,
defaultSpaceId: normalized.defaultSpaceId,
activeSpaceId: normalized.activeSpaceId,
updatedAt: normalized.updatedAt updatedAt: normalized.updatedAt
}; };
if (normalized.moduleColumns === 1) compact.moduleColumns = 1; if (normalized.moduleColumns === 1) compact.moduleColumns = 1;
@ -402,6 +470,7 @@ export function createToolboxExportPayload(toolbox, moduleData) {
const source = normalizeToolbox(toolbox); const source = normalizeToolbox(toolbox);
const nextId = createExportIdFactory(); const nextId = createExportIdFactory();
const moduleIdMap = new Map(); const moduleIdMap = new Map();
const spaceIdMap = new Map(source.spaces.map((space) => [space.id, nextId("space")]));
const exportedToolbox = compactToolboxForStorage({ const exportedToolbox = compactToolboxForStorage({
...source, ...source,
id: nextId("tbx"), id: nextId("tbx"),
@ -410,7 +479,13 @@ export function createToolboxExportPayload(toolbox, moduleData) {
moduleIdMap.set(module.id, id); moduleIdMap.set(module.id, id);
return { ...module, id }; return { ...module, id };
}), }),
moduleOrder: remapModuleOrderIds(source.moduleOrder, moduleIdMap) spaces: source.spaces.map((space) => ({
...space,
id: spaceIdMap.get(space.id),
moduleOrder: remapModuleOrderIds(space.moduleOrder, moduleIdMap)
})),
defaultSpaceId: spaceIdMap.get(source.defaultSpaceId),
activeSpaceId: spaceIdMap.get(source.activeSpaceId)
}); });
const modules = Object.fromEntries(source.modules const modules = Object.fromEntries(source.modules
.map((module) => [ .map((module) => [
@ -418,7 +493,7 @@ export function createToolboxExportPayload(toolbox, moduleData) {
remapModuleDataForExport(module.type, moduleData[moduleStorageKey(source.id, module.id)] || null, nextId) remapModuleDataForExport(module.type, moduleData[moduleStorageKey(source.id, module.id)] || null, nextId)
]) ])
.filter(([, data]) => data != null)); .filter(([, data]) => data != null));
return { toolbox: exportedToolbox, modules }; return { version: 2, toolbox: exportedToolbox, modules };
} }
export function createGlobalExportPayload(toolboxes, links, moduleData) { export function createGlobalExportPayload(toolboxes, links, moduleData) {
@ -430,7 +505,7 @@ export function createGlobalExportPayload(toolboxes, links, moduleData) {
}); });
}); });
return { return {
version: 1, version: 2,
exportedAt: new Date().toISOString(), exportedAt: new Date().toISOString(),
toolboxes: compactToolboxesForStorage(toolboxes), toolboxes: compactToolboxesForStorage(toolboxes),
modules, modules,

View file

@ -0,0 +1,163 @@
// Rôle : applique les créations, suppressions et déplacements d'outils entre espaces toolbox.
import { MAX_TOOLBOX_SPACES, normalizeModuleOrder, uid } from "./storage/toolboxStorage.js";
export const SPACE_GROUP_SEPARATOR = "::";
export function getActiveToolboxSpace(toolbox) {
return toolbox.spaces.find((space) => space.id === toolbox.activeSpaceId) || toolbox.spaces[0];
}
export function getModuleSpace(toolbox, moduleId) {
return toolbox.spaces.find((space) => space.moduleOrder.one.includes(moduleId)) || toolbox.spaces[0];
}
export function encodeSpaceColumn(spaceId, column = "one") {
return `${spaceId}${SPACE_GROUP_SEPARATOR}${column}`;
}
export function decodeSpaceColumn(value) {
const [spaceId = "", column = "one"] = String(value || "").split(SPACE_GROUP_SEPARATOR);
return { spaceId, column };
}
function removeId(order, moduleId) {
return {
one: order.one.filter((id) => id !== moduleId),
two: order.two.map((column) => column.filter((id) => id !== moduleId))
};
}
function insertAround(ids, moduleId, targetId, placement) {
const next = ids.filter((id) => id !== moduleId);
const targetIndex = next.indexOf(targetId);
if (targetIndex < 0) return [...next, moduleId];
next.splice(placement === "after" ? targetIndex + 1 : targetIndex, 0, moduleId);
return next;
}
export function addModuleToToolboxSpace(toolbox, module, spaceId = toolbox.activeSpaceId) {
const modules = [...toolbox.modules, module];
const targetId = toolbox.spaces.some((space) => space.id === spaceId) ? spaceId : toolbox.defaultSpaceId;
return {
...toolbox,
modules,
spaces: toolbox.spaces.map((space) => {
if (space.id !== targetId) return space;
const order = normalizeModuleOrder(space.moduleOrder, modules.filter((item) => space.moduleOrder.one.includes(item.id)));
const two = order.two.map((column) => column.filter((id) => id !== module.id));
const targetColumn = two[0].length <= two[1].length ? 0 : 1;
two[targetColumn].push(module.id);
return { ...space, moduleOrder: { one: [...order.one.filter((id) => id !== module.id), module.id], two } };
})
};
}
export function removeModuleFromToolboxSpaces(toolbox, moduleId) {
return {
...toolbox,
modules: toolbox.modules.filter((module) => module.id !== moduleId),
spaces: toolbox.spaces.map((space) => ({ ...space, moduleOrder: removeId(space.moduleOrder, moduleId) }))
};
}
export function createToolboxSpace(toolbox, name) {
if (toolbox.spaces.length >= MAX_TOOLBOX_SPACES || !String(name || "").trim()) return toolbox;
const id = uid("space");
return {
...toolbox,
spaces: [...toolbox.spaces, { id, name: String(name || "").trim(), moduleOrder: { one: [], two: [[], []] } }],
activeSpaceId: id
};
}
export function deleteToolboxSpace(toolbox, spaceId) {
if (spaceId === toolbox.defaultSpaceId) return toolbox;
const removed = toolbox.spaces.find((space) => space.id === spaceId);
const fallback = toolbox.spaces.find((space) => space.id === toolbox.defaultSpaceId);
if (!removed || !fallback) return toolbox;
const mergedOrder = {
one: [...fallback.moduleOrder.one, ...removed.moduleOrder.one],
two: [
[...fallback.moduleOrder.two[0], ...removed.moduleOrder.two[0]],
[...fallback.moduleOrder.two[1], ...removed.moduleOrder.two[1]]
]
};
return {
...toolbox,
spaces: toolbox.spaces.filter((space) => space.id !== spaceId).map((space) => (
space.id === fallback.id ? { ...space, moduleOrder: mergedOrder } : space
)),
activeSpaceId: fallback.id
};
}
export function reorderToolboxSpaces(toolbox, sourceId, targetId, placement = "before") {
if (sourceId === toolbox.defaultSpaceId || sourceId === targetId) return toolbox;
const secondary = toolbox.spaces.filter((space) => space.id !== toolbox.defaultSpaceId && space.id !== sourceId);
const moving = toolbox.spaces.find((space) => space.id === sourceId);
if (!moving) return toolbox;
let targetIndex = secondary.findIndex((space) => space.id === targetId);
if (targetId === toolbox.defaultSpaceId) targetIndex = 0;
else if (placement === "after" && targetIndex >= 0) targetIndex += 1;
if (targetIndex < 0) targetIndex = secondary.length;
secondary.splice(targetIndex, 0, moving);
const fallback = toolbox.spaces.find((space) => space.id === toolbox.defaultSpaceId);
return { ...toolbox, spaces: [fallback, ...secondary] };
}
export function moveToolboxModule(toolbox, moduleId, {
targetSpaceId,
targetModuleId = "",
targetColumn = "",
placement = "after",
moduleColumns = toolbox.moduleColumns
}) {
const sourceSpace = getModuleSpace(toolbox, moduleId);
const destination = toolbox.spaces.find((space) => space.id === targetSpaceId);
if (!sourceSpace || !destination) return toolbox;
const sourceColumnIndex = sourceSpace.moduleOrder.two[1].includes(moduleId) ? 1 : 0;
if (sourceSpace.id === destination.id) {
return {
...toolbox,
spaces: toolbox.spaces.map((space) => {
if (space.id !== destination.id) return space;
const next = { one: [...space.moduleOrder.one], two: space.moduleOrder.two.map((column) => [...column]) };
if (Number(moduleColumns) === 1) {
next.one = insertAround(next.one, moduleId, targetModuleId, placement);
} else {
next.two = next.two.map((column) => column.filter((id) => id !== moduleId));
const foundColumnIndex = next.two.findIndex((column) => column.includes(targetModuleId));
const columnIndex = targetColumn === "right" ? 1 : targetColumn === "left" ? 0 : foundColumnIndex >= 0 ? foundColumnIndex : sourceColumnIndex;
next.two[columnIndex] = targetModuleId
? insertAround(next.two[columnIndex], moduleId, targetModuleId, placement)
: placement === "before" ? [moduleId, ...next.two[columnIndex]] : [...next.two[columnIndex], moduleId];
}
return { ...space, moduleOrder: next };
})
};
}
const spaces = toolbox.spaces.map((space) => ({ ...space, moduleOrder: removeId(space.moduleOrder, moduleId) }));
return {
...toolbox,
spaces: spaces.map((space) => {
if (space.id !== destination.id) return space;
const next = { one: [...space.moduleOrder.one], two: space.moduleOrder.two.map((column) => [...column]) };
const targetColumnIndex = targetColumn === "right"
? 1
: targetColumn === "left"
? 0
: Number(moduleColumns) === 2 ? next.two.findIndex((column) => column.includes(targetModuleId)) : -1;
next.one = targetModuleId && Number(moduleColumns) === 1
? insertAround(next.one, moduleId, targetModuleId, placement)
: [...next.one, moduleId];
const columnIndex = targetColumnIndex >= 0 ? targetColumnIndex : sourceColumnIndex;
next.two[columnIndex] = targetModuleId && Number(moduleColumns) === 2
? insertAround(next.two[columnIndex], moduleId, targetModuleId, placement)
: targetColumn && placement === "before" ? [moduleId, ...next.two[columnIndex]] : [...next.two[columnIndex], moduleId];
return { ...space, moduleOrder: next };
})
};
}

View file

@ -5,12 +5,14 @@ import {
createToolboxExportPayload, createToolboxExportPayload,
globalModuleKey, globalModuleKey,
labelFromFileName, labelFromFileName,
MAX_TOOLBOX_SPACES,
normalizeToolbox, normalizeToolbox,
remapModuleOrderIds, remapModuleOrderIds,
uid uid
} from "./storage/toolboxStorage.js"; } from "./storage/toolboxStorage.js";
import { compressImage } from "../../utils/imageCompression.js"; import { compressImage } from "../../utils/imageCompression.js";
import { getBackupFileName } from "./storage/autoBackup.js"; import { getBackupFileName } from "./storage/autoBackup.js";
import { addModuleToToolboxSpace } from "./toolboxSpaceUtils.js";
function downloadJson(payload, filename) { function downloadJson(payload, filename) {
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
@ -38,7 +40,7 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
title: creationTemplate?.links?.title || "Librairie des outils", title: creationTemplate?.links?.title || "Librairie des outils",
url: getLibraryUrl() url: getLibraryUrl()
}; };
const toolbox = { const toolbox = normalizeToolbox({
id: uid("tbx"), id: uid("tbx"),
name: name.trim() || "Nouvelle toolbox", name: name.trim() || "Nouvelle toolbox",
updatedAt: now, updatedAt: now,
@ -46,7 +48,7 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
{ id: memosModuleId, type: "memos", title: creationTemplate?.memos?.title || "Mémos" }, { id: memosModuleId, type: "memos", title: creationTemplate?.memos?.title || "Mémos" },
{ id: linksModuleId, type: "links", title: creationTemplate?.links?.moduleTitle || "Liens utiles" } { id: linksModuleId, type: "links", title: creationTemplate?.links?.moduleTitle || "Liens utiles" }
] ]
}; });
if (options.tutorial === true) toolbox.tutorial = true; if (options.tutorial === true) toolbox.tutorial = true;
const memos = compactModuleDataForStorage("memos", { items: [{ id: uid("memo"), text: welcomeText, color: "#f6c453", updatedAt: now }] }); const memos = compactModuleDataForStorage("memos", { items: [{ id: uid("memo"), text: welcomeText, color: "#f6c453", updatedAt: now }] });
const links = compactModuleDataForStorage("links", { links: [libraryLink] }); const links = compactModuleDataForStorage("links", { links: [libraryLink] });
@ -141,7 +143,7 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
if (!confirmed) return; if (!confirmed) return;
const updatedAt = new Date().toISOString(); const updatedAt = new Date().toISOString();
store.setToolboxes((toolboxes) => toolboxes.map((item) => item.id === toolboxId store.setToolboxes((toolboxes) => toolboxes.map((item) => item.id === toolboxId
? { ...item, modules: [...item.modules, { id: moduleId, type: "checklist", title: list.title || "Checklist" }], updatedAt } ? { ...addModuleToToolboxSpace(item, { id: moduleId, type: "checklist", title: list.title || "Checklist" }, item.activeSpaceId), updatedAt }
: item)); : item));
store.updateModuleData(toolboxId, moduleId, { sections }, "checklist"); store.updateModuleData(toolboxId, moduleId, { sections }, "checklist");
notify("Checklist créée dans la toolbox associée."); notify("Checklist créée dans la toolbox associée.");
@ -153,6 +155,9 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
async function importToolboxPayload(file, gameId = "") { async function importToolboxPayload(file, gameId = "") {
const payload = JSON.parse(await file.text()); const payload = JSON.parse(await file.text());
if (!payload.toolbox?.modules) throw new Error("Format d'outils invalide"); if (!payload.toolbox?.modules) throw new Error("Format d'outils invalide");
if (Array.isArray(payload.toolbox.spaces) && payload.toolbox.spaces.length > MAX_TOOLBOX_SPACES) {
throw new Error(`Une toolbox ne peut pas contenir plus de ${MAX_TOOLBOX_SPACES} espaces.`);
}
const imported = normalizeToolbox({ ...payload.toolbox, id: uid("tbx"), updatedAt: new Date().toISOString() }); const imported = normalizeToolbox({ ...payload.toolbox, id: uid("tbx"), updatedAt: new Date().toISOString() });
const moduleIdMap = new Map(); const moduleIdMap = new Map();
imported.modules = imported.modules.map((module) => { imported.modules = imported.modules.map((module) => {
@ -160,7 +165,14 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
moduleIdMap.set(module.id, nextId); moduleIdMap.set(module.id, nextId);
return { ...module, id: nextId }; return { ...module, id: nextId };
}); });
imported.moduleOrder = remapModuleOrderIds(imported.moduleOrder, moduleIdMap); const spaceIdMap = new Map(imported.spaces.map((space) => [space.id, uid("space")]));
imported.spaces = imported.spaces.map((space) => ({
...space,
id: spaceIdMap.get(space.id),
moduleOrder: remapModuleOrderIds(space.moduleOrder, moduleIdMap)
}));
imported.defaultSpaceId = spaceIdMap.get(imported.defaultSpaceId);
imported.activeSpaceId = spaceIdMap.get(imported.activeSpaceId);
const normalizedImport = normalizeToolbox(imported); const normalizedImport = normalizeToolbox(imported);
const nextModuleData = {}; const nextModuleData = {};
Object.entries(payload.modules || {}).forEach(([oldId, data]) => { Object.entries(payload.modules || {}).forEach(([oldId, data]) => {
@ -185,6 +197,9 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
if (!Array.isArray(payload.toolboxes) || !payload.modules || typeof payload.modules !== "object") { if (!Array.isArray(payload.toolboxes) || !payload.modules || typeof payload.modules !== "object") {
throw new Error("Format d'import global invalide"); throw new Error("Format d'import global invalide");
} }
if (payload.toolboxes.some((toolbox) => Array.isArray(toolbox.spaces) && toolbox.spaces.length > MAX_TOOLBOX_SPACES)) {
throw new Error(`Une toolbox ne peut pas contenir plus de ${MAX_TOOLBOX_SPACES} espaces.`);
}
const toolboxIdMap = new Map(); const toolboxIdMap = new Map();
const moduleIdMap = new Map(); const moduleIdMap = new Map();
const importedToolboxes = payload.toolboxes.map((toolbox) => { const importedToolboxes = payload.toolboxes.map((toolbox) => {
@ -197,8 +212,23 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
localModuleIdMap.set(module.id, nextModuleId); localModuleIdMap.set(module.id, nextModuleId);
return { ...module, id: nextModuleId }; return { ...module, id: nextModuleId };
}); });
const moduleOrder = remapModuleOrderIds(toolbox.moduleOrder, localModuleIdMap); const normalizedSource = normalizeToolbox({ ...toolbox, modules: toolbox.modules || [] });
return normalizeToolbox({ ...toolbox, id: nextToolboxId, name: `${toolbox.name || "Toolbox"} (import)`, modules, moduleOrder, updatedAt: new Date().toISOString() }); const spaceIdMap = new Map(normalizedSource.spaces.map((space) => [space.id, uid("space")]));
const spaces = normalizedSource.spaces.map((space) => ({
...space,
id: spaceIdMap.get(space.id),
moduleOrder: remapModuleOrderIds(space.moduleOrder, localModuleIdMap)
}));
return normalizeToolbox({
...toolbox,
id: nextToolboxId,
name: `${toolbox.name || "Toolbox"} (import)`,
modules,
spaces,
defaultSpaceId: spaceIdMap.get(normalizedSource.defaultSpaceId),
activeSpaceId: spaceIdMap.get(normalizedSource.activeSpaceId),
updatedAt: new Date().toISOString()
});
}); });
const nextLinks = {}; const nextLinks = {};
Object.entries(payload.links || {}).forEach(([gameId, oldToolboxId]) => { Object.entries(payload.links || {}).forEach(([gameId, oldToolboxId]) => {

View file

@ -20,6 +20,16 @@ import { normalizeCalendarData, normalizeTimerData } from "./features/toolboxes/
import { useAutoBackup } from "./features/toolboxes/storage/useAutoBackup.js"; import { useAutoBackup } from "./features/toolboxes/storage/useAutoBackup.js";
import { GlobalMemosTicker } from "./features/toolboxes/GlobalMemosTicker.jsx"; import { GlobalMemosTicker } from "./features/toolboxes/GlobalMemosTicker.jsx";
const assetRecoveryUrl = new URL(window.location.href);
if (assetRecoveryUrl.searchParams.has("_asset-recovery")) {
assetRecoveryUrl.searchParams.delete("_asset-recovery");
window.history.replaceState(
window.history.state,
"",
`${assetRecoveryUrl.pathname}${assetRecoveryUrl.search}${assetRecoveryUrl.hash}`
);
}
const ToolboxDrawer = lazy(() => import("./features/toolboxes/ToolboxPages.jsx").then((module) => ({ default: module.ToolboxDrawer }))); const ToolboxDrawer = lazy(() => import("./features/toolboxes/ToolboxPages.jsx").then((module) => ({ default: module.ToolboxDrawer })));
const DEFAULT_NOTIFICATION_DURATION_MS = 120000; const DEFAULT_NOTIFICATION_DURATION_MS = 120000;
const SHORT_NOTIFICATION_DURATION_MS = 5000; const SHORT_NOTIFICATION_DURATION_MS = 5000;

View file

@ -66,6 +66,7 @@
--font-size-3xl: 2rem; --font-size-3xl: 2rem;
--font-size-4xl: 2.5rem; --font-size-4xl: 2.5rem;
--space-1: 0.25rem;
--space-2: 0.5rem; --space-2: 0.5rem;
--space-3: 0.75rem; --space-3: 0.75rem;
--space-4: 1rem; --space-4: 1rem;

View file

@ -6,3 +6,4 @@
@use "layout/add-tools"; @use "layout/add-tools";
@use "layout/preferences"; @use "layout/preferences";
@use "layout/modules"; @use "layout/modules";
@use "layout/spaces";

View file

@ -22,6 +22,11 @@
font-size: 17px; font-size: 17px;
} }
.module-space-select {
width: auto;
max-width: 150px;
}
.module-content { .module-content {
min-width: 0; min-width: 0;
} }

View file

@ -0,0 +1,192 @@
// Rôle : stylise les onglets, actions et états de dépôt des espaces toolbox.
.toolbox-spaces-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
min-width: 0;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: rgba(16, 20, 38, 0.58);
}
.toolbox-spaces-toolbar.is-single {
justify-content: flex-end;
}
.toolbox-space-tabs-row {
display: flex;
flex: 1 1 auto;
align-items: center;
gap: var(--space-2);
min-width: 0;
overflow-x: auto;
padding: var(--space-1);
}
.toolbox-space-tabs {
display: flex;
flex: 0 0 auto;
gap: var(--space-3);
}
.toolbox-space-tab-wrap {
position: relative;
display: flex;
flex: 0 0 auto;
align-items: center;
min-height: 40px;
overflow: hidden;
border: 1px solid rgba(165, 180, 252, 0.14);
border-radius: var(--radius-md);
background: rgba(7, 10, 24, 0.45);
transition:
border-color var(--duration-fast) var(--ease-standard),
background-color var(--duration-fast) var(--ease-standard),
box-shadow var(--duration-fast) var(--ease-standard);
}
.toolbox-space-tab-wrap:hover:not(.active) {
border-color: rgba(196, 181, 253, 0.34);
background: rgba(139, 92, 246, 0.12);
box-shadow:
0 0 14px rgba(139, 92, 246, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.toolbox-space-tab-wrap:focus-within {
outline: 2px solid var(--color-accent-gold);
outline-offset: 2px;
}
.toolbox-space-tab-wrap.active {
border-color: rgba(246, 196, 83, 0.48);
background: rgba(246, 196, 83, 0.09);
}
.toolbox-space-tab-wrap.active:hover {
border-color: rgba(246, 196, 83, 0.62);
background: rgba(246, 196, 83, 0.13);
}
.toolbox-space-tab-wrap.is-drop-target {
border-color: rgba(246, 196, 83, 0.72);
box-shadow: none;
}
.toolbox-space-tab-wrap.is-drop-target::before {
position: absolute;
z-index: 3;
top: 4px;
bottom: 4px;
left: 0;
width: 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;
}
.toolbox-space-tab-wrap.is-drop-target.drop-after::before {
right: 0;
left: auto;
}
.toolbox-space-tab-wrap.is-module-drop-target {
border-color: rgba(246, 196, 83, 0.72);
background: rgba(246, 196, 83, 0.14);
box-shadow: inset 0 -3px 0 rgba(246, 196, 83, 0.9);
}
.toolbox-space-tab,
.toolbox-space-drag-handle {
border: 0;
background: transparent;
}
.toolbox-space-tab {
min-height: 38px;
padding: 0 var(--space-3) 0 var(--space-2);
color: var(--color-text-secondary);
cursor: pointer;
transition: color var(--duration-fast) var(--ease-standard);
}
.toolbox-space-tab:first-child {
padding-inline: var(--space-3);
}
.toolbox-space-tab.active {
color: var(--color-text-primary);
}
.toolbox-space-tab-wrap.is-module-drop-target .toolbox-space-tab {
color: var(--color-accent);
}
.toolbox-space-drag-handle,
.toolbox-space-drag-handle.nds-icon-button {
flex: 0 0 30px;
width: 30px;
min-width: 30px;
height: 30px;
margin-left: var(--space-1);
border-right: 1px solid rgba(165, 180, 252, 0.1);
border-radius: var(--radius-sm);
cursor: grab;
opacity: 0.65;
touch-action: none;
}
.toolbox-space-tab-wrap > .toolbox-space-tab:hover:not(:disabled),
.toolbox-space-tab-wrap > .toolbox-space-drag-handle.nds-icon-button:hover {
border-color: transparent;
background: transparent;
box-shadow: none;
}
.toolbox-space-tab-wrap > .toolbox-space-tab:focus-visible,
.toolbox-space-tab-wrap > .toolbox-space-drag-handle.nds-icon-button:focus-visible {
outline: none;
}
.toolbox-space-tab-wrap:hover .toolbox-space-drag-handle,
.toolbox-space-tab-wrap:focus-within .toolbox-space-drag-handle {
opacity: 0.9;
}
.toolbox-space-actions {
display: flex;
flex: 0 0 auto;
gap: var(--space-2);
}
.toolbox-space-add-compact {
flex: 0 0 auto;
}
.toolbox-space-empty {
grid-column: 1 / -1;
width: 100%;
}
.toolbox-space-modal {
width: min(440px, calc(100vw - 32px));
}
@media (max-width: 760px) {
.toolbox-spaces-toolbar {
align-items: stretch;
flex-direction: column;
}
.toolbox-space-actions {
justify-content: flex-end;
}
.toolbox-space-add {
flex: 1 1 auto;
}
}