diff --git a/DESIGN_SYSTEM.md b/DESIGN_SYSTEM.md index dcb5e09..d14041b 100644 --- a/DESIGN_SYSTEM.md +++ b/DESIGN_SYSTEM.md @@ -192,6 +192,12 @@ Style courant : Le mot visible cote utilisateur est **outil**, pas **module**. Le terme module reste acceptable uniquement dans le code. +Les outils qui gèrent une collection affichent un message vide compact et discret tant +qu'aucun élément n'existe. Ce message réutilise le traitement visuel en pointillés du +planificateur de tâches, sans remplacer les états vides métier plus détaillés déjà +présents. Lorsqu'un outil vient d'être ajouté, la page défile doucement jusqu'à son +panneau afin qu'il reste immédiatement visible, y compris s'il a été inséré hors cadre. + ### Outils En Deux Colonnes Les outils avec controles a gauche et resultats a droite doivent reutiliser les classes generiques `tool-split-*`. @@ -563,6 +569,7 @@ Filtres : - lorsque la condition ne concerne qu’un groupe avancé, comme les catégories Diablo IV, son sélecteur est placé dans le popover correspondant avec 12 px d’espace avant les options ; - toute option réduite à son icône doit utiliser le composant commun `Tooltip` et conserver un nom accessible (`aria-label`) ; l’attribut HTML `title` ne remplace pas le tooltip ; - les options directes avec libellé suivent la largeur naturelle de leur contenu ; lorsqu’elles passent en icône seule, elles retrouvent une largeur compacte commune ; +- la recherche des barres horizontales est plafonnée à `260px` afin de ne pas étirer inutilement les filtres ; - `nds-active-filters` forme une seconde section compacte dans la barre principale, séparée sur toute la largeur par un trait doré semi-transparent sans ombre ; la section haute conserve les 10 px de padding vertical de la barre, tandis que la section basse se limite à 4 px ; elle affiche un badge fermable par recherche ou critère actif, dont le bouton retire uniquement le filtre correspondant ; - les barres sont sticky sous la topbar sur desktop, reviennent à la ligne et redeviennent statiques sur mobile ; - reset avec bouton icône `rubber`. @@ -616,9 +623,13 @@ Modales : - panneau centre ; - fermeture au clic exterieur quand l'action le permet ; - boutons icones pour fermer/zoomer quand approprie. +- la visionneuse d'un outil Images peut recevoir la collection du bloc courant : sa galerie utilise de grandes vignettes non recadrees dans une grille responsive et scrollable ; +- la vue detaillee expose la position, un retour explicite a la galerie et des boutons precedent/suivant bloques aux extremites ; les fleches gauche/droite reprennent cette navigation ; +- une visionneuse d'image seule, notamment pour les annotations, ne doit afficher ni galerie ni navigation de collection. Drawer toolbox : +- le bouton de topbar associe icone et texte visible : « Afficher la toolbox » quand le drawer est ferme, puis « Masquer la toolbox » lorsqu'il est ouvert ; - panneau lateral ancre a droite, ouvert a `700px` et redimensionnable uniquement vers une largeur superieure ; - le shell reserve toujours `700px` au panneau : toute largeur supplementaire recouvre la partie droite de la page sans la reduire davantage ; - la page reste visible et interactive, sans backdrop bloquant ni fermeture au clic exterieur ; @@ -849,6 +860,12 @@ dissocie la précédente. Le switch « Masquer les cochés » est désactivé su non associées. Les commandes sont centrées et reviennent à la ligne sur mobile ; le nombre de correspondances figure dessous. Une liste active devenue incompatible garde ses commandes avec un message explicite. Aucun sélecteur global n'est affiché. +Quand une liste est associée, un encart compact placé en haut à droite du header +du guide expose directement le switch « Masquer les cochés » et l'action +« Dissocier la checklist ». La colonne d'actions occupe la hauteur existante du +header afin que cet encart n'ajoute pas de ligne ou d'espace vertical. Cette +association est conservée dans `localStorage` entre les sessions ; elle est retirée +si la checklist, la toolbox liée ou la liaison jeu/toolbox disparaît. L'état terminé désature et atténue uniquement les portraits (ou les icônes de catégorie Diablo). Le recto utilise uniquement la checkbox interactive ; la coche accessible « Coché » reste sur le verso des cartes retournables. Les textes restent diff --git a/docs/FEATURE_CHECKLIST.md b/docs/FEATURE_CHECKLIST.md index bd84ec1..fa7983e 100644 --- a/docs/FEATURE_CHECKLIST.md +++ b/docs/FEATURE_CHECKLIST.md @@ -50,7 +50,7 @@ Checklist à suivre lors de l'ajout ou de la modification d'un outil, d'une page - Vérifier la page `/games`. - Vérifier la page `/games/`. - Vérifier le chargement à la première visite, la conservation des données pendant la navigation et les erreurs réseau ; sous StrictMode, les requêtes abandonnées ne doivent pas remplacer les données actuelles ni afficher d'erreur d'annulation. -- Vérifier le bouton toolbox et le panneau latéral. +- Vérifier que le bouton texte « Afficher la toolbox » devient « Masquer la toolbox » avec le panneau latéral ouvert. - Vérifier le breadcrumb. - Vérifier les textes avec retours à la ligne. - Pour les guides à cartes, vérifier le toggle de lien à côté du « + » dans l'en-tête de chaque checklist compatible : il affiche les commandes, masquées par défaut. Vérifier l'association exclusive, le masquage désactivé sur les autres listes et la mise à jour immédiate depuis le panneau latéral. Refermer les commandes conserve l'association. La checkbox des cartes met à jour toutes les lignes exactes correspondantes, respecte les quantités cibles et ne retourne pas les cartes. diff --git a/docs/STORAGE_SCHEMA.md b/docs/STORAGE_SCHEMA.md index 6750400..e4372aa 100644 --- a/docs/STORAGE_SCHEMA.md +++ b/docs/STORAGE_SCHEMA.md @@ -236,7 +236,7 @@ Stockage compact : ## Outil Checklist -### Association temporaire aux guides +### Association persistante aux guides Les pages Aniimo, monstres MHW, faune MHW et affixes Diablo peuvent refléter une checklist de la toolbox actuellement associée au jeu. Chaque checklist compatible @@ -248,12 +248,13 @@ réglage de masquage ; dissocier réaffiche tous les éléments. Replier les com ne modifie pas l'association ; cet état d'affichage reste local au composant. Cette liaison ne modifie ni les données de checklist ni les liens persistants jeu/toolbox. -`sessionStorage["sokkog:guide-checklists"]` contient un objet indexé par +`localStorage["sokkog:guide-checklists"]` contient un objet indexé par `aniimo/aniimos`, `mhwilds/monsters`, `mhwilds/endemic` ou `diablo4/affixes`. Chaque entrée contient `{ toolboxId, moduleId, hideCompleted }`. Aucune liaison -n'est choisie automatiquement. La navigation et le rechargement la conservent ; +n'est choisie automatiquement. La navigation, le rechargement et une nouvelle +session du navigateur la conservent ; la dissociation, la suppression du module ou le changement de toolbox la retirent. -Sans accès au stockage de session, l'état reste en mémoire dans l'application. +Sans accès au stockage local, l'état reste en mémoire dans l'application. Ces associations ne sont pas exportées et ne se synchronisent pas entre onglets. La reconnaissance compare les libellés complets sans tenir compte de la casse, @@ -266,7 +267,7 @@ sélectionnée mais sans correspondance reste liée, avec un message explicite. Une checkbox sur la carte d'un guide peut modifier la progression de toutes les lignes exactes qui lui correspondent : cocher porte `qtyCurrent` à `qtyTarget`, décocher le remet à `0`. Les données passent par le flux React/IndexedDB existant ; -aucune copie de progression n'est conservée dans l'état de session du guide. +aucune copie de progression n'est conservée dans l'association du guide. La copie Aniimo utilise `Nom:1` en affichage regroupé et `Nom — Forme:1` en affichage séparé. Les noms et formes français et anglais sont reconnus. Une ligne @@ -298,6 +299,7 @@ Format avec catégories : ```json { + "hideCompletedItems": true, "hideCompletedSections": true, "hideCompletedSectionsFully": false, "sections": [ @@ -323,6 +325,7 @@ Stockage compact : - `qtyTarget` est omis si la valeur vaut `1`. - `qtyCurrent` est omis si la valeur vaut `0`. +- `hideCompletedItems` est omis si `false`. - `hideCompletedSections` est omis si `false`. - `hideCompletedSectionsFully` est omis si `false`. - `title` est omis si vide. diff --git a/docs/TEST_STRATEGY.md b/docs/TEST_STRATEGY.md index 40e7a84..ea4ef51 100644 --- a/docs/TEST_STRATEGY.md +++ b/docs/TEST_STRATEGY.md @@ -21,7 +21,9 @@ not bugs, missing scenarios or a percentage of work remaining. | Toolbox creation, import/export, text exchange | `e2e/toolbox.spec.js`, `text-exchange.spec.js`; Node toolbox module tests | | Links: creation, copy, category removal preserving links, deletion and reload | `e2e/basic-tools.spec.js` | | Counters: creation, signed values, reset, deletion and reload | `e2e/basic-tools.spec.js` | -| Checklist: quantities, clamping, completed category visibility and persistence | `e2e/basic-tools.spec.js` | +| Checklist: quantities, clamping, completed item/category visibility and persistence | `e2e/basic-tools.spec.js` | +| Images: gallery, detail navigation, keyboard controls and focus restoration | `e2e/basic-tools.spec.js` | +| Guide checklists: persistent association, page controls, drawer scope and live completion | `e2e/guide-checklist.spec.js` | | Planner: editing, recurrence, prerequisites, subtree reorder and persistence | `e2e/task-planner.spec.js` | | Representative visual states and responsive layouts | `e2e/style-layouts.spec.js`, `tool-style-states.spec.js`, `card-surfaces.spec.js`, `nds-controls.spec.js`, `nds-patterns.spec.js` | | Production onboarding | `e2e/production/onboarding.spec.js` | diff --git a/e2e/basic-tools.spec.js b/e2e/basic-tools.spec.js index cf54adf..8d992a8 100644 --- a/e2e/basic-tools.spec.js +++ b/e2e/basic-tools.spec.js @@ -64,6 +64,32 @@ test("counters persist signed values and reset without affecting other counters" await expect(tool.locator(".counter-value span")).toHaveText("Other"); }); +test("empty collection tools explain that they have no content", async ({ page }) => { + await page.goto("/#/toolboxes"); + await page.getByText("Importer une toolbox", { exact: true }).locator('input[type="file"]').setInputFiles({ + name: "empty-tools.json", + mimeType: "application/json", + buffer: Buffer.from(JSON.stringify({ + toolbox: { + id: "empty-tools", + name: "Empty tools", + modules: [ + { id: "checklist", type: "checklist", title: "Checklist" }, + { id: "images", type: "images", title: "Images" }, + { id: "links", type: "links", title: "Links" }, + { id: "counters", type: "counters", title: "Counters" } + ] + }, + modules: {} + })) + }); + await page.getByRole("link", { name: "Ouvrir Empty tools", exact: true }).first().click(); + await expect(page.getByText("Aucun élément dans cette checklist.", { exact: true })).toBeVisible(); + await expect(page.getByText("Aucune image.", { exact: true })).toBeVisible(); + await expect(page.getByText("Aucun lien.", { exact: true })).toBeVisible(); + await expect(page.getByText("Aucun compteur.", { exact: true })).toBeVisible(); +}); + test("checklist quantities are clamped and completed categories hide without losing data", async ({ page }) => { const tool = await setup(page, "checklist", { sections: [] }); await tool.getByPlaceholder("Catégorie", { exact: true }).fill("Craft"); @@ -81,6 +107,14 @@ test("checklist quantities are clamped and completed categories hide without los await expect(tool.getByRole("radio", { name: "Cachée quand terminée", exact: true })).toHaveAttribute("aria-checked", "true"); await tool.getByRole("radio", { name: "Visible quand terminée", exact: true }).click(); await expect(quantity).toHaveValue("3"); + const hideCompleted = tool.getByRole("button", { name: "Masquer les éléments terminés", exact: true }); + await hideCompleted.click(); + await expect(hideCompleted).toHaveAttribute("aria-pressed", "true"); + await expect(quantity).toHaveCount(0); + await page.reload(); + await expect(hideCompleted).toHaveAttribute("aria-pressed", "true"); + await hideCompleted.click(); + await expect(quantity).toHaveValue("3"); await tool.getByRole("button", { name: "Retirer une quantité", exact: true }).click(); await page.reload(); await expect(quantity).toHaveValue("2"); @@ -88,3 +122,63 @@ test("checklist quantities are clamped and completed categories hide without los await page.reload(); await expect(quantity).toHaveCount(0); }); + +test("a simple checklist can hide and restore checked rows", async ({ page }) => { + const tool = await setup(page, "checklist", { + items: [ + { id: "done", label: "Done", qtyCurrent: 1 }, + { id: "pending", label: "Pending" } + ] + }); + const hideCompleted = tool.getByRole("button", { name: "Masquer les éléments terminés", exact: true }); + await hideCompleted.click(); + await expect(tool.getByRole("checkbox", { name: "Terminer Done", exact: true })).toHaveCount(0); + await expect(tool.getByRole("checkbox", { name: "Terminer Pending", exact: true })).toBeVisible(); + await hideCompleted.click(); + await expect(tool.getByRole("checkbox", { name: "Terminer Done", exact: true })).toBeVisible(); +}); + +test("images open a gallery and navigate the current collection without looping", async ({ page }) => { + const makeImage = (color) => `data:image/svg+xml,${encodeURIComponent(``)}`; + const tool = await setup(page, "images", { + images: [ + { id: "one", label: "First", dataUrl: makeImage("#8b5cf6") }, + { id: "two", label: "Second", dataUrl: makeImage("#22d3ee") }, + { id: "three", dataUrl: makeImage("#f6c453") } + ] + }); + + await tool.getByRole("button", { name: "Galerie", exact: true }).click(); + const dialog = page.getByRole("dialog", { name: "Galerie d'images", exact: true }); + await expect(dialog).toBeVisible(); + await expect(dialog.locator(".image-viewer-gallery-item")).toHaveCount(3); + await dialog.getByRole("button", { name: "Afficher en grand : Second", exact: true }).click(); + await expect(page.locator(".image-viewer-title")).toHaveText("Second"); + await expect(page.locator(".image-viewer-position")).toHaveText("2 / 3"); + + await page.keyboard.press("ArrowRight"); + await expect(page.locator(".image-viewer-title")).toHaveText("Image"); + await expect(page.getByRole("button", { name: "Image suivante", exact: true })).toBeDisabled(); + await page.keyboard.press("ArrowLeft"); + await expect(page.locator(".image-viewer-title")).toHaveText("Second"); + + await page.getByRole("button", { name: "Revenir à la galerie", exact: true }).click(); + const secondThumbnail = dialog.getByRole("button", { name: "Afficher en grand : Second", exact: true }); + await expect(secondThumbnail).toBeFocused(); + await page.keyboard.press("Escape"); + await expect(dialog).toHaveCount(0); + + await page.setViewportSize({ width: 390, height: 844 }); + await tool.getByRole("button", { name: "Galerie", exact: true }).click(); + const galleryBox = await dialog.boundingBox(); + expect(galleryBox.x).toBeGreaterThanOrEqual(0); + expect(galleryBox.y).toBeGreaterThanOrEqual(0); + expect(galleryBox.x + galleryBox.width).toBeLessThanOrEqual(390); + expect(galleryBox.y + galleryBox.height).toBeLessThanOrEqual(844); + await dialog.getByRole("button", { name: "Fermer", exact: true }).click(); + + await tool.locator(".image-preview").nth(2).click(); + await expect(page.locator(".image-viewer-position")).toHaveText("3 / 3"); + await expect(page.getByRole("button", { name: "Image suivante", exact: true })).toBeDisabled(); + await page.getByRole("button", { name: "Fermer", exact: true }).click(); +}); diff --git a/e2e/deferred-media.spec.js b/e2e/deferred-media.spec.js index 3b1e490..6d8352f 100644 --- a/e2e/deferred-media.spec.js +++ b/e2e/deferred-media.spec.js @@ -138,7 +138,7 @@ test("opening and editing one toolbox only reads its media and reuses it on retu test("the game drawer loads only the linked toolbox media", async ({ page }) => { const seeded = await setup(page); await page.goto("/#/games/mhwilds"); - await page.getByRole("button", { name: "Ouvrir la toolbox Alpha", exact: true }).click(); + await page.getByRole("button", { name: "Afficher la toolbox", exact: true }).click(); const drawer = page.locator("#toolbox-drawer .drawer-panel"); await expect(drawer.locator('.image-preview img')).toHaveAttribute("src", seeded.modules["a:images"].images[0].dataUrl); await expect(drawer.locator(".annotation-marker")).toHaveCount(1); diff --git a/e2e/game-filters.spec.js b/e2e/game-filters.spec.js index e159850..eef991d 100644 --- a/e2e/game-filters.spec.js +++ b/e2e/game-filters.spec.js @@ -38,7 +38,7 @@ for (const width of [1920, 900]) { await expect(first).toHaveAttribute("aria-pressed", "false"); await expect(page.locator(".game-filters")).toHaveCSS("position", width <= 760 ? "static" : "sticky"); await expect(page.locator(".game-filters")).toHaveCSS("top", width <= 760 ? "auto" : "68px"); - expect((await filters.locator(".nds-filter-search").boundingBox())?.width).toBeLessThanOrEqual(280); + expect((await filters.locator(".nds-filter-search").boundingBox())?.width).toBeLessThanOrEqual(260); expect(await page.evaluate(() => globalThis.document.documentElement.scrollWidth <= globalThis.innerWidth)).toBe(true); }); } diff --git a/e2e/guide-checklist.spec.js b/e2e/guide-checklist.spec.js index 19d5d85..8102a31 100644 --- a/e2e/guide-checklist.spec.js +++ b/e2e/guide-checklist.spec.js @@ -1,4 +1,4 @@ -// Rôle : vérifie copie, liaison de session et progression des checklists dans les guides. +// Rôle : vérifie copie, liaison persistante et progression des checklists dans les guides. import { test, expect } from "@playwright/test"; import { readFileSync } from "node:fs"; import { resetSokkoStorage } from "./helpers/storage.js"; @@ -6,7 +6,7 @@ import { importChecklistText } from "../website/src/features/toolboxes/modules/t import { normalizeChecklistData } from "../website/src/features/toolboxes/storage/modules/checklist.js"; const route = "/#/games/aniimo/aniimos"; -const sessionKey = "sokkog:guide-checklists"; +const storageKey = "sokkog:guide-checklists"; const controls = (page, id) => page.locator('[data-module-type="checklist"]').filter({ has: page.getByRole("button", { name: `Retirer ${id}`, exact: true }) }).locator(".guide-checklist-controls"); const controlsToggle = (page, id) => page.getByRole("button", { name: `Lien avec le guide — ${id}`, exact: true }); const linkButton = (page, id) => controls(page, id).locator(".guide-checklist-link"); @@ -125,7 +125,7 @@ test("Aniimo copies visible species or forms and round-trips through checklist i }); for (const width of [1280, 390]) { - test(`drawer linking, live completion and session restoration at ${width}px`, async ({ page }) => { + test(`drawer linking, page controls and persistent restoration at ${width}px`, async ({ page }) => { test.setTimeout(60000); await page.setViewportSize({ width, height: 900 }); await seed(page); @@ -164,6 +164,19 @@ for (const width of [1280, 390]) { expect(actionsBounds.x + actionsBounds.width / 2).toBeCloseTo(bounds.x + bounds.width / 2, 0); await page.screenshot({ path: test.info().outputPath("linked-drawer.png") }); await closeDrawer(page); + const pageControls = page.locator(".guide-checklist-page-controls"); + await expect(pageControls).toBeVisible(); + const pageControlsBounds = await pageControls.boundingBox(); + const pageHeadingBounds = await page.locator(".page-heading").boundingBox(); + const headingActionsBounds = await page.locator(".game-heading-actions").boundingBox(); + expect(pageControlsBounds.y).toBeGreaterThanOrEqual(pageHeadingBounds.y); + expect(pageControlsBounds.y + pageControlsBounds.height).toBeLessThanOrEqual(pageHeadingBounds.y + pageHeadingBounds.height); + if (width > 760) { + expect(headingActionsBounds.height).toBeCloseTo(pageHeadingBounds.height, 0); + expect(pageControlsBounds.y).toBeCloseTo(pageHeadingBounds.y, 0); + } + await expect(pageControls.getByRole("button", { name: "Masquer les cochés", exact: true })).toHaveAttribute("aria-pressed", "false"); + await expect(pageControls.getByRole("button", { name: "Dissocier la checklist", exact: true })).toBeVisible(); await expect(card).toHaveAttribute("data-checklist-complete", "true"); await expect(card.locator(".aniimo-front .guide-checklist-mark")).toHaveCount(0); await card.getByRole("button", { name: /Afficher les capacités/ }).click(); @@ -177,7 +190,7 @@ for (const width of [1280, 390]) { await expect.poll(async () => (await readModule(page, "forms")).items.find((item) => item.label.includes("Forme de base")).qtyCurrent || 0).toBe(0); await card.getByRole("checkbox", { name: "Cocher dans la checklist" }).check(); await expect(card).toHaveAttribute("data-checklist-complete", "true"); - await toggleHidden(page); + await pageControls.getByRole("button", { name: "Masquer les cochés", exact: true }).click(); await expect(card.getByRole("tab")).toHaveCount(2); await expect(card).toHaveAttribute("data-form", "mountain_woods_form"); expect(normalizeChecklistData(await readModule(page, "forms")).items).toEqual(normalizeChecklistData(before).items); @@ -188,6 +201,8 @@ for (const width of [1280, 390]) { await drawer.locator('[data-module-type="checklist"]').filter({ has: page.getByRole("button", { name: "Retirer capture", exact: true }) }).getByRole("checkbox", { name: "Terminer Loufeuteau", exact: true }).check(); await closeDrawer(page); await expect(card).toHaveCount(0); + await expect.poll(() => page.evaluate((key) => globalThis.localStorage.getItem(key), storageKey)).not.toBeNull(); + await page.evaluate(() => globalThis.sessionStorage.clear()); await page.reload(); await assertLink(page, "capture", true); await page.getByRole("searchbox").fill("emberpup"); @@ -198,16 +213,15 @@ for (const width of [1280, 390]) { await closeDrawer(page); await expect(card).toHaveAttribute("data-checklist-complete", "true"); await page.getByRole("button", { name: "Réinitialiser les filtres", exact: true }).click(); + await page.locator(".guide-checklist-page-controls").getByRole("button", { name: "Dissocier la checklist", exact: true }).click(); await openDrawer(page); await openControls(page, "capture"); - await expect(linkButton(page, "capture")).toHaveAttribute("aria-pressed", "true"); - await linkButton(page, "capture").click(); await expect(linkButton(page, "capture")).toHaveAttribute("aria-pressed", "false"); await expect(activeButtons(page)).toHaveCount(0); await expect(hiddenSwitch(page)).toHaveCount(0); await closeDrawer(page); await expect(page.locator('[data-checklist-complete="true"]')).toHaveCount(0); - expect(await page.evaluate((key) => globalThis.sessionStorage.getItem(key), sessionKey)).toBeNull(); + expect(await page.evaluate((key) => globalThis.localStorage.getItem(key), storageKey)).toBeNull(); expect(await page.evaluate(() => globalThis.document.documentElement.scrollWidth <= globalThis.innerWidth)).toBe(true); }); } @@ -261,7 +275,7 @@ test("renames retain a zero-match selection; changing toolbox clears the associa await dialog.getByRole("button", { name: "Valider", exact: true }).click(); await expect(activeButtons(page)).toHaveCount(0); await expect(page.locator(".guide-checklist-controls")).toHaveCount(1); - expect(await page.evaluate((key) => globalThis.sessionStorage.getItem(key), sessionKey)).toBeNull(); + expect(await page.evaluate((key) => globalThis.localStorage.getItem(key), storageKey)).toBeNull(); }); test("deleting a selected checklist clears its link immediately", async ({ page }) => { @@ -276,20 +290,20 @@ test("deleting a selected checklist clears its link immediately", async ({ page await expect(hiddenSwitch(page)).toHaveCount(0); await page.reload(); await assertLink(page, "", false); - expect(await page.evaluate((key) => globalThis.sessionStorage.getItem(key), sessionKey)).toBeNull(); + expect(await page.evaluate((key) => globalThis.localStorage.getItem(key), storageKey)).toBeNull(); }); -test("unavailable session storage retains links across navigation; overview has no guide controls", async ({ page }) => { +test("unavailable local storage retains links in memory across navigation; overview has no guide controls", async ({ page }) => { await seed(page); await page.addInitScript((key) => { for (const method of ["getItem", "setItem", "removeItem"]) { const original = globalThis.Storage.prototype[method]; globalThis.Storage.prototype[method] = function (name, ...args) { - if (name === key) throw new Error("Session storage unavailable"); + if (name === key) throw new Error("Local storage unavailable"); return original.call(this, name, ...args); }; } - }, sessionKey); + }, storageKey); await page.reload(); await page.goto(route); await choose(page, "perfect"); @@ -304,4 +318,8 @@ test("unavailable session storage retains links across navigation; overview has await expect(page.locator(".module-guide-button")).toHaveCount(0); await page.goto(route); await assertLink(page, "perfect", true); + await openDrawer(page); + await page.goto("/#/toolboxes"); + await expect(page.locator("#toolbox-drawer .drawer-panel")).toHaveCount(0); + await expect(page.locator(".app-shell")).not.toHaveClass(/toolbox-drawer-open/); }); diff --git a/e2e/module-order.spec.js b/e2e/module-order.spec.js index 90e92fe..09a000d 100644 --- a/e2e/module-order.spec.js +++ b/e2e/module-order.spec.js @@ -63,3 +63,17 @@ test("cross-column drops and empty column boundaries preserve every module and t await page.getByRole("button", { name: "Afficher en lignes", exact: true }).click(); await expect(page.locator(".module-title")).toHaveText(["A", "B", "C", "D"]); }); + +test("adding an off-screen tool scrolls it into view", async ({ page }) => { + await page.setViewportSize({ width: 900, height: 420 }); + await setup(page, 1); + await page.getByRole("button", { name: "Afficher l'ajout rapide", exact: true }).click(); + await page.getByRole("button", { name: "Ajouter Compteurs", exact: true }).click(); + const added = page.locator('article[data-module-type="counters"]').last(); + await expect(page.locator("article.module")).toHaveCount(5); + await expect.poll(async () => added.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return rect.top >= 0 && rect.top < globalThis.window.innerHeight && rect.bottom > 0; + })).toBe(true); + expect(await page.evaluate(() => globalThis.window.scrollY)).toBeGreaterThan(0); +}); diff --git a/e2e/toolbox-cover.spec.js b/e2e/toolbox-cover.spec.js index 4ecf2fe..86ee94b 100644 --- a/e2e/toolbox-cover.spec.js +++ b/e2e/toolbox-cover.spec.js @@ -97,7 +97,7 @@ test("cancelled and unreadable replacements preserve the cover and allow selecti test("game association takes precedence without discarding the custom cover", async ({ page }) => { await page.goto("/#/games/mhwilds"); - await page.getByRole("button", { name: "Associer une toolbox", exact: true }).click(); + await page.getByRole("button", { name: "Afficher la toolbox", exact: true }).click(); const drawer = page.locator("#toolbox-drawer .drawer-panel"); await importToolbox(page, "Linked cover", drawer); await expect(drawer.getByRole("heading", { name: "Linked cover" })).toBeVisible(); @@ -111,7 +111,7 @@ test("game association takes precedence without discarding the custom cover", as await expect(page.locator(".toolbox-game-icon img")).toHaveAttribute("src", gameCover); await expect(picker(page)).toHaveCount(0); await page.goto("/#/games/mhwilds"); - await page.getByRole("button", { name: "Ouvrir la toolbox Linked cover", exact: true }).click(); + await page.getByRole("button", { name: "Afficher la toolbox", exact: true }).click(); await drawer.getByRole("button", { name: "Lier une toolbox", exact: true }).click(); const dialog = page.getByRole("dialog", { name: "Lier une toolbox", exact: true }); await dialog.getByLabel("Toolbox associée").selectOption(""); diff --git a/e2e/toolbox-drawer-layout.spec.js b/e2e/toolbox-drawer-layout.spec.js index e1d94d0..5fbd50d 100644 --- a/e2e/toolbox-drawer-layout.spec.js +++ b/e2e/toolbox-drawer-layout.spec.js @@ -2,7 +2,10 @@ import { test, expect } from "@playwright/test"; async function openDrawer(page) { - await page.locator(".drawer-button").click(); + const trigger = page.getByRole("button", { name: "Afficher la toolbox", exact: true }); + await expect(trigger).toBeVisible(); + await trigger.click(); + await expect(page.getByRole("button", { name: "Masquer la toolbox", exact: true })).toHaveAttribute("aria-pressed", "true"); const drawer = page.locator("#toolbox-drawer"); await expect(drawer.locator(".drawer-panel")).toBeVisible(); return drawer; @@ -61,6 +64,7 @@ test("drawer reserves 700px while additional width overlays the page", async ({ await expect.poll(async () => (await panel.boundingBox()).width).toBeCloseTo(800, 0); await drawer.getByRole("button", { name: "Fermer", exact: true }).click(); await expect(drawer).toHaveCount(0); + await expect(page.getByRole("button", { name: "Afficher la toolbox", exact: true })).toHaveAttribute("aria-pressed", "false"); await expect(sidebar).not.toHaveAttribute("data-variant"); await expect(sidebar).toHaveCSS("width", "248px"); const reopenedDrawer = await openDrawer(page); diff --git a/e2e/toolbox.spec.js b/e2e/toolbox.spec.js index 811ecf9..2c9e25c 100644 --- a/e2e/toolbox.spec.js +++ b/e2e/toolbox.spec.js @@ -204,7 +204,7 @@ test("loads randomizer tabs after module split", async ({ page }) => { test("creates a game-linked toolbox from the drawer", async ({ page }) => { await page.goto("/#/games/mhwilds"); await expect(page.getByRole("heading", { name: /Monster Hunter.*Wilds/ })).toBeVisible(); - await page.getByRole("button", { name: "Associer une toolbox" }).click(); + await page.getByRole("button", { name: "Afficher la toolbox", exact: true }).click(); const drawer = page.locator("#toolbox-drawer .drawer-panel"); await expect(drawer).toBeVisible(); @@ -216,7 +216,7 @@ test("creates a game-linked toolbox from the drawer", async ({ page }) => { await expect(drawer.getByRole("heading", { name: /Monster Hunter.*Wilds/ })).toBeVisible(); await expect(drawer.locator("[data-module-type='memos']")).toBeVisible(); await expect(drawer.locator("[data-module-type='links']")).toBeVisible(); - await expect(page.getByRole("button", { name: /Ouvrir la toolbox Monster Hunter.*Wilds/ })).toBeVisible(); + await expect(page.getByRole("button", { name: "Masquer la toolbox", exact: true })).toBeVisible(); }); test("imports and exports a toolbox payload", async ({ page }) => { diff --git a/tests/guide-checklist.test.mjs b/tests/guide-checklist.test.mjs index 61ddff8..ad015ec 100644 --- a/tests/guide-checklist.test.mjs +++ b/tests/guide-checklist.test.mjs @@ -1,4 +1,4 @@ -// Rôle : vérifie les correspondances des guides et la validité des associations de session. +// Rôle : vérifie les correspondances des guides et la validité des associations persistantes. import assert from "node:assert/strict"; import test from "node:test"; import { aniimoChecklistEntries, matchGuideChecklist, normalizeGuideLabel, toggleGuideChecklistRows, validGuideAssociations } from "../website/src/features/games/guideChecklist.js"; @@ -58,7 +58,7 @@ test("card toggles update every exact matching row to its target or zero", () => assert.equal(data.sections[0].items[0].qtyCurrent, 1); }); -test("session associations require the current linked toolbox and an existing checklist", () => { +test("persistent associations require the current linked toolbox and an existing checklist", () => { const toolbox = { id: "tb", modules: [{ id: "capture", type: "checklist" }, { id: "notes", type: "notepad" }] }; const association = { toolboxId: "tb", moduleId: "capture", hideCompleted: true }; const state = { "aniimo/aniimos": association, "mhwilds/monsters": { ...association, moduleId: "notes" }, unknown: association }; diff --git a/tests/helpers/data-validation.mjs b/tests/helpers/data-validation.mjs index 1d4b803..eeab27e 100644 --- a/tests/helpers/data-validation.mjs +++ b/tests/helpers/data-validation.mjs @@ -320,12 +320,14 @@ export function validateSiteContent(site) { "toolboxes.modules.checklist.sectionPlaceholder", "toolboxes.modules.checklist.quantityLabel", "toolboxes.modules.checklist.addButton", + "toolboxes.modules.checklist.emptyItems", "toolboxes.modules.checklist.importPlaceholder", "toolboxes.modules.checklist.importOpenButton", "toolboxes.modules.checklist.importModalTitle", "toolboxes.modules.checklist.importButton", "toolboxes.modules.checklist.hideCompletedSectionsTitle", "toolboxes.modules.checklist.hideCompletedSectionsFullyTitle", + "toolboxes.modules.checklist.hideCompletedItemsTitle", "toolboxes.modules.checklist.completedSectionsModeTitle", "toolboxes.modules.checklist.completedSectionsVisibleLabel", "toolboxes.modules.checklist.completedSectionsReducedLabel", @@ -343,12 +345,20 @@ export function validateSiteContent(site) { "toolboxes.modules.checklist.editTitle", "toolboxes.modules.checklist.deleteTitle", "toolboxes.modules.images.addImages", + "toolboxes.modules.images.emptyImages", "toolboxes.modules.images.pastePlaceholder", "toolboxes.modules.images.pasteAriaLabel", "toolboxes.modules.images.imageAlt", "toolboxes.modules.images.labelPlaceholder", "toolboxes.modules.images.labelAriaLabel", "toolboxes.modules.images.previewAriaLabel", + "toolboxes.modules.images.galleryButton", + "toolboxes.modules.images.galleryTitle", + "toolboxes.modules.images.galleryImageAriaLabel", + "toolboxes.modules.images.returnGalleryTitle", + "toolboxes.modules.images.previousImageTitle", + "toolboxes.modules.images.nextImageTitle", + "toolboxes.modules.images.positionLabel", "toolboxes.modules.images.annotateAriaLabel", "toolboxes.modules.images.annotateTitle", "toolboxes.modules.images.deleteAriaLabel", @@ -356,6 +366,7 @@ export function validateSiteContent(site) { "toolboxes.modules.links.titlePlaceholder", "toolboxes.modules.links.urlPlaceholder", "toolboxes.modules.links.addButton", + "toolboxes.modules.links.emptyLinks", "toolboxes.modules.links.importPlaceholder", "toolboxes.modules.links.importOpenButton", "toolboxes.modules.links.importModalTitle", @@ -365,6 +376,7 @@ export function validateSiteContent(site) { "toolboxes.modules.links.deleteTitle", "toolboxes.modules.counters.labelPlaceholder", "toolboxes.modules.counters.addButton", + "toolboxes.modules.counters.emptyCounters", "toolboxes.modules.counters.decrementLabel", "toolboxes.modules.counters.incrementLabel", "toolboxes.modules.counters.resetTitle", diff --git a/tests/static-toolboxes.test.mjs b/tests/static-toolboxes.test.mjs index a1ef905..e230f00 100644 --- a/tests/static-toolboxes.test.mjs +++ b/tests/static-toolboxes.test.mjs @@ -170,6 +170,8 @@ test("toolbox module registry and modules expose expected behavior", async () => const textImportPlanning = await readFile("website/src/features/toolboxes/modules/textImport/planning.js", "utf8"); const textImportTime = await readFile("website/src/features/toolboxes/modules/textImport/time.js", "utf8"); const imageViewer = await readFile("website/src/components/ImageViewer.jsx", "utf8"); + const imageGallery = await readFile("website/src/components/ImageGallery.jsx", "utf8"); + const moduleEmptyMessage = await readFile("website/src/features/toolboxes/modules/ModuleEmptyMessage.jsx", "utf8"); const randomizerModule = await readFile("website/src/features/toolboxes/modules/RandomizerModule.jsx", "utf8"); const randomizerDice = await readFile("website/src/features/toolboxes/modules/randomizer/RandomizerDice.jsx", "utf8"); const randomizerWheel = await readFile("website/src/features/toolboxes/modules/randomizer/RandomizerWheel.jsx", "utf8"); @@ -228,6 +230,8 @@ test("toolbox module registry and modules expose expected behavior", async () => assert.match(addToolControls, /tool-add-quick-toggle/); assert.match(moduleMetadataRegistry, /scrollable: true/); assert.match(moduleShell, /module-content/); + assert.match(moduleShell, /scrollIntoView/); + assert.match(moduleShell, /data-module-id/); assert.match(moduleShell, /module-scroll-button/); assert.match(moduleShell, /module-collapse-button/); assert.match(moduleShell, /aria-expanded=\{!collapsed\}/); @@ -395,6 +399,8 @@ test("toolbox module registry and modules expose expected behavior", async () => assert.match(checklistParts, /checklist-qty-current/); assert.match(checklistParts, /checklist-delete-button nds-icon-button/); assert.match(checklistModule, /useGroupedReorder/); + assert.match(checklistModule, /hideCompletedItems/); + assert.match(checklistParts, /visibleItems/); assert.match(checklistBundle, /getItemProps/); assert.match(checklistParts, /getGroupProps/); assert.match(checklistModule, /getGroupBoundaryProps/); @@ -411,6 +417,8 @@ test("toolbox module registry and modules expose expected behavior", async () => assert.match(imagesModule, /createImageAnnotationModule/); assert.match(imagesModule, /TextExchangeActions/); assert.match(imagesModule, /image-annotate-button/); + assert.match(imagesModule, /kind: "collection"/); + assert.match(imagesModule, /galleryButton/); assert.match(imagesModule, /dataTransfer\.files/); assert.match(imageAnnotationModule, /export function ImageAnnotationModule/); assert.match(imageAnnotationModule, /annotation-marker/); @@ -455,6 +463,11 @@ test("toolbox module registry and modules expose expected behavior", async () => assert.match(imageViewer, /!hasMarkers &&/); assert.match(imageViewer, / { diff --git a/tests/toolbox-modules.test.mjs b/tests/toolbox-modules.test.mjs index 3841ecf..2d0d29a 100644 --- a/tests/toolbox-modules.test.mjs +++ b/tests/toolbox-modules.test.mjs @@ -1193,6 +1193,33 @@ test("checklist data drops empty titled sections", () => { assert.deepEqual(compact.sections.map((section) => section.id), ["filled"]); }); +test("checklist completed-item visibility survives compact storage and toolbox export", () => { + const data = { + hideCompletedItems: true, + hideCompletedSections: true, + hideCompletedSectionsFully: false, + sections: [{ id: "section", title: "Craft", items: [{ id: "item", label: "Ore", qtyTarget: 3, qtyCurrent: 3 }] }] + }; + const normalized = normalizeChecklistData(data); + assert.equal(normalized.hideCompletedItems, true); + + const compact = compactModuleDataForStorage("checklist", data); + assert.equal(compact.hideCompletedItems, true); + assert.equal(compact.hideCompletedSections, true); + assert.equal("hideCompletedSectionsFully" in compact, false); + assert.deepEqual(compactModuleDataForStorage("checklist", { hideCompletedItems: true, sections: [] }), { hideCompletedItems: true }); + + const payload = createToolboxExportPayload({ + id: "source", + name: "Checklist export", + modules: [{ id: "checklist", type: "checklist", title: "Checklist" }] + }, { [moduleStorageKey("source", "checklist")]: data }); + const exported = Object.values(payload.modules)[0]; + assert.equal(exported.hideCompletedItems, true); + assert.equal(exported.hideCompletedSections, true); + assert.equal(exported.sections[0].items[0].qtyCurrent, 3); +}); + test("checklist helpers merge categories and move items deterministically", () => { const context = { uid: (prefix) => `${prefix}-new` }; const sections = [ diff --git a/website/public/data/site.json b/website/public/data/site.json index 7eba1f7..60e0398 100644 --- a/website/public/data/site.json +++ b/website/public/data/site.json @@ -1991,6 +1991,7 @@ "sectionPlaceholder": "Catégorie", "quantityLabel": "Quantité cible", "addButton": "Ajouter", + "emptyItems": "Aucun élément dans cette checklist.", "importPlaceholder": "Importer plusieurs items...\n# Potions\nPotion:10\nMéga potion:5\n\n# Armures\nCasque Rey Dau:1", "importOpenButton": "Importer du texte", "exportOpenButton": "Exporter en format texte", @@ -2003,6 +2004,7 @@ "importError": "Format de checklist invalide.", "hideCompletedSectionsTitle": "Réduire les catégories terminées", "hideCompletedSectionsFullyTitle": "Cacher les catégories terminées", + "hideCompletedItemsTitle": "Masquer les éléments terminés", "completedSectionsModeTitle": "Affichage des catégories terminées", "completedSectionsVisibleLabel": "Visible quand terminée", "completedSectionsReducedLabel": "Réduite quand terminée", @@ -2033,12 +2035,20 @@ "importError": "Format d'images invalide.", "exportInfo": "Les images sont exportées en base64 complet. Le texte peut donc être très long.", "addImages": "Ajouter des images", + "emptyImages": "Aucune image.", "pastePlaceholder": "Coller une image ici", "pasteAriaLabel": "Coller une image depuis le presse-papiers", "imageAlt": "Image", "labelPlaceholder": "Libellé de l'image", "labelAriaLabel": "Libellé de l'image", "previewAriaLabel": "Agrandir l'image", + "galleryButton": "Galerie", + "galleryTitle": "Galerie d'images", + "galleryImageAriaLabel": "Afficher en grand", + "returnGalleryTitle": "Revenir à la galerie", + "previousImageTitle": "Image précédente", + "nextImageTitle": "Image suivante", + "positionLabel": "Image {current} sur {total}", "reorderAriaLabel": "Déplacer l'image", "reorderTitle": "Déplacer", "annotateAriaLabel": "Annoter l'image", @@ -2051,6 +2061,7 @@ "urlPlaceholder": "https://...", "categoryPlaceholder": "Catégorie", "addButton": "Ajouter", + "emptyLinks": "Aucun lien.", "reorderCategoryTitle": "Déplacer la catégorie", "showCategoryTitle": "Afficher la catégorie", "hideCategoryTitle": "Réduire la catégorie", @@ -2072,6 +2083,7 @@ "counters": { "labelPlaceholder": "Nom du compteur", "addButton": "Ajouter", + "emptyCounters": "Aucun compteur.", "importPlaceholder": "Victoire: 12\nDéfaite: -2", "importOpenButton": "Importer du texte", "exportOpenButton": "Exporter en format texte", @@ -2759,7 +2771,7 @@ "associate": "Associer à la page", "noMatches": "Cette checklist ne contient plus de correspondance avec le guide.", "matches": "correspondances", - "unlink": "Dissocier", + "unlink": "Dissocier la checklist", "hideCompleted": "Masquer les cochés", "completed": "Coché", "checkCard": "Cocher dans la checklist", diff --git a/website/src/components/ImageGallery.jsx b/website/src/components/ImageGallery.jsx new file mode 100644 index 0000000..e93bc1c --- /dev/null +++ b/website/src/components/ImageGallery.jsx @@ -0,0 +1,42 @@ +// Rôle : affiche la collection d'un outil Images dans la galerie de la visionneuse. +import { useEffect, useRef } from "react"; + +export function ImageGallery({ images, activeIndex, focusIndex = -1, onSelect, textContent = {} }) { + const galleryRef = useRef(null); + + useEffect(() => { + if (focusIndex < 0) return; + const frame = window.requestAnimationFrame(() => { + galleryRef.current?.querySelector(`[data-image-index="${focusIndex}"]`)?.focus(); + }); + return () => window.cancelAnimationFrame(frame); + }, [focusIndex]); + + return ( +
+ {images.map((image, index) => { + const position = `${index + 1} / ${images.length}`; + const label = image.label || `${textContent.imageAlt || "Image"} ${index + 1}`; + return ( + + ); + })} +
+ ); +} diff --git a/website/src/components/ImageViewer.jsx b/website/src/components/ImageViewer.jsx index ef8dc5b..ea3b3ea 100644 --- a/website/src/components/ImageViewer.jsx +++ b/website/src/components/ImageViewer.jsx @@ -1,10 +1,11 @@ -// Rôle : affiche les images en modale, avec support des annotations. +// Rôle : affiche en modale une image annotable ou une collection navigable avec galerie. import { useEffect, useRef, useState } from "react"; import { DrawingControls } from "../features/toolboxes/modules/DrawingControls.jsx"; import { DrawingOverlay } from "../features/toolboxes/modules/DrawingOverlay.jsx"; import { lockBodyScroll } from "../utils/bodyScrollLock.js"; import { DRAWING_COLORS, DRAWING_WIDTHS } from "../utils/drawingOptions.js"; import { Icon } from "./Icon.jsx"; +import { ImageGallery } from "./ImageGallery.jsx"; import { Tooltip } from "./Tooltip.jsx"; function clampPercent(value) { @@ -32,27 +33,49 @@ function openImageInNewTab(dataUrl) { } } +function formatPositionLabel(template, current, total) { + return String(template || "Image {current} sur {total}") + .replace("{current}", current) + .replace("{total}", total); +} + export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.crypto?.randomUUID?.() || `marker-${Date.now()}` }) { useEffect(() => lockBodyScroll(), []); - const canAnnotate = Boolean(image.canAnnotate && image.onChangeMarkers); - const canDraw = Boolean(canAnnotate && image.onChangeDrawings); - const [viewerMarkers, setViewerMarkers] = useState(() => Array.isArray(image.markers) ? image.markers : []); - const [viewerDrawings, setViewerDrawings] = useState(() => image.drawings?.strokes ? image.drawings : { strokes: [] }); - const [drawingMode, setDrawingMode] = useState(image.drawingMode === "temporary" ? "temporary" : "permanent"); + const isCollection = image.kind === "collection" && Array.isArray(image.images) && image.images.length > 0; + const collection = isCollection ? image.images : [image]; + const initialIndex = isCollection ? Math.min(collection.length - 1, Math.max(0, Number(image.activeIndex) || 0)) : 0; + const initialView = isCollection && image.initialView === "gallery" ? "gallery" : "detail"; + const [activeIndex, setActiveIndex] = useState(initialIndex); + const [view, setView] = useState(initialView); + const [galleryFocusIndex, setGalleryFocusIndex] = useState(-1); + const [previousRequest, setPreviousRequest] = useState(image); + if (previousRequest !== image) { + setPreviousRequest(image); + setActiveIndex(initialIndex); + setView(initialView); + setGalleryFocusIndex(-1); + } + const activeImage = collection[activeIndex] || collection[0] || image; + const textContent = isCollection ? image.textContent || {} : {}; + const canAnnotate = Boolean(activeImage.canAnnotate && activeImage.onChangeMarkers); + const canDraw = Boolean(canAnnotate && activeImage.onChangeDrawings); + const [viewerMarkers, setViewerMarkers] = useState(() => Array.isArray(activeImage.markers) ? activeImage.markers : []); + const [viewerDrawings, setViewerDrawings] = useState(() => activeImage.drawings?.strokes ? activeImage.drawings : { strokes: [] }); + const [drawingMode, setDrawingMode] = useState(activeImage.drawingMode === "temporary" ? "temporary" : "permanent"); const [drawingActive, setDrawingActive] = useState(false); const [eraseActive, setEraseActive] = useState(false); const [drawingColor, setDrawingColor] = useState("#f6c453"); const [drawingWidth, setDrawingWidth] = useState(4); - const [previousImage, setPreviousImage] = useState(image); + const [previousImage, setPreviousImage] = useState(activeImage); const viewerRef = useRef(null); const mediaRef = useRef(null); const hasMarkers = viewerMarkers.length > 0; - if (previousImage !== image) { - setPreviousImage(image); - setViewerMarkers(Array.isArray(image.markers) ? image.markers : []); - setViewerDrawings(image.drawings?.strokes ? image.drawings : { strokes: [] }); - setDrawingMode(image.drawingMode === "temporary" ? "temporary" : "permanent"); + if (previousImage !== activeImage) { + setPreviousImage(activeImage); + setViewerMarkers(Array.isArray(activeImage.markers) ? activeImage.markers : []); + setViewerDrawings(activeImage.drawings?.strokes ? activeImage.drawings : { strokes: [] }); + setDrawingMode(activeImage.drawingMode === "temporary" ? "temporary" : "permanent"); setDrawingActive(false); setEraseActive(false); } @@ -76,31 +99,43 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis. observer.disconnect(); window.removeEventListener("resize", syncImageHeight); }; - }, [image.dataUrl, canAnnotate]); + }, [activeImage.dataUrl, canAnnotate, view]); useEffect(() => { const onKeyDown = (event) => { - if (event.key === "Escape") onClose(); + if (event.key === "Escape") { + onClose(); + return; + } + if (!isCollection || view !== "detail") return; + if (event.key === "ArrowLeft" && activeIndex > 0) { + event.preventDefault(); + setActiveIndex((index) => index - 1); + } + if (event.key === "ArrowRight" && activeIndex < collection.length - 1) { + event.preventDefault(); + setActiveIndex((index) => index + 1); + } }; document.addEventListener("keydown", onKeyDown); return () => document.removeEventListener("keydown", onKeyDown); - }, [onClose]); + }, [activeIndex, collection.length, isCollection, onClose, view]); function updateViewerMarkers(nextMarkers) { setViewerMarkers(nextMarkers); - image.onChangeMarkers?.(nextMarkers); + activeImage.onChangeMarkers?.(nextMarkers); } function updateViewerDrawings(nextStrokes) { const nextDrawings = { strokes: nextStrokes }; setViewerDrawings(nextDrawings); - image.onChangeDrawings?.(nextDrawings, drawingMode); + activeImage.onChangeDrawings?.(nextDrawings, drawingMode); } function toggleDrawingMode() { const nextMode = drawingMode === "permanent" ? "temporary" : "permanent"; setDrawingMode(nextMode); - image.onChangeDrawingMode?.(nextMode, viewerDrawings); + activeImage.onChangeDrawingMode?.(nextMode, viewerDrawings); } function getMediaSurfaceSize() { @@ -126,6 +161,19 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis. updateViewerMarkers(nextMarkers); } + function openGallery() { + setGalleryFocusIndex(activeIndex); + setView("gallery"); + } + + function openGalleryImage(index) { + setGalleryFocusIndex(-1); + setActiveIndex(index); + setView("detail"); + } + + const positionLabel = formatPositionLabel(textContent.positionLabel, activeIndex + 1, collection.length); + function updateViewerMarker(markerId, updater) { updateViewerMarkers(viewerMarkers.map((marker) => marker.id === markerId ? updater(marker) : marker)); } @@ -137,17 +185,32 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis. return (
-
+
- {image.label && {image.label}} +
+ {(view === "gallery" || isCollection || activeImage.label) && ( + {view === "gallery" ? textContent.galleryTitle || "Galerie d'images" : activeImage.label || textContent.imageAlt || "Image"} + )} + {isCollection && view === "detail" && {activeIndex + 1} / {collection.length}} +
- {!canAnnotate && !hasMarkers && ( - + {isCollection && view === "detail" && ( + + )} + {view === "detail" && !canAnnotate && !hasMarkers && ( + )}
-
+ {view === "gallery" ? ( + + ) :
1 ? "has-navigation" : ""}`}> + {isCollection && collection.length > 1 && ( + + )}
globalThis. onClick={addViewerMarker} role={canAnnotate ? "button" : undefined} tabIndex={canAnnotate ? 0 : undefined} - aria-label={canAnnotate ? image.addMarkerAriaLabel || "Ajouter un marqueur sur l'image" : undefined} + aria-label={canAnnotate ? activeImage.addMarkerAriaLabel || "Ajouter un marqueur sur l'image" : undefined} > - {image.alt + {activeImage.alt globalThis. width={drawingWidth} onChange={updateViewerDrawings} getSurfaceSize={getMediaSurfaceSize} - createStrokeId={image.createStrokeId} + createStrokeId={activeImage.createStrokeId} coordinateMode="percent" /> {canDraw && ( setDrawingActive((value) => !value)} eraseActive={eraseActive} @@ -193,7 +256,7 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis. as="span" className="annotation-marker image-viewer-marker" key={marker.id || `${marker.x}-${marker.y}-${index}`} - content={marker.label || `${image.markerPrefix || "Marqueur"} ${index + 1}`} + content={marker.label || `${activeImage.markerPrefix || "Marqueur"} ${index + 1}`} position="top" style={{ "--marker-x": `${marker.x}%`, "--marker-y": `${marker.y}%` }} > @@ -202,6 +265,11 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis. ))}
+ {isCollection && collection.length > 1 && ( + + )} {canAnnotate && ( )} -
+
}
); diff --git a/website/src/components/Shell.jsx b/website/src/components/Shell.jsx index 8052be1..30adc47 100644 --- a/website/src/components/Shell.jsx +++ b/website/src/components/Shell.jsx @@ -3,12 +3,12 @@ import { Icon } from "./Icon.jsx"; import { ImportButton } from "./ImportButton.jsx"; import { Tooltip } from "./Tooltip.jsx"; -export function Shell({ route, content, games, toolboxes, links, drawerGameId = "", actions, children }) { +export function Shell({ route, content, games, drawerGameId = "", actions, children }) { const gameId = route.split("/")[1] === "games" ? route.split("/")[2] || "" : ""; const game = games.find((item) => item.id === gameId); - const linkedToolbox = game ? toolboxes.find((item) => item.id === links[game.id]) : null; const topbarLabel = route === "/design-system" && import.meta.env.DEV ? "Design system" : route.startsWith("/toolbox") ? content.topbar.toolbox : route.startsWith("/games") ? content.topbar.games : route === "/library" ? content.topbar.library : content.topbar.dashboard; - const toolboxLabel = linkedToolbox ? `Ouvrir la toolbox ${linkedToolbox.name}` : "Associer une toolbox"; + const toolboxOpen = Boolean(game && drawerGameId === game.id); + const toolboxLabel = toolboxOpen ? "Masquer la toolbox" : "Afficher la toolbox"; const compactSidebar = Boolean(drawerGameId); return ( @@ -48,15 +48,16 @@ export function Shell({ route, content, games, toolboxes, links, drawerGameId = {game && (
)} diff --git a/website/src/features/games/GuideChecklistControls.jsx b/website/src/features/games/GuideChecklistControls.jsx index d47d28e..901b7a2 100644 --- a/website/src/features/games/GuideChecklistControls.jsx +++ b/website/src/features/games/GuideChecklistControls.jsx @@ -30,6 +30,21 @@ export function GuideChecklistControls({ checklist, list, id, open }) {
; } +export function GuideChecklistPageControls({ checklist }) { + if (!checklist.selected) return null; + const { content, hideCompleted } = checklist; + return ( +