Massively zoom in on images, adds panning
This commit is contained in:
parent
68b5064cb1
commit
68cef84c29
3 changed files with 155 additions and 16 deletions
|
@ -72,18 +72,23 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__shadow-container {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0 16px;
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
&__object {
|
&__object {
|
||||||
&--container {
|
&--container {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin: 0 40px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
&--zoomed {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
height: auto;
|
height: auto;
|
||||||
|
@ -92,15 +97,32 @@
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
padding: 0 40px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__object--container--zoomed &__object {
|
&__shadow-container &__object {
|
||||||
width: 100%;
|
max-height: 200%;
|
||||||
|
max-width: 200%;
|
||||||
|
padding: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__object--container--fill &__object {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__object--container--zoom &__object {
|
||||||
|
left: 0;
|
||||||
|
max-height: 200%;
|
||||||
|
max-width: 200%;
|
||||||
|
padding: 0;
|
||||||
|
top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__unsupported {
|
&__unsupported {
|
||||||
|
@ -135,7 +157,8 @@
|
||||||
cursor: zoom-in;
|
cursor: zoom-in;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__object--container--zoomed {
|
&__object--container--zoom,
|
||||||
|
&__object--container--fill {
|
||||||
.Lightbox__zoom-button {
|
.Lightbox__zoom-button {
|
||||||
cursor: zoom-out;
|
cursor: zoom-out;
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
// 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,
|
||||||
|
@ -40,6 +41,12 @@ export type PropsType = {
|
||||||
selectedIndex?: number;
|
selectedIndex?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
enum ZoomType {
|
||||||
|
None,
|
||||||
|
FillScreen,
|
||||||
|
ZoomAndPan,
|
||||||
|
}
|
||||||
|
|
||||||
export function Lightbox({
|
export function Lightbox({
|
||||||
children,
|
children,
|
||||||
close,
|
close,
|
||||||
|
@ -60,9 +67,15 @@ export function Lightbox({
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
const [videoTime, setVideoTime] = useState<number | undefined>();
|
const [videoTime, setVideoTime] = useState<number | undefined>();
|
||||||
const [zoomed, setZoomed] = useState(false);
|
const [zoomType, setZoomType] = useState<ZoomType>(ZoomType.None);
|
||||||
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 [imagePanStyle, setImagePanStyle] = useState<CSSProperties>({});
|
||||||
|
const zoomCoordsRef = useRef<
|
||||||
|
| { screenWidth: number; screenHeight: number; x: number; y: number }
|
||||||
|
| undefined
|
||||||
|
>();
|
||||||
|
|
||||||
const onPrevious = useCallback(
|
const onPrevious = useCallback(
|
||||||
(
|
(
|
||||||
|
@ -123,13 +136,14 @@ export function Lightbox({
|
||||||
const onKeyDown = useCallback(
|
const onKeyDown = useCallback(
|
||||||
(event: KeyboardEvent) => {
|
(event: KeyboardEvent) => {
|
||||||
switch (event.key) {
|
switch (event.key) {
|
||||||
case 'Escape':
|
case 'Escape': {
|
||||||
close();
|
close();
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'ArrowLeft':
|
case 'ArrowLeft':
|
||||||
onPrevious(event);
|
onPrevious(event);
|
||||||
|
@ -207,9 +221,62 @@ export function Lightbox({
|
||||||
};
|
};
|
||||||
}, [isViewOnce, isAttachmentGIF, onTimeUpdate, playVideo, videoElement]);
|
}, [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;
|
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 {
|
||||||
|
@ -222,6 +289,19 @@ 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"
|
||||||
|
@ -231,7 +311,22 @@ export function Lightbox({
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
||||||
setZoomed(!zoomed);
|
if (zoomType === ZoomType.None) {
|
||||||
|
if (canPanImage()) {
|
||||||
|
setZoomType(ZoomType.ZoomAndPan);
|
||||||
|
zoomCoordsRef.current = {
|
||||||
|
screenWidth: document.documentElement.clientWidth,
|
||||||
|
screenHeight: document.documentElement.clientHeight,
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
};
|
||||||
|
positionImage();
|
||||||
|
} else {
|
||||||
|
setZoomType(ZoomType.FillScreen);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setZoomType(ZoomType.None);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
|
@ -249,6 +344,7 @@ export function Lightbox({
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
src={objectURL}
|
src={objectURL}
|
||||||
|
style={zoomType === ZoomType.ZoomAndPan ? imagePanStyle : {}}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
@ -308,8 +404,10 @@ export function Lightbox({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasNext = !zoomed && selectedIndex < media.length - 1;
|
const isZoomed = zoomType !== ZoomType.None;
|
||||||
const hasPrevious = !zoomed && selectedIndex > 0;
|
|
||||||
|
const hasNext = isZoomed && selectedIndex < media.length - 1;
|
||||||
|
const hasPrevious = isZoomed && selectedIndex > 0;
|
||||||
|
|
||||||
return root
|
return root
|
||||||
? createPortal(
|
? createPortal(
|
||||||
|
@ -339,7 +437,7 @@ export function Lightbox({
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
ref={focusRef}
|
ref={focusRef}
|
||||||
>
|
>
|
||||||
{!zoomed && (
|
{!isZoomed && (
|
||||||
<div className="Lightbox__header">
|
<div className="Lightbox__header">
|
||||||
{getConversation ? (
|
{getConversation ? (
|
||||||
<LightboxHeader
|
<LightboxHeader
|
||||||
|
@ -378,11 +476,15 @@ export function Lightbox({
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={classNames('Lightbox__object--container', {
|
className={classNames('Lightbox__object--container', {
|
||||||
'Lightbox__object--container--zoomed': zoomed,
|
'Lightbox__object--container--fill':
|
||||||
|
zoomType === ZoomType.FillScreen,
|
||||||
|
'Lightbox__object--container--zoom':
|
||||||
|
zoomType === ZoomType.ZoomAndPan,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
|
{shadowImage}
|
||||||
{hasPrevious && (
|
{hasPrevious && (
|
||||||
<div className="Lightbox__nav-prev">
|
<div className="Lightbox__nav-prev">
|
||||||
<button
|
<button
|
||||||
|
@ -404,7 +506,7 @@ export function Lightbox({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!zoomed && (
|
{!isZoomed && (
|
||||||
<div className="Lightbox__footer">
|
<div className="Lightbox__footer">
|
||||||
{isViewOnce && videoTime ? (
|
{isViewOnce && videoTime ? (
|
||||||
<div className="Lightbox__timestamp">
|
<div className="Lightbox__timestamp">
|
||||||
|
|
|
@ -12564,6 +12564,20 @@
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2021-08-23T18:39:37.081Z"
|
"updated": "2021-08-23T18:39:37.081Z"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"rule": "React-useRef",
|
||||||
|
"path": "ts/components/Lightbox.tsx",
|
||||||
|
"line": " const imageRef = useRef<HTMLImageElement | null>(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",
|
"rule": "React-createRef",
|
||||||
"path": "ts/components/MainHeader.js",
|
"path": "ts/components/MainHeader.js",
|
||||||
|
|
Loading…
Add table
Reference in a new issue