diff options
Diffstat (limited to 'app/javascript/flavours/glitch/features')
8 files changed, 290 insertions, 41 deletions
diff --git a/app/javascript/flavours/glitch/features/composer/options/dropdown/content/index.js b/app/javascript/flavours/glitch/features/composer/options/dropdown/content/index.js index b3a472999..b76410561 100644 --- a/app/javascript/flavours/glitch/features/composer/options/dropdown/content/index.js +++ b/app/javascript/flavours/glitch/features/composer/options/dropdown/content/index.js @@ -13,7 +13,6 @@ import { assignHandlers } from 'flavours/glitch/util/react_helpers'; // Handlers. const handlers = { - // When the document is clicked elsewhere, we close the dropdown. handleDocumentClick ({ target }) { const { node } = this; @@ -45,6 +44,10 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent // Instance variables. this.node = null; + + this.state = { + mounted: false, + }; } // On mounting, we add our listeners. @@ -52,6 +55,7 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent const { handleDocumentClick } = this.handlers; document.addEventListener('click', handleDocumentClick, false); document.addEventListener('touchend', handleDocumentClick, withPassive); + this.setState({ mounted: true }); } // On unmounting, we remove our listeners. @@ -63,6 +67,7 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent // Rendering. render () { + const { mounted } = this.state; const { handleRef } = this.handlers; const { items, @@ -87,13 +92,16 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent }} > {({ opacity, scaleX, scaleY }) => ( + // It should not be transformed when mounting because the resulting + // size will be used to determine the coordinate of the menu by + // react-overlays <div className='composer--options--dropdown--content' ref={handleRef} style={{ ...style, opacity: opacity, - transform: `scale(${scaleX}, ${scaleY})`, + transform: mounted ? `scale(${scaleX}, ${scaleY})` : null, }} > {items ? items.map( diff --git a/app/javascript/flavours/glitch/features/composer/options/dropdown/index.js b/app/javascript/flavours/glitch/features/composer/options/dropdown/index.js index d63d90a9f..b3462e25a 100644 --- a/app/javascript/flavours/glitch/features/composer/options/dropdown/index.js +++ b/app/javascript/flavours/glitch/features/composer/options/dropdown/index.js @@ -29,7 +29,7 @@ const handlers = { } = this.handlers; switch (key) { case 'Enter': - handleToggle(); + handleToggle(key); break; case 'Escape': handleClose(); @@ -79,7 +79,7 @@ const handlers = { }, // Toggles opening and closing the dropdown. - handleToggle () { + handleToggle ({ target }) { const { handleMakeModal } = this.handlers; const { onModalOpen } = this.props; const { open } = this.state; @@ -98,6 +98,8 @@ const handlers = { } } + const { top } = target.getBoundingClientRect(); + this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' }); // Otherwise, we just set our state to open. this.setState({ open: !open }); }, @@ -129,6 +131,7 @@ export default class ComposerOptionsDropdown extends React.PureComponent { this.state = { needsModalUpdate: false, open: false, + placement: null, }; } @@ -161,7 +164,7 @@ export default class ComposerOptionsDropdown extends React.PureComponent { onChange, value, } = this.props; - const { open } = this.state; + const { open, placement } = this.state; const computedClass = classNames('composer--options--dropdown', { active, open, @@ -188,7 +191,7 @@ export default class ComposerOptionsDropdown extends React.PureComponent { /> <Overlay containerPadding={20} - placement='bottom' + placement={placement} show={open} target={this} > diff --git a/app/javascript/flavours/glitch/features/status/components/card.js b/app/javascript/flavours/glitch/features/status/components/card.js index bb83374b9..680bf63ab 100644 --- a/app/javascript/flavours/glitch/features/status/components/card.js +++ b/app/javascript/flavours/glitch/features/status/components/card.js @@ -1,5 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; +import Immutable from 'immutable'; import ImmutablePropTypes from 'react-immutable-proptypes'; import punycode from 'punycode'; import classnames from 'classnames'; @@ -24,6 +25,7 @@ export default class Card extends React.PureComponent { static propTypes = { card: ImmutablePropTypes.map, maxDescription: PropTypes.number, + onOpenMedia: PropTypes.func.isRequired, }; static defaultProps = { @@ -34,6 +36,27 @@ export default class Card extends React.PureComponent { width: 0, }; + handlePhotoClick = () => { + const { card, onOpenMedia } = this.props; + + onOpenMedia( + Immutable.fromJS([ + { + type: 'image', + url: card.get('url'), + description: card.get('title'), + meta: { + original: { + width: card.get('width'), + height: card.get('height'), + }, + }, + }, + ]), + 0 + ); + }; + renderLink () { const { card, maxDescription } = this.props; @@ -73,9 +96,16 @@ export default class Card extends React.PureComponent { const { card } = this.props; return ( - <a href={card.get('url')} className='status-card-photo' target='_blank' rel='noopener'> - <img src={card.get('url')} alt={card.get('title')} width={card.get('width')} height={card.get('height')} /> - </a> + <img + className='status-card-photo' + onClick={this.handlePhotoClick} + role='button' + tabIndex='0' + src={card.get('url')} + alt={card.get('title')} + width={card.get('width')} + height={card.get('height')} + /> ); } diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.js b/app/javascript/flavours/glitch/features/status/components/detailed_status.js index 9e42481c5..684cd797b 100644 --- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js +++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js @@ -77,7 +77,7 @@ export default class DetailedStatus extends ImmutablePureComponent { ); mediaIcon = 'picture-o'; } - } else media = <CardContainer statusId={status.get('id')} />; + } else media = <CardContainer onOpenMedia={this.props.onOpenMedia} statusId={status.get('id')} />; if (status.get('application')) { applicationLink = <span> · <a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener'>{status.getIn(['application', 'name'])}</a></span>; diff --git a/app/javascript/flavours/glitch/features/ui/components/image_loader.js b/app/javascript/flavours/glitch/features/ui/components/image_loader.js index aad594380..c7360a726 100644 --- a/app/javascript/flavours/glitch/features/ui/components/image_loader.js +++ b/app/javascript/flavours/glitch/features/ui/components/image_loader.js @@ -1,15 +1,17 @@ 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 { static propTypes = { alt: PropTypes.string, src: PropTypes.string.isRequired, - previewSrc: PropTypes.string.isRequired, + 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,11 +46,15 @@ export default class ImageLoader extends React.PureComponent { } } + componentWillUnmount () { + this.removeEventListeners(); + } + loadImage (props) { this.removeEventListeners(); this.setState({ loading: true, error: false }); Promise.all([ - this.loadPreviewCanvas(props), + props.previewSrc && this.loadPreviewCanvas(props), this.hasSize() && this.loadOriginalImage(props), ].filter(Boolean)) .then(() => { @@ -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/flavours/glitch/features/ui/components/media_modal.js b/app/javascript/flavours/glitch/features/ui/components/media_modal.js index e56147c5b..6ab6770ed 100644 --- a/app/javascript/flavours/glitch/features/ui/components/media_modal.js +++ b/app/javascript/flavours/glitch/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 'flavours/glitch/components/extended_video_player'; +import classNames from 'classnames'; import { defineMessages, injectIntl } from 'react-intl'; import IconButton from 'flavours/glitch/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,33 +101,77 @@ 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('preview_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; }).toArray(); + // 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 + const swipeableViewsStyle = { + width: '100%', + height: '100%', + }; + const containerStyle = { 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}> + <div + className='media-modal__closer' + role='presentation' + onClick={onClose} + > + <ReactSwipeableViews + style={swipeableViewsStyle} + containerStyle={containerStyle} + onChangeIndex={this.handleSwipe} + onSwitching={this.handleSwitching} + index={index} + > {content} </ReactSwipeableViews> </div> - <ul className='media-modal__pagination'> - {pagination} - </ul> - - {rightNav} + <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> </div> ); } diff --git a/app/javascript/flavours/glitch/features/ui/components/video_modal.js b/app/javascript/flavours/glitch/features/ui/components/video_modal.js index 4412fd0f7..e0cb7fc09 100644 --- a/app/javascript/flavours/glitch/features/ui/components/video_modal.js +++ b/app/javascript/flavours/glitch/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/flavours/glitch/features/ui/components/zoomable_image.js b/app/javascript/flavours/glitch/features/ui/components/zoomable_image.js new file mode 100644 index 000000000..0a0a4d41a --- /dev/null +++ b/app/javascript/flavours/glitch/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> + ); + } + +} |