kopia lustrzana https://gitlab.com/soapbox-pub/soapbox
Merge branch 'updating-zoom' into 'main'
feat: add 2x zoom on desktop See merge request soapbox-pub/soapbox!3328
commit
12d7e6da91
|
@ -12,6 +12,8 @@ interface IImageLoader {
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
onClick?: React.MouseEventHandler;
|
onClick?: React.MouseEventHandler;
|
||||||
|
isMobile?: boolean;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class ImageLoader extends PureComponent<IImageLoader> {
|
class ImageLoader extends PureComponent<IImageLoader> {
|
||||||
|
@ -135,7 +137,7 @@ class ImageLoader extends PureComponent<IImageLoader> {
|
||||||
const { alt, src, width, height, onClick } = this.props;
|
const { alt, src, width, height, onClick } = this.props;
|
||||||
const { loading } = this.state;
|
const { loading } = this.state;
|
||||||
|
|
||||||
const className = 'relative h-screen flex items-center justify-center flex-col';
|
const className = clsx('relative flex h-screen w-full grow flex-col items-center justify-center');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
|
@ -154,6 +156,7 @@ class ImageLoader extends PureComponent<IImageLoader> {
|
||||||
alt={alt}
|
alt={alt}
|
||||||
src={src}
|
src={src}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
isMobile={this.props.isMobile}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -24,6 +24,7 @@ import PlaceholderStatus from 'soapbox/features/placeholder/components/placehold
|
||||||
import Thread from 'soapbox/features/status/components/thread.tsx';
|
import Thread from 'soapbox/features/status/components/thread.tsx';
|
||||||
import Video from 'soapbox/features/video/index.tsx';
|
import Video from 'soapbox/features/video/index.tsx';
|
||||||
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
|
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
|
||||||
|
import { useIsMobile } from 'soapbox/hooks/useIsMobile.ts';
|
||||||
import { userTouching } from 'soapbox/is-mobile.ts';
|
import { userTouching } from 'soapbox/is-mobile.ts';
|
||||||
import { normalizeStatus } from 'soapbox/normalizers/index.ts';
|
import { normalizeStatus } from 'soapbox/normalizers/index.ts';
|
||||||
import { Status as StatusEntity, Attachment } from 'soapbox/schemas/index.ts';
|
import { Status as StatusEntity, Attachment } from 'soapbox/schemas/index.ts';
|
||||||
|
@ -72,6 +73,7 @@ const MediaModal: React.FC<IMediaModal> = (props) => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const actualStatus = status ? getActualStatus(status) : undefined;
|
const actualStatus = status ? getActualStatus(status) : undefined;
|
||||||
|
|
||||||
|
@ -143,6 +145,7 @@ const MediaModal: React.FC<IMediaModal> = (props) => {
|
||||||
alt={attachment.description}
|
alt={attachment.description}
|
||||||
key={attachment.url}
|
key={attachment.url}
|
||||||
onClick={toggleNavigation}
|
onClick={toggleNavigation}
|
||||||
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (attachment.type === 'video') {
|
} else if (attachment.type === 'video') {
|
||||||
|
@ -241,7 +244,7 @@ const MediaModal: React.FC<IMediaModal> = (props) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='media-modal pointer-events-auto fixed inset-0 z-[9999] flex size-full bg-gray-900/90'>
|
<div className='pointer-events-auto fixed inset-0 z-[9999] flex size-full bg-gray-900/90'>
|
||||||
<div
|
<div
|
||||||
className='absolute inset-0'
|
className='absolute inset-0'
|
||||||
role='presentation'
|
role='presentation'
|
||||||
|
@ -256,7 +259,7 @@ const MediaModal: React.FC<IMediaModal> = (props) => {
|
||||||
}
|
}
|
||||||
justifyContent='between'
|
justifyContent='between'
|
||||||
>
|
>
|
||||||
<Stack className='relative h-full'>
|
<Stack className='relative h-full items-center'>
|
||||||
<HStack
|
<HStack
|
||||||
alignItems='center'
|
alignItems='center'
|
||||||
justifyContent='between'
|
justifyContent='between'
|
||||||
|
@ -295,10 +298,10 @@ const MediaModal: React.FC<IMediaModal> = (props) => {
|
||||||
|
|
||||||
{/* Height based on height of top/bottom bars */}
|
{/* Height based on height of top/bottom bars */}
|
||||||
<div
|
<div
|
||||||
className='relative h-[calc(100vh-120px)] w-full grow'
|
className='relative flex h-[calc(100vh-120px)] w-full grow items-center justify-center'
|
||||||
>
|
>
|
||||||
{hasMultipleImages && (
|
{hasMultipleImages && (
|
||||||
<div className={clsx('absolute inset-y-0 left-5 z-10 flex items-center transition-opacity', navigationHiddenClassName)}>
|
<div className={clsx('absolute left-5 z-10 flex items-center transition-opacity', navigationHiddenClassName)}>
|
||||||
<button
|
<button
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className='flex size-10 items-center justify-center rounded-full bg-gray-900 text-white'
|
className='flex size-10 items-center justify-center rounded-full bg-gray-900 text-white'
|
||||||
|
@ -310,23 +313,20 @@ const MediaModal: React.FC<IMediaModal> = (props) => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className='size-full'>
|
<ReactSwipeableViews
|
||||||
<ReactSwipeableViews
|
style={swipeableViewsStyle}
|
||||||
style={swipeableViewsStyle}
|
containerStyle={containerStyle}
|
||||||
containerStyle={containerStyle}
|
onChangeIndex={handleSwipe}
|
||||||
onChangeIndex={handleSwipe}
|
index={getIndex()}
|
||||||
className='flex items-center justify-center'
|
>
|
||||||
index={getIndex()}
|
{content}
|
||||||
>
|
</ReactSwipeableViews>
|
||||||
{content}
|
|
||||||
</ReactSwipeableViews>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasMultipleImages && (
|
{hasMultipleImages && (
|
||||||
<div className={clsx('absolute inset-y-0 right-5 z-10 flex items-center transition-opacity', navigationHiddenClassName)}>
|
<div className={clsx('absolute right-5 flex items-center transition-opacity', navigationHiddenClassName)}>
|
||||||
<button
|
<button
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className='flex size-10 items-center justify-center rounded-full bg-gray-900 text-white'
|
className='z-10 flex size-10 items-center justify-center rounded-full bg-gray-900 text-white'
|
||||||
onClick={handleNextClick}
|
onClick={handleNextClick}
|
||||||
aria-label={intl.formatMessage(messages.next)}
|
aria-label={intl.formatMessage(messages.next)}
|
||||||
>
|
>
|
||||||
|
@ -336,10 +336,10 @@ const MediaModal: React.FC<IMediaModal> = (props) => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{actualStatus && (
|
{isFullScreen && actualStatus && (
|
||||||
<HStack
|
<HStack
|
||||||
justifyContent='center'
|
justifyContent='center'
|
||||||
className={clsx('absolute bottom-2 flex w-full transition-opacity', navigationHiddenClassName)}
|
className={clsx('absolute bottom-2 flex transition-opacity', navigationHiddenClassName)}
|
||||||
>
|
>
|
||||||
<StatusActionBar
|
<StatusActionBar
|
||||||
status={normalizeStatus(actualStatus) as LegacyStatus}
|
status={normalizeStatus(actualStatus) as LegacyStatus}
|
||||||
|
|
|
@ -20,6 +20,7 @@ interface IZoomableImage {
|
||||||
alt?: string;
|
alt?: string;
|
||||||
src: string;
|
src: string;
|
||||||
onClick?: React.MouseEventHandler;
|
onClick?: React.MouseEventHandler;
|
||||||
|
isMobile?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
class ZoomableImage extends PureComponent<IZoomableImage> {
|
class ZoomableImage extends PureComponent<IZoomableImage> {
|
||||||
|
@ -32,11 +33,20 @@ class ZoomableImage extends PureComponent<IZoomableImage> {
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
scale: MIN_SCALE,
|
scale: MIN_SCALE,
|
||||||
|
isDragging: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
container: HTMLDivElement | null = null;
|
container: HTMLDivElement | null = null;
|
||||||
image: HTMLImageElement | null = null;
|
image: HTMLImageElement | null = null;
|
||||||
lastDistance = 0;
|
lastDistance = 0;
|
||||||
|
isDragging = false;
|
||||||
|
isMouseDown = false;
|
||||||
|
clickStartTime = 0;
|
||||||
|
startX = 0;
|
||||||
|
startY = 0;
|
||||||
|
startScrollLeft = 0;
|
||||||
|
startScrollTop = 0;
|
||||||
|
isMobile = this.props.isMobile;
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this.container?.addEventListener('touchstart', this.handleTouchStart);
|
this.container?.addEventListener('touchstart', this.handleTouchStart);
|
||||||
|
@ -47,7 +57,7 @@ class ZoomableImage extends PureComponent<IZoomableImage> {
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
this.container?.removeEventListener('touchstart', this.handleTouchStart);
|
this.container?.removeEventListener('touchstart', this.handleTouchStart);
|
||||||
this.container?.removeEventListener('touchend', this.handleTouchMove);
|
this.container?.removeEventListener('touchmove', this.handleTouchMove);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleTouchStart = (e: TouchEvent) => {
|
handleTouchStart = (e: TouchEvent) => {
|
||||||
|
@ -81,11 +91,54 @@ class ZoomableImage extends PureComponent<IZoomableImage> {
|
||||||
this.lastDistance = distance;
|
this.lastDistance = distance;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
handleMouseDown = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (this.state.isDragging) {
|
||||||
|
this.setState({ isDragging: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.state.scale === 1 || !this.container) return;
|
||||||
|
|
||||||
|
this.isDragging = true;
|
||||||
|
this.startX = e.clientX;
|
||||||
|
this.startY = e.clientY;
|
||||||
|
this.startScrollLeft = this.container.scrollLeft;
|
||||||
|
this.startScrollTop = this.container.scrollTop;
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', this.handleMouseMove);
|
||||||
|
window.addEventListener('mouseup', this.handleMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleMouseMove = (e: MouseEvent) => {
|
||||||
|
if (!this.isDragging || !this.container) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const deltaX = this.startX - e.clientX;
|
||||||
|
const deltaY = this.startY - e.clientY;
|
||||||
|
|
||||||
|
if (Math.abs(deltaX) > 5 || Math.abs(deltaY) > 5) this.setState({ isDragging: true });
|
||||||
|
|
||||||
|
this.container.scrollLeft = this.startScrollLeft + deltaX;
|
||||||
|
this.container.scrollTop = this.startScrollTop + deltaY;
|
||||||
|
};
|
||||||
|
|
||||||
|
handleMouseUp = (e: MouseEvent) => {
|
||||||
|
this.isDragging = false;
|
||||||
|
window.removeEventListener('mousemove', this.handleMouseMove);
|
||||||
|
window.removeEventListener('mouseup', this.handleMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
zoom(nextScale: number, midpoint: Point) {
|
zoom(nextScale: number, midpoint: Point) {
|
||||||
if (!this.container) return;
|
if (!this.container || this.state.isDragging) return;
|
||||||
|
|
||||||
const { scale } = this.state;
|
const { scale } = this.state;
|
||||||
const { scrollLeft, scrollTop } = this.container;
|
const { scrollLeft, scrollTop, clientWidth, clientHeight } = this.container;
|
||||||
|
|
||||||
// math memo:
|
// math memo:
|
||||||
// x = (scrollLeft + midpoint.x) / scrollWidth
|
// x = (scrollLeft + midpoint.x) / scrollWidth
|
||||||
|
@ -93,6 +146,9 @@ class ZoomableImage extends PureComponent<IZoomableImage> {
|
||||||
// scrollWidth = clientWidth * scale
|
// scrollWidth = clientWidth * scale
|
||||||
// scrollWidth' = clientWidth * nextScale
|
// scrollWidth' = clientWidth * nextScale
|
||||||
// Solve x = x' for nextScrollLeft
|
// Solve x = x' for nextScrollLeft
|
||||||
|
const originX = (midpoint.x / clientWidth) * 100;
|
||||||
|
const originY = (midpoint.y / clientHeight) * 100;
|
||||||
|
|
||||||
const nextScrollLeft = (scrollLeft + midpoint.x) * nextScale / scale - midpoint.x;
|
const nextScrollLeft = (scrollLeft + midpoint.x) * nextScale / scale - midpoint.x;
|
||||||
const nextScrollTop = (scrollTop + midpoint.y) * nextScale / scale - midpoint.y;
|
const nextScrollTop = (scrollTop + midpoint.y) * nextScale / scale - midpoint.y;
|
||||||
|
|
||||||
|
@ -100,14 +156,30 @@ class ZoomableImage extends PureComponent<IZoomableImage> {
|
||||||
if (!this.container) return;
|
if (!this.container) return;
|
||||||
this.container.scrollLeft = nextScrollLeft;
|
this.container.scrollLeft = nextScrollLeft;
|
||||||
this.container.scrollTop = nextScrollTop;
|
this.container.scrollTop = nextScrollTop;
|
||||||
|
if (!this.isMobile) this.image!.style.transformOrigin = `${originX}% ${originY}%`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
handleClick: React.MouseEventHandler = e => {
|
handleClick: React.MouseEventHandler = e => {
|
||||||
// don't propagate event to MediaModal
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const handler = this.props.onClick;
|
|
||||||
if (handler) handler(e);
|
if (this.isMobile) {
|
||||||
|
const handler = this.props.onClick;
|
||||||
|
if (handler) handler(e);
|
||||||
|
} else {
|
||||||
|
if (this.state.scale !== 1) {
|
||||||
|
this.zoom(1, { x: 0, y: 0 });
|
||||||
|
} else {
|
||||||
|
if (!this.image) return;
|
||||||
|
|
||||||
|
const rect = this.image.getBoundingClientRect();
|
||||||
|
const clickX = e.clientX - rect.left;
|
||||||
|
const clickY = e.clientY - rect.top;
|
||||||
|
|
||||||
|
const midpoint: Point = { x: clickX, y: clickY };
|
||||||
|
this.zoom(2, midpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
setContainerRef = (c: HTMLDivElement) => {
|
setContainerRef = (c: HTMLDivElement) => {
|
||||||
|
@ -127,20 +199,21 @@ class ZoomableImage extends PureComponent<IZoomableImage> {
|
||||||
<div
|
<div
|
||||||
className='relative flex size-full items-center justify-center'
|
className='relative flex size-full items-center justify-center'
|
||||||
ref={this.setContainerRef}
|
ref={this.setContainerRef}
|
||||||
style={{ overflow }}
|
style={{ overflow, cursor: scale > 1 ? 'grab' : 'default' }}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
role='presentation'
|
role='presentation'
|
||||||
ref={this.setImageRef}
|
ref={this.setImageRef}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
className={clsx('size-auto max-h-[80%] max-w-full object-contain', { 'size-full max-h-full': scale !== 1 })}
|
className={clsx('size-auto max-h-[80%] max-w-full object-contain', scale !== 1 ? 'size-full' : 'hover:cursor-pointer')}
|
||||||
title={alt}
|
title={alt}
|
||||||
src={src}
|
src={src}
|
||||||
style={{
|
style={{
|
||||||
transform: `scale(${scale})`,
|
transform: `scale(${scale})`,
|
||||||
transformOrigin: '0 0',
|
transformOrigin: `${scale > 1 && !this.isMobile ? 'center' : '0 0'}`,
|
||||||
}}
|
}}
|
||||||
onClick={this.handleClick}
|
onClick={this.handleClick}
|
||||||
|
onMouseDown={this.handleMouseDown}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -534,7 +534,7 @@ const Video: React.FC<IVideo> = ({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role='menuitem'
|
role='menuitem'
|
||||||
className={clsx('relative box-border flex max-w-full overflow-hidden rounded-[10px] bg-black text-white focus:outline-0', { 'w-full h-full m-0': fullscreen })}
|
className={clsx('relative box-border flex h-screen max-w-full items-center justify-center overflow-hidden rounded-[10px] bg-black text-white focus:outline-0', { 'w-full h-full m-0': fullscreen })}
|
||||||
style={playerStyle}
|
style={playerStyle}
|
||||||
ref={player}
|
ref={player}
|
||||||
onClick={handleClickRoot}
|
onClick={handleClickRoot}
|
||||||
|
|
Ładowanie…
Reference in New Issue