Add persistent gallery zoom and proportional card layout
All checks were successful
Deploy Sokko G / deploy (push) Successful in 18s

This commit is contained in:
Shinuwa 2026-09-28 09:02:10 +02:00
parent 386212041f
commit 273bc5d528
11 changed files with 158 additions and 19 deletions

View file

@ -623,7 +623,7 @@ 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 visionneuse d'un outil Images peut recevoir la collection du bloc courant : sa galerie utilise de grandes cards `4:3`, avec image non recadrée et libellé toujours visible, dans un flux responsive centré et scrollable uniquement verticalement ; les boutons `−` et `+` règlent leur largeur de 180 à 420 px par paliers de 40 px ; les gouttières entre cards restent compactes tandis que le bord de la galerie conserve un padding plus généreux ;
- 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.

View file

@ -27,6 +27,7 @@ Checklist à suivre lors de l'ajout ou de la modification d'un outil, d'une page
- Pour les dessins temporaires et l'historique du simulateur de chance, vérifier l'isolation par clé de session lors des changements de page/outil et après rechargement ; une navigation seule ne doit pas écraser les données de l'autre clé.
- Pour le simulateur de chance, vérifier que les brouillons suivent le taux et l'outil actifs, restent intacts lors d'un rendu sans rapport et se valident au blur selon le champ.
- Pour le visualiseur d'images, vérifier la remise à zéro des marqueurs et des modes dessin/gomme au remplacement de l'image.
- Pour la galerie d'images, vérifier les boutons de taille `−` / `+`, leur blocage aux limites, la persistance après rechargement, les cards `4:3` avec image et libellé contenus, la redistribution centrée des vignettes, le padding périphérique et l'absence de scroll horizontal.
- Pour l'éditeur de couverture, vérifier les décodages concurrents, les erreurs après fermeture et la libération des bitmaps/URLs ; une image en chargement ne doit pas permettre de valider la couverture précédente.
- Les parcours `e2e/toolbox-cover.spec.js` vérifient le choix d'icône, l'enregistrement d'une couverture 620 × 248, sa persistance après rechargement et import/export, l'annulation et les fichiers illisibles. L'association à un jeu doit afficher sa couverture sans perdre l'image personnalisée, restaurée après dissociation ; choisir une icône doit supprimer la couverture personnalisée.
- Les parcours `e2e/table.spec.js` vérifient les en-têtes éditables sans changer les références des formules, le redimensionnement sans restauration des cellules supprimées, la navigation et la sélection clavier, la copie des valeurs TSV, le collage et l'effacement de blocs, le déplacement à la souris et l'insertion d'une plage dans une formule. Les valeurs et dimensions doivent survivre au rechargement.

View file

@ -3,6 +3,13 @@
Ce document décrit les données persistées dans IndexedDB pour les toolboxes.
Il doit être mis à jour à chaque ajout ou modification d'outil.
## Préférences locales hors toolbox
`localStorage["sokkog:image-gallery-thumbnail-width"]` conserve la largeur cible
des aperçus de la galerie d'images. La valeur est bornée de 180 à 420 px, alignée
sur des paliers de 40 px et vaut 260 px par défaut. Cette préférence d'interface
globale n'est pas incluse dans les exports de toolboxes.
## IndexedDB
Base : `sokkog`

View file

@ -1,4 +1,4 @@
// Rôle : couvre les mutations persistantes des liens, compteurs et checklists.
// Rôle : couvre les parcours persistants des liens, compteurs, checklists et galeries d'images.
import { expect, test } from "@playwright/test";
import { Buffer } from "node:buffer";
@ -151,7 +151,20 @@ test("images open a gallery and navigate the current collection without looping"
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);
const thumbnails = dialog.locator(".image-viewer-gallery-item");
const gallery = dialog.locator(".image-viewer-gallery");
await expect(thumbnails).toHaveCount(3);
const initialThumbnailWidth = (await thumbnails.first().boundingBox()).width;
await dialog.getByRole("button", { name: "Agrandir les aperçus", exact: true }).click();
await expect.poll(async () => (await thumbnails.first().boundingBox()).width).toBeCloseTo(initialThumbnailWidth + 40, 0);
expect(await page.evaluate(() => globalThis.localStorage.getItem("sokkog:image-gallery-thumbnail-width"))).toBe("300");
expect(await gallery.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true);
await dialog.getByRole("button", { name: "Fermer", exact: true }).click();
await page.reload();
await tool.getByRole("button", { name: "Galerie", exact: true }).click();
await expect.poll(async () => (await thumbnails.first().boundingBox()).width).toBeCloseTo(300, 0);
await dialog.getByRole("button", { name: "Réduire les aperçus", exact: true }).click();
await expect.poll(async () => (await thumbnails.first().boundingBox()).width).toBeCloseTo(260, 0);
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");
@ -175,6 +188,11 @@ test("images open a gallery and navigate the current collection without looping"
expect(galleryBox.y).toBeGreaterThanOrEqual(0);
expect(galleryBox.x + galleryBox.width).toBeLessThanOrEqual(390);
expect(galleryBox.y + galleryBox.height).toBeLessThanOrEqual(844);
const zoomIn = dialog.getByRole("button", { name: "Agrandir les aperçus", exact: true });
for (let step = 0; step < 4; step += 1) await zoomIn.click();
await expect(zoomIn).toBeDisabled();
expect(await page.evaluate(() => globalThis.localStorage.getItem("sokkog:image-gallery-thumbnail-width"))).toBe("420");
expect(await gallery.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true);
await dialog.getByRole("button", { name: "Fermer", exact: true }).click();
await tool.locator(".image-preview").nth(2).click();
@ -182,3 +200,48 @@ test("images open a gallery and navigate the current collection without looping"
await expect(page.getByRole("button", { name: "Image suivante", exact: true })).toBeDisabled();
await page.getByRole("button", { name: "Fermer", exact: true }).click();
});
test("image gallery keeps proportional cards and compact centered rows at maximum zoom", async ({ page }) => {
await page.setViewportSize({ width: 1550, height: 900 });
const makeImage = (index) => `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="640" height="360"><rect width="640" height="360" fill="hsl(${index * 45} 70% 45%)"/></svg>`)}`;
const tool = await setup(page, "images", {
images: Array.from({ length: 7 }, (_, index) => ({ id: `image-${index}`, label: `Image ${index + 1}`, dataUrl: makeImage(index) }))
});
await page.evaluate(() => globalThis.localStorage.setItem("sokkog:image-gallery-thumbnail-width", "420"));
await tool.getByRole("button", { name: "Galerie", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Galerie d'images", exact: true });
const gallery = dialog.locator(".image-viewer-gallery");
const cards = gallery.locator(".image-viewer-gallery-item");
await expect(cards).toHaveCount(7);
await expect.poll(() => cards.first().locator("img").evaluate((image) => image.naturalWidth)).toBe(640);
const boxes = await cards.evaluateAll((elements) => elements.map((element) => {
const box = element.getBoundingClientRect();
return { x: box.x, y: box.y, width: box.width, height: box.height };
}));
expect(boxes[0].width / boxes[0].height).toBeCloseTo(4 / 3, 2);
expect(boxes.slice(0, 3).every((box) => Math.abs(box.y - boxes[0].y) < 1)).toBe(true);
expect(boxes.slice(3, 6).every((box) => Math.abs(box.y - boxes[3].y) < 1)).toBe(true);
expect(boxes[3].y).toBeGreaterThan(boxes[0].y + boxes[0].height);
expect(boxes[6].y).toBeGreaterThan(boxes[3].y + boxes[3].height);
const gap = boxes[1].x - boxes[0].x - boxes[0].width;
const galleryBox = await gallery.boundingBox();
expect(gap).toBeGreaterThanOrEqual(10);
expect(gap).toBeLessThanOrEqual(14);
expect(boxes[0].x - galleryBox.x).toBeGreaterThan(gap * 2);
expect(galleryBox.x + galleryBox.width - boxes[2].x - boxes[2].width).toBeGreaterThan(gap * 2);
expect(boxes[6].x + boxes[6].width / 2).toBeCloseTo(galleryBox.x + galleryBox.width / 2, 0);
const firstCard = cards.first();
const frameBox = await firstCard.locator(".image-viewer-gallery-frame").boundingBox();
const imageBox = await firstCard.locator("img").boundingBox();
const captionBox = await firstCard.locator(".image-viewer-gallery-caption").boundingBox();
expect(imageBox.x).toBeGreaterThanOrEqual(frameBox.x);
expect(imageBox.y).toBeGreaterThanOrEqual(frameBox.y);
expect(imageBox.x + imageBox.width).toBeLessThanOrEqual(frameBox.x + frameBox.width);
expect(imageBox.y + imageBox.height).toBeLessThanOrEqual(frameBox.y + frameBox.height);
expect(captionBox.y + captionBox.height).toBeLessThanOrEqual(boxes[0].y + boxes[0].height);
expect(await gallery.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true);
expect(await gallery.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true);
});

View file

@ -356,6 +356,9 @@ export function validateSiteContent(site) {
"toolboxes.modules.images.galleryTitle",
"toolboxes.modules.images.galleryImageAriaLabel",
"toolboxes.modules.images.returnGalleryTitle",
"toolboxes.modules.images.galleryZoomLabel",
"toolboxes.modules.images.galleryZoomOutTitle",
"toolboxes.modules.images.galleryZoomInTitle",
"toolboxes.modules.images.previousImageTitle",
"toolboxes.modules.images.nextImageTitle",
"toolboxes.modules.images.positionLabel",

View file

@ -466,6 +466,9 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(imageViewer, /ArrowLeft/);
assert.match(imageViewer, /ArrowRight/);
assert.match(imageViewer, /<ImageGallery/);
assert.match(imageViewer, /sokkog:image-gallery-thumbnail-width/);
assert.match(imageViewer, /galleryZoomOutTitle/);
assert.match(imageViewer, /galleryZoomInTitle/);
assert.match(imageGallery, /image-viewer-gallery-item/);
assert.match(moduleEmptyMessage, /toolbox-module-empty/);
});

View file

@ -2046,6 +2046,9 @@
"galleryTitle": "Galerie d'images",
"galleryImageAriaLabel": "Afficher en grand",
"returnGalleryTitle": "Revenir à la galerie",
"galleryZoomLabel": "Taille des aperçus",
"galleryZoomOutTitle": "Réduire les aperçus",
"galleryZoomInTitle": "Agrandir les aperçus",
"previousImageTitle": "Image précédente",
"nextImageTitle": "Image suivante",
"positionLabel": "Image {current} sur {total}",

View file

@ -1,7 +1,7 @@
// 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 = {} }) {
export function ImageGallery({ images, activeIndex, focusIndex = -1, thumbnailWidth = 260, onSelect, textContent = {} }) {
const galleryRef = useRef(null);
useEffect(() => {
@ -13,7 +13,7 @@ export function ImageGallery({ images, activeIndex, focusIndex = -1, onSelect, t
}, [focusIndex]);
return (
<div ref={galleryRef} className="image-viewer-gallery themed-scrollbar">
<div ref={galleryRef} className="image-viewer-gallery themed-scrollbar" style={{ "--image-gallery-thumbnail-width": `${thumbnailWidth}px` }}>
{images.map((image, index) => {
const position = `${index + 1} / ${images.length}`;
const label = image.label || `${textContent.imageAlt || "Image"} ${index + 1}`;

View file

@ -8,6 +8,28 @@ import { Icon } from "./Icon.jsx";
import { ImageGallery } from "./ImageGallery.jsx";
import { Tooltip } from "./Tooltip.jsx";
const IMAGE_GALLERY_SIZE_STORAGE_KEY = "sokkog:image-gallery-thumbnail-width";
const IMAGE_GALLERY_SIZE_MIN = 180;
const IMAGE_GALLERY_SIZE_MAX = 420;
const IMAGE_GALLERY_SIZE_STEP = 40;
const IMAGE_GALLERY_SIZE_DEFAULT = 260;
function normalizeGalleryThumbnailWidth(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return IMAGE_GALLERY_SIZE_DEFAULT;
const clamped = Math.min(IMAGE_GALLERY_SIZE_MAX, Math.max(IMAGE_GALLERY_SIZE_MIN, parsed));
return IMAGE_GALLERY_SIZE_MIN + Math.round((clamped - IMAGE_GALLERY_SIZE_MIN) / IMAGE_GALLERY_SIZE_STEP) * IMAGE_GALLERY_SIZE_STEP;
}
function readGalleryThumbnailWidth() {
try {
const stored = localStorage.getItem(IMAGE_GALLERY_SIZE_STORAGE_KEY);
return stored == null || stored === "" ? IMAGE_GALLERY_SIZE_DEFAULT : normalizeGalleryThumbnailWidth(stored);
} catch {
return IMAGE_GALLERY_SIZE_DEFAULT;
}
}
function clampPercent(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return 0;
@ -48,6 +70,7 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.
const [activeIndex, setActiveIndex] = useState(initialIndex);
const [view, setView] = useState(initialView);
const [galleryFocusIndex, setGalleryFocusIndex] = useState(-1);
const [galleryThumbnailWidth, setGalleryThumbnailWidth] = useState(readGalleryThumbnailWidth);
const [previousRequest, setPreviousRequest] = useState(image);
if (previousRequest !== image) {
setPreviousRequest(image);
@ -172,6 +195,14 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.
setView("detail");
}
function resizeGalleryThumbnail(direction) {
const next = normalizeGalleryThumbnailWidth(galleryThumbnailWidth + direction * IMAGE_GALLERY_SIZE_STEP);
setGalleryThumbnailWidth(next);
try {
localStorage.setItem(IMAGE_GALLERY_SIZE_STORAGE_KEY, String(next));
} catch { /* La préférence reste active pour la modale courante si le stockage est indisponible. */ }
}
const positionLabel = formatPositionLabel(textContent.positionLabel, activeIndex + 1, collection.length);
function updateViewerMarker(markerId, updater) {
@ -194,6 +225,12 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.
{isCollection && view === "detail" && <span className="image-viewer-position" aria-label={positionLabel} title={positionLabel} aria-live="polite">{activeIndex + 1} / {collection.length}</span>}
</div>
<div className="image-viewer-actions">
{view === "gallery" && (
<div className="image-viewer-gallery-zoom" role="group" aria-label={textContent.galleryZoomLabel || "Taille des aperçus"}>
<button className="image-viewer-gallery-zoom-button nds-icon-button" data-size="sm" type="button" disabled={galleryThumbnailWidth <= IMAGE_GALLERY_SIZE_MIN} onClick={() => resizeGalleryThumbnail(-1)} aria-label={textContent.galleryZoomOutTitle || "Réduire les aperçus"} title={textContent.galleryZoomOutTitle || "Réduire les aperçus"}><span aria-hidden="true">−</span></button>
<button className="image-viewer-gallery-zoom-button nds-icon-button" data-size="sm" type="button" disabled={galleryThumbnailWidth >= IMAGE_GALLERY_SIZE_MAX} onClick={() => resizeGalleryThumbnail(1)} aria-label={textContent.galleryZoomInTitle || "Agrandir les aperçus"} title={textContent.galleryZoomInTitle || "Agrandir les aperçus"}><span aria-hidden="true">+</span></button>
</div>
)}
{isCollection && view === "detail" && (
<button className="image-viewer-button nds-icon-button" data-size="sm" onClick={openGallery} aria-label={textContent.returnGalleryTitle || "Revenir à la galerie"} title={textContent.returnGalleryTitle || "Revenir à la galerie"}><Icon name="picture" /></button>
)}
@ -204,7 +241,7 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.
</div>
</header>
{view === "gallery" ? (
<ImageGallery images={collection} activeIndex={activeIndex} focusIndex={galleryFocusIndex} onSelect={openGalleryImage} textContent={textContent} />
<ImageGallery images={collection} activeIndex={activeIndex} focusIndex={galleryFocusIndex} thumbnailWidth={galleryThumbnailWidth} onSelect={openGalleryImage} textContent={textContent} />
) : <div className={`image-viewer-body ${canAnnotate ? "has-annotation-side" : ""} ${isCollection && collection.length > 1 ? "has-navigation" : ""}`}>
{isCollection && collection.length > 1 && (
<button className="image-viewer-nav image-viewer-nav-previous nds-icon-button" data-size="md" type="button" disabled={activeIndex === 0} onClick={() => setActiveIndex((index) => index - 1)} aria-label={textContent.previousImageTitle || "Image précédente"} title={textContent.previousImageTitle || "Image précédente"}>

View file

@ -265,13 +265,8 @@
}
.image-viewer-gallery {
grid-template-columns: repeat(auto-fill, minmax(min(220px, 100%), 1fr));
gap: var(--space-3);
}
.image-viewer-gallery-frame {
min-height: 180px;
height: min(42vh, 260px);
padding: var(--space-3);
}
.module-column {

View file

@ -70,6 +70,17 @@
gap: var(--space-2);
}
.image-viewer-gallery-zoom {
display: inline-flex;
gap: 4px;
}
.image-viewer-gallery-zoom-button > span {
font-size: 20px;
font-weight: 800;
line-height: 1;
}
.image-viewer-button {
flex: 0 0 40px;
}
@ -196,25 +207,39 @@
}
.image-viewer.is-gallery {
min-width: 0;
overflow: hidden;
height: min(860px, calc(100vh - 48px));
grid-template-rows: auto minmax(0, 1fr);
}
.image-viewer-gallery {
display: grid;
--image-gallery-thumbnail-width: 260px;
display: flex;
width: 100%;
max-width: 100%;
min-height: 0;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
min-width: 0;
flex-wrap: wrap;
align-content: start;
gap: var(--space-4);
justify-content: center;
gap: 12px;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
padding: 2px 6px 2px 2px;
padding: 20px 28px 28px;
}
.image-viewer-gallery-item {
display: grid;
width: min(100%, var(--image-gallery-thumbnail-width));
max-width: 100%;
min-width: 0;
gap: 10px;
aspect-ratio: 4 / 3;
flex: 0 1 var(--image-gallery-thumbnail-width);
grid-template-rows: minmax(0, 1fr) auto;
align-self: flex-start;
gap: 8px;
padding: 10px;
color: var(--color-text);
text-align: left;
@ -228,8 +253,9 @@
.image-viewer-gallery-frame {
display: grid;
min-height: 200px;
height: clamp(200px, 24vh, 280px);
width: 100%;
height: 100%;
min-height: 0;
place-items: center;
overflow: hidden;
border-radius: var(--radius-md);
@ -245,6 +271,7 @@
.image-viewer-gallery-caption {
display: grid;
width: 100%;
min-width: 0;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;