diff options
6 files changed, 330 insertions, 93 deletions
diff --git a/app/javascript/mastodon/components/extended_video_player.js b/app/javascript/mastodon/components/extended_video_player.js index f8bd067e8..9e2f6835a 100644 --- a/app/javascript/mastodon/components/extended_video_player.js +++ b/app/javascript/mastodon/components/extended_video_player.js @@ -11,6 +11,7 @@ export default class ExtendedVideoPlayer extends React.PureComponent { time: PropTypes.number, controls: PropTypes.bool.isRequired, muted: PropTypes.bool.isRequired, + onClick: PropTypes.func, }; handleLoadedData = () => { @@ -31,6 +32,12 @@ export default class ExtendedVideoPlayer extends React.PureComponent { this.video = c; } + handleClick = e => { + e.stopPropagation(); + const handler = this.props.onClick; + if (handler) handler(); + } + render () { const { src, muted, controls, alt } = this.props; @@ -46,6 +53,7 @@ export default class ExtendedVideoPlayer extends React.PureComponent { muted={muted} controls={controls} loop={!controls} + onClick={this.handleClick} /> </div> ); diff --git a/app/javascript/mastodon/features/ui/components/image_loader.js b/app/javascript/mastodon/features/ui/components/image_loader.js index e3e7197c5..c7360a726 100644 --- a/app/javascript/mastodon/features/ui/components/image_loader.js +++ b/app/javascript/mastodon/features/ui/components/image_loader.js @@ -1,6 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; +import ZoomableImage from './zoomable_image'; export default class ImageLoader extends React.PureComponent { @@ -10,6 +11,7 @@ export default class ImageLoader extends React.PureComponent { previewSrc: PropTypes.string, width: PropTypes.number, height: PropTypes.number, + onClick: PropTypes.func, } static defaultProps = { @@ -24,6 +26,7 @@ export default class ImageLoader extends React.PureComponent { } removers = []; + canvas = null; get canvasContext() { if (!this.canvas) { @@ -43,6 +46,10 @@ export default class ImageLoader extends React.PureComponent { } } + componentWillUnmount () { + this.removeEventListeners(); + } + loadImage (props) { this.removeEventListeners(); this.setState({ loading: true, error: false }); @@ -118,7 +125,7 @@ export default class ImageLoader extends React.PureComponent { } render () { - const { alt, src, width, height } = this.props; + const { alt, src, width, height, onClick } = this.props; const { loading } = this.state; const className = classNames('image-loader', { @@ -128,22 +135,19 @@ export default class ImageLoader extends React.PureComponent { return ( <div className={className}> - <canvas - className='image-loader__preview-canvas' - width={width} - height={height} - ref={this.setCanvasRef} - style={{ opacity: loading ? 1 : 0 }} - /> - - {!loading && ( - <img - alt={alt} - className='image-loader__img' - src={src} + {loading ? ( + <canvas + className='image-loader__preview-canvas' + ref={this.setCanvasRef} width={width} height={height} /> + ) : ( + <ZoomableImage + alt={alt} + src={src} + onClick={onClick} + /> )} </div> ); diff --git a/app/javascript/mastodon/features/ui/components/media_modal.js b/app/javascript/mastodon/features/ui/components/media_modal.js index 02591a51f..72ef32256 100644 --- a/app/javascript/mastodon/features/ui/components/media_modal.js +++ b/app/javascript/mastodon/features/ui/components/media_modal.js @@ -3,6 +3,7 @@ import ReactSwipeableViews from 'react-swipeable-views'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import ExtendedVideoPlayer from '../../../components/extended_video_player'; +import classNames from 'classnames'; import { defineMessages, injectIntl } from 'react-intl'; import IconButton from '../../../components/icon_button'; import ImmutablePureComponent from 'react-immutable-pure-component'; @@ -26,6 +27,7 @@ export default class MediaModal extends ImmutablePureComponent { state = { index: null, + navigationHidden: false, }; handleSwipe = (index) => { @@ -68,14 +70,21 @@ export default class MediaModal extends ImmutablePureComponent { return this.state.index !== null ? this.state.index : this.props.index; } + toggleNavigation = () => { + this.setState(prevState => ({ + navigationHidden: !prevState.navigationHidden, + })); + }; + render () { const { media, intl, onClose } = this.props; + const { navigationHidden } = this.state; const index = this.getIndex(); let pagination = []; - const leftNav = media.size > 1 && <button tabIndex='0' className='modal-container__nav modal-container__nav--left' onClick={this.handlePrevClick} aria-label={intl.formatMessage(messages.previous)}><i className='fa fa-fw fa-chevron-left' /></button>; - const rightNav = media.size > 1 && <button tabIndex='0' className='modal-container__nav modal-container__nav--right' onClick={this.handleNextClick} aria-label={intl.formatMessage(messages.next)}><i className='fa fa-fw fa-chevron-right' /></button>; + const leftNav = media.size > 1 && <button tabIndex='0' className='media-modal__nav media-modal__nav--left' onClick={this.handlePrevClick} aria-label={intl.formatMessage(messages.previous)}><i className='fa fa-fw fa-chevron-left' /></button>; + const rightNav = media.size > 1 && <button tabIndex='0' className='media-modal__nav media-modal__nav--right' onClick={this.handleNextClick} aria-label={intl.formatMessage(messages.next)}><i className='fa fa-fw fa-chevron-right' /></button>; if (media.size > 1) { pagination = media.map((item, i) => { @@ -92,9 +101,30 @@ export default class MediaModal extends ImmutablePureComponent { const height = image.getIn(['meta', 'original', 'height']) || null; if (image.get('type') === 'image') { - return <ImageLoader previewSrc={image.get('preview_url')} src={image.get('url')} width={width} height={height} alt={image.get('description')} key={image.get('url')} />; + return ( + <ImageLoader + previewSrc={image.get('preview_url')} + src={image.get('url')} + width={width} + height={height} + alt={image.get('description')} + key={image.get('url')} + onClick={this.toggleNavigation} + /> + ); } else if (image.get('type') === 'gifv') { - return <ExtendedVideoPlayer src={image.get('url')} muted controls={false} width={width} height={height} key={image.get('preview_url')} alt={image.get('description')} />; + return ( + <ExtendedVideoPlayer + src={image.get('url')} + muted + controls={false} + width={width} + height={height} + key={image.get('preview_url')} + alt={image.get('description')} + onClick={this.toggleNavigation} + /> + ); } return null; @@ -104,21 +134,43 @@ export default class MediaModal extends ImmutablePureComponent { alignItems: 'center', // center vertically }; + const navigationClassName = classNames('media-modal__navigation', { + 'media-modal__navigation--hidden': navigationHidden, + }); + return ( <div className='modal-root__modal media-modal'> - {leftNav} - - <div className='media-modal__content'> - <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} /> - <ReactSwipeableViews containerStyle={containerStyle} onChangeIndex={this.handleSwipe} index={index}> - {content} - </ReactSwipeableViews> + <div + className='media-modal__closer' + role='presentation' + onClick={onClose} + > + <div className='media-modal__content'> + <ReactSwipeableViews + style={{ + // you can't use 100vh, because the viewport height is taller + // than the visible part of the document in some mobile + // browsers when it's address bar is visible. + // https://developers.google.com/web/updates/2016/12/url-bar-resizing + height: `${document.body.clientHeight}px`, + }} + containerStyle={containerStyle} + onChangeIndex={this.handleSwipe} + onSwitching={this.handleSwitching} + index={index} + > + {content} + </ReactSwipeableViews> + </div> + </div> + <div className={navigationClassName}> + <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={40} /> + {leftNav} + {rightNav} + <ul className='media-modal__pagination'> + {pagination} + </ul> </div> - <ul className='media-modal__pagination'> - {pagination} - </ul> - - {rightNav} </div> ); } diff --git a/app/javascript/mastodon/features/ui/components/video_modal.js b/app/javascript/mastodon/features/ui/components/video_modal.js index 6a883759f..9ed4a43ad 100644 --- a/app/javascript/mastodon/features/ui/components/video_modal.js +++ b/app/javascript/mastodon/features/ui/components/video_modal.js @@ -16,7 +16,7 @@ export default class VideoModal extends ImmutablePureComponent { const { media, time, onClose } = this.props; return ( - <div className='modal-root__modal media-modal'> + <div className='modal-root__modal video-modal'> <div> <Video preview={media.get('preview_url')} diff --git a/app/javascript/mastodon/features/ui/components/zoomable_image.js b/app/javascript/mastodon/features/ui/components/zoomable_image.js new file mode 100644 index 000000000..0a0a4d41a --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/zoomable_image.js @@ -0,0 +1,151 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const MIN_SCALE = 1; +const MAX_SCALE = 4; + +const getMidpoint = (p1, p2) => ({ + x: (p1.clientX + p2.clientX) / 2, + y: (p1.clientY + p2.clientY) / 2, +}); + +const getDistance = (p1, p2) => + Math.sqrt(Math.pow(p1.clientX - p2.clientX, 2) + Math.pow(p1.clientY - p2.clientY, 2)); + +const clamp = (min, max, value) => Math.min(max, Math.max(min, value)); + +export default class ZoomableImage extends React.PureComponent { + + static propTypes = { + alt: PropTypes.string, + src: PropTypes.string.isRequired, + width: PropTypes.number, + height: PropTypes.number, + onClick: PropTypes.func, + } + + static defaultProps = { + alt: '', + width: null, + height: null, + }; + + state = { + scale: MIN_SCALE, + } + + removers = []; + container = null; + image = null; + lastTouchEndTime = 0; + lastDistance = 0; + + componentDidMount () { + let handler = this.handleTouchStart; + this.container.addEventListener('touchstart', handler); + this.removers.push(() => this.container.removeEventListener('touchstart', handler)); + handler = this.handleTouchMove; + // on Chrome 56+, touch event listeners will default to passive + // https://www.chromestatus.com/features/5093566007214080 + this.container.addEventListener('touchmove', handler, { passive: false }); + this.removers.push(() => this.container.removeEventListener('touchend', handler)); + } + + componentWillUnmount () { + this.removeEventListeners(); + } + + removeEventListeners () { + this.removers.forEach(listeners => listeners()); + this.removers = []; + } + + handleTouchStart = e => { + if (e.touches.length !== 2) return; + + this.lastDistance = getDistance(...e.touches); + } + + handleTouchMove = e => { + const { scrollTop, scrollHeight, clientHeight } = this.container; + if (e.touches.length === 1 && scrollTop !== scrollHeight - clientHeight) { + // prevent propagating event to MediaModal + e.stopPropagation(); + return; + } + if (e.touches.length !== 2) return; + + e.preventDefault(); + e.stopPropagation(); + + const distance = getDistance(...e.touches); + const midpoint = getMidpoint(...e.touches); + const scale = clamp(MIN_SCALE, MAX_SCALE, this.state.scale * distance / this.lastDistance); + + this.zoom(scale, midpoint); + + this.lastMidpoint = midpoint; + this.lastDistance = distance; + } + + zoom(nextScale, midpoint) { + const { scale } = this.state; + const { scrollLeft, scrollTop } = this.container; + + // math memo: + // x = (scrollLeft + midpoint.x) / scrollWidth + // x' = (nextScrollLeft + midpoint.x) / nextScrollWidth + // scrollWidth = clientWidth * scale + // scrollWidth' = clientWidth * nextScale + // Solve x = x' for nextScrollLeft + const nextScrollLeft = (scrollLeft + midpoint.x) * nextScale / scale - midpoint.x; + const nextScrollTop = (scrollTop + midpoint.y) * nextScale / scale - midpoint.y; + + this.setState({ scale: nextScale }, () => { + this.container.scrollLeft = nextScrollLeft; + this.container.scrollTop = nextScrollTop; + }); + } + + handleClick = e => { + // don't propagate event to MediaModal + e.stopPropagation(); + const handler = this.props.onClick; + if (handler) handler(); + } + + setContainerRef = c => { + this.container = c; + } + + setImageRef = c => { + this.image = c; + } + + render () { + const { alt, src } = this.props; + const { scale } = this.state; + const overflow = scale === 1 ? 'hidden' : 'scroll'; + + return ( + <div + className='zoomable-image' + ref={this.setContainerRef} + style={{ overflow }} + > + <img + role='presentation' + ref={this.setImageRef} + alt={alt} + src={src} + style={{ + transform: `scale(${scale})`, + transformOrigin: '0 0', + }} + onClick={this.handleClick} + /> + </div> + ); + } + +} diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index a0ff0953b..bc6123cc7 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -1433,36 +1433,29 @@ .image-loader { position: relative; + width: 100%; + height: 100%; &.image-loader--loading { + display: flex; + align-content: center; + .image-loader__preview-canvas { filter: blur(2px); } } - .image-loader__img { - position: absolute; - top: 0; - left: 0; - right: 0; - max-width: 100%; - max-height: 100%; - background-image: none; + &.image-loader--amorphous .image-loader__preview-canvas { + display: none; } +} - &.image-loader--amorphous { - position: static; - - .image-loader__preview-canvas { - display: none; - } - - .image-loader__img { - position: static; - width: auto; - height: auto; - } - } +.zoomable-image { + position: relative; + width: 100%; + height: 100%; + display: flex; + align-content: center; } .navigation-bar { @@ -2799,29 +2792,6 @@ a.status-card { } } -.modal-container__nav { - align-items: center; - background: rgba($base-overlay-background, 0.5); - box-sizing: border-box; - border: 0; - color: $primary-text-color; - cursor: pointer; - display: flex; - font-size: 24px; - height: 100%; - padding: 30px 15px; - position: absolute; - top: 0; -} - -.modal-container__nav--left { - left: -61px; -} - -.modal-container__nav--right { - right: -61px; -} - .account--follows-info { color: $primary-text-color; position: absolute; @@ -3418,29 +3388,27 @@ a.status-card { z-index: 9999; } +.video-modal { + max-width: 100vw; + max-height: 100vh; + position: relative; +} + .media-modal { - max-width: 80vw; - max-height: 80vh; + width: 100%; + height: 100%; position: relative; - .extended-video-player, img, canvas, video { - max-width: 80vw; - max-height: 80vh; + max-width: 100vw; + max-height: 100vh; width: auto; height: auto; margin: auto; } - .extended-video-player, - video { - display: flex; - width: 80vw; - height: 80vh; - } - img, canvas { display: block; @@ -3449,12 +3417,65 @@ a.status-card { } .react-swipeable-view-container { - max-width: 80vw; + width: 100vw; + height: 100%; } } -.media-modal__content { - background: $base-overlay-background; +.media-modal__closer { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; +} + +.media-modal__navigation { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; + transition: opacity 0.3s linear; + will-change: opacity; + + * { + pointer-events: auto; + } + + &.media-modal__navigation--hidden { + opacity: 0; + + * { + pointer-events: none; + } + } +} + +.media-modal__nav { + background: rgba($base-overlay-background, 0.5); + box-sizing: border-box; + border: 0; + color: $primary-text-color; + cursor: pointer; + display: flex; + align-items: center; + font-size: 24px; + height: 20vmax; + margin: auto 0; + padding: 30px 15px; + position: absolute; + top: 0; + bottom: 0; +} + +.media-modal__nav--left { + left: 0; +} + +.media-modal__nav--right { + right: 0; } .media-modal__pagination { @@ -3462,7 +3483,8 @@ a.status-card { text-align: center; position: absolute; left: 0; - bottom: -40px; + bottom: 20px; + pointer-events: none; } .media-modal__page-dot { @@ -3486,8 +3508,8 @@ a.status-card { .media-modal__close { position: absolute; - right: 4px; - top: 4px; + right: 8px; + top: 8px; z-index: 100; } |