Add persistent gallery card ratio switch
All checks were successful
Deploy Sokko G / deploy (push) Successful in 18s

This commit is contained in:
Shinuwa 2026-09-28 10:09:58 +02:00
parent bd4059c9c1
commit 3f86d863ac
10 changed files with 72 additions and 10 deletions

View file

@ -624,7 +624,7 @@ Modales :
- panneau centre ; - panneau centre ;
- fermeture au clic exterieur quand l'action le permet ; - fermeture au clic exterieur quand l'action le permet ;
- boutons icones pour fermer/zoomer quand approprie. - 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 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 visionneuse d'un outil Images peut recevoir la collection du bloc courant : sa galerie utilise de grandes cards commutables entre `4:3` et `1:1`, avec image non recadrée et libellé toujours visible, dans un flux responsive centré et scrollable uniquement verticalement ; un bouton texte affiche le ratio actif, tandis que les boutons `−` et `+` règlent leur largeur de 180 à 420 px par paliers de 40 px ; le ratio et la largeur sont mémorisés comme préférences locales ; 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 ; - 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. - une visionneuse d'image seule, notamment pour les annotations, ne doit afficher ni galerie ni navigation de collection.

View file

@ -27,7 +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 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 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 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 la galerie d'images, vérifier les boutons de taille `−` / `+`, leur blocage aux limites, le switch de ratio `4:3` / `1:1`, la persistance de ces préférences après rechargement, les images et libellés contenus dans les deux formats, 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. - 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/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. - 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

@ -10,6 +10,10 @@ des aperçus de la galerie d'images. La valeur est bornée de 180 à 420 px, ali
sur des paliers de 40 px et vaut 260 px par défaut. Cette préférence d'interface 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. globale n'est pas incluse dans les exports de toolboxes.
`localStorage["sokkog:image-gallery-card-ratio"]` conserve le format des cards de
la galerie. Les valeurs acceptées sont `4:3`, utilisé par défaut, et `1:1`. Cette
préférence d'interface globale n'est pas incluse dans les exports de toolboxes.
## IndexedDB ## IndexedDB
Base : `sokkog` Base : `sokkog`

View file

@ -153,7 +153,20 @@ test("images open a gallery and navigate the current collection without looping"
await expect(dialog).toBeVisible(); await expect(dialog).toBeVisible();
const thumbnails = dialog.locator(".image-viewer-gallery-item"); const thumbnails = dialog.locator(".image-viewer-gallery-item");
const gallery = dialog.locator(".image-viewer-gallery"); const gallery = dialog.locator(".image-viewer-gallery");
const ratioButton = dialog.locator(".image-viewer-gallery-ratio-button");
await expect(thumbnails).toHaveCount(3); await expect(thumbnails).toHaveCount(3);
await expect(ratioButton).toHaveText("4:3");
await expect(ratioButton).toHaveAccessibleName(/Passer au format 1:1/);
const initialCardBox = await thumbnails.first().boundingBox();
expect(initialCardBox.width / initialCardBox.height).toBeCloseTo(4 / 3, 2);
await ratioButton.click();
await expect(ratioButton).toHaveText("1:1");
await expect(ratioButton).toHaveAccessibleName(/Passer au format 4:3/);
await expect.poll(async () => {
const box = await thumbnails.first().boundingBox();
return box.width / box.height;
}).toBeCloseTo(1, 2);
expect(await page.evaluate(() => globalThis.localStorage.getItem("sokkog:image-gallery-card-ratio"))).toBe("1:1");
const initialThumbnailWidth = (await thumbnails.first().boundingBox()).width; const initialThumbnailWidth = (await thumbnails.first().boundingBox()).width;
await dialog.getByRole("button", { name: "Agrandir les aperçus", exact: true }).click(); 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); await expect.poll(async () => (await thumbnails.first().boundingBox()).width).toBeCloseTo(initialThumbnailWidth + 40, 0);
@ -163,6 +176,10 @@ test("images open a gallery and navigate the current collection without looping"
await page.reload(); await page.reload();
await tool.getByRole("button", { name: "Galerie", exact: true }).click(); await tool.getByRole("button", { name: "Galerie", exact: true }).click();
await expect.poll(async () => (await thumbnails.first().boundingBox()).width).toBeCloseTo(300, 0); await expect.poll(async () => (await thumbnails.first().boundingBox()).width).toBeCloseTo(300, 0);
await expect(ratioButton).toHaveText("1:1");
await ratioButton.click();
await expect(ratioButton).toHaveText("4:3");
expect(await page.evaluate(() => globalThis.localStorage.getItem("sokkog:image-gallery-card-ratio"))).toBe("4:3");
await dialog.getByRole("button", { name: "Réduire les aperçus", exact: true }).click(); 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 expect.poll(async () => (await thumbnails.first().boundingBox()).width).toBeCloseTo(260, 0);
await dialog.getByRole("button", { name: "Afficher en grand : Second", exact: true }).click(); await dialog.getByRole("button", { name: "Afficher en grand : Second", exact: true }).click();
@ -188,6 +205,8 @@ test("images open a gallery and navigate the current collection without looping"
expect(galleryBox.y).toBeGreaterThanOrEqual(0); expect(galleryBox.y).toBeGreaterThanOrEqual(0);
expect(galleryBox.x + galleryBox.width).toBeLessThanOrEqual(390); expect(galleryBox.x + galleryBox.width).toBeLessThanOrEqual(390);
expect(galleryBox.y + galleryBox.height).toBeLessThanOrEqual(844); expect(galleryBox.y + galleryBox.height).toBeLessThanOrEqual(844);
await ratioButton.click();
await expect(ratioButton).toHaveText("1:1");
const zoomIn = dialog.getByRole("button", { name: "Agrandir les aperçus", exact: true }); const zoomIn = dialog.getByRole("button", { name: "Agrandir les aperçus", exact: true });
for (let step = 0; step < 4; step += 1) await zoomIn.click(); for (let step = 0; step < 4; step += 1) await zoomIn.click();
await expect(zoomIn).toBeDisabled(); await expect(zoomIn).toBeDisabled();

View file

@ -361,6 +361,9 @@ export function validateSiteContent(site) {
"toolboxes.modules.images.galleryZoomLabel", "toolboxes.modules.images.galleryZoomLabel",
"toolboxes.modules.images.galleryZoomOutTitle", "toolboxes.modules.images.galleryZoomOutTitle",
"toolboxes.modules.images.galleryZoomInTitle", "toolboxes.modules.images.galleryZoomInTitle",
"toolboxes.modules.images.galleryRatioLabel",
"toolboxes.modules.images.galleryRatioSquareTitle",
"toolboxes.modules.images.galleryRatioLandscapeTitle",
"toolboxes.modules.images.previousImageTitle", "toolboxes.modules.images.previousImageTitle",
"toolboxes.modules.images.nextImageTitle", "toolboxes.modules.images.nextImageTitle",
"toolboxes.modules.images.positionLabel", "toolboxes.modules.images.positionLabel",

View file

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

View file

@ -2049,6 +2049,9 @@
"galleryZoomLabel": "Taille des aperçus", "galleryZoomLabel": "Taille des aperçus",
"galleryZoomOutTitle": "Réduire les aperçus", "galleryZoomOutTitle": "Réduire les aperçus",
"galleryZoomInTitle": "Agrandir les aperçus", "galleryZoomInTitle": "Agrandir les aperçus",
"galleryRatioLabel": "Format des cartes",
"galleryRatioSquareTitle": "Passer au format 1:1",
"galleryRatioLandscapeTitle": "Passer au format 4:3",
"previousImageTitle": "Image précédente", "previousImageTitle": "Image précédente",
"nextImageTitle": "Image suivante", "nextImageTitle": "Image suivante",
"positionLabel": "Image {current} sur {total}", "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. // Rôle : affiche la collection d'un outil Images dans la galerie de la visionneuse.
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
export function ImageGallery({ images, activeIndex, focusIndex = -1, thumbnailWidth = 260, onSelect, textContent = {} }) { export function ImageGallery({ images, activeIndex, focusIndex = -1, thumbnailWidth = 260, cardRatio = "4:3", onSelect, textContent = {} }) {
const galleryRef = useRef(null); const galleryRef = useRef(null);
useEffect(() => { useEffect(() => {
@ -13,7 +13,7 @@ export function ImageGallery({ images, activeIndex, focusIndex = -1, thumbnailWi
}, [focusIndex]); }, [focusIndex]);
return ( return (
<div ref={galleryRef} className="image-viewer-gallery themed-scrollbar" style={{ "--image-gallery-thumbnail-width": `${thumbnailWidth}px` }}> <div ref={galleryRef} className="image-viewer-gallery themed-scrollbar" data-card-ratio={cardRatio} style={{ "--image-gallery-thumbnail-width": `${thumbnailWidth}px` }}>
{images.map((image, index) => { {images.map((image, index) => {
const position = `${index + 1} / ${images.length}`; const position = `${index + 1} / ${images.length}`;
const label = image.label || `${textContent.imageAlt || "Image"} ${index + 1}`; const label = image.label || `${textContent.imageAlt || "Image"} ${index + 1}`;

View file

@ -9,6 +9,7 @@ import { ImageGallery } from "./ImageGallery.jsx";
import { Tooltip } from "./Tooltip.jsx"; import { Tooltip } from "./Tooltip.jsx";
const IMAGE_GALLERY_SIZE_STORAGE_KEY = "sokkog:image-gallery-thumbnail-width"; const IMAGE_GALLERY_SIZE_STORAGE_KEY = "sokkog:image-gallery-thumbnail-width";
const IMAGE_GALLERY_RATIO_STORAGE_KEY = "sokkog:image-gallery-card-ratio";
const IMAGE_GALLERY_SIZE_MIN = 180; const IMAGE_GALLERY_SIZE_MIN = 180;
const IMAGE_GALLERY_SIZE_MAX = 420; const IMAGE_GALLERY_SIZE_MAX = 420;
const IMAGE_GALLERY_SIZE_STEP = 40; const IMAGE_GALLERY_SIZE_STEP = 40;
@ -30,6 +31,14 @@ function readGalleryThumbnailWidth() {
} }
} }
function readGalleryCardRatio() {
try {
return localStorage.getItem(IMAGE_GALLERY_RATIO_STORAGE_KEY) === "1:1" ? "1:1" : "4:3";
} catch {
return "4:3";
}
}
function clampPercent(value) { function clampPercent(value) {
const parsed = Number(value); const parsed = Number(value);
if (!Number.isFinite(parsed)) return 0; if (!Number.isFinite(parsed)) return 0;
@ -71,6 +80,7 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.
const [view, setView] = useState(initialView); const [view, setView] = useState(initialView);
const [galleryFocusIndex, setGalleryFocusIndex] = useState(-1); const [galleryFocusIndex, setGalleryFocusIndex] = useState(-1);
const [galleryThumbnailWidth, setGalleryThumbnailWidth] = useState(readGalleryThumbnailWidth); const [galleryThumbnailWidth, setGalleryThumbnailWidth] = useState(readGalleryThumbnailWidth);
const [galleryCardRatio, setGalleryCardRatio] = useState(readGalleryCardRatio);
const [previousRequest, setPreviousRequest] = useState(image); const [previousRequest, setPreviousRequest] = useState(image);
if (previousRequest !== image) { if (previousRequest !== image) {
setPreviousRequest(image); setPreviousRequest(image);
@ -203,7 +213,19 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.
} catch { /* La préférence reste active pour la modale courante si le stockage est indisponible. */ } } catch { /* La préférence reste active pour la modale courante si le stockage est indisponible. */ }
} }
function toggleGalleryCardRatio() {
const next = galleryCardRatio === "4:3" ? "1:1" : "4:3";
setGalleryCardRatio(next);
try {
localStorage.setItem(IMAGE_GALLERY_RATIO_STORAGE_KEY, 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); const positionLabel = formatPositionLabel(textContent.positionLabel, activeIndex + 1, collection.length);
const nextGalleryCardRatio = galleryCardRatio === "4:3" ? "1:1" : "4:3";
const galleryRatioAction = nextGalleryCardRatio === "1:1"
? textContent.galleryRatioSquareTitle || "Passer au format 1:1"
: textContent.galleryRatioLandscapeTitle || "Passer au format 4:3";
function updateViewerMarker(markerId, updater) { function updateViewerMarker(markerId, updater) {
updateViewerMarkers(viewerMarkers.map((marker) => marker.id === markerId ? updater(marker) : marker)); updateViewerMarkers(viewerMarkers.map((marker) => marker.id === markerId ? updater(marker) : marker));
@ -226,10 +248,13 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.
</div> </div>
<div className="image-viewer-actions"> <div className="image-viewer-actions">
{view === "gallery" && ( {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-ratio-button nds-button" data-size="sm" data-variant="secondary" type="button" onClick={toggleGalleryCardRatio} aria-label={`${textContent.galleryRatioLabel || "Format des cartes"} : ${galleryCardRatio}. ${galleryRatioAction}`} title={galleryRatioAction}>{galleryCardRatio}</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 className="image-viewer-gallery-zoom" role="group" aria-label={textContent.galleryZoomLabel || "Taille des aperçus"}>
</div> <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" && ( {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> <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>
@ -241,7 +266,7 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.
</div> </div>
</header> </header>
{view === "gallery" ? ( {view === "gallery" ? (
<ImageGallery images={collection} activeIndex={activeIndex} focusIndex={galleryFocusIndex} thumbnailWidth={galleryThumbnailWidth} onSelect={openGalleryImage} textContent={textContent} /> <ImageGallery images={collection} activeIndex={activeIndex} focusIndex={galleryFocusIndex} thumbnailWidth={galleryThumbnailWidth} cardRatio={galleryCardRatio} onSelect={openGalleryImage} textContent={textContent} />
) : <div className={`image-viewer-body ${canAnnotate ? "has-annotation-side" : ""} ${isCollection && collection.length > 1 ? "has-navigation" : ""}`}> ) : <div className={`image-viewer-body ${canAnnotate ? "has-annotation-side" : ""} ${isCollection && collection.length > 1 ? "has-navigation" : ""}`}>
{isCollection && collection.length > 1 && ( {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"}> <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

@ -215,6 +215,7 @@
.image-viewer-gallery { .image-viewer-gallery {
--image-gallery-thumbnail-width: 260px; --image-gallery-thumbnail-width: 260px;
--image-gallery-card-ratio: 4 / 3;
display: flex; display: flex;
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
@ -230,12 +231,16 @@
padding: 20px 28px 28px; padding: 20px 28px 28px;
} }
.image-viewer-gallery[data-card-ratio="1:1"] {
--image-gallery-card-ratio: 1 / 1;
}
.image-viewer-gallery-item { .image-viewer-gallery-item {
display: grid; display: grid;
width: min(100%, var(--image-gallery-thumbnail-width)); width: min(100%, var(--image-gallery-thumbnail-width));
max-width: 100%; max-width: 100%;
min-width: 0; min-width: 0;
aspect-ratio: 4 / 3; aspect-ratio: var(--image-gallery-card-ratio);
flex: 0 1 var(--image-gallery-thumbnail-width); flex: 0 1 var(--image-gallery-thumbnail-width);
grid-template-rows: minmax(0, 1fr) auto; grid-template-rows: minmax(0, 1fr) auto;
align-self: flex-start; align-self: flex-start;