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 {
 | 
			
		||||
    @include button-reset;
 | 
			
		||||
    position: relative;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,7 +4,6 @@
 | 
			
		|||
import type { ReactNode } from 'react';
 | 
			
		||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
 | 
			
		||||
import classNames from 'classnames';
 | 
			
		||||
import moment from 'moment';
 | 
			
		||||
import { createPortal } from 'react-dom';
 | 
			
		||||
import { noop } from 'lodash';
 | 
			
		||||
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 { Avatar, AvatarSize } from './Avatar';
 | 
			
		||||
import { IMAGE_PNG, isImage, isVideo } from '../types/MIME';
 | 
			
		||||
import { formatDateTimeForAttachment } from '../util/timestamp';
 | 
			
		||||
import { formatDuration } from '../util/formatDuration';
 | 
			
		||||
import { isGIF } from '../types/Attachment';
 | 
			
		||||
import { useRestoreFocus } from '../hooks/useRestoreFocus';
 | 
			
		||||
import { usePrevious } from '../hooks/usePrevious';
 | 
			
		||||
 | 
			
		||||
export type PropsType = {
 | 
			
		||||
  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({
 | 
			
		||||
  children,
 | 
			
		||||
  closeLightbox,
 | 
			
		||||
| 
						 | 
				
			
			@ -73,6 +85,9 @@ export function Lightbox({
 | 
			
		|||
  hasNextMessage,
 | 
			
		||||
  hasPrevMessage,
 | 
			
		||||
}: 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 [videoElement, setVideoElement] = useState<HTMLVideoElement | null>(
 | 
			
		||||
| 
						 | 
				
			
			@ -271,6 +286,43 @@ export function Lightbox({
 | 
			
		|||
    () => 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(
 | 
			
		||||
    (x: number, y: number): [number, number] => {
 | 
			
		||||
      const zoomCache = zoomCacheRef.current;
 | 
			
		||||
| 
						 | 
				
			
			@ -643,16 +695,13 @@ export function Lightbox({
 | 
			
		|||
              {caption ? (
 | 
			
		||||
                <div className="Lightbox__caption">{caption}</div>
 | 
			
		||||
              ) : null}
 | 
			
		||||
              {media.length > 1 ? (
 | 
			
		||||
              <div className="Lightbox__thumbnails--container">
 | 
			
		||||
                  <div
 | 
			
		||||
                <animated.div
 | 
			
		||||
                  className="Lightbox__thumbnails"
 | 
			
		||||
                    style={{
 | 
			
		||||
                      marginLeft:
 | 
			
		||||
                        0 - (selectedIndex * 44 + selectedIndex * 8 + 22),
 | 
			
		||||
                    }}
 | 
			
		||||
                  style={thumbnailsStyle}
 | 
			
		||||
                >
 | 
			
		||||
                    {media.map((item, index) => (
 | 
			
		||||
                  {hasThumbnails
 | 
			
		||||
                    ? media.map((item, index) => (
 | 
			
		||||
                        <button
 | 
			
		||||
                          className={classNames({
 | 
			
		||||
                            Lightbox__thumbnail: true,
 | 
			
		||||
| 
						 | 
				
			
			@ -662,7 +711,10 @@ export function Lightbox({
 | 
			
		|||
                          key={item.thumbnailObjectUrl}
 | 
			
		||||
                          type="button"
 | 
			
		||||
                          onClick={(
 | 
			
		||||
                          event: React.MouseEvent<HTMLButtonElement, MouseEvent>
 | 
			
		||||
                            event: React.MouseEvent<
 | 
			
		||||
                              HTMLButtonElement,
 | 
			
		||||
                              MouseEvent
 | 
			
		||||
                            >
 | 
			
		||||
                          ) => {
 | 
			
		||||
                            event.stopPropagation();
 | 
			
		||||
                            event.preventDefault();
 | 
			
		||||
| 
						 | 
				
			
			@ -679,13 +731,11 @@ export function Lightbox({
 | 
			
		|||
                            <div className="Lightbox__thumbnail--unavailable" />
 | 
			
		||||
                          )}
 | 
			
		||||
                        </button>
 | 
			
		||||
                    ))}
 | 
			
		||||
                      ))
 | 
			
		||||
                    : undefined}
 | 
			
		||||
                </animated.div>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
              ) : (
 | 
			
		||||
                <div className="Lightbox__thumbnails_placeholder" />
 | 
			
		||||
              )}
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>,
 | 
			
		||||
        root
 | 
			
		||||
| 
						 | 
				
			
			@ -704,6 +754,8 @@ function LightboxHeader({
 | 
			
		|||
}): JSX.Element {
 | 
			
		||||
  const conversation = getConversation(message.conversationId);
 | 
			
		||||
 | 
			
		||||
  const now = Date.now();
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="Lightbox__header--container">
 | 
			
		||||
      <div className="Lightbox__header--avatar">
 | 
			
		||||
| 
						 | 
				
			
			@ -726,7 +778,7 @@ function LightboxHeader({
 | 
			
		|||
      <div className="Lightbox__header--content">
 | 
			
		||||
        <div className="Lightbox__header--name">{conversation.title}</div>
 | 
			
		||||
        <div className="Lightbox__header--timestamp">
 | 
			
		||||
          {moment(message.received_at_ms).format('L LT')}
 | 
			
		||||
          {formatDateTimeForAttachment(i18n, message.received_at_ms ?? now)}
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -34,5 +34,6 @@ export namespace DurationInSeconds {
 | 
			
		|||
  export const HOUR = DurationInSeconds.fromHours(1);
 | 
			
		||||
  export const MINUTE = DurationInSeconds.fromMinutes(1);
 | 
			
		||||
  export const DAY = DurationInSeconds.fromDays(1);
 | 
			
		||||
  export const WEEK = DurationInSeconds.fromWeeks(1);
 | 
			
		||||
}
 | 
			
		||||
/* eslint-enable @typescript-eslint/no-namespace, @typescript-eslint/no-redeclare */
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -74,6 +74,49 @@ export function formatDateTimeShort(
 | 
			
		|||
  }).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(
 | 
			
		||||
  i18n: LocalizerType,
 | 
			
		||||
  rawTimestamp: RawTimestamp
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue