From 7dca54429500e8349b02b63506ef212b263eec10 Mon Sep 17 00:00:00 2001 From: Josh Perez <60019601+josh-signal@users.noreply.github.com> Date: Tue, 12 Oct 2021 16:25:09 -0400 Subject: [PATCH] Animate lightbox and better touch support --- stylesheets/components/Lightbox.scss | 61 +++-- ts/components/Lightbox.tsx | 345 ++++++++++++++------------- ts/util/lint/exceptions.json | 18 +- 3 files changed, 223 insertions(+), 201 deletions(-) diff --git a/stylesheets/components/Lightbox.scss b/stylesheets/components/Lightbox.scss index 959db943d34..525537c0c9e 100644 --- a/stylesheets/components/Lightbox.scss +++ b/stylesheets/components/Lightbox.scss @@ -5,18 +5,21 @@ &__container { background-color: $color-black-alpha-90; bottom: 0; - display: flex; - flex-direction: column; left: 0; - padding: 0 16px; position: absolute; right: 0; top: 0; z-index: 10; + } - &--zoom { - padding: 0; - } + &__animated { + bottom: 0; + left: 0; + position: absolute; + right: 0; + top: 0; + display: flex; + flex-direction: column; } &__main-container { @@ -76,15 +79,6 @@ } } - &__shadow-container { - display: flex; - height: 100%; - padding: 0; - position: absolute; - width: 100%; - visibility: hidden; - } - &__object { &--container { display: inline-flex; @@ -93,34 +87,21 @@ overflow: hidden; position: relative; z-index: 1; + + &--zoom { + backface-visibility: hidden; + } } height: auto; - left: 50%; max-height: 100%; max-width: 100%; object-fit: contain; outline: none; padding: 0 40px; - position: absolute; - top: 50%; - transform: translate(-50%, -50%); width: auto; } - &__shadow-container &__object { - max-height: 200%; - max-width: 200%; - padding: 10%; - visibility: hidden; - } - - &__object--container--zoom &__object { - max-height: 200%; - max-width: 200%; - padding: 10%; - } - &__object--container--fill &__object { height: 100%; padding: 0; @@ -206,6 +187,9 @@ height: 56px; justify-content: space-between; margin-top: 24px; + opacity: 1; + padding: 0 16px; + transition: opacity 150ms cubic-bezier(0.17, 0.17, 0, 1); &--container { display: flex; @@ -226,6 +210,19 @@ } } + &__footer { + opacity: 1; + padding: 0 16px; + transition: opacity 150ms cubic-bezier(0.17, 0.17, 0, 1); + } + + &__container--zoom { + .Lightbox__header, + .Lightbox__footer { + opacity: 0; + } + } + &__button { @include button-reset; diff --git a/ts/components/Lightbox.tsx b/ts/components/Lightbox.tsx index 87a4ce263ff..a87d0248a59 100644 --- a/ts/components/Lightbox.tsx +++ b/ts/components/Lightbox.tsx @@ -2,7 +2,6 @@ // SPDX-License-Identifier: AGPL-3.0-only import React, { - CSSProperties, ReactNode, useCallback, useEffect, @@ -13,6 +12,7 @@ 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'; import * as GoogleChrome from '../util/GoogleChrome'; import { AttachmentType, isGIF } from '../types/Attachment'; @@ -41,11 +41,13 @@ export type PropsType = { selectedIndex?: number; }; -enum ZoomType { - None, - FillScreen, - ZoomAndPan, -} +const ZOOM_SCALE = 3; + +const INITIAL_IMAGE_TRANSFORM = { + scale: 1, + translateX: 0, + translateY: 0, +}; export function Lightbox({ children, @@ -67,25 +69,30 @@ export function Lightbox({ null ); const [videoTime, setVideoTime] = useState(); - const [zoomType, setZoomType] = useState(ZoomType.None); + const [isZoomed, setIsZoomed] = useState(false); const containerRef = useRef(null); const [focusRef] = useRestoreFocus(); + const animateRef = useRef(null); + const dragCacheRef = useRef< + | { + startX: number; + startY: number; + translateX: number; + translateY: number; + } + | undefined + >(); const imageRef = useRef(null); - const [imagePanStyle, setImagePanStyle] = useState({}); - const zoomCoordsRef = useRef< + const zoomCacheRef = useRef< | { - initX: number; - initY: number; + maxX: number; + maxY: number; screenWidth: number; screenHeight: number; - x: number; - y: number; } | undefined >(); - const isZoomed = zoomType !== ZoomType.None; - const onPrevious = useCallback( ( event: KeyboardEvent | React.MouseEvent @@ -238,122 +245,162 @@ export function Lightbox({ }; }, [isViewOnce, isAttachmentGIF, onTimeUpdate, playVideo, videoElement]); - const positionImage = useCallback( - (ev?: { clientX: number; clientY: number }) => { - const imageNode = imageRef.current; - const zoomCoords = zoomCoordsRef.current; - if (!imageNode || !zoomCoords) { - return; - } - - if (ev) { - zoomCoords.x = ev.clientX; - zoomCoords.y = ev.clientY; - } + const [{ scale, translateX, translateY }, springApi] = useSpring( + () => INITIAL_IMAGE_TRANSFORM + ); - const shouldTransformX = imageNode.naturalWidth > zoomCoords.screenWidth; - const shouldTransformY = - imageNode.naturalHeight > zoomCoords.screenHeight; + const maxBoundsLimiter = useCallback((x: number, y: number): [ + number, + number + ] => { + const zoomCache = zoomCacheRef.current; - const nextImagePanStyle: CSSProperties = { - left: '50%', - top: '50%', - }; + if (!zoomCache) { + return [0, 0]; + } - let translateX = '-50%'; - let translateY = '-50%'; + const { maxX, maxY } = zoomCache; - if (shouldTransformX) { - const offset = imageNode.offsetWidth - zoomCoords.screenWidth; + const posX = Math.min(maxX, Math.max(-maxX, x)); + const posY = Math.min(maxY, Math.max(-maxY, y)); - const scaleX = (-1 / zoomCoords.screenWidth) * offset; + return [posX, posY]; + }, []); - const posX = Math.max( - 0, - Math.min(zoomCoords.screenWidth, zoomCoords.x) - ); + const positionImage = useCallback( + (ev: MouseEvent) => { + const zoomCache = zoomCacheRef.current; - translateX = `${posX * scaleX}px`; - nextImagePanStyle.left = 0; + if (!zoomCache) { + return; } - if (shouldTransformY) { - const offset = imageNode.offsetHeight - zoomCoords.screenHeight; + const { screenWidth, screenHeight } = zoomCache; - const scaleY = (-1 / zoomCoords.screenHeight) * offset; - - const posY = Math.max( - 0, - Math.min(zoomCoords.screenHeight, zoomCoords.y) - ); + const offsetX = screenWidth / 2 - ev.clientX; + const offsetY = screenHeight / 2 - ev.clientY; + const posX = offsetX * ZOOM_SCALE; + const posY = offsetY * ZOOM_SCALE; + const [x, y] = maxBoundsLimiter(posX, posY); - translateY = `${posY * scaleY}px`; - nextImagePanStyle.top = 0; - } - - setImagePanStyle({ - ...nextImagePanStyle, - transform: `translate(${translateX}, ${translateY})`, + springApi.start({ + scale: ZOOM_SCALE, + translateX: x, + translateY: y, }); }, - [] + [maxBoundsLimiter, springApi] ); - function canPanImage(): boolean { - const imageNode = imageRef.current; + const handleTouchStart = useCallback( + (ev: TouchEvent) => { + const [touch] = ev.touches; - return Boolean( - imageNode && - (imageNode.naturalWidth > document.documentElement.clientWidth || - imageNode.naturalHeight > document.documentElement.clientHeight) - ); - } + dragCacheRef.current = { + startX: touch.clientX, + startY: touch.clientY, + translateX: translateX.get(), + translateY: translateY.get(), + }; + }, + [translateY, translateX] + ); const handleTouchMove = useCallback( (ev: TouchEvent) => { - const imageNode = imageRef.current; - const zoomCoords = zoomCoordsRef.current; + const dragCache = dragCacheRef.current; - ev.preventDefault(); - ev.stopPropagation(); - - if (!imageNode || !zoomCoords) { + if (!dragCache) { return; } const [touch] = ev.touches; - const { initX, initY } = zoomCoords; - positionImage({ - clientX: initX + (initX - touch.clientX), - clientY: initY + (initY - touch.clientY), + const deltaX = touch.clientX - dragCache.startX; + const deltaY = touch.clientY - dragCache.startY; + + const x = dragCache.translateX + deltaX; + const y = dragCache.translateY + deltaY; + + springApi.start({ + scale: ZOOM_SCALE, + translateX: x, + translateY: y, }); }, - [positionImage] + [springApi] + ); + + const zoomButtonHandler = useCallback( + (ev: React.MouseEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + const imageNode = imageRef.current; + const animateNode = animateRef.current; + if (!imageNode || !animateNode) { + return; + } + + if (!isZoomed) { + zoomCacheRef.current = { + maxX: imageNode.offsetWidth, + maxY: imageNode.offsetHeight, + screenHeight: window.innerHeight, + screenWidth: window.innerWidth, + }; + + const { + height, + left, + top, + width, + } = animateNode.getBoundingClientRect(); + + const offsetX = ev.clientX - left - width / 2; + const offsetY = ev.clientY - top - height / 2; + const posX = -offsetX * ZOOM_SCALE + translateX.get(); + const posY = -offsetY * ZOOM_SCALE + translateY.get(); + const [x, y] = maxBoundsLimiter(posX, posY); + + springApi.start({ + scale: ZOOM_SCALE, + translateX: x, + translateY: y, + }); + + setIsZoomed(true); + } else { + springApi.start(INITIAL_IMAGE_TRANSFORM); + setIsZoomed(false); + } + }, + [isZoomed, maxBoundsLimiter, translateX, translateY, springApi] ); useEffect(() => { - const imageNode = imageRef.current; + const animateNode = animateRef.current; let hasListener = false; - if (imageNode && zoomType !== ZoomType.None && canPanImage()) { + if (animateNode && isZoomed) { hasListener = true; document.addEventListener('mousemove', positionImage); document.addEventListener('touchmove', handleTouchMove); + document.addEventListener('touchstart', handleTouchStart); } return () => { if (hasListener) { document.removeEventListener('mousemove', positionImage); document.removeEventListener('touchmove', handleTouchMove); + document.removeEventListener('touchstart', handleTouchStart); } }; - }, [handleTouchMove, positionImage, zoomType]); + }, [handleTouchMove, handleTouchStart, isZoomed, positionImage]); const caption = attachment?.caption; let content: JSX.Element; - let shadowImage: JSX.Element | undefined; if (!contentType) { content = <>{children}; } else { @@ -366,64 +413,27 @@ export function Lightbox({ if (isImageTypeSupported) { if (objectURL) { - shadowImage = ( -
-
- {i18n('lightboxImageAlt')} -
-
- ); content = ( ); @@ -490,7 +500,7 @@ export function Lightbox({ ? createPortal(
) => { event.stopPropagation(); @@ -511,12 +521,12 @@ export function Lightbox({ ref={containerRef} role="presentation" > -
- {!isZoomed && ( +
+
{getConversation ? (
- )} -
- {content} + `translate(${x}px, ${y}px) scale(${s})` + ), + }} + > + {content} + + {hasPrevious && ( +
+
+ )} + {hasNext && ( +
+
+ )}
- {shadowImage} - {hasPrevious && ( -
-
- )} - {hasNext && ( -
-
- )} -
- {!isZoomed && (
{isViewOnce && videoTime ? (
@@ -636,7 +647,7 @@ export function Lightbox({
)}
- )} +
, root ) diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index c4ebde3ecb1..74379f75145 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -12677,9 +12677,23 @@ { "rule": "React-useRef", "path": "ts/components/Lightbox.tsx", - "line": " const zoomCoordsRef = useRef<", + "line": " const animateRef = useRef(null);", "reasonCategory": "usageTrusted", - "updated": "2021-09-24T00:03:36.061Z" + "updated": "2021-10-11T21:21:08.188Z" + }, + { + "rule": "React-useRef", + "path": "ts/components/Lightbox.tsx", + "line": " const dragCacheRef = useRef<", + "reasonCategory": "usageTrusted", + "updated": "2021-10-11T21:21:08.188Z" + }, + { + "rule": "React-useRef", + "path": "ts/components/Lightbox.tsx", + "line": " const zoomCacheRef = useRef<", + "reasonCategory": "usageTrusted", + "updated": "2021-10-11T21:21:08.188Z" }, { "rule": "React-createRef",