diff --git a/packages/pl-fe/src/features/ui/components/image-loader.tsx b/packages/pl-fe/src/features/ui/components/image-loader.tsx deleted file mode 100644 index d1ef2a163..000000000 --- a/packages/pl-fe/src/features/ui/components/image-loader.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import clsx from 'clsx'; -import React from 'react'; - -import ZoomableImage from './zoomable-image'; - -type EventRemover = () => void; - -interface IImageLoader { - alt?: string; - src: string; - previewSrc?: string; - width?: number; - height?: number; - onClick?: () => void; -} - -class ImageLoader extends React.PureComponent { - - static defaultProps = { - alt: '', - width: null, - height: null, - }; - - state = { - loading: true, - error: false, - width: null, - }; - - removers: EventRemover[] = []; - canvas: HTMLCanvasElement | null = null; - _canvasContext: CanvasRenderingContext2D | null = null; - - get canvasContext() { - if (!this.canvas) { - return null; - } - this._canvasContext = this._canvasContext || this.canvas.getContext('2d'); - return this._canvasContext; - } - - componentDidMount() { - this.loadImage(this.props); - } - - componentDidUpdate(prevProps: IImageLoader) { - if (prevProps.src !== this.props.src) { - this.loadImage(this.props); - } - } - - componentWillUnmount() { - this.removeEventListeners(); - } - - loadImage(props: IImageLoader) { - this.removeEventListeners(); - this.setState({ loading: true, error: false }); - Promise.all([ - props.previewSrc && this.loadPreviewCanvas(props), - this.hasSize() && this.loadOriginalImage(props), - ].filter(Boolean)) - .then(() => { - this.setState({ loading: false, error: false }); - this.clearPreviewCanvas(); - }) - .catch(() => this.setState({ loading: false, error: true })); - } - - loadPreviewCanvas = ({ previewSrc, width, height }: IImageLoader) => new Promise((resolve, reject) => { - const image = new Image(); - const removeEventListeners = () => { - image.removeEventListener('error', handleError); - image.removeEventListener('load', handleLoad); - }; - const handleError = () => { - removeEventListeners(); - reject(); - }; - const handleLoad = () => { - removeEventListeners(); - this.canvasContext?.drawImage(image, 0, 0, width || 0, height || 0); - resolve(); - }; - image.addEventListener('error', handleError); - image.addEventListener('load', handleLoad); - image.src = previewSrc || ''; - this.removers.push(removeEventListeners); - }); - - clearPreviewCanvas() { - if (this.canvas && this.canvasContext) { - const { width, height } = this.canvas; - this.canvasContext.clearRect(0, 0, width, height); - } - } - - loadOriginalImage = ({ src }: IImageLoader) => new Promise((resolve, reject) => { - const image = new Image(); - const removeEventListeners = () => { - image.removeEventListener('error', handleError); - image.removeEventListener('load', handleLoad); - }; - const handleError = () => { - removeEventListeners(); - reject(); - }; - const handleLoad = () => { - removeEventListeners(); - resolve(); - }; - image.addEventListener('error', handleError); - image.addEventListener('load', handleLoad); - image.src = src; - this.removers.push(removeEventListeners); - }); - - removeEventListeners() { - this.removers.forEach(listeners => listeners()); - this.removers = []; - } - - hasSize() { - const { width, height } = this.props; - return typeof width === 'number' && typeof height === 'number'; - } - - setCanvasRef = (c: HTMLCanvasElement) => { - this.canvas = c; - if (c) this.setState({ width: c.offsetWidth }); - }; - - render() { - const { alt, src, width, height, onClick } = this.props; - const { loading } = this.state; - - return ( -
- {loading ? ( - - ) : ( - - )} -
- ); - } - -} - -export { ImageLoader as default }; diff --git a/packages/pl-fe/src/modals/media-modal.tsx b/packages/pl-fe/src/modals/media-modal.tsx index 7b78ca32e..062e565c5 100644 --- a/packages/pl-fe/src/modals/media-modal.tsx +++ b/packages/pl-fe/src/modals/media-modal.tsx @@ -1,6 +1,6 @@ import { Link } from '@tanstack/react-router'; import clsx from 'clsx'; -import React, { useCallback, useEffect, useState } from 'react'; +import React, { type RefCallback, useCallback, useEffect, useState } from 'react'; import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; import ReactSwipeableViews from 'react-swipeable-views'; @@ -15,7 +15,7 @@ import Stack from 'pl-fe/components/ui/stack'; import Audio from 'pl-fe/features/audio'; import PlaceholderStatus from 'pl-fe/features/placeholder/components/placeholder-status'; import Thread from 'pl-fe/features/status/components/thread'; -import ImageLoader from 'pl-fe/features/ui/components/image-loader'; +import ZoomableImage from 'pl-fe/features/ui/components/zoomable-image'; import Video from 'pl-fe/features/video'; import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch'; import { useAppSelector } from 'pl-fe/hooks/use-app-selector'; @@ -31,6 +31,8 @@ const messages = defineMessages({ minimize: { id: 'lightbox.minimize', defaultMessage: 'Minimize' }, next: { id: 'lightbox.next', defaultMessage: 'Next' }, previous: { id: 'lightbox.previous', defaultMessage: 'Previous' }, + zoomIn: { id: 'lightbox.zoom_in', defaultMessage: 'Zoom to actual size' }, + zoomOut: { id: 'lightbox.zoom_out', defaultMessage: 'Zoom to fit' }, }); // you can't use 100vh, because the viewport height is taller @@ -51,6 +53,7 @@ interface MediaModalProps { statusId?: string; index: number; time?: number; + lang?: string; } const MediaModal: React.FC = (props) => { @@ -68,15 +71,30 @@ const MediaModal: React.FC = (props) => { const media = status?.media_attachments || props.media || []; const [isLoaded, setIsLoaded] = useState(!!status); - const [index, setIndex] = useState(null); + const [index, setIndex] = useState(props.index || 0); + const [zoomedIn, setZoomedIn] = useState(false); const [navigationHidden, setNavigationHidden] = useState(false); const [isFullScreen, setIsFullScreen] = useState(!status); + const [viewportDimensions, setViewportDimensions] = useState<{ + width: number; + height: number; + }>({ width: 0, height: 0 }); + + const handleRef: RefCallback = useCallback((ele) => { + if (ele?.clientWidth && ele.clientHeight) { + setViewportDimensions({ + width: ele.clientWidth, + height: ele.clientHeight, + }); + } + }, []); + const hasMultipleImages = media.length > 1; const handleSwipe = (index: number) => setIndex(index % media.length); - const handleNextClick = () => setIndex((getIndex() + 1) % media.length); - const handlePrevClick = () => setIndex((media.length + getIndex() - 1) % media.length); + const handleNextClick = () => setIndex((index + 1) % media.length); + const handlePrevClick = () => setIndex((media.length + index - 1) % media.length); const navigationHiddenClassName = navigationHidden ? 'pointer-events-none opacity-0' : ''; @@ -100,12 +118,20 @@ const MediaModal: React.FC = (props) => { window.open(mediaItem?.url); }; - const getIndex = () => index !== null ? index : props.index; - const toggleNavigation = () => { setNavigationHidden(value => !value && userTouching.matches); }; + const currentMedia = media[index]; + + const zoomable = + currentMedia.type === 'image' && currentMedia.meta.original && + (currentMedia.meta.original.width > viewportDimensions.width || currentMedia.meta.original.height > viewportDimensions.height); + + const handleZoomClick = useCallback(() => { + setZoomedIn((prev) => !prev); + }, []); + const content = media.map((attachment, i) => { let width: number | undefined, height: number | undefined; if (attachment.type === 'image' || attachment.type === 'gifv' || attachment.type === 'video') { @@ -121,14 +147,19 @@ const MediaModal: React.FC = (props) => { if (attachment.type === 'image') { return ( - ); } else if (attachment.type === 'video') { @@ -141,7 +172,7 @@ const MediaModal: React.FC = (props) => { height={height} startTime={time} detailed - autoFocus={i === getIndex()} + autoFocus={i === index} link={link} alt={attachment.description} key={attachment.url} @@ -229,6 +260,7 @@ const MediaModal: React.FC = (props) => { }) } justifyContent='between' + ref={handleRef} > = (props) => { /> + {zoomable && ( + + )} + = (props) => { style={swipeableViewsStyle} containerStyle={containerStyle} onChangeIndex={handleSwipe} - index={getIndex()} + index={index} > {content}