Animate lightbox and better touch support

This commit is contained in:
Josh Perez 2021-10-12 16:25:09 -04:00 committed by GitHub
parent 7488fa5abc
commit 7dca544295
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 231 additions and 209 deletions

View file

@ -5,18 +5,21 @@
&__container { &__container {
background-color: $color-black-alpha-90; background-color: $color-black-alpha-90;
bottom: 0; bottom: 0;
display: flex;
flex-direction: column;
left: 0; left: 0;
padding: 0 16px;
position: absolute; position: absolute;
right: 0; right: 0;
top: 0; top: 0;
z-index: 10; z-index: 10;
}
&--zoom { &__animated {
padding: 0; bottom: 0;
} left: 0;
position: absolute;
right: 0;
top: 0;
display: flex;
flex-direction: column;
} }
&__main-container { &__main-container {
@ -76,15 +79,6 @@
} }
} }
&__shadow-container {
display: flex;
height: 100%;
padding: 0;
position: absolute;
width: 100%;
visibility: hidden;
}
&__object { &__object {
&--container { &--container {
display: inline-flex; display: inline-flex;
@ -93,34 +87,21 @@
overflow: hidden; overflow: hidden;
position: relative; position: relative;
z-index: 1; z-index: 1;
&--zoom {
backface-visibility: hidden;
}
} }
height: auto; height: auto;
left: 50%;
max-height: 100%; max-height: 100%;
max-width: 100%; max-width: 100%;
object-fit: contain; object-fit: contain;
outline: none; outline: none;
padding: 0 40px; padding: 0 40px;
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: auto; 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 { &__object--container--fill &__object {
height: 100%; height: 100%;
padding: 0; padding: 0;
@ -206,6 +187,9 @@
height: 56px; height: 56px;
justify-content: space-between; justify-content: space-between;
margin-top: 24px; margin-top: 24px;
opacity: 1;
padding: 0 16px;
transition: opacity 150ms cubic-bezier(0.17, 0.17, 0, 1);
&--container { &--container {
display: flex; 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 { &__button {
@include button-reset; @include button-reset;

View file

@ -2,7 +2,6 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React, { import React, {
CSSProperties,
ReactNode, ReactNode,
useCallback, useCallback,
useEffect, useEffect,
@ -13,6 +12,7 @@ import classNames from 'classnames';
import moment from 'moment'; 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 * as GoogleChrome from '../util/GoogleChrome'; import * as GoogleChrome from '../util/GoogleChrome';
import { AttachmentType, isGIF } from '../types/Attachment'; import { AttachmentType, isGIF } from '../types/Attachment';
@ -41,11 +41,13 @@ export type PropsType = {
selectedIndex?: number; selectedIndex?: number;
}; };
enum ZoomType { const ZOOM_SCALE = 3;
None,
FillScreen, const INITIAL_IMAGE_TRANSFORM = {
ZoomAndPan, scale: 1,
} translateX: 0,
translateY: 0,
};
export function Lightbox({ export function Lightbox({
children, children,
@ -67,24 +69,29 @@ export function Lightbox({
null null
); );
const [videoTime, setVideoTime] = useState<number | undefined>(); const [videoTime, setVideoTime] = useState<number | undefined>();
const [zoomType, setZoomType] = useState<ZoomType>(ZoomType.None); const [isZoomed, setIsZoomed] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const [focusRef] = useRestoreFocus(); const [focusRef] = useRestoreFocus();
const imageRef = useRef<HTMLImageElement | null>(null); const animateRef = useRef<HTMLDivElement | null>(null);
const [imagePanStyle, setImagePanStyle] = useState<CSSProperties>({}); const dragCacheRef = useRef<
const zoomCoordsRef = useRef<
| { | {
initX: number; startX: number;
initY: number; startY: number;
screenWidth: number; translateX: number;
screenHeight: number; translateY: number;
x: number; }
y: number; | undefined
>();
const imageRef = useRef<HTMLImageElement | null>(null);
const zoomCacheRef = useRef<
| {
maxX: number;
maxY: number;
screenWidth: number;
screenHeight: number;
} }
| undefined | undefined
>(); >();
const isZoomed = zoomType !== ZoomType.None;
const onPrevious = useCallback( const onPrevious = useCallback(
( (
@ -238,122 +245,162 @@ export function Lightbox({
}; };
}, [isViewOnce, isAttachmentGIF, onTimeUpdate, playVideo, videoElement]); }, [isViewOnce, isAttachmentGIF, onTimeUpdate, playVideo, videoElement]);
const [{ scale, translateX, translateY }, springApi] = useSpring(
() => INITIAL_IMAGE_TRANSFORM
);
const maxBoundsLimiter = useCallback((x: number, y: number): [
number,
number
] => {
const zoomCache = zoomCacheRef.current;
if (!zoomCache) {
return [0, 0];
}
const { maxX, maxY } = zoomCache;
const posX = Math.min(maxX, Math.max(-maxX, x));
const posY = Math.min(maxY, Math.max(-maxY, y));
return [posX, posY];
}, []);
const positionImage = useCallback( const positionImage = useCallback(
(ev?: { clientX: number; clientY: number }) => { (ev: MouseEvent) => {
const imageNode = imageRef.current; const zoomCache = zoomCacheRef.current;
const zoomCoords = zoomCoordsRef.current;
if (!imageNode || !zoomCoords) { if (!zoomCache) {
return; return;
} }
if (ev) { const { screenWidth, screenHeight } = zoomCache;
zoomCoords.x = ev.clientX;
zoomCoords.y = ev.clientY;
}
const shouldTransformX = imageNode.naturalWidth > zoomCoords.screenWidth; const offsetX = screenWidth / 2 - ev.clientX;
const shouldTransformY = const offsetY = screenHeight / 2 - ev.clientY;
imageNode.naturalHeight > zoomCoords.screenHeight; const posX = offsetX * ZOOM_SCALE;
const posY = offsetY * ZOOM_SCALE;
const [x, y] = maxBoundsLimiter(posX, posY);
const nextImagePanStyle: CSSProperties = { springApi.start({
left: '50%', scale: ZOOM_SCALE,
top: '50%', translateX: x,
}; translateY: y,
let translateX = '-50%';
let translateY = '-50%';
if (shouldTransformX) {
const offset = imageNode.offsetWidth - zoomCoords.screenWidth;
const scaleX = (-1 / zoomCoords.screenWidth) * offset;
const posX = Math.max(
0,
Math.min(zoomCoords.screenWidth, zoomCoords.x)
);
translateX = `${posX * scaleX}px`;
nextImagePanStyle.left = 0;
}
if (shouldTransformY) {
const offset = imageNode.offsetHeight - zoomCoords.screenHeight;
const scaleY = (-1 / zoomCoords.screenHeight) * offset;
const posY = Math.max(
0,
Math.min(zoomCoords.screenHeight, zoomCoords.y)
);
translateY = `${posY * scaleY}px`;
nextImagePanStyle.top = 0;
}
setImagePanStyle({
...nextImagePanStyle,
transform: `translate(${translateX}, ${translateY})`,
}); });
}, },
[] [maxBoundsLimiter, springApi]
); );
function canPanImage(): boolean { const handleTouchStart = useCallback(
const imageNode = imageRef.current; (ev: TouchEvent) => {
const [touch] = ev.touches;
return Boolean( dragCacheRef.current = {
imageNode && startX: touch.clientX,
(imageNode.naturalWidth > document.documentElement.clientWidth || startY: touch.clientY,
imageNode.naturalHeight > document.documentElement.clientHeight) translateX: translateX.get(),
); translateY: translateY.get(),
} };
},
[translateY, translateX]
);
const handleTouchMove = useCallback( const handleTouchMove = useCallback(
(ev: TouchEvent) => { (ev: TouchEvent) => {
const imageNode = imageRef.current; const dragCache = dragCacheRef.current;
const zoomCoords = zoomCoordsRef.current;
ev.preventDefault(); if (!dragCache) {
ev.stopPropagation();
if (!imageNode || !zoomCoords) {
return; return;
} }
const [touch] = ev.touches; const [touch] = ev.touches;
const { initX, initY } = zoomCoords;
positionImage({ const deltaX = touch.clientX - dragCache.startX;
clientX: initX + (initX - touch.clientX), const deltaY = touch.clientY - dragCache.startY;
clientY: initY + (initY - touch.clientY),
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<HTMLButtonElement, 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(() => { useEffect(() => {
const imageNode = imageRef.current; const animateNode = animateRef.current;
let hasListener = false; let hasListener = false;
if (imageNode && zoomType !== ZoomType.None && canPanImage()) { if (animateNode && isZoomed) {
hasListener = true; hasListener = true;
document.addEventListener('mousemove', positionImage); document.addEventListener('mousemove', positionImage);
document.addEventListener('touchmove', handleTouchMove); document.addEventListener('touchmove', handleTouchMove);
document.addEventListener('touchstart', handleTouchStart);
} }
return () => { return () => {
if (hasListener) { if (hasListener) {
document.removeEventListener('mousemove', positionImage); document.removeEventListener('mousemove', positionImage);
document.removeEventListener('touchmove', handleTouchMove); document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchstart', handleTouchStart);
} }
}; };
}, [handleTouchMove, positionImage, zoomType]); }, [handleTouchMove, handleTouchStart, isZoomed, positionImage]);
const caption = attachment?.caption; const caption = attachment?.caption;
let content: JSX.Element; let content: JSX.Element;
let shadowImage: JSX.Element | undefined;
if (!contentType) { if (!contentType) {
content = <>{children}</>; content = <>{children}</>;
} else { } else {
@ -366,64 +413,27 @@ export function Lightbox({
if (isImageTypeSupported) { if (isImageTypeSupported) {
if (objectURL) { if (objectURL) {
shadowImage = (
<div className="Lightbox__shadow-container">
<div className="Lightbox__object--container">
<img
alt={i18n('lightboxImageAlt')}
className="Lightbox__object"
ref={imageRef}
src={objectURL}
tabIndex={-1}
/>
</div>
</div>
);
content = ( content = (
<button <button
className="Lightbox__zoom-button" className="Lightbox__zoom-button"
onClick={( onClick={zoomButtonHandler}
event: React.MouseEvent<HTMLButtonElement, MouseEvent>
) => {
event.preventDefault();
event.stopPropagation();
if (zoomType === ZoomType.None) {
if (canPanImage()) {
setZoomType(ZoomType.ZoomAndPan);
zoomCoordsRef.current = {
initX: event.clientX,
initY: event.clientY,
screenHeight: document.documentElement.clientHeight,
screenWidth: document.documentElement.clientWidth,
x: event.clientX,
y: event.clientY,
};
positionImage();
} else {
setZoomType(ZoomType.FillScreen);
}
} else {
setZoomType(ZoomType.None);
}
}}
type="button" type="button"
> >
<img <img
alt={i18n('lightboxImageAlt')} alt={i18n('lightboxImageAlt')}
className="Lightbox__object" className="Lightbox__object"
onContextMenu={(event: React.MouseEvent<HTMLImageElement>) => { onContextMenu={(ev: React.MouseEvent<HTMLImageElement>) => {
// These are the only image types supported by Electron's NativeImage // These are the only image types supported by Electron's NativeImage
if ( if (
event && ev &&
contentType !== IMAGE_PNG && contentType !== IMAGE_PNG &&
!/image\/jpe?g/g.test(contentType) !/image\/jpe?g/g.test(contentType)
) { ) {
event.preventDefault(); ev.preventDefault();
} }
}} }}
src={objectURL} src={objectURL}
style={zoomType === ZoomType.ZoomAndPan ? imagePanStyle : {}} ref={imageRef}
/> />
</button> </button>
); );
@ -490,7 +500,7 @@ export function Lightbox({
? createPortal( ? createPortal(
<div <div
className={classNames('Lightbox Lightbox__container', { className={classNames('Lightbox Lightbox__container', {
'Lightbox__container--zoom': zoomType === ZoomType.ZoomAndPan, 'Lightbox__container--zoom': isZoomed,
})} })}
onClick={(event: React.MouseEvent<HTMLDivElement>) => { onClick={(event: React.MouseEvent<HTMLDivElement>) => {
event.stopPropagation(); event.stopPropagation();
@ -511,12 +521,12 @@ export function Lightbox({
ref={containerRef} ref={containerRef}
role="presentation" role="presentation"
> >
<div <div className="Lightbox__animated">
className="Lightbox__main-container" <div
tabIndex={-1} className="Lightbox__main-container"
ref={focusRef} tabIndex={-1}
> ref={focusRef}
{!isZoomed && ( >
<div className="Lightbox__header"> <div className="Lightbox__header">
{getConversation ? ( {getConversation ? (
<LightboxHeader <LightboxHeader
@ -552,40 +562,41 @@ export function Lightbox({
/> />
</div> </div>
</div> </div>
)} <animated.div
<div className={classNames('Lightbox__object--container', {
className={classNames('Lightbox__object--container', { 'Lightbox__object--container--zoom': isZoomed,
'Lightbox__object--container--fill': })}
zoomType === ZoomType.FillScreen, ref={animateRef}
'Lightbox__object--container--zoom': style={{
zoomType === ZoomType.ZoomAndPan, transform: to(
})} [scale, translateX, translateY],
> (s, x, y) => `translate(${x}px, ${y}px) scale(${s})`
{content} ),
}}
>
{content}
</animated.div>
{hasPrevious && (
<div className="Lightbox__nav-prev">
<button
aria-label={i18n('previous')}
className="Lightbox__button Lightbox__button--previous"
onClick={onPrevious}
type="button"
/>
</div>
)}
{hasNext && (
<div className="Lightbox__nav-next">
<button
aria-label={i18n('next')}
className="Lightbox__button Lightbox__button--next"
onClick={onNext}
type="button"
/>
</div>
)}
</div> </div>
{shadowImage}
{hasPrevious && (
<div className="Lightbox__nav-prev">
<button
aria-label={i18n('previous')}
className="Lightbox__button Lightbox__button--previous"
onClick={onPrevious}
type="button"
/>
</div>
)}
{hasNext && (
<div className="Lightbox__nav-next">
<button
aria-label={i18n('next')}
className="Lightbox__button Lightbox__button--next"
onClick={onNext}
type="button"
/>
</div>
)}
</div>
{!isZoomed && (
<div className="Lightbox__footer"> <div className="Lightbox__footer">
{isViewOnce && videoTime ? ( {isViewOnce && videoTime ? (
<div className="Lightbox__timestamp"> <div className="Lightbox__timestamp">
@ -636,7 +647,7 @@ export function Lightbox({
</div> </div>
)} )}
</div> </div>
)} </div>
</div>, </div>,
root root
) )

View file

@ -12677,9 +12677,23 @@
{ {
"rule": "React-useRef", "rule": "React-useRef",
"path": "ts/components/Lightbox.tsx", "path": "ts/components/Lightbox.tsx",
"line": " const zoomCoordsRef = useRef<", "line": " const animateRef = useRef<HTMLDivElement | null>(null);",
"reasonCategory": "usageTrusted", "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", "rule": "React-createRef",