/* Copyright 2024 New Vector Ltd. Copyright 2020, 2021 Šimon Brandner Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> Copyright 2015, 2016 OpenMarket Ltd SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE files in the repository root for full details. */ import React, { type JSX, createRef, type CSSProperties, useRef, useState } from "react"; import FocusLock from "react-focus-lock"; import { type MatrixEvent, parseErrorResponse } from "matrix-js-sdk/src/matrix"; import { _t } from "../../../languageHandler"; import MemberAvatar from "../avatars/MemberAvatar"; import { ContextMenuTooltipButton } from "../../../accessibility/context_menu/ContextMenuTooltipButton"; import MessageContextMenu from "../context_menus/MessageContextMenu"; import { aboveLeftOf } from "../../structures/ContextMenu"; import MessageTimestamp from "../messages/MessageTimestamp"; import SettingsStore from "../../../settings/SettingsStore"; import { formatFullDate } from "../../../DateUtils"; import dis from "../../../dispatcher/dispatcher"; import { Action } from "../../../dispatcher/actions"; import { type RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import { normalizeWheelEvent } from "../../../utils/Mouse"; import UIStore from "../../../stores/UIStore"; import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import { presentableTextForFile } from "../../../utils/FileUtils"; import AccessibleButton from "./AccessibleButton"; import Modal from "../../../Modal"; import ErrorDialog from "../dialogs/ErrorDialog"; import { FileDownloader } from "../../../utils/FileDownloader"; // Max scale to keep gaps around the image const MAX_SCALE = 0.95; // This is used for the buttons const ZOOM_STEP = 0.1; // This is used for mouse wheel events 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 { src: string; // the source of the image being displayed name?: string; // the main title ('name') for the image link?: string; // the link (if any) applied to the name of the image width?: number; // width of the image src in pixels height?: number; // height of the image src in pixels fileSize?: number; // size of the image src in bytes // the event (if any) that the Image is displaying. Used for event-specific stuff like // 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; thumbnailInfo?: { positionX: number; positionY: number; width: number; height: number; }; onFinished(): void; } interface IState { zoom: number; minZoom: number; maxZoom: number; rotation: number; translationX: number; translationY: number; moving: boolean; contextMenuDisplayed: boolean; } export default class ImageView extends React.Component { public constructor(props: IProps) { super(props); const { thumbnailInfo } = this.props; let translationX = 0; let translationY = 0; if (thumbnailInfo) { translationX = thumbnailInfo.positionX + thumbnailInfo.width / 2 - UIStore.instance.windowWidth / 2; translationY = thumbnailInfo.positionY + thumbnailInfo.height / 2 - UIStore.instance.windowHeight / 2 - getPanelHeight() / 2; } this.state = { zoom: 0, // We default to 0 and override this in imageLoaded once we have naturalSize minZoom: MAX_SCALE, maxZoom: MAX_SCALE, rotation: 0, translationX, translationY, moving: false, contextMenuDisplayed: false, }; } // XXX: Refs to functional components private contextMenuButton = createRef(); private focusLock = createRef(); private imageWrapper = createRef(); private image = createRef(); private initX = 0; private initY = 0; private previousX = 0; private previousY = 0; private animatingLoading = false; private imageIsLoaded = false; public componentDidMount(): void { // 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.imageLoaded); } public componentWillUnmount(): void { this.focusLock.current.removeEventListener("wheel", this.onWheel); window.removeEventListener("resize", this.recalculateZoom); this.image.current?.removeEventListener("load", this.imageLoaded); } private imageLoaded = (): void => { if (!this.image.current) return; // 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 = (): void => { this.setZoomAndRotation(); }; private setZoomAndRotation = (inputRotation?: number): void => { const image = this.image.current; const imageWrapper = this.imageWrapper.current; if (!image || !imageWrapper) return; const rotation = inputRotation ?? this.state.rotation; const imageIsNotFlipped = rotation % 180 === 0; // If the image is rotated take it into account const width = imageIsNotFlipped ? image.naturalWidth : image.naturalHeight; const height = imageIsNotFlipped ? image.naturalHeight : image.naturalWidth; const zoomX = imageWrapper.clientWidth / width; const zoomY = imageWrapper.clientHeight / height; // If the image is smaller in both dimensions set its the zoom to 1 to // display it in its original size if (zoomX >= 1 && zoomY >= 1) { this.setState({ zoom: 1, minZoom: 1, maxZoom: 1, rotation: rotation, }); return; } // We set minZoom to the min of the zoomX and zoomY to avoid overflow in // any direction. We also multiply by MAX_SCALE to get a gap around the // image by default const minZoom = Math.min(zoomX, zoomY) * MAX_SCALE; // If zoom is smaller than minZoom don't go below that value const zoom = this.state.zoom <= this.state.minZoom ? minZoom : this.state.zoom; this.setState({ minZoom: minZoom, maxZoom: 1, rotation: rotation, zoom: zoom, }); }; private zoomDelta(delta: number, anchorX?: number, anchorY?: number): void { this.zoom(this.state.zoom + delta, anchorX, anchorY); } private zoom(zoomLevel: number, anchorX?: number, anchorY?: number): void { const oldZoom = this.state.zoom; const maxZoom = this.state.maxZoom === this.state.minZoom ? 2 * this.state.maxZoom : this.state.maxZoom; const newZoom = Math.min(zoomLevel, maxZoom); if (newZoom <= this.state.minZoom) { // Zoom out fully this.setState({ zoom: this.state.minZoom, translationX: 0, translationY: 0, }); } else if (typeof anchorX !== "number" || typeof anchorY !== "number") { // Zoom relative to the center of the view this.setState({ zoom: newZoom, translationX: (this.state.translationX * newZoom) / oldZoom, translationY: (this.state.translationY * newZoom) / oldZoom, }); } else if (this.image.current) { // Zoom relative to the given point on the image. // First we need to figure out the offset of the anchor point // relative to the center of the image, accounting for rotation. let offsetX: number; let offsetY: number; // The modulo operator can return negative values for some // rotations, so we have to do some extra work to normalize it const rotation = (((this.state.rotation % 360) + 360) % 360) as 0 | 90 | 180 | 270; switch (rotation) { case 0: offsetX = this.image.current.clientWidth / 2 - anchorX; offsetY = this.image.current.clientHeight / 2 - anchorY; break; case 90: offsetX = anchorY - this.image.current.clientHeight / 2; offsetY = this.image.current.clientWidth / 2 - anchorX; break; case 180: offsetX = anchorX - this.image.current.clientWidth / 2; offsetY = anchorY - this.image.current.clientHeight / 2; break; case 270: offsetX = this.image.current.clientHeight / 2 - anchorY; offsetY = anchorX - this.image.current.clientWidth / 2; } // Apply the zoom and offset this.setState({ zoom: newZoom, translationX: this.state.translationX + (newZoom - oldZoom) * offsetX, translationY: this.state.translationY + (newZoom - oldZoom) * offsetY, }); } } private onWheel = (ev: WheelEvent): void => { if (ev.target === this.image.current) { ev.stopPropagation(); ev.preventDefault(); const { deltaY } = normalizeWheelEvent(ev); // Zoom in on the point on the image targeted by the cursor this.zoomDelta(-deltaY * ZOOM_COEFFICIENT, ev.offsetX, ev.offsetY); } }; private onZoomInClick = (): void => { this.zoomDelta(ZOOM_STEP); }; private onZoomOutClick = (): void => { this.zoomDelta(-ZOOM_STEP); }; private onKeyDown = (ev: KeyboardEvent): void => { const action = getKeyBindingsManager().getAccessibilityAction(ev); switch (action) { case KeyBindingAction.Escape: ev.stopPropagation(); ev.preventDefault(); this.props.onFinished(); break; } }; private onRotateCounterClockwiseClick = (): void => { const cur = this.state.rotation; this.setZoomAndRotation(cur - 90); }; private onRotateClockwiseClick = (): void => { const cur = this.state.rotation; this.setZoomAndRotation(cur + 90); }; private onOpenContextMenu = (): void => { this.setState({ contextMenuDisplayed: true, }); }; private onCloseContextMenu = (): void => { this.setState({ contextMenuDisplayed: false, }); }; private onPermalinkClicked = (ev: React.MouseEvent): void => { // This allows the permalink to be opened in a new tab/window or copied as // matrix.to, but also for it to enable routing within Element when clicked. ev.preventDefault(); dis.dispatch({ action: Action.ViewRoom, event_id: this.props.mxEvent?.getId(), highlighted: true, room_id: this.props.mxEvent?.getRoomId(), metricsTrigger: undefined, // room doesn't change }); this.props.onFinished(); }; private onStartMoving = (ev: React.MouseEvent): void => { ev.stopPropagation(); ev.preventDefault(); // Don't do anything if we pressed any // other button than the left one if (ev.button !== 0) return; // Zoom in if we are completely zoomed out and increase the zoom factor for images // smaller than the viewport size if (this.state.zoom === this.state.minZoom) { this.zoom( this.state.maxZoom === this.state.minZoom ? 2 * this.state.maxZoom : this.state.maxZoom, ev.nativeEvent.offsetX, ev.nativeEvent.offsetY, ); return; } this.setState({ moving: true }); this.previousX = this.state.translationX; this.previousY = this.state.translationY; this.initX = ev.pageX - this.state.translationX; this.initY = ev.pageY - this.state.translationY; }; private onMoving = (ev: React.MouseEvent): void => { ev.stopPropagation(); ev.preventDefault(); if (!this.state.moving) return; this.setState({ translationX: ev.pageX - this.initX, translationY: ev.pageY - this.initY, }); }; private onEndMoving = (): void => { // Zoom out if we haven't moved much if ( this.state.moving && Math.abs(this.state.translationX - this.previousX) < ZOOM_DISTANCE && Math.abs(this.state.translationY - this.previousY) < ZOOM_DISTANCE ) { this.zoom(this.state.minZoom); this.initX = 0; this.initY = 0; } this.setState({ moving: false }); }; private renderContextMenu(): JSX.Element { let contextMenu: JSX.Element | undefined; if (this.state.contextMenuDisplayed && this.props.mxEvent) { contextMenu = ( ); } return {contextMenu}; } public render(): React.ReactNode { const showEventMeta = !!this.props.mxEvent; let transitionClassName; if (this.animatingLoading) transitionClassName = "mx_ImageView_image_animatingLoading"; else if (this.state.moving || !this.imageIsLoaded) transitionClassName = ""; else transitionClassName = "mx_ImageView_image_animating"; const rotationDegrees = this.state.rotation + "deg"; const zoom = this.state.zoom; const translatePixelsX = this.state.translationX + "px"; const translatePixelsY = this.state.translationY + "px"; // The order of the values is important! // First, we translate and only then we rotate, otherwise // we would apply the translation to an already rotated // image causing it translate in the wrong direction. const style: CSSProperties = { transform: `translateX(${translatePixelsX}) translateY(${translatePixelsY}) scale(${zoom}) rotate(${rotationDegrees})`, }; if (this.state.moving) style.cursor = "grabbing"; else if (this.state.zoom === this.state.minZoom) style.cursor = "zoom-in"; else style.cursor = "zoom-out"; let info: JSX.Element | undefined; if (showEventMeta) { const mxEvent = this.props.mxEvent!; const showTwelveHour = SettingsStore.getValue("showTwelveHourTimestamps"); let permalink = "#"; if (this.props.permalinkCreator) { permalink = this.props.permalinkCreator.forEvent(mxEvent.getId()!); } const senderName = mxEvent.sender?.name ?? mxEvent.getSender(); const sender =
{senderName}
; const messageTimestamp = ( ); const avatar = ( ); info = (
{avatar}
{sender} {messageTimestamp}
); } else { // If there is no event - we're viewing an avatar, we set // an empty div here, since the panel uses space-between // and we want the same placement of elements info =
; } let contextMenuButton: JSX.Element | undefined; if (this.props.mxEvent) { contextMenuButton = ( ); } const zoomOutButton = ( ); const zoomInButton = ( ); let title: JSX.Element | undefined; if (this.props.mxEvent?.getContent()) { title = (
{presentableTextForFile(this.props.mxEvent?.getContent(), _t("common|image"), true)}
); } return (
{info} {title}
{zoomOutButton} {zoomInButton} {contextMenuButton} {this.renderContextMenu()}
{this.props.name}
); } } function DownloadButton({ url, fileName }: { url: string; fileName?: string }): JSX.Element { const downloader = useRef(new FileDownloader()).current; const [loading, setLoading] = useState(false); const blobRef = useRef(); function showError(e: unknown): void { Modal.createDialog(ErrorDialog, { title: _t("timeline|download_failed"), description: ( <>
{_t("timeline|download_failed_description")}
{e instanceof Error ? e.toString() : ""}
), }); setLoading(false); } const onDownloadClick = async (): Promise => { try { if (loading) return; setLoading(true); if (blobRef.current) { // Cheat and trigger a download, again. return downloadBlob(blobRef.current); } const res = await fetch(url); if (!res.ok) { throw parseErrorResponse(res, await res.text()); } const blob = await res.blob(); blobRef.current = blob; await downloadBlob(blob); } catch (e) { showError(e); } }; async function downloadBlob(blob: Blob): Promise { await downloader.download({ blob, name: fileName ?? _t("common|image"), }); setLoading(false); } return ( ); }