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 {
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;

View file

@ -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,24 +69,29 @@ export function Lightbox({
null
);
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 [focusRef] = useRestoreFocus();
const imageRef = useRef<HTMLImageElement | null>(null);
const [imagePanStyle, setImagePanStyle] = useState<CSSProperties>({});
const zoomCoordsRef = useRef<
const animateRef = useRef<HTMLDivElement | null>(null);
const dragCacheRef = useRef<
| {
initX: number;
initY: number;
screenWidth: number;
screenHeight: number;
x: number;
y: number;
startX: number;
startY: number;
translateX: number;
translateY: number;
}
| undefined
>();
const imageRef = useRef<HTMLImageElement | null>(null);
const zoomCacheRef = useRef<
| {
maxX: number;
maxY: number;
screenWidth: number;
screenHeight: number;
}
| undefined
>();
const isZoomed = zoomType !== ZoomType.None;
const onPrevious = useCallback(
(
@ -238,122 +245,162 @@ export function Lightbox({
};
}, [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(
(ev?: { clientX: number; clientY: number }) => {
const imageNode = imageRef.current;
const zoomCoords = zoomCoordsRef.current;
if (!imageNode || !zoomCoords) {
(ev: MouseEvent) => {
const zoomCache = zoomCacheRef.current;
if (!zoomCache) {
return;
}
if (ev) {
zoomCoords.x = ev.clientX;
zoomCoords.y = ev.clientY;
}
const { screenWidth, screenHeight } = zoomCache;
const shouldTransformX = imageNode.naturalWidth > zoomCoords.screenWidth;
const shouldTransformY =
imageNode.naturalHeight > zoomCoords.screenHeight;
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);
const nextImagePanStyle: CSSProperties = {
left: '50%',
top: '50%',
};
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})`,
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<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(() => {
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 = (
<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 = (
<button
className="Lightbox__zoom-button"
onClick={(
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);
}
}}
onClick={zoomButtonHandler}
type="button"
>
<img
alt={i18n('lightboxImageAlt')}
className="Lightbox__object"
onContextMenu={(event: React.MouseEvent<HTMLImageElement>) => {
onContextMenu={(ev: React.MouseEvent<HTMLImageElement>) => {
// These are the only image types supported by Electron's NativeImage
if (
event &&
ev &&
contentType !== IMAGE_PNG &&
!/image\/jpe?g/g.test(contentType)
) {
event.preventDefault();
ev.preventDefault();
}
}}
src={objectURL}
style={zoomType === ZoomType.ZoomAndPan ? imagePanStyle : {}}
ref={imageRef}
/>
</button>
);
@ -490,7 +500,7 @@ export function Lightbox({
? createPortal(
<div
className={classNames('Lightbox Lightbox__container', {
'Lightbox__container--zoom': zoomType === ZoomType.ZoomAndPan,
'Lightbox__container--zoom': isZoomed,
})}
onClick={(event: React.MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
@ -511,12 +521,12 @@ export function Lightbox({
ref={containerRef}
role="presentation"
>
<div
className="Lightbox__main-container"
tabIndex={-1}
ref={focusRef}
>
{!isZoomed && (
<div className="Lightbox__animated">
<div
className="Lightbox__main-container"
tabIndex={-1}
ref={focusRef}
>
<div className="Lightbox__header">
{getConversation ? (
<LightboxHeader
@ -552,40 +562,41 @@ export function Lightbox({
/>
</div>
</div>
)}
<div
className={classNames('Lightbox__object--container', {
'Lightbox__object--container--fill':
zoomType === ZoomType.FillScreen,
'Lightbox__object--container--zoom':
zoomType === ZoomType.ZoomAndPan,
})}
>
{content}
<animated.div
className={classNames('Lightbox__object--container', {
'Lightbox__object--container--zoom': isZoomed,
})}
ref={animateRef}
style={{
transform: to(
[scale, translateX, translateY],
(s, x, y) => `translate(${x}px, ${y}px) scale(${s})`
),
}}
>
{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>
{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">
{isViewOnce && videoTime ? (
<div className="Lightbox__timestamp">
@ -636,7 +647,7 @@ export function Lightbox({
</div>
)}
</div>
)}
</div>
</div>,
root
)

View file

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