Use spring to animate lightbox thumbnails
This commit is contained in:
parent
5d07167222
commit
74097a0efa
4 changed files with 139 additions and 47 deletions
|
@ -52,10 +52,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__thumbnails_placeholder {
|
|
||||||
height: 44px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__thumbnail {
|
&__thumbnail {
|
||||||
@include button-reset;
|
@include button-reset;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 */
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue