Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

A nicer opening animation for the Image View #6454

Merged
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions res/css/_common.scss
Original file line number Diff line number Diff line change
Expand Up @@ -315,9 +315,20 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus {
opacity: 0.4;
}

@keyframes mx_Dialog_lightbox_background_keyframes {
SimonBrandner marked this conversation as resolved.
Show resolved Hide resolved
from {
opacity: 0;
}
to {
opacity: $lightbox-background-bg-opacity;
}
}

.mx_Dialog_lightbox .mx_Dialog_background {
opacity: $lightbox-background-bg-opacity;
background-color: $lightbox-background-bg-color;
animation-name: mx_Dialog_lightbox_background_keyframes;
SimonBrandner marked this conversation as resolved.
Show resolved Hide resolved
animation-duration: 300ms;
}

.mx_Dialog_lightbox .mx_Dialog {
Expand Down
11 changes: 11 additions & 0 deletions res/css/views/elements/_ImageView.scss
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,23 @@ $button-gap: 24px;
flex-shrink: 0;
}

@keyframes mx_ImageView_panel_keyframes {
SimonBrandner marked this conversation as resolved.
Show resolved Hide resolved
from {
opacity: 0;
}
to {
opacity: 1;
}
}

.mx_ImageView_panel {
width: 100%;
height: 68px;
display: flex;
justify-content: space-between;
align-items: center;
animation-name: mx_ImageView_panel_keyframes;
SimonBrandner marked this conversation as resolved.
Show resolved Hide resolved
animation-duration: 300ms;
}

.mx_ImageView_info_wrapper {
Expand Down
82 changes: 65 additions & 17 deletions src/components/views/elements/ImageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { normalizeWheelEvent } from "../../../utils/Mouse";
import { IDialogProps } from '../dialogs/IDialogProps';
import UIStore from '../../../stores/UIStore';

// Max scale to keep gaps around the image
const MAX_SCALE = 0.95;
Expand All @@ -44,6 +45,8 @@ const ZOOM_COEFFICIENT = 0.0025;
// If we have moved only this much we can zoom
const ZOOM_DISTANCE = 10;

const PANEL_HEIGHT = 68;
SimonBrandner marked this conversation as resolved.
Show resolved Hide resolved

interface IProps extends IDialogProps {
src: string; // the source of the image being displayed
name?: string; // the main title ('name') for the image
Expand All @@ -56,8 +59,15 @@ interface IProps extends IDialogProps {
// redactions, senders, timestamps etc. Other descriptors are taken from the explicit
// properties above, which let us use lightboxes to display images which aren't associated
// with events.
mxEvent: MatrixEvent;
permalinkCreator: RoomPermalinkCreator;
mxEvent?: MatrixEvent;
permalinkCreator?: RoomPermalinkCreator;

thumbnailInfo?: {
positionX: number;
positionY: number;
width: number;
height: number;
};
}

interface IState {
Expand All @@ -75,13 +85,25 @@ interface IState {
export default class ImageView extends React.Component<IProps, IState> {
constructor(props) {
super(props);

const { thumbnailInfo } = this.props;

this.state = {
zoom: 0,
zoom: 0, // We default to 0 and override this in imageLoaded once we have naturalSize
minZoom: MAX_SCALE,
maxZoom: MAX_SCALE,
rotation: 0,
translationX: 0,
translationY: 0,
translationX: (
thumbnailInfo?.positionX +
(thumbnailInfo?.width / 2) -
(UIStore.instance.windowWidth / 2)
) ?? 0,
translationY: (
thumbnailInfo?.positionY +
(thumbnailInfo?.height / 2) -
(UIStore.instance.windowHeight / 2) -
(PANEL_HEIGHT / 2)
) ?? 0,
moving: false,
contextMenuDisplayed: false,
};
Expand All @@ -98,22 +120,47 @@ export default class ImageView extends React.Component<IProps, IState> {
private previousX = 0;
private previousY = 0;

private animatingLoading = false;
private imageIsLoaded = false;

componentDidMount() {
// We have to use addEventListener() because the listener
// needs to be passive in order to work with Chromium
this.focusLock.current.addEventListener('wheel', this.onWheel, { passive: false });
// We want to recalculate zoom whenever the window's size changes
window.addEventListener("resize", this.recalculateZoom);
// After the image loads for the first time we want to calculate the zoom
this.image.current.addEventListener("load", this.recalculateZoom);
this.image.current.addEventListener("load", this.imageLoaded);
}

componentWillUnmount() {
this.focusLock.current.removeEventListener('wheel', this.onWheel);
window.removeEventListener("resize", this.recalculateZoom);
this.image.current.removeEventListener("load", this.recalculateZoom);
this.image.current.removeEventListener("load", this.imageLoaded);
}

private imageLoaded = async () => {
// First, we calculate the zoom, so that the image has the same size as
// the thumbnail
const { thumbnailInfo } = this.props;
if (thumbnailInfo?.width) {
await this.setState({ zoom: thumbnailInfo.width / this.image.current.naturalWidth });
}

// Once the zoom is set, we the image is considered loaded and we can
// start animating it into the center of the screen
this.imageIsLoaded = true;
this.animatingLoading = true;
this.setZoomAndRotation();
await this.setState({
SimonBrandner marked this conversation as resolved.
Show resolved Hide resolved
translationX: 0,
translationY: 0,
});

// Once the position is set, there is no need to animate anymore
this.animatingLoading = false;
};

private recalculateZoom = () => {
this.setZoomAndRotation();
};
Expand Down Expand Up @@ -360,16 +407,17 @@ export default class ImageView extends React.Component<IProps, IState> {
const showEventMeta = !!this.props.mxEvent;
const zoomingDisabled = this.state.maxZoom === this.state.minZoom;

let transition;
if (this.animatingLoading) transition = "transform 300ms ease 0s";
SimonBrandner marked this conversation as resolved.
Show resolved Hide resolved
SimonBrandner marked this conversation as resolved.
Show resolved Hide resolved
else if (this.state.moving || !this.imageIsLoaded) transition = null;
else transition = "transform 200ms ease 0s";

let cursor;
if (this.state.moving) {
cursor= "grabbing";
} else if (zoomingDisabled) {
cursor = "default";
} else if (this.state.zoom === this.state.minZoom) {
cursor = "zoom-in";
} else {
cursor = "zoom-out";
}
if (this.state.moving) cursor = "grabbing";
else if (zoomingDisabled) cursor = "default";
else if (this.state.zoom === this.state.minZoom) cursor = "zoom-in";
else cursor = "zoom-out";

const rotationDegrees = this.state.rotation + "deg";
const zoom = this.state.zoom;
const translatePixelsX = this.state.translationX + "px";
Expand All @@ -380,7 +428,7 @@ export default class ImageView extends React.Component<IProps, IState> {
// image causing it translate in the wrong direction.
const style = {
cursor: cursor,
transition: this.state.moving ? null : "transform 200ms ease 0s",
transition: transition,
transform: `translateX(${translatePixelsX})
translateY(${translatePixelsY})
scale(${zoom})
Expand Down
11 changes: 11 additions & 0 deletions src/components/views/messages/MImageBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,17 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
params.fileSize = content.info.size;
}

if (this.image.current) {
const clientRect = this.image.current.getBoundingClientRect();
SimonBrandner marked this conversation as resolved.
Show resolved Hide resolved

params.thumbnailInfo = {
width: clientRect.width,
height: clientRect.height,
positionX: clientRect.x,
positionY: clientRect.y,
};
}

Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", null, true);
}
};
Expand Down
18 changes: 15 additions & 3 deletions src/components/views/rooms/LinkPreviewWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

import React, { createRef } from 'react';
import React, { ComponentProps, createRef } from 'react';
import { AllHtmlEntities } from 'html-entities';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { IPreviewUrlResponse } from 'matrix-js-sdk/src/client';
Expand All @@ -36,6 +36,7 @@ interface IProps {
@replaceableComponent("views.rooms.LinkPreviewWidget")
export default class LinkPreviewWidget extends React.Component<IProps> {
private readonly description = createRef<HTMLDivElement>();
private image = createRef<HTMLImageElement>();

componentDidMount() {
if (this.description.current) {
Expand All @@ -59,7 +60,7 @@ export default class LinkPreviewWidget extends React.Component<IProps> {
src = mediaFromMxc(src).srcHttp;
}

const params = {
const params: Omit<ComponentProps<typeof ImageView>, "onFinished"> = {
src: src,
width: p["og:image:width"],
height: p["og:image:height"],
Expand All @@ -68,6 +69,17 @@ export default class LinkPreviewWidget extends React.Component<IProps> {
link: this.props.link,
};

if (this.image.current) {
const clientRect = this.image.current.getBoundingClientRect();
SimonBrandner marked this conversation as resolved.
Show resolved Hide resolved

params.thumbnailInfo = {
width: clientRect.width,
height: clientRect.height,
positionX: clientRect.x,
positionY: clientRect.y,
};
}

Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", null, true);
};

Expand Down Expand Up @@ -100,7 +112,7 @@ export default class LinkPreviewWidget extends React.Component<IProps> {
let img;
if (image) {
img = <div className="mx_LinkPreviewWidget_image" style={{ height: thumbHeight }}>
<img style={{ maxWidth: imageMaxWidth, maxHeight: imageMaxHeight }} src={image} onClick={this.onImageClick} />
<img ref={this.image} style={{ maxWidth: imageMaxWidth, maxHeight: imageMaxHeight }} src={image} onClick={this.onImageClick} />
</div>;
}

Expand Down