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)
+ })
+})