Merge branch 'updating-zoom' into 'main'

feat: add 2x zoom on desktop

See merge request soapbox-pub/soapbox!3328
Daniel 2025-05-03 22:51:09 +00:00
commit 12d7e6da91
4 zmienionych plików z 106 dodań i 30 usunięć

Wyświetl plik

@ -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>

Wyświetl plik

@ -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}

Wyświetl plik

@ -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>
); );

Wyświetl plik

@ -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}