[Glitch] Add expand/compress image button on image view box

Port 6a2db10f76 to glitch-soc

Signed-off-by: Thibaut Girka <thib@sitedethib.com>
This commit is contained in:
Mashiro 2020-11-03 04:16:38 +08:00 committed by Thibaut Girka
parent f5df3d1ae2
commit 40af1cf65b
5 changed files with 335 additions and 21 deletions

View File

@ -13,6 +13,7 @@ export default class ImageLoader extends React.PureComponent {
width: PropTypes.number, width: PropTypes.number,
height: PropTypes.number, height: PropTypes.number,
onClick: PropTypes.func, onClick: PropTypes.func,
zoomButtonHidden: PropTypes.bool,
} }
static defaultProps = { static defaultProps = {
@ -151,6 +152,9 @@ export default class ImageLoader extends React.PureComponent {
alt={alt} alt={alt}
src={src} src={src}
onClick={onClick} onClick={onClick}
width={width}
height={height}
zoomButtonHidden={this.props.zoomButtonHidden}
/> />
)} )}
</div> </div>

View File

@ -35,23 +35,39 @@ class MediaModal extends ImmutablePureComponent {
state = { state = {
index: null, index: null,
navigationHidden: false, navigationHidden: false,
zoomButtonHidden: false,
}; };
handleSwipe = (index) => { handleSwipe = (index) => {
this.setState({ index: index % this.props.media.size }); this.setState({ index: index % this.props.media.size });
} }
handleTransitionEnd = () => {
this.setState({
zoomButtonHidden: false,
});
}
handleNextClick = () => { handleNextClick = () => {
this.setState({ index: (this.getIndex() + 1) % this.props.media.size }); this.setState({
index: (this.getIndex() + 1) % this.props.media.size,
zoomButtonHidden: true,
});
} }
handlePrevClick = () => { handlePrevClick = () => {
this.setState({ index: (this.props.media.size + this.getIndex() - 1) % this.props.media.size }); this.setState({
index: (this.props.media.size + this.getIndex() - 1) % this.props.media.size,
zoomButtonHidden: true,
});
} }
handleChangeIndex = (e) => { handleChangeIndex = (e) => {
const index = Number(e.currentTarget.getAttribute('data-index')); const index = Number(e.currentTarget.getAttribute('data-index'));
this.setState({ index: index % this.props.media.size }); this.setState({
index: index % this.props.media.size,
zoomButtonHidden: true,
});
} }
handleKeyDown = (e) => { handleKeyDown = (e) => {
@ -128,6 +144,7 @@ class MediaModal extends ImmutablePureComponent {
alt={image.get('description')} alt={image.get('description')}
key={image.get('url')} key={image.get('url')}
onClick={this.toggleNavigation} onClick={this.toggleNavigation}
zoomButtonHidden={this.state.zoomButtonHidden}
/> />
); );
} else if (image.get('type') === 'video') { } else if (image.get('type') === 'video') {
@ -191,6 +208,7 @@ class MediaModal extends ImmutablePureComponent {
style={swipeableViewsStyle} style={swipeableViewsStyle}
containerStyle={containerStyle} containerStyle={containerStyle}
onChangeIndex={this.handleSwipe} onChangeIndex={this.handleSwipe}
onTransitionEnd={this.handleTransitionEnd}
index={index} index={index}
> >
{content} {content}

View File

@ -1,8 +1,16 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import IconButton from 'flavours/glitch/components/icon_button';
import { defineMessages, injectIntl } from 'react-intl';
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 MIN_SCALE = 1;
const MAX_SCALE = 4; const MAX_SCALE = 4;
const NAV_BAR_HEIGHT = 66;
const getMidpoint = (p1, p2) => ({ const getMidpoint = (p1, p2) => ({
x: (p1.clientX + p2.clientX) / 2, x: (p1.clientX + p2.clientX) / 2,
@ -14,7 +22,77 @@ const getDistance = (p1, p2) =>
const clamp = (min, max, value) => Math.min(max, Math.max(min, value)); const clamp = (min, max, value) => Math.min(max, Math.max(min, value));
export default class ZoomableImage extends React.PureComponent { // Normalizing mousewheel speed across browsers
// copy from: https://github.com/facebookarchive/fixed-data-table/blob/master/src/vendor_upstream/dom/normalizeWheel.js
const normalizeWheel = event => {
// Reasonable defaults
const PIXEL_STEP = 10;
const LINE_HEIGHT = 40;
const PAGE_HEIGHT = 800;
let sX = 0,
sY = 0, // spinX, spinY
pX = 0,
pY = 0; // pixelX, pixelY
// Legacy
if ('detail' in event) {
sY = event.detail;
}
if ('wheelDelta' in event) {
sY = -event.wheelDelta / 120;
}
if ('wheelDeltaY' in event) {
sY = -event.wheelDeltaY / 120;
}
if ('wheelDeltaX' in event) {
sX = -event.wheelDeltaX / 120;
}
// side scrolling on FF with DOMMouseScroll
if ('axis' in event && event.axis === event.HORIZONTAL_AXIS) {
sX = sY;
sY = 0;
}
pX = sX * PIXEL_STEP;
pY = sY * PIXEL_STEP;
if ('deltaY' in event) {
pY = event.deltaY;
}
if ('deltaX' in event) {
pX = event.deltaX;
}
if ((pX || pY) && event.deltaMode) {
if (event.deltaMode === 1) { // delta in LINE units
pX *= LINE_HEIGHT;
pY *= LINE_HEIGHT;
} else { // delta in PAGE units
pX *= PAGE_HEIGHT;
pY *= PAGE_HEIGHT;
}
}
// Fall-back if spin cannot be determined
if (pX && !sX) {
sX = (pX < 1) ? -1 : 1;
}
if (pY && !sY) {
sY = (pY < 1) ? -1 : 1;
}
return {
spinX: sX,
spinY: sY,
pixelX: pX,
pixelY: pY,
};
};
export default @injectIntl
class ZoomableImage extends React.PureComponent {
static propTypes = { static propTypes = {
alt: PropTypes.string, alt: PropTypes.string,
@ -22,6 +100,8 @@ export default class ZoomableImage extends React.PureComponent {
width: PropTypes.number, width: PropTypes.number,
height: PropTypes.number, height: PropTypes.number,
onClick: PropTypes.func, onClick: PropTypes.func,
zoomButtonHidden: PropTypes.bool,
intl: PropTypes.object.isRequired,
} }
static defaultProps = { static defaultProps = {
@ -32,6 +112,22 @@ export default class ZoomableImage extends React.PureComponent {
state = { state = {
scale: MIN_SCALE, scale: MIN_SCALE,
zoomMatrix: {
type: null, // 'full-width' 'full-height'
rate: null, // full screen scale rate
clientWidth: null,
clientHeight: null,
offsetWidth: null,
offsetHeight: null,
clientHeightFixed: null,
scrollTop: null,
scrollLeft: null,
},
zoomState: 'expand', // 'expand' 'compress'
navigationHidden: false,
dragPosition: { top: 0, left: 0, x: 0, y: 0 },
dragged: false,
lockScroll: { x: 0, y: 0 },
} }
removers = []; removers = [];
@ -49,17 +145,101 @@ export default class ZoomableImage extends React.PureComponent {
// https://www.chromestatus.com/features/5093566007214080 // https://www.chromestatus.com/features/5093566007214080
this.container.addEventListener('touchmove', handler, { passive: false }); this.container.addEventListener('touchmove', handler, { passive: false });
this.removers.push(() => this.container.removeEventListener('touchend', handler)); this.removers.push(() => this.container.removeEventListener('touchend', handler));
handler = this.mouseDownHandler;
this.container.addEventListener('mousedown', handler);
this.removers.push(() => this.container.removeEventListener('mousedown', handler));
handler = this.mouseWheelHandler;
this.container.addEventListener('wheel', handler);
this.removers.push(() => this.container.removeEventListener('wheel', handler));
// Old Chrome
this.container.addEventListener('mousewheel', handler);
this.removers.push(() => this.container.removeEventListener('mousewheel', handler));
// Old Firefox
this.container.addEventListener('DOMMouseScroll', handler);
this.removers.push(() => this.container.removeEventListener('DOMMouseScroll', handler));
this.initZoomMatrix();
} }
componentWillUnmount () { componentWillUnmount () {
this.removeEventListeners(); this.removeEventListeners();
} }
componentDidUpdate () {
if (this.props.zoomButtonHidden) {
this.setState({ scale: MIN_SCALE }, () => {
this.container.scrollLeft = 0;
this.container.scrollTop = 0;
});
}
this.setState({ zoomState: this.state.scale >= this.state.zoomMatrix.rate ? 'compress' : 'expand' });
if (this.state.scale === 1) {
this.container.style.removeProperty('cursor');
}
}
removeEventListeners () { removeEventListeners () {
this.removers.forEach(listeners => listeners()); this.removers.forEach(listeners => listeners());
this.removers = []; this.removers = [];
} }
mouseWheelHandler = e => {
e.preventDefault();
const event = normalizeWheel(e);
if (this.state.zoomMatrix.type === 'full-width') {
// full width, scroll vertical
this.container.scrollTop = this.container.scrollTop + event.pixelY;
} else {
// full height, scroll horizontal
this.container.scrollLeft = this.container.scrollLeft + event.pixelY;
}
}
mouseDownHandler = e => {
this.container.style.cursor = 'grabbing';
this.container.style.userSelect = 'none';
this.setState({ dragPosition: {
left: this.container.scrollLeft,
top: this.container.scrollTop,
// Get the current mouse position
x: e.clientX,
y: e.clientY,
} });
this.image.addEventListener('mousemove', this.mouseMoveHandler);
this.image.addEventListener('mouseup', this.mouseUpHandler);
}
mouseMoveHandler = e => {
const dx = e.clientX - this.state.dragPosition.x;
const dy = e.clientY - this.state.dragPosition.y;
if ((this.state.dragPosition.left - dx) >= this.state.lockScroll.x) {
this.container.scrollLeft = this.state.dragPosition.left - dx;
}
if ((this.state.dragPosition.top - dy) >= this.state.lockScroll.y) {
this.container.scrollTop = this.state.dragPosition.top - dy;
}
this.setState({ dragged: true });
}
mouseUpHandler = () => {
this.container.style.cursor = 'grab';
this.container.style.removeProperty('user-select');
this.image.removeEventListener('mousemove', this.mouseMoveHandler);
this.image.removeEventListener('mouseup', this.mouseUpHandler);
}
handleTouchStart = e => { handleTouchStart = e => {
if (e.touches.length !== 2) return; if (e.touches.length !== 2) return;
@ -80,7 +260,8 @@ export default class ZoomableImage extends React.PureComponent {
const distance = getDistance(...e.touches); const distance = getDistance(...e.touches);
const midpoint = getMidpoint(...e.touches); const midpoint = getMidpoint(...e.touches);
const scale = clamp(MIN_SCALE, MAX_SCALE, this.state.scale * distance / this.lastDistance); 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);
@ -110,8 +291,72 @@ export default class ZoomableImage extends React.PureComponent {
handleClick = e => { handleClick = e => {
// don't propagate event to MediaModal // don't propagate event to MediaModal
e.stopPropagation(); e.stopPropagation();
const dragged = this.state.dragged;
this.setState({ dragged: false });
if (dragged) return;
const handler = this.props.onClick; const handler = this.props.onClick;
if (handler) handler(); if (handler) handler();
this.setState({ navigationHidden: !this.state.navigationHidden });
}
handleMouseDown = e => {
e.preventDefault();
}
initZoomMatrix = () => {
const { width, height } = this.props;
const { clientWidth, clientHeight } = this.container;
const { offsetWidth, offsetHeight } = this.image;
const clientHeightFixed = clientHeight - NAV_BAR_HEIGHT;
const type = width/height < clientWidth / clientHeightFixed ? 'full-width' : 'full-height';
const rate = type === 'full-width' ? clientWidth / offsetWidth : clientHeightFixed / offsetHeight;
const scrollTop = type === 'full-width' ? (clientHeight - offsetHeight) / 2 - NAV_BAR_HEIGHT : (clientHeightFixed - offsetHeight) / 2;
const scrollLeft = (clientWidth - offsetWidth) / 2;
this.setState({
zoomMatrix: {
type: type,
rate: rate,
clientWidth: clientWidth,
clientHeight: clientHeight,
offsetWidth: offsetWidth,
offsetHeight: offsetHeight,
clientHeightFixed: clientHeightFixed,
scrollTop: scrollTop,
scrollLeft: scrollLeft,
},
});
}
handleZoomClick = e => {
e.preventDefault();
e.stopPropagation();
const { scale, zoomMatrix } = this.state;
if ( scale >= zoomMatrix.rate ) {
this.setState({ scale: MIN_SCALE }, () => {
this.container.scrollLeft = 0;
this.container.scrollTop = 0;
this.setState({ lockScroll: {
x: 0,
y: 0,
} });
});
} else {
this.setState({ scale: zoomMatrix.rate }, () => {
this.container.scrollLeft = zoomMatrix.scrollLeft;
this.container.scrollTop = zoomMatrix.scrollTop;
this.setState({ lockScroll: {
x: zoomMatrix.scrollLeft,
y: zoomMatrix.scrollTop,
} });
});
}
this.container.style.cursor = 'grab';
this.container.style.removeProperty('user-select');
} }
setContainerRef = c => { setContainerRef = c => {
@ -123,29 +368,47 @@ export default class ZoomableImage extends React.PureComponent {
} }
render () { render () {
const { alt, src } = this.props; const { alt, src, width, height, intl } = this.props;
const { scale } = this.state; const { scale } = this.state;
const overflow = scale === 1 ? 'hidden' : 'scroll'; const overflow = scale === 1 ? 'hidden' : 'scroll';
const zoomButtonSshouldHide = !this.state.navigationHidden && !this.props.zoomButtonHidden ? '' : 'media-modal__zoom-button--hidden';
const zoomButtonTitle = this.state.zoomState === 'compress' ? intl.formatMessage(messages.compress) : intl.formatMessage(messages.expand);
return ( return (
<div <React.Fragment>
className='zoomable-image' <IconButton
ref={this.setContainerRef} className={`media-modal__zoom-button ${zoomButtonSshouldHide}`}
style={{ overflow }} title={zoomButtonTitle}
> icon={this.state.zoomState}
<img onClick={this.handleZoomClick}
role='presentation' size={40}
ref={this.setImageRef}
alt={alt}
title={alt}
src={src}
style={{ style={{
transform: `scale(${scale})`, fontSize: '30px', /* Fontawesome's fa-compress fa-expand is larger than fa-close */
transformOrigin: '0 0',
}} }}
onClick={this.handleClick}
/> />
</div> <div
className='zoomable-image'
ref={this.setContainerRef}
style={{ overflow }}
>
<img
role='presentation'
ref={this.setImageRef}
alt={alt}
title={alt}
src={src}
width={width}
height={height}
style={{
transform: `scale(${scale})`,
transformOrigin: '0 0',
}}
draggable={false}
onClick={this.handleClick}
onMouseDown={this.handleMouseDown}
/>
</div>
</React.Fragment>
); );
} }

View File

@ -425,6 +425,20 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-direction: column; flex-direction: column;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE 10+ */
* {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE 10+ */
}
&::-webkit-scrollbar,
*::-webkit-scrollbar {
width: 0;
height: 0;
background: transparent; /* Chrome/Safari/Webkit */
}
.image-loader__preview-canvas { .image-loader__preview-canvas {
max-width: $media-modal-media-max-width; max-width: $media-modal-media-max-width;

View File

@ -40,6 +40,21 @@
z-index: 9999; z-index: 9999;
} }
.media-modal__zoom-button {
position: absolute;
right: 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, .onboarding-modal,
.error-modal, .error-modal,
.embed-modal { .embed-modal {