diff --git a/frontend/src/components/MediaViewer.css b/frontend/src/components/MediaViewer.css index c526b45..350864c 100644 --- a/frontend/src/components/MediaViewer.css +++ b/frontend/src/components/MediaViewer.css @@ -508,3 +508,8 @@ .mv-edit-quote { font-family: var(--pz-serif); font-size: 18px; resize: vertical; } .mv-edit-error { margin: 0; font: 400 13px var(--pz-sans); color: var(--pz-a700); } .mv-edit-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; } + +/* Mouvements réduits : la carte n'a pas la classe .pz-modal que vise pz.css, elle jouait donc sa montée quand même */ +@media (prefers-reduced-motion: reduce) { + .media-viewer-card { animation: none; } +} diff --git a/frontend/src/components/MediaViewer.jsx b/frontend/src/components/MediaViewer.jsx index 79bbbf5..66982dd 100644 --- a/frontend/src/components/MediaViewer.jsx +++ b/frontend/src/components/MediaViewer.jsx @@ -1,4 +1,4 @@ -import { useEffect, useId, useMemo, useState } from 'react' +import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react' import { Pencil, UserPlus, X } from 'lucide-react' import { MediaPeopleList, MediaPeopleMarkers } from './MediaPeople.jsx' import { @@ -13,6 +13,7 @@ import { t } from '../i18n/index.js' import { firstNameLabel } from '../utils/personName.js' import { eventTitle } from '../utils/lifeTimeline.js' import { getAccountErrorMessage } from '../utils/errorMessages.js' +import { growFromTransform, takeOrigin } from '../utils/openOrigin.js' const HTTP_URL_RE = /^https?:\/\/\S+$/i const MAX_TITLE_LENGTH = 280 @@ -562,11 +563,30 @@ const MediaViewer = ({ } } + // Ouvert depuis une vignette de la fiche : la carte part de la vignette et grandit jusqu'à sa place (Patch, 27/10). + // Avant la peinture, pour ne jamais montrer la carte à sa place une image plus tôt. + const cardRef = useRef(null) + const mediaId = media?.id + useLayoutEffect(() => { + const card = cardRef.current + const from = takeOrigin() + if (!card || !from) return + card.style.animation = 'none' + if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return + // Sur téléphone, la fenêtre est collée en bas (pz.css, place-items: end) : on part du bord bas + const anchor = getComputedStyle(card.parentElement).alignItems.includes('end') ? 'bottom' : 'center' + const start = growFromTransform(from, card.getBoundingClientRect().width, { width: window.innerWidth, height: window.innerHeight }, anchor) + if (!start) return + card.style.transformOrigin = start.origin + card.animate?.([{ transform: start.transform }, { transform: 'none' }], { duration: 280, easing: 'cubic-bezier(.23, 1, .32, 1)' }) + }, [mediaId]) + if (!media) return null return (
onMediaSelect?.(media, person)} + onClick={(e) => { rememberOrigin(e.currentTarget); onMediaSelect?.(media, person) }} title={media.label || TYPE_LABELS[media.type]} > {media.type === 'photo' && media.url @@ -877,7 +878,7 @@ const PersonCard = ({ key={media.id} type="button" className={`pc-tile pc-tile--${media.type}`} - onClick={() => owner && onMediaSelect?.(media, owner)} + onClick={(e) => { if (!owner) return; rememberOrigin(e.currentTarget); onMediaSelect?.(media, owner) }} title={media.label || TYPE_LABELS[media.type]} > {media.type === 'photo' && media.url diff --git a/frontend/src/utils/openOrigin.js b/frontend/src/utils/openOrigin.js new file mode 100644 index 0000000..4945cce --- /dev/null +++ b/frontend/src/utils/openOrigin.js @@ -0,0 +1,43 @@ +// Ouverture d'un souvenir depuis sa vignette : la vignette touchée est retenue un court instant, puis la visionneuse +// part de là et grandit jusqu'à sa place (transform seul, pas de fondu). Sans vignette retenue, rien ne change. +// Le calcul est pur et testé dans openOrigin.test.js ; le branchement est dans PersonCard.jsx et MediaViewer.jsx. + +// Au-delà, l'origine retenue est périmée (la visionneuse s'ouvre juste après le clic, bien avant) +const ORIGIN_TTL_MS = 1000 + +let origin = null + +/** Retient le rectangle de l'élément touché (une vignette) pour la prochaine ouverture */ +export function rememberOrigin(element, now = Date.now()) { + const rect = element?.getBoundingClientRect?.() + origin = rect ? { left: rect.left, top: rect.top, width: rect.width, height: rect.height, at: now } : null +} + +/** Rend l'origine retenue une seule fois, ou null si elle manque ou a vieilli */ +export function takeOrigin(now = Date.now()) { + const taken = origin + origin = null + if (!taken || now - taken.at > ORIGIN_TTL_MS) return null + return taken +} + +/** + * Départ d'une carte posée sur l'origine, même largeur : { transform, origin } à appliquer à la carte. + * La hauteur de la carte change quand l'image arrive : on part donc du point de la carte qui ne bouge pas. + * - anchor 'center' (fenêtre centrée, ordinateur) : le centre de la carte est celui de l'écran ; + * - anchor 'bottom' (fenêtre collée en bas, téléphone) : le milieu du bord bas est le bas de l'écran. + */ +export function growFromTransform(from, cardWidth, viewport, anchor = 'center') { + if (!from || !(cardWidth > 0) || !(from.width > 0)) return null + const scale = from.width / cardWidth + const dx = from.left + from.width / 2 - viewport.width / 2 + const dy = anchor === 'bottom' + ? from.top + from.height - viewport.height + : from.top + from.height / 2 - viewport.height / 2 + return { + transform: `translate(${round(dx)}px, ${round(dy)}px) scale(${round(scale, 4)})`, + origin: anchor === 'bottom' ? '50% 100%' : '50% 50%', + } +} + +const round = (n, digits = 2) => Number(n.toFixed(digits)) diff --git a/frontend/src/utils/openOrigin.test.js b/frontend/src/utils/openOrigin.test.js new file mode 100644 index 0000000..fc72fae --- /dev/null +++ b/frontend/src/utils/openOrigin.test.js @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { growFromTransform, rememberOrigin, takeOrigin } from './openOrigin' + +const element = (left, top, width, height) => ({ getBoundingClientRect: () => ({ left, top, width, height }) }) + +describe('rememberOrigin / takeOrigin', () => { + it('rend l\'origine une seule fois', () => { + rememberOrigin(element(10, 20, 100, 100), 1000) + expect(takeOrigin(1100)).toMatchObject({ left: 10, top: 20, width: 100 }) + expect(takeOrigin(1100)).toBe(null) + }) + + it('oublie une origine trop vieille', () => { + rememberOrigin(element(10, 20, 100, 100), 1000) + expect(takeOrigin(2500)).toBe(null) + }) + + it('ne retient rien sans élément mesurable', () => { + rememberOrigin(null, 1000) + expect(takeOrigin(1000)).toBe(null) + }) +}) + +describe('growFromTransform', () => { + const viewport = { width: 1000, height: 800 } + + it('pose la carte sur la vignette : même centre, même largeur', () => { + // Vignette de 72 px centrée en (136, 636), carte de 720 px centrée dans l'écran + expect(growFromTransform({ left: 100, top: 600, width: 72, height: 72 }, 720, viewport)) + .toEqual({ transform: 'translate(-364px, 236px) scale(0.1)', origin: '50% 50%' }) + }) + + it('fenêtre collée en bas : pose le bord bas de la carte sur celui de la vignette', () => { + // Vignette de 72 px, bord bas à 672 ; écran de 800 px de haut + expect(growFromTransform({ left: 100, top: 600, width: 72, height: 72 }, 720, viewport, 'bottom')) + .toEqual({ transform: 'translate(-364px, -128px) scale(0.1)', origin: '50% 100%' }) + }) + + it('ne part de rien sans origine ou sans largeur', () => { + expect(growFromTransform(null, 720, viewport)).toBe(null) + expect(growFromTransform({ left: 0, top: 0, width: 72, height: 72 }, 0, viewport)).toBe(null) + expect(growFromTransform({ left: 0, top: 0, width: 0, height: 0 }, 720, viewport)).toBe(null) + }) +})