Merge pull request #5916 from SimonBrandner/image-view-zoom
Dynamic max and min zoom in the new ImageView
This commit is contained in:
commit
9401a6d6dc
3 changed files with 124 additions and 78 deletions
|
@ -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 {
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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",
|
||||||
|
|
Loading…
Reference in a new issue