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;
|
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;
|
||||||
}
|
}
|
||||||
|
@ -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 {
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user