Merge pull request #6454 from SimonBrandner/feature/image-view-load-anim/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 <simon.bra.ag@gmail.com>
This commit is contained in:
Dariusz Niemczyk 2021-09-21 18:05:13 +02:00 committed by GitHub
commit 7bd3535b9f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 149 additions and 23 deletions

View File

@ -34,18 +34,43 @@ limitations under the License.
transition: opacity 300ms ease; transition: opacity 300ms ease;
} }
@keyframes mx--anim-pulse { @keyframes mx--anim-pulse {
0% { opacity: 1; } 0% { opacity: 1; }
50% { opacity: 0.7; } 50% { opacity: 0.7; }
100% { opacity: 1; } 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) { @media (prefers-reduced-motion) {
@keyframes mx--anim-pulse { @keyframes mx--anim-pulse {
// Override all keyframes in reduced-motion // 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 { .mx_rtg--fade-enter-active {
transition: none; transition: none;
} }

View File

@ -318,6 +318,8 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus {
.mx_Dialog_lightbox .mx_Dialog_background { .mx_Dialog_lightbox .mx_Dialog_background {
opacity: $lightbox-background-bg-opacity; opacity: $lightbox-background-bg-opacity;
background-color: $lightbox-background-bg-color; background-color: $lightbox-background-bg-color;
animation-name: mx_Dialog_lightbox_background_keyframes;
animation-duration: 300ms;
} }
.mx_Dialog_lightbox .mx_Dialog { .mx_Dialog_lightbox .mx_Dialog {

View File

@ -18,6 +18,10 @@ $button-size: 32px;
$icon-size: 22px; $icon-size: 22px;
$button-gap: 24px; $button-gap: 24px;
:root {
--image-view-panel-height: 68px;
}
.mx_ImageView { .mx_ImageView {
display: flex; display: flex;
width: 100%; width: 100%;
@ -36,14 +40,24 @@ $button-gap: 24px;
.mx_ImageView_image { .mx_ImageView_image {
flex-shrink: 0; flex-shrink: 0;
&.mx_ImageView_image_animating {
transition: transform 200ms ease 0s;
}
&.mx_ImageView_image_animatingLoading {
transition: transform 300ms ease 0s;
}
} }
.mx_ImageView_panel { .mx_ImageView_panel {
width: 100%; width: 100%;
height: 68px; height: var(--image-view-panel-height);
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
animation-name: mx_ImageView_panel_keyframes;
animation-duration: 300ms;
} }
.mx_ImageView_info_wrapper { .mx_ImageView_info_wrapper {
@ -124,3 +138,13 @@ $button-gap: 24px;
mask-size: 40%; mask-size: 40%;
} }
} }
@media (prefers-reduced-motion) {
.mx_ImageView_image_animating {
transition: none !important;
}
.mx_ImageView_image_animatingLoading {
transition: none !important;
}
}

View File

@ -34,6 +34,7 @@ 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";
import { IDialogProps } from '../dialogs/IDialogProps'; import { IDialogProps } from '../dialogs/IDialogProps';
import UIStore from '../../../stores/UIStore';
// Max scale to keep gaps around the image // Max scale to keep gaps around the image
const MAX_SCALE = 0.95; const MAX_SCALE = 0.95;
@ -44,6 +45,13 @@ 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;
// 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 { interface IProps extends IDialogProps {
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
@ -56,8 +64,15 @@ interface IProps extends IDialogProps {
// redactions, senders, timestamps etc. Other descriptors are taken from the explicit // 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 // properties above, which let us use lightboxes to display images which aren't associated
// with events. // with events.
mxEvent: MatrixEvent; mxEvent?: MatrixEvent;
permalinkCreator: RoomPermalinkCreator; permalinkCreator?: RoomPermalinkCreator;
thumbnailInfo?: {
positionX: number;
positionY: number;
width: number;
height: number;
};
} }
interface IState { interface IState {
@ -75,13 +90,25 @@ interface IState {
export default class ImageView extends React.Component<IProps, IState> { export default class ImageView extends React.Component<IProps, IState> {
constructor(props) { constructor(props) {
super(props); super(props);
const { thumbnailInfo } = this.props;
this.state = { this.state = {
zoom: 0, zoom: 0, // We default to 0 and override this in imageLoaded once we have naturalSize
minZoom: MAX_SCALE, minZoom: MAX_SCALE,
maxZoom: MAX_SCALE, maxZoom: MAX_SCALE,
rotation: 0, rotation: 0,
translationX: 0, translationX: (
translationY: 0, 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, moving: false,
contextMenuDisplayed: false, contextMenuDisplayed: false,
}; };
@ -98,6 +125,9 @@ export default class ImageView extends React.Component<IProps, IState> {
private previousX = 0; private previousX = 0;
private previousY = 0; private previousY = 0;
private animatingLoading = false;
private imageIsLoaded = false;
componentDidMount() { componentDidMount() {
// 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
@ -105,15 +135,37 @@ export default class ImageView extends React.Component<IProps, IState> {
// We want to recalculate zoom whenever the window's size changes // We want to recalculate zoom whenever the window's size changes
window.addEventListener("resize", this.recalculateZoom); window.addEventListener("resize", this.recalculateZoom);
// After the image loads for the first time we want to calculate the zoom // 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() { componentWillUnmount() {
this.focusLock.current.removeEventListener('wheel', this.onWheel); this.focusLock.current.removeEventListener('wheel', this.onWheel);
window.removeEventListener("resize", this.recalculateZoom); 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 = () => { private recalculateZoom = () => {
this.setZoomAndRotation(); this.setZoomAndRotation();
}; };
@ -360,16 +412,17 @@ export default class ImageView extends React.Component<IProps, IState> {
const showEventMeta = !!this.props.mxEvent; const showEventMeta = !!this.props.mxEvent;
const zoomingDisabled = this.state.maxZoom === this.state.minZoom; 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; let cursor;
if (this.state.moving) { if (this.state.moving) cursor = "grabbing";
cursor= "grabbing"; else if (zoomingDisabled) cursor = "default";
} else if (zoomingDisabled) { else if (this.state.zoom === this.state.minZoom) cursor = "zoom-in";
cursor = "default"; else cursor = "zoom-out";
} else if (this.state.zoom === this.state.minZoom) {
cursor = "zoom-in";
} else {
cursor = "zoom-out";
}
const rotationDegrees = this.state.rotation + "deg"; const rotationDegrees = this.state.rotation + "deg";
const zoom = this.state.zoom; const zoom = this.state.zoom;
const translatePixelsX = this.state.translationX + "px"; const translatePixelsX = this.state.translationX + "px";
@ -380,7 +433,6 @@ export default class ImageView extends React.Component<IProps, IState> {
// image causing it translate in the wrong direction. // image causing it translate in the wrong direction.
const style = { const style = {
cursor: cursor, cursor: cursor,
transition: this.state.moving ? null : "transform 200ms ease 0s",
transform: `translateX(${translatePixelsX}) transform: `translateX(${translatePixelsX})
translateY(${translatePixelsY}) translateY(${translatePixelsY})
scale(${zoom}) scale(${zoom})
@ -528,7 +580,7 @@ export default class ImageView extends React.Component<IProps, IState> {
style={style} style={style}
alt={this.props.name} alt={this.props.name}
ref={this.image} ref={this.image}
className="mx_ImageView_image" className={`mx_ImageView_image ${transitionClassName}`}
draggable={true} draggable={true}
onMouseDown={this.onStartMoving} onMouseDown={this.onStartMoving}
/> />

View File

@ -117,6 +117,17 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
params.fileSize = content.info.size; 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); Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", null, true);
} }
}; };

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { createRef } from 'react'; import React, { ComponentProps, createRef } from 'react';
import { AllHtmlEntities } from 'html-entities'; import { AllHtmlEntities } from 'html-entities';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { IPreviewUrlResponse } from 'matrix-js-sdk/src/client'; import { IPreviewUrlResponse } from 'matrix-js-sdk/src/client';
@ -36,6 +36,7 @@ interface IProps {
@replaceableComponent("views.rooms.LinkPreviewWidget") @replaceableComponent("views.rooms.LinkPreviewWidget")
export default class LinkPreviewWidget extends React.Component<IProps> { export default class LinkPreviewWidget extends React.Component<IProps> {
private readonly description = createRef<HTMLDivElement>(); private readonly description = createRef<HTMLDivElement>();
private image = createRef<HTMLImageElement>();
componentDidMount() { componentDidMount() {
if (this.description.current) { if (this.description.current) {
@ -59,7 +60,7 @@ export default class LinkPreviewWidget extends React.Component<IProps> {
src = mediaFromMxc(src).srcHttp; src = mediaFromMxc(src).srcHttp;
} }
const params = { const params: Omit<ComponentProps<typeof ImageView>, "onFinished"> = {
src: src, src: src,
width: p["og:image:width"], width: p["og:image:width"],
height: p["og:image:height"], height: p["og:image:height"],
@ -68,6 +69,17 @@ export default class LinkPreviewWidget extends React.Component<IProps> {
link: this.props.link, 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); Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", null, true);
}; };
@ -100,7 +112,7 @@ export default class LinkPreviewWidget extends React.Component<IProps> {
let img; let img;
if (image) { if (image) {
img = <div className="mx_LinkPreviewWidget_image" style={{ height: thumbHeight }}> 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>; </div>;
} }