diff --git a/app/javascript/mastodon/components/modal_root.jsx b/app/javascript/mastodon/components/modal_root.jsx index fd13564af2..e7fa5e6f9a 100644 --- a/app/javascript/mastodon/components/modal_root.jsx +++ b/app/javascript/mastodon/components/modal_root.jsx @@ -148,7 +148,7 @@ class ModalRoot extends PureComponent { return (
-
+
{children}
diff --git a/app/javascript/mastodon/features/ui/components/image_loader.jsx b/app/javascript/mastodon/features/ui/components/image_loader.jsx index 9dabc621b4..b1417deda7 100644 --- a/app/javascript/mastodon/features/ui/components/image_loader.jsx +++ b/app/javascript/mastodon/features/ui/components/image_loader.jsx @@ -17,7 +17,7 @@ export default class ImageLoader extends PureComponent { width: PropTypes.number, height: PropTypes.number, onClick: PropTypes.func, - zoomButtonHidden: PropTypes.bool, + zoomedIn: PropTypes.bool, }; static defaultProps = { @@ -134,7 +134,7 @@ export default class ImageLoader extends PureComponent { }; render () { - const { alt, lang, src, width, height, onClick } = this.props; + const { alt, lang, src, width, height, onClick, zoomedIn } = this.props; const { loading } = this.state; const className = classNames('image-loader', { @@ -149,6 +149,7 @@ export default class ImageLoader extends PureComponent {
+ )}
diff --git a/app/javascript/mastodon/features/ui/components/media_modal.jsx b/app/javascript/mastodon/features/ui/components/media_modal.jsx index 0f6e8a727b..d69ceba539 100644 --- a/app/javascript/mastodon/features/ui/components/media_modal.jsx +++ b/app/javascript/mastodon/features/ui/components/media_modal.jsx @@ -12,6 +12,8 @@ import ReactSwipeableViews from 'react-swipeable-views'; import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react'; import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'; import CloseIcon from '@/material-icons/400-24px/close.svg?react'; +import FitScreenIcon from '@/material-icons/400-24px/fit_screen.svg?react'; +import ActualSizeIcon from '@/svg-icons/actual_size.svg?react'; import { getAverageFromBlurhash } from 'mastodon/blurhash'; import { GIFV } from 'mastodon/components/gifv'; import { Icon } from 'mastodon/components/icon'; @@ -26,6 +28,8 @@ const messages = defineMessages({ close: { id: 'lightbox.close', defaultMessage: 'Close' }, previous: { id: 'lightbox.previous', defaultMessage: 'Previous' }, next: { id: 'lightbox.next', defaultMessage: 'Next' }, + zoomIn: { id: 'lightbox.zoom_in', defaultMessage: 'Zoom to actual size' }, + zoomOut: { id: 'lightbox.zoom_out', defaultMessage: 'Zoom to fit' }, }); class MediaModal extends ImmutablePureComponent { @@ -46,30 +50,39 @@ class MediaModal extends ImmutablePureComponent { state = { index: null, navigationHidden: false, - zoomButtonHidden: false, + zoomedIn: false, + }; + + handleZoomClick = () => { + this.setState(prevState => ({ + zoomedIn: !prevState.zoomedIn, + })); }; handleSwipe = (index) => { - this.setState({ index: index % this.props.media.size }); + this.setState({ + index: index % this.props.media.size, + zoomedIn: false, + }); }; handleTransitionEnd = () => { this.setState({ - zoomButtonHidden: false, + zoomedIn: false, }); }; handleNextClick = () => { this.setState({ index: (this.getIndex() + 1) % this.props.media.size, - zoomButtonHidden: true, + zoomedIn: false, }); }; handlePrevClick = () => { this.setState({ index: (this.props.media.size + this.getIndex() - 1) % this.props.media.size, - zoomButtonHidden: true, + zoomedIn: false, }); }; @@ -78,7 +91,7 @@ class MediaModal extends ImmutablePureComponent { this.setState({ index: index % this.props.media.size, - zoomButtonHidden: true, + zoomedIn: false, }); }; @@ -130,15 +143,22 @@ class MediaModal extends ImmutablePureComponent { return this.state.index !== null ? this.state.index : this.props.index; } - toggleNavigation = () => { + handleToggleNavigation = () => { this.setState(prevState => ({ navigationHidden: !prevState.navigationHidden, })); }; + setRef = c => { + this.setState({ + viewportWidth: c?.clientWidth, + viewportHeight: c?.clientHeight, + }); + }; + render () { const { media, statusId, lang, intl, onClose } = this.props; - const { navigationHidden } = this.state; + const { navigationHidden, zoomedIn, viewportWidth, viewportHeight } = this.state; const index = this.getIndex(); @@ -160,8 +180,8 @@ class MediaModal extends ImmutablePureComponent { alt={description} lang={lang} key={image.get('url')} - onClick={this.toggleNavigation} - zoomButtonHidden={this.state.zoomButtonHidden} + onClick={this.handleToggleNavigation} + zoomedIn={zoomedIn} /> ); } else if (image.get('type') === 'video') { @@ -230,9 +250,12 @@ class MediaModal extends ImmutablePureComponent { )); } + const currentMedia = media.get(index); + const zoomable = currentMedia.get('type') === 'image' && (currentMedia.getIn(['meta', 'original', 'width']) > viewportWidth || currentMedia.getIn(['meta', 'original', 'height']) > viewportHeight); + return ( -
-
+
+
- +
+ {zoomable && } + +
{leftNav} {rightNav} diff --git a/app/javascript/mastodon/features/ui/components/zoomable_image.jsx b/app/javascript/mastodon/features/ui/components/zoomable_image.jsx index 272a3cff00..c4129bf260 100644 --- a/app/javascript/mastodon/features/ui/components/zoomable_image.jsx +++ b/app/javascript/mastodon/features/ui/components/zoomable_image.jsx @@ -1,17 +1,6 @@ import PropTypes from 'prop-types'; import { PureComponent } from 'react'; -import { defineMessages, injectIntl } from 'react-intl'; - -import FullscreenExitIcon from '@/material-icons/400-24px/fullscreen_exit.svg?react'; -import RectangleIcon from '@/material-icons/400-24px/rectangle.svg?react'; -import { IconButton } from 'mastodon/components/icon_button'; - -const messages = defineMessages({ - compress: { id: 'lightbox.compress', defaultMessage: 'Compress image view box' }, - expand: { id: 'lightbox.expand', defaultMessage: 'Expand image view box' }, -}); - const MIN_SCALE = 1; const MAX_SCALE = 4; const NAV_BAR_HEIGHT = 66; @@ -104,8 +93,7 @@ class ZoomableImage extends PureComponent { width: PropTypes.number, height: PropTypes.number, onClick: PropTypes.func, - zoomButtonHidden: PropTypes.bool, - intl: PropTypes.object.isRequired, + zoomedIn: PropTypes.bool, }; static defaultProps = { @@ -131,8 +119,6 @@ class ZoomableImage extends PureComponent { translateX: null, translateY: null, }, - zoomState: 'expand', // 'expand' 'compress' - navigationHidden: false, dragPosition: { top: 0, left: 0, x: 0, y: 0 }, dragged: false, lockScroll: { x: 0, y: 0 }, @@ -169,35 +155,20 @@ class ZoomableImage extends PureComponent { this.container.addEventListener('DOMMouseScroll', handler); this.removers.push(() => this.container.removeEventListener('DOMMouseScroll', handler)); - this.initZoomMatrix(); + this._initZoomMatrix(); } componentWillUnmount () { - this.removeEventListeners(); + this._removeEventListeners(); } - componentDidUpdate () { - this.setState({ zoomState: this.state.scale >= this.state.zoomMatrix.rate ? 'compress' : 'expand' }); - - if (this.state.scale === MIN_SCALE) { - this.container.style.removeProperty('cursor'); + componentDidUpdate (prevProps) { + if (prevProps.zoomedIn !== this.props.zoomedIn) { + this._toggleZoom(); } } - UNSAFE_componentWillReceiveProps () { - // reset when slide to next image - if (this.props.zoomButtonHidden) { - this.setState({ - scale: MIN_SCALE, - lockTranslate: { x: 0, y: 0 }, - }, () => { - this.container.scrollLeft = 0; - this.container.scrollTop = 0; - }); - } - } - - removeEventListeners () { + _removeEventListeners () { this.removers.forEach(listeners => listeners()); this.removers = []; } @@ -220,9 +191,6 @@ class ZoomableImage extends PureComponent { }; mouseDownHandler = e => { - this.container.style.cursor = 'grabbing'; - this.container.style.userSelect = 'none'; - this.setState({ dragPosition: { left: this.container.scrollLeft, top: this.container.scrollTop, @@ -246,9 +214,6 @@ class ZoomableImage extends PureComponent { }; mouseUpHandler = () => { - this.container.style.cursor = 'grab'; - this.container.style.removeProperty('user-select'); - this.image.removeEventListener('mousemove', this.mouseMoveHandler); this.image.removeEventListener('mouseup', this.mouseUpHandler); }; @@ -276,13 +241,13 @@ class ZoomableImage extends PureComponent { const _MAX_SCALE = Math.max(MAX_SCALE, this.state.zoomMatrix.rate); const scale = clamp(MIN_SCALE, _MAX_SCALE, this.state.scale * distance / this.lastDistance); - this.zoom(scale, midpoint); + this._zoom(scale, midpoint); this.lastMidpoint = midpoint; this.lastDistance = distance; }; - zoom(nextScale, midpoint) { + _zoom(nextScale, midpoint) { const { scale, zoomMatrix } = this.state; const { scrollLeft, scrollTop } = this.container; @@ -318,14 +283,13 @@ class ZoomableImage extends PureComponent { if (dragged) return; const handler = this.props.onClick; if (handler) handler(); - this.setState({ navigationHidden: !this.state.navigationHidden }); }; handleMouseDown = e => { e.preventDefault(); }; - initZoomMatrix = () => { + _initZoomMatrix = () => { const { width, height } = this.props; const { clientWidth, clientHeight } = this.container; const { offsetWidth, offsetHeight } = this.image; @@ -357,10 +321,7 @@ class ZoomableImage extends PureComponent { }); }; - handleZoomClick = e => { - e.preventDefault(); - e.stopPropagation(); - + _toggleZoom () { const { scale, zoomMatrix } = this.state; if ( scale >= zoomMatrix.rate ) { @@ -394,10 +355,7 @@ class ZoomableImage extends PureComponent { this.container.scrollTop = zoomMatrix.scrollTop; }); } - - this.container.style.cursor = 'grab'; - this.container.style.removeProperty('user-select'); - }; + } setContainerRef = c => { this.container = c; @@ -408,52 +366,37 @@ class ZoomableImage extends PureComponent { }; render () { - const { alt, lang, src, width, height, intl } = this.props; - const { scale, lockTranslate } = this.state; + const { alt, lang, src, width, height } = this.props; + const { scale, lockTranslate, dragged } = this.state; const overflow = scale === MIN_SCALE ? 'hidden' : 'scroll'; - const zoomButtonShouldHide = this.state.navigationHidden || this.props.zoomButtonHidden || this.state.zoomMatrix.rate <= MIN_SCALE ? 'media-modal__zoom-button--hidden' : ''; - const zoomButtonTitle = this.state.zoomState === 'compress' ? intl.formatMessage(messages.compress) : intl.formatMessage(messages.expand); + const cursor = scale === MIN_SCALE ? null : (dragged ? 'grabbing' : 'grab'); return ( - <> - + {alt} -
- {alt} -
- +
); } - } -export default injectIntl(ZoomableImage); +export default ZoomableImage; diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 7f2e88d440..d0563bb1b2 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -432,10 +432,10 @@ "keyboard_shortcuts.unfocus": "Unfocus compose textarea/search", "keyboard_shortcuts.up": "Move up in the list", "lightbox.close": "Close", - "lightbox.compress": "Compress image view box", - "lightbox.expand": "Expand image view box", "lightbox.next": "Next", "lightbox.previous": "Previous", + "lightbox.zoom_in": "Zoom to actual size", + "lightbox.zoom_out": "Zoom to fit", "limited_account_hint.action": "Show profile anyway", "limited_account_hint.title": "This profile has been hidden by the moderators of {domain}.", "link_preview.author": "By {name}", diff --git a/app/javascript/material-icons/400-24px/fit_screen-fill.svg b/app/javascript/material-icons/400-24px/fit_screen-fill.svg new file mode 100644 index 0000000000..a2ed8ca581 --- /dev/null +++ b/app/javascript/material-icons/400-24px/fit_screen-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/material-icons/400-24px/fit_screen.svg b/app/javascript/material-icons/400-24px/fit_screen.svg new file mode 100644 index 0000000000..d8d06f6e8b --- /dev/null +++ b/app/javascript/material-icons/400-24px/fit_screen.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 495481622a..53becb3b92 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -5764,19 +5764,34 @@ a.status-card { height: 100%; position: relative; - &__close, - &__zoom-button { - color: rgba($white, 0.7); + &__buttons { + position: absolute; + inset-inline-end: 8px; + top: 8px; + z-index: 100; + display: flex; + gap: 8px; + align-items: center; - &:hover, - &:focus, - &:active { - color: $white; - background-color: rgba($white, 0.15); - } + .icon-button { + color: rgba($white, 0.7); + padding: 8px; - &:focus { - background-color: rgba($white, 0.3); + .icon { + width: 24px; + height: 24px; + } + + &:hover, + &:focus, + &:active { + color: $white; + background-color: rgba($white, 0.15); + } + + &:focus { + background-color: rgba($white, 0.3); + } } } } @@ -5937,28 +5952,6 @@ a.status-card { } } -.media-modal__close { - position: absolute; - inset-inline-end: 8px; - top: 8px; - z-index: 100; -} - -.media-modal__zoom-button { - position: absolute; - inset-inline-end: 64px; - top: 8px; - z-index: 100; - pointer-events: auto; - transition: opacity 0.3s linear; - will-change: opacity; -} - -.media-modal__zoom-button--hidden { - pointer-events: none; - opacity: 0; -} - .onboarding-modal, .error-modal, .embed-modal { diff --git a/app/javascript/svg-icons/actual_size.svg b/app/javascript/svg-icons/actual_size.svg new file mode 100644 index 0000000000..75939cac87 --- /dev/null +++ b/app/javascript/svg-icons/actual_size.svg @@ -0,0 +1,4 @@ + + + +