Use spring to animate lightbox thumbnails

This commit is contained in:
Fedor Indutny 2023-03-08 17:32:18 -08:00 committed by GitHub
parent 5d07167222
commit 74097a0efa
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 139 additions and 47 deletions

View file

@ -52,10 +52,6 @@
} }
} }
&__thumbnails_placeholder {
height: 44px;
}
&__thumbnail { &__thumbnail {
@include button-reset; @include button-reset;
position: relative; position: relative;

View file

@ -4,7 +4,6 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import moment from 'moment';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { noop } from 'lodash'; import { noop } from 'lodash';
import { useSpring, animated, to } from '@react-spring/web'; import { useSpring, animated, to } from '@react-spring/web';
@ -20,9 +19,11 @@ import * as GoogleChrome from '../util/GoogleChrome';
import * as log from '../logging/log'; import * as log from '../logging/log';
import { Avatar, AvatarSize } from './Avatar'; import { Avatar, AvatarSize } from './Avatar';
import { IMAGE_PNG, isImage, isVideo } from '../types/MIME'; import { IMAGE_PNG, isImage, isVideo } from '../types/MIME';
import { formatDateTimeForAttachment } from '../util/timestamp';
import { formatDuration } from '../util/formatDuration'; import { formatDuration } from '../util/formatDuration';
import { isGIF } from '../types/Attachment'; import { isGIF } from '../types/Attachment';
import { useRestoreFocus } from '../hooks/useRestoreFocus'; import { useRestoreFocus } from '../hooks/useRestoreFocus';
import { usePrevious } from '../hooks/usePrevious';
export type PropsType = { export type PropsType = {
children?: ReactNode; children?: ReactNode;
@ -56,6 +57,17 @@ const INITIAL_IMAGE_TRANSFORM = {
}, },
}; };
const THUMBNAIL_SPRING_CONFIG = {
mass: 1,
tension: 986,
friction: 64,
velocity: 0,
};
const THUMBNAIL_WIDTH = 44;
const THUMBNAIL_PADDING = 8;
const THUMBNAIL_FULL_WIDTH = THUMBNAIL_WIDTH + THUMBNAIL_PADDING;
export function Lightbox({ export function Lightbox({
children, children,
closeLightbox, closeLightbox,
@ -73,6 +85,9 @@ export function Lightbox({
hasNextMessage, hasNextMessage,
hasPrevMessage, hasPrevMessage,
}: PropsType): JSX.Element | null { }: PropsType): JSX.Element | null {
const hasThumbnails = media.length > 1;
const hadThumbnails = usePrevious(hasThumbnails, hasThumbnails);
const justGotThumbnails = !hadThumbnails && hasThumbnails;
const [root, setRoot] = React.useState<HTMLElement | undefined>(); const [root, setRoot] = React.useState<HTMLElement | undefined>();
const [videoElement, setVideoElement] = useState<HTMLVideoElement | null>( const [videoElement, setVideoElement] = useState<HTMLVideoElement | null>(
@ -271,6 +286,43 @@ export function Lightbox({
() => INITIAL_IMAGE_TRANSFORM () => INITIAL_IMAGE_TRANSFORM
); );
const thumbnailsMarginLeft =
0 - (selectedIndex * THUMBNAIL_FULL_WIDTH + THUMBNAIL_WIDTH / 2);
const [thumbnailsStyle, thumbnailsAnimation] = useSpring(
{
config: THUMBNAIL_SPRING_CONFIG,
to: {
marginLeft: thumbnailsMarginLeft,
opacity: hasThumbnails ? 1 : 0,
},
},
[selectedIndex, hasThumbnails]
);
useEffect(() => {
if (!justGotThumbnails) {
return;
}
thumbnailsAnimation.stop();
thumbnailsAnimation.set({
marginLeft:
thumbnailsMarginLeft +
(selectedIndex === 0 ? -1 : 1) * THUMBNAIL_FULL_WIDTH,
opacity: 0,
});
thumbnailsAnimation.start({
marginLeft: thumbnailsMarginLeft,
opacity: 1,
});
}, [
justGotThumbnails,
selectedIndex,
thumbnailsMarginLeft,
thumbnailsAnimation,
]);
const maxBoundsLimiter = useCallback( const maxBoundsLimiter = useCallback(
(x: number, y: number): [number, number] => { (x: number, y: number): [number, number] => {
const zoomCache = zoomCacheRef.current; const zoomCache = zoomCacheRef.current;
@ -643,48 +695,46 @@ export function Lightbox({
{caption ? ( {caption ? (
<div className="Lightbox__caption">{caption}</div> <div className="Lightbox__caption">{caption}</div>
) : null} ) : null}
{media.length > 1 ? ( <div className="Lightbox__thumbnails--container">
<div className="Lightbox__thumbnails--container"> <animated.div
<div className="Lightbox__thumbnails"
className="Lightbox__thumbnails" style={thumbnailsStyle}
style={{ >
marginLeft: {hasThumbnails
0 - (selectedIndex * 44 + selectedIndex * 8 + 22), ? media.map((item, index) => (
}} <button
> className={classNames({
{media.map((item, index) => ( Lightbox__thumbnail: true,
<button 'Lightbox__thumbnail--selected':
className={classNames({ index === selectedIndex,
Lightbox__thumbnail: true, })}
'Lightbox__thumbnail--selected': key={item.thumbnailObjectUrl}
index === selectedIndex, type="button"
})} onClick={(
key={item.thumbnailObjectUrl} event: React.MouseEvent<
type="button" HTMLButtonElement,
onClick={( MouseEvent
event: React.MouseEvent<HTMLButtonElement, MouseEvent> >
) => { ) => {
event.stopPropagation(); event.stopPropagation();
event.preventDefault(); event.preventDefault();
onSelectAttachment(index); onSelectAttachment(index);
}} }}
> >
{item.thumbnailObjectUrl ? ( {item.thumbnailObjectUrl ? (
<img <img
alt={i18n('lightboxImageAlt')} alt={i18n('lightboxImageAlt')}
src={item.thumbnailObjectUrl} src={item.thumbnailObjectUrl}
/> />
) : ( ) : (
<div className="Lightbox__thumbnail--unavailable" /> <div className="Lightbox__thumbnail--unavailable" />
)} )}
</button> </button>
))} ))
</div> : undefined}
</div> </animated.div>
) : ( </div>
<div className="Lightbox__thumbnails_placeholder" />
)}
</div> </div>
</div> </div>
</div>, </div>,
@ -704,6 +754,8 @@ function LightboxHeader({
}): JSX.Element { }): JSX.Element {
const conversation = getConversation(message.conversationId); const conversation = getConversation(message.conversationId);
const now = Date.now();
return ( return (
<div className="Lightbox__header--container"> <div className="Lightbox__header--container">
<div className="Lightbox__header--avatar"> <div className="Lightbox__header--avatar">
@ -726,7 +778,7 @@ function LightboxHeader({
<div className="Lightbox__header--content"> <div className="Lightbox__header--content">
<div className="Lightbox__header--name">{conversation.title}</div> <div className="Lightbox__header--name">{conversation.title}</div>
<div className="Lightbox__header--timestamp"> <div className="Lightbox__header--timestamp">
{moment(message.received_at_ms).format('L LT')} {formatDateTimeForAttachment(i18n, message.received_at_ms ?? now)}
</div> </div>
</div> </div>
</div> </div>

View file

@ -34,5 +34,6 @@ export namespace DurationInSeconds {
export const HOUR = DurationInSeconds.fromHours(1); export const HOUR = DurationInSeconds.fromHours(1);
export const MINUTE = DurationInSeconds.fromMinutes(1); export const MINUTE = DurationInSeconds.fromMinutes(1);
export const DAY = DurationInSeconds.fromDays(1); export const DAY = DurationInSeconds.fromDays(1);
export const WEEK = DurationInSeconds.fromWeeks(1);
} }
/* eslint-enable @typescript-eslint/no-namespace, @typescript-eslint/no-redeclare */ /* eslint-enable @typescript-eslint/no-namespace, @typescript-eslint/no-redeclare */

View file

@ -74,6 +74,49 @@ export function formatDateTimeShort(
}).format(timestamp); }).format(timestamp);
} }
export function formatDateTimeForAttachment(
i18n: LocalizerType,
rawTimestamp: RawTimestamp
): string {
const timestamp = rawTimestamp.valueOf();
const now = Date.now();
const diff = now - timestamp;
const locale = window.getPreferredSystemLocales();
if (diff < HOUR || isToday(timestamp)) {
return formatTime(i18n, rawTimestamp, now);
}
const m = moment(timestamp);
if (diff < WEEK && m.isSame(now, 'month')) {
return new Intl.DateTimeFormat(locale, {
weekday: 'short',
hour: 'numeric',
minute: 'numeric',
}).format(timestamp);
}
if (m.isSame(now, 'year')) {
return new Intl.DateTimeFormat(locale, {
day: 'numeric',
month: 'short',
hour: 'numeric',
minute: 'numeric',
}).format(timestamp);
}
return new Intl.DateTimeFormat(locale, {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
}).format(timestamp);
}
export function formatDateTimeLong( export function formatDateTimeLong(
i18n: LocalizerType, i18n: LocalizerType,
rawTimestamp: RawTimestamp rawTimestamp: RawTimestamp