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 all 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
27 changes: 26 additions & 1 deletion res/css/_animations.scss
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,43 @@ limitations under the License.
transition: opacity 300ms ease;
}


@keyframes mx--anim-pulse {
0% { opacity: 1; }
50% { opacity: 0.7; }
100% { opacity: 1; }
}

@keyframes mx_Dialog_lightbox_background_keyframes {
from {
opacity: 0;
}
to {
opacity: $lightbox-background-bg-opacity;
}
}

@keyframes mx_ImageView_panel_keyframes {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

@media (prefers-reduced-motion) {
@keyframes mx--anim-pulse {
// Override all keyframes in reduced-motion
}

@keyframes mx_Dialog_lightbox_background_keyframes {
// Override all keyframes in reduced-motion
}

@keyframes mx_ImageView_panel_keyframes {
// Override all keyframes in reduced-motion
}

.mx_rtg--fade-enter-active {
transition: none;
}
Expand Down
2 changes: 2 additions & 0 deletions res/css/_common.scss
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,8 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus {
.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
26 changes: 25 additions & 1 deletion res/css/views/elements/_ImageView.scss
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ $button-size: 32px;
$icon-size: 22px;
$button-gap: 24px;

:root {
--image-view-panel-height: 68px;
}

.mx_ImageView {
display: flex;
width: 100%;
Expand All @@ -36,14 +40,24 @@ $button-gap: 24px;

.mx_ImageView_image {
flex-shrink: 0;

&.mx_ImageView_image_animating {
transition: transform 200ms ease 0s;
}

&.mx_ImageView_image_animatingLoading {
transition: transform 300ms ease 0s;
}
}

.mx_ImageView_panel {
width: 100%;
height: 68px;
height: var(--image-view-panel-height);
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 Expand Up @@ -124,3 +138,13 @@ $button-gap: 24px;
mask-size: 40%;
}
}

@media (prefers-reduced-motion) {
.mx_ImageView_image_animating {
transition: none !important;
}

.mx_ImageView_image_animatingLoading {
transition: none !important;
}
}
88 changes: 70 additions & 18 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,13 @@ const ZOOM_COEFFICIENT = 0.0025;
// If we have moved only this much we can zoom
const ZOOM_DISTANCE = 10;

// Height of mx_ImageView_panel
const getPanelHeight = (): number => {
const value = getComputedStyle(document.documentElement).getPropertyValue("--image-view-panel-height");
// Return the value as a number without the unit
return parseInt(value.slice(0, value.length - 2));
};

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 +64,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 +90,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) -
(getPanelHeight() / 2)
) ?? 0,
moving: false,
contextMenuDisplayed: false,
};
Expand All @@ -98,22 +125,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 = () => {
// First, we calculate the zoom, so that the image has the same size as
// the thumbnail
const { thumbnailInfo } = this.props;
if (thumbnailInfo?.width) {
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();
this.setState({
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 +412,17 @@ export default class ImageView extends React.Component<IProps, IState> {
const showEventMeta = !!this.props.mxEvent;
const zoomingDisabled = this.state.maxZoom === this.state.minZoom;

let transitionClassName;
if (this.animatingLoading) transitionClassName = "mx_ImageView_image_animatingLoading";
else if (this.state.moving || !this.imageIsLoaded) transitionClassName = "";
else transitionClassName = "mx_ImageView_image_animating";

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 +433,6 @@ 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",
transform: `translateX(${translatePixelsX})
translateY(${translatePixelsY})
scale(${zoom})
Expand Down Expand Up @@ -528,7 +580,7 @@ export default class ImageView extends React.Component<IProps, IState> {
style={style}
alt={this.props.name}
ref={this.image}
className="mx_ImageView_image"
className={`mx_ImageView_image ${transitionClassName}`}
draggable={true}
onMouseDown={this.onStartMoving}
/>
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