diff --git a/res/css/_animations.scss b/res/css/_animations.scss index 4d3ad971413..26252fcaf61 100644 --- a/res/css/_animations.scss +++ b/res/css/_animations.scss @@ -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; } diff --git a/res/css/_common.scss b/res/css/_common.scss index a16e7d4d8f8..d7f8355d81a 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -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 { diff --git a/res/css/views/elements/_ImageView.scss b/res/css/views/elements/_ImageView.scss index cf92ffec643..787d33ddc22 100644 --- a/res/css/views/elements/_ImageView.scss +++ b/res/css/views/elements/_ImageView.scss @@ -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; + } +} diff --git a/src/components/views/elements/ImageView.tsx b/src/components/views/elements/ImageView.tsx index 7a1efb7a627..44ff6644d75 100644 --- a/src/components/views/elements/ImageView.tsx +++ b/src/components/views/elements/ImageView.tsx @@ -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 { 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 { 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 { // 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 { 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 { // 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 { style={style} alt={this.props.name} ref={this.image} - className="mx_ImageView_image" + className={`mx_ImageView_image ${transitionClassName}`} draggable={true} onMouseDown={this.onStartMoving} /> diff --git a/src/components/views/messages/MImageBody.tsx b/src/components/views/messages/MImageBody.tsx index cb52155f426..01bbf3403f1 100644 --- a/src/components/views/messages/MImageBody.tsx +++ b/src/components/views/messages/MImageBody.tsx @@ -117,6 +117,17 @@ export default class MImageBody extends React.Component { 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); } }; diff --git a/src/components/views/rooms/LinkPreviewWidget.tsx b/src/components/views/rooms/LinkPreviewWidget.tsx index 55e123f4e02..5e7154dc8ac 100644 --- a/src/components/views/rooms/LinkPreviewWidget.tsx +++ b/src/components/views/rooms/LinkPreviewWidget.tsx @@ -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 { private readonly description = createRef(); + private image = createRef(); componentDidMount() { if (this.description.current) { @@ -59,7 +60,7 @@ export default class LinkPreviewWidget extends React.Component { src = mediaFromMxc(src).srcHttp; } - const params = { + const params: Omit, "onFinished"> = { src: src, width: p["og:image:width"], height: p["og:image:height"], @@ -68,6 +69,17 @@ export default class LinkPreviewWidget extends React.Component { 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 { let img; if (image) { img =
- +
; }