commit: 4e929b2d173fa22b722c58c0e9f8223eb4f44b0e
parent: ef44c62d17085e71a807705f25c549553676c5f1
Author: Yuto Tokunaga <yuntan.sub1@gmail.com>
Date: Mon, 5 Mar 2018 04:32:24 +0900
[RFC] Improved media modal (#5956)
* Improved media modal
ImageLoader: Impliment pinch zoom by CSS `transform: scale(X)`
ImageLoader: Impliment panning by CSS `overflow: scroll`
ImageLoader: Larger image
MediaModal: Larger close button
MediaModal: Close the modal by swiping vertically
MediaModal: Show/hide close button and right/left navigation on tapping image
MediaModal: Change the `pointer-event` CSS prpp to get more blank space to close the modal
ImageLoader: Zoom/reset zoom on double tap
MediaModal: disable vertical swiping while horizontally swiped
ImageLoader: prevent propagating touchmove event to MediaModal
MediaModal: Adjust size and potision of buttons
ImageLoader: Adjust scroll potision on pinch zoom
* Remove "swipe to close" and "double tap to zoom" features
* remove unused prop and functions
removed `onScroll` prop and `handleScroll` func in ImageLoader
* separate zoom functionary to ZoomableImage component
adjust styling of ImageLoader
add styling for ZoomableImage
* adjust size and potision of close button of media modal
* Fix for gif video
add `onClick` prop to ExtendedVideoPlayer
specify `onClick` prop to video tag for switching nav of `MediaModal`
add `.video-modal` class to scss to separate styling for `VideoModal`
* fix styling for centering
specify height of `ZoomableImage` by pixel
clean styling for `ImageLoader`
* fix lint errors
* small fix
* fixed designated parts
Diffstat:
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
}