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

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