Merge pull request #5916 from SimonBrandner/image-view-zoom

Dynamic max and min zoom in the new ImageView
This commit is contained in:
J. Ryan Stinnett 2021-04-26 15:46:26 +01:00 committed by GitHub
commit 9401a6d6dc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 124 additions and 78 deletions

View file

@ -31,8 +31,7 @@ limitations under the License.
.mx_ImageView_image { .mx_ImageView_image {
pointer-events: all; pointer-events: all;
max-width: 95%; flex-shrink: 0;
max-height: 95%;
} }
.mx_ImageView_panel { .mx_ImageView_panel {

View file

@ -34,16 +34,15 @@ import {RoomPermalinkCreator} from "../../../utils/permalinks/Permalinks"
import {MatrixEvent} from "matrix-js-sdk/src/models/event"; import {MatrixEvent} from "matrix-js-sdk/src/models/event";
import {normalizeWheelEvent} from "../../../utils/Mouse"; import {normalizeWheelEvent} from "../../../utils/Mouse";
const MIN_ZOOM = 100; // Max scale to keep gaps around the image
const MAX_ZOOM = 300; const MAX_SCALE = 0.95;
// This is used for the buttons // This is used for the buttons
const ZOOM_STEP = 10; const ZOOM_STEP = 0.10;
// This is used for mouse wheel events // This is used for mouse wheel events
const ZOOM_COEFFICIENT = 0.5; const ZOOM_COEFFICIENT = 0.0025;
// If we have moved only this much we can zoom // If we have moved only this much we can zoom
const ZOOM_DISTANCE = 10; const ZOOM_DISTANCE = 10;
interface IProps { interface IProps {
src: string, // the source of the image being displayed src: string, // the source of the image being displayed
name?: string, // the main title ('name') for the image name?: string, // the main title ('name') for the image
@ -62,8 +61,10 @@ interface IProps {
} }
interface IState { interface IState {
rotation: number,
zoom: number, zoom: number,
minZoom: number,
maxZoom: number,
rotation: number,
translationX: number, translationX: number,
translationY: number, translationY: number,
moving: boolean, moving: boolean,
@ -75,8 +76,10 @@ export default class ImageView extends React.Component<IProps, IState> {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
zoom: 0,
minZoom: MAX_SCALE,
maxZoom: MAX_SCALE,
rotation: 0, rotation: 0,
zoom: MIN_ZOOM,
translationX: 0, translationX: 0,
translationY: 0, translationY: 0,
moving: false, moving: false,
@ -87,6 +90,8 @@ export default class ImageView extends React.Component<IProps, IState> {
// XXX: Refs to functional components // XXX: Refs to functional components
private contextMenuButton = createRef<any>(); private contextMenuButton = createRef<any>();
private focusLock = createRef<any>(); private focusLock = createRef<any>();
private imageWrapper = createRef<HTMLDivElement>();
private image = createRef<HTMLImageElement>();
private initX = 0; private initX = 0;
private initY = 0; private initY = 0;
@ -99,12 +104,87 @@ export default class ImageView extends React.Component<IProps, IState> {
// We have to use addEventListener() because the listener // We have to use addEventListener() because the listener
// needs to be passive in order to work with Chromium // needs to be passive in order to work with Chromium
this.focusLock.current.addEventListener('wheel', this.onWheel, { passive: false }); this.focusLock.current.addEventListener('wheel', this.onWheel, { passive: false });
// We want to recalculate zoom whenever the window's size changes
window.addEventListener("resize", this.calculateZoom);
// After the image loads for the first time we want to calculate the zoom
this.image.current.addEventListener("load", this.calculateZoom);
// Try to precalculate the zoom from width and height props
this.calculateZoom();
} }
componentWillUnmount() { componentWillUnmount() {
this.focusLock.current.removeEventListener('wheel', this.onWheel); this.focusLock.current.removeEventListener('wheel', this.onWheel);
} }
private calculateZoom = () => {
const image = this.image.current;
const imageWrapper = this.imageWrapper.current;
const width = this.props.width || image.naturalWidth;
const height = this.props.height || image.naturalHeight;
const zoomX = imageWrapper.clientWidth / width;
const zoomY = imageWrapper.clientHeight / height;
// If the image is smaller in both dimensions set its the zoom to 1 to
// display it in its original size
if (zoomX >= 1 && zoomY >= 1) {
this.setState({
zoom: 1,
minZoom: 1,
maxZoom: 1,
});
return;
}
// We set minZoom to the min of the zoomX and zoomY to avoid overflow in
// any direction. We also multiply by MAX_SCALE to get a gap around the
// image by default
const minZoom = Math.min(zoomX, zoomY) * MAX_SCALE;
if (this.state.zoom <= this.state.minZoom) this.setState({zoom: minZoom});
this.setState({
minZoom: minZoom,
maxZoom: 1,
});
}
private zoom(delta: number) {
const newZoom = this.state.zoom + delta;
if (newZoom <= this.state.minZoom) {
this.setState({
zoom: this.state.minZoom,
translationX: 0,
translationY: 0,
});
return;
}
if (newZoom >= this.state.maxZoom) {
this.setState({zoom: this.state.maxZoom});
return;
}
this.setState({
zoom: newZoom,
});
}
private onWheel = (ev: WheelEvent) => {
ev.stopPropagation();
ev.preventDefault();
const {deltaY} = normalizeWheelEvent(ev);
this.zoom(-(deltaY * ZOOM_COEFFICIENT));
};
private onZoomInClick = () => {
this.zoom(ZOOM_STEP);
};
private onZoomOutClick = () => {
this.zoom(-ZOOM_STEP);
};
private onKeyDown = (ev: KeyboardEvent) => { private onKeyDown = (ev: KeyboardEvent) => {
if (ev.key === Key.ESCAPE) { if (ev.key === Key.ESCAPE) {
ev.stopPropagation(); ev.stopPropagation();
@ -113,31 +193,6 @@ export default class ImageView extends React.Component<IProps, IState> {
} }
}; };
private onWheel = (ev: WheelEvent) => {
ev.stopPropagation();
ev.preventDefault();
const {deltaY} = normalizeWheelEvent(ev);
const newZoom = this.state.zoom - (deltaY * ZOOM_COEFFICIENT);
if (newZoom <= MIN_ZOOM) {
this.setState({
zoom: MIN_ZOOM,
translationX: 0,
translationY: 0,
});
return;
}
if (newZoom >= MAX_ZOOM) {
this.setState({zoom: MAX_ZOOM});
return;
}
this.setState({
zoom: newZoom,
});
};
private onRotateCounterClockwiseClick = () => { private onRotateCounterClockwiseClick = () => {
const cur = this.state.rotation; const cur = this.state.rotation;
const rotationDegrees = cur - 90; const rotationDegrees = cur - 90;
@ -150,31 +205,6 @@ export default class ImageView extends React.Component<IProps, IState> {
this.setState({ rotation: rotationDegrees }); this.setState({ rotation: rotationDegrees });
}; };
private onZoomInClick = () => {
if (this.state.zoom >= MAX_ZOOM) {
this.setState({zoom: MAX_ZOOM});
return;
}
this.setState({
zoom: this.state.zoom + ZOOM_STEP,
});
};
private onZoomOutClick = () => {
if (this.state.zoom <= MIN_ZOOM) {
this.setState({
zoom: MIN_ZOOM,
translationX: 0,
translationY: 0,
});
return;
}
this.setState({
zoom: this.state.zoom - ZOOM_STEP,
});
};
private onDownloadClick = () => { private onDownloadClick = () => {
const a = document.createElement("a"); const a = document.createElement("a");
a.href = this.props.src; a.href = this.props.src;
@ -217,8 +247,8 @@ export default class ImageView extends React.Component<IProps, IState> {
if (ev.button !== 0) return; if (ev.button !== 0) return;
// Zoom in if we are completely zoomed out // Zoom in if we are completely zoomed out
if (this.state.zoom === MIN_ZOOM) { if (this.state.zoom === this.state.minZoom) {
this.setState({zoom: MAX_ZOOM}); this.setState({zoom: this.state.maxZoom});
return; return;
} }
@ -251,7 +281,7 @@ export default class ImageView extends React.Component<IProps, IState> {
Math.abs(this.state.translationY - this.previousY) < ZOOM_DISTANCE Math.abs(this.state.translationY - this.previousY) < ZOOM_DISTANCE
) { ) {
this.setState({ this.setState({
zoom: MIN_ZOOM, zoom: this.state.minZoom,
translationX: 0, translationX: 0,
translationY: 0, translationY: 0,
}); });
@ -286,17 +316,20 @@ export default class ImageView extends React.Component<IProps, IState> {
render() { render() {
const showEventMeta = !!this.props.mxEvent; const showEventMeta = !!this.props.mxEvent;
const zoomingDisabled = this.state.maxZoom === this.state.minZoom;
let cursor; let cursor;
if (this.state.moving) { if (this.state.moving) {
cursor= "grabbing"; cursor= "grabbing";
} else if (this.state.zoom === MIN_ZOOM) { } else if (zoomingDisabled) {
cursor = "default";
} else if (this.state.zoom === this.state.minZoom) {
cursor = "zoom-in"; cursor = "zoom-in";
} else { } else {
cursor = "zoom-out"; cursor = "zoom-out";
} }
const rotationDegrees = this.state.rotation + "deg"; const rotationDegrees = this.state.rotation + "deg";
const zoomPercentage = this.state.zoom/100; const zoom = this.state.zoom;
const translatePixelsX = this.state.translationX + "px"; const translatePixelsX = this.state.translationX + "px";
const translatePixelsY = this.state.translationY + "px"; const translatePixelsY = this.state.translationY + "px";
// The order of the values is important! // The order of the values is important!
@ -308,7 +341,7 @@ export default class ImageView extends React.Component<IProps, IState> {
transition: this.state.moving ? null : "transform 200ms ease 0s", transition: this.state.moving ? null : "transform 200ms ease 0s",
transform: `translateX(${translatePixelsX}) transform: `translateX(${translatePixelsX})
translateY(${translatePixelsY}) translateY(${translatePixelsY})
scale(${zoomPercentage}) scale(${zoom})
rotate(${rotationDegrees})`, rotate(${rotationDegrees})`,
}; };
@ -380,6 +413,25 @@ export default class ImageView extends React.Component<IProps, IState> {
); );
} }
let zoomOutButton;
let zoomInButton;
if (!zoomingDisabled) {
zoomOutButton = (
<AccessibleTooltipButton
className="mx_ImageView_button mx_ImageView_button_zoomOut"
title={_t("Zoom out")}
onClick={this.onZoomOutClick}>
</AccessibleTooltipButton>
);
zoomInButton = (
<AccessibleTooltipButton
className="mx_ImageView_button mx_ImageView_button_zoomIn"
title={_t("Zoom in")}
onClick={ this.onZoomInClick }>
</AccessibleTooltipButton>
);
}
return ( return (
<FocusLock <FocusLock
returnFocus={true} returnFocus={true}
@ -403,16 +455,8 @@ export default class ImageView extends React.Component<IProps, IState> {
title={_t("Rotate Left")} title={_t("Rotate Left")}
onClick={ this.onRotateCounterClockwiseClick }> onClick={ this.onRotateCounterClockwiseClick }>
</AccessibleTooltipButton> </AccessibleTooltipButton>
<AccessibleTooltipButton {zoomOutButton}
className="mx_ImageView_button mx_ImageView_button_zoomOut" {zoomInButton}
title={_t("Zoom out")}
onClick={ this.onZoomOutClick }>
</AccessibleTooltipButton>
<AccessibleTooltipButton
className="mx_ImageView_button mx_ImageView_button_zoomIn"
title={_t("Zoom in")}
onClick={ this.onZoomInClick }>
</AccessibleTooltipButton>
<AccessibleTooltipButton <AccessibleTooltipButton
className="mx_ImageView_button mx_ImageView_button_download" className="mx_ImageView_button mx_ImageView_button_download"
title={_t("Download")} title={_t("Download")}
@ -427,11 +471,14 @@ export default class ImageView extends React.Component<IProps, IState> {
{this.renderContextMenu()} {this.renderContextMenu()}
</div> </div>
</div> </div>
<div className="mx_ImageView_image_wrapper"> <div
className="mx_ImageView_image_wrapper"
ref={this.imageWrapper}>
<img <img
src={this.props.src} src={this.props.src}
title={this.props.name} title={this.props.name}
style={style} style={style}
ref={this.image}
className="mx_ImageView_image" className="mx_ImageView_image"
draggable={true} draggable={true}
onMouseDown={this.onStartMoving} onMouseDown={this.onStartMoving}

View file

@ -1924,10 +1924,10 @@
"%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s", "%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s",
"%(count)s people you know have already joined|other": "%(count)s people you know have already joined", "%(count)s people you know have already joined|other": "%(count)s people you know have already joined",
"%(count)s people you know have already joined|one": "%(count)s person you know has already joined", "%(count)s people you know have already joined|one": "%(count)s person you know has already joined",
"Rotate Right": "Rotate Right",
"Rotate Left": "Rotate Left",
"Zoom out": "Zoom out", "Zoom out": "Zoom out",
"Zoom in": "Zoom in", "Zoom in": "Zoom in",
"Rotate Right": "Rotate Right",
"Rotate Left": "Rotate Left",
"Download": "Download", "Download": "Download",
"Information": "Information", "Information": "Information",
"View message": "View message", "View message": "View message",