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

Commit

Permalink
Merge pull request #6454 from SimonBrandner/feature/image-view-load-a…
Browse files Browse the repository at this point in the history
…nim/18186

* Give lightbox a background load animation
* Extends IMediaEventContent by thumbnail info
* Give image view panel a loading animation
* Initial implementation of loading animation
* Take panel height into account
* Update animation speed
* Add some null guards
* Fix animation issues
* Move animations into _animations
* Where does that magic number come from?
* Remove awaiting setState()
* Use CSS var in JS
* Handle prefers-reduced-motion
* More prefers-reduced-motion friendliness

Signed-off-by: Šimon Brandner <[email protected]>
  • Loading branch information
Palid authored Sep 21, 2021
2 parents 2d1d42b + b2c0f57 commit 7bd3535
Show file tree
Hide file tree
Showing 6 changed files with 149 additions and 23 deletions.
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;
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;
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();

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();

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

0 comments on commit 7bd3535

Please sign in to comment.