Merge pull request #171 from IliesAllali/feat/mouvement-souvenir

Souvenir : grandit depuis sa vignette, et la visionneuse respecte les mouvements réduits
This commit is contained in:
IliesAllali authored and GitHub committed 2026-10-27 14:29:16 +01:00
commit a0afcfeafe
5 files changed
+116 -3

No files matched your search

+5
View File
@@ -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; }
}
+21 -1
View File
@@ -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 (
<div className="pz-overlay media-viewer-overlay" onClick={handleOverlayClick}>
<div
ref={cardRef}
className={`media-viewer-card media-viewer-card--${mediaType}`}
role="dialog"
aria-modal="true"
+3 -2
View File
@@ -11,6 +11,7 @@ import ImmichFacePanel from './ImmichFacePanel.jsx'
import './PersonCard.css'
import PzBusy from './PzBusy.jsx'
import { useSheetDrag, useSheetMode } from '../hooks/useSheetDrag'
import { rememberOrigin } from '../utils/openOrigin'
import PortraitCropModal from './PortraitCropModal.jsx'
import { imagePlacement } from '../utils/portraitCrop.js'
import { t, dateLocale } from '../i18n/index.js'
@@ -849,7 +850,7 @@ const PersonCard = ({
key={media.id}
type="button"
className={`pc-tile pc-tile--${media.type}`}
onClick={() => 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
+43
View File
@@ -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))
+44
View File
@@ -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)
})
})