mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-06 23:32:08 +00:00
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:
5 files changed
+116
-3
No files matched your search
@@ -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; }
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user