From 136d333814b68fb528f61d98cc9bc38873e8ac8f Mon Sep 17 00:00:00 2001 From: automated-signal <37887102+automated-signal@users.noreply.github.com> Date: Tue, 28 Sep 2021 15:13:12 -0700 Subject: [PATCH] Massively zoom in on images, adds panning Co-authored-by: Josh Perez <60019601+josh-signal@users.noreply.github.com> --- stylesheets/components/Lightbox.scss | 39 +++++++-- ts/components/Lightbox.tsx | 118 +++++++++++++++++++++++++-- ts/util/lint/exceptions.json | 14 ++++ 3 files changed, 155 insertions(+), 16 deletions(-) diff --git a/stylesheets/components/Lightbox.scss b/stylesheets/components/Lightbox.scss index fa5d547c00a..7135ba80427 100644 --- a/stylesheets/components/Lightbox.scss +++ b/stylesheets/components/Lightbox.scss @@ -72,18 +72,23 @@ } } + &__shadow-container { + display: flex; + height: 100%; + padding: 0 16px; + position: absolute; + width: 100%; + z-index: 0; + } + &__object { &--container { display: inline-flex; flex-grow: 1; justify-content: center; - margin: 0 40px; overflow: hidden; position: relative; - - &--zoomed { - margin: 0; - } + z-index: 1; } height: auto; @@ -92,15 +97,32 @@ max-width: 100%; object-fit: contain; outline: none; + padding: 0 40px; position: absolute; top: 50%; transform: translate(-50%, -50%); width: auto; } - &__object--container--zoomed &__object { - width: 100%; + &__shadow-container &__object { + max-height: 200%; + max-width: 200%; + padding: 0; + visibility: hidden; + } + + &__object--container--fill &__object { height: 100%; + padding: 0; + width: 100%; + } + + &__object--container--zoom &__object { + left: 0; + max-height: 200%; + max-width: 200%; + padding: 0; + top: 0; } &__unsupported { @@ -135,7 +157,8 @@ cursor: zoom-in; } - &__object--container--zoomed { + &__object--container--zoom, + &__object--container--fill { .Lightbox__zoom-button { cursor: zoom-out; } diff --git a/ts/components/Lightbox.tsx b/ts/components/Lightbox.tsx index 08af20ac8bf..78fa7229127 100644 --- a/ts/components/Lightbox.tsx +++ b/ts/components/Lightbox.tsx @@ -2,6 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import React, { + CSSProperties, ReactNode, useCallback, useEffect, @@ -40,6 +41,12 @@ export type PropsType = { selectedIndex?: number; }; +enum ZoomType { + None, + FillScreen, + ZoomAndPan, +} + export function Lightbox({ children, close, @@ -60,9 +67,15 @@ export function Lightbox({ null ); const [videoTime, setVideoTime] = useState(); - const [zoomed, setZoomed] = useState(false); + const [zoomType, setZoomType] = useState(ZoomType.None); const containerRef = useRef(null); const [focusRef] = useRestoreFocus(); + const imageRef = useRef(null); + const [imagePanStyle, setImagePanStyle] = useState({}); + const zoomCoordsRef = useRef< + | { screenWidth: number; screenHeight: number; x: number; y: number } + | undefined + >(); const onPrevious = useCallback( ( @@ -123,13 +136,14 @@ export function Lightbox({ const onKeyDown = useCallback( (event: KeyboardEvent) => { switch (event.key) { - case 'Escape': + case 'Escape': { close(); event.preventDefault(); event.stopPropagation(); break; + } case 'ArrowLeft': onPrevious(event); @@ -207,9 +221,62 @@ export function Lightbox({ }; }, [isViewOnce, isAttachmentGIF, onTimeUpdate, playVideo, videoElement]); + const positionImage = useCallback((ev?: MouseEvent) => { + const imageNode = imageRef.current; + const zoomCoords = zoomCoordsRef.current; + if (!imageNode || !zoomCoords) { + return; + } + + if (ev) { + zoomCoords.x = ev.clientX; + zoomCoords.y = ev.clientY; + } + + const scaleX = + (-1 / zoomCoords.screenWidth) * + (imageNode.offsetWidth - zoomCoords.screenWidth); + const scaleY = + (-1 / zoomCoords.screenHeight) * + (imageNode.offsetHeight - zoomCoords.screenHeight); + + setImagePanStyle({ + transform: `translate(${zoomCoords.x * scaleX}px, ${ + zoomCoords.y * scaleY + }px)`, + }); + }, []); + + function canPanImage(): boolean { + const imageNode = imageRef.current; + + return Boolean( + imageNode && + (imageNode.naturalWidth > document.documentElement.clientWidth || + imageNode.naturalHeight > document.documentElement.clientHeight) + ); + } + + useEffect(() => { + const imageNode = imageRef.current; + let hasListener = false; + + if (imageNode && zoomType !== ZoomType.None && canPanImage()) { + hasListener = true; + document.addEventListener('mousemove', positionImage); + } + + return () => { + if (hasListener) { + document.removeEventListener('mousemove', positionImage); + } + }; + }, [positionImage, zoomType]); + const caption = attachment?.caption; let content: JSX.Element; + let shadowImage: JSX.Element | undefined; if (!contentType) { content = <>{children}; } else { @@ -222,6 +289,19 @@ export function Lightbox({ if (isImageTypeSupported) { if (objectURL) { + shadowImage = ( +
+
+ {i18n('lightboxImageAlt')} +
+
+ ); content = ( ); @@ -308,8 +404,10 @@ export function Lightbox({ } } - const hasNext = !zoomed && selectedIndex < media.length - 1; - const hasPrevious = !zoomed && selectedIndex > 0; + const isZoomed = zoomType !== ZoomType.None; + + const hasNext = isZoomed && selectedIndex < media.length - 1; + const hasPrevious = isZoomed && selectedIndex > 0; return root ? createPortal( @@ -339,7 +437,7 @@ export function Lightbox({ tabIndex={-1} ref={focusRef} > - {!zoomed && ( + {!isZoomed && (
{getConversation ? ( {content}
+ {shadowImage} {hasPrevious && (
- {!zoomed && ( + {!isZoomed && (
{isViewOnce && videoTime ? (
diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 2272702c933..b1961959d4a 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -12636,6 +12636,20 @@ "reasonCategory": "usageTrusted", "updated": "2021-08-23T18:39:37.081Z" }, + { + "rule": "React-useRef", + "path": "ts/components/Lightbox.tsx", + "line": " const imageRef = useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2021-09-24T00:03:36.061Z" + }, + { + "rule": "React-useRef", + "path": "ts/components/Lightbox.tsx", + "line": " const zoomCoordsRef = useRef<", + "reasonCategory": "usageTrusted", + "updated": "2021-09-24T00:03:36.061Z" + }, { "rule": "React-createRef", "path": "ts/components/MainHeader.js",