mirror of
https://github.com/vector-im/element-web.git
synced 2024-11-16 13:14:58 +08:00
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:
commit
7bd3535b9f
@ -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;
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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%;
|
||||
@ -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 {
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
@ -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
|
||||
@ -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 {
|
||||
@ -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,
|
||||
};
|
||||
@ -98,6 +125,9 @@ 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
|
||||
@ -105,15 +135,37 @@ export default class ImageView extends React.Component<IProps, IState> {
|
||||
// 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();
|
||||
};
|
||||
@ -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";
|
||||
@ -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})
|
||||
@ -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}
|
||||
/>
|
||||
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
@ -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';
|
||||
@ -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) {
|
||||
@ -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"],
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user