mirror of
https://github.com/vector-im/element-web.git
synced 2025-08-06 06:17:03 +02:00
Enhancement: Save image on CTRL+S (#30330)
* Save image on CTRL+S * fixed cosmetic comments * fixed test * refactored out downloading functionality from buttons to useDownloadMedia hook * ImageView CTRL+S use button component * added CTRL+S test & lint * removed forwardRef * fix lint * i18n
This commit is contained in:
parent
3e11a62a3f
commit
77cb4b3157
@ -19,3 +19,6 @@ include:
|
||||
|
||||
* Thom Cleary (https://github.com/thomcatdotrocks)
|
||||
Small update for tarball deployment
|
||||
|
||||
* Alexander (https://github.com/ioalexander)
|
||||
Save image on CTRL + S shortcut
|
||||
|
@ -146,6 +146,7 @@ export enum KeyBindingAction {
|
||||
ArrowDown = "KeyBinding.arrowDown",
|
||||
Tab = "KeyBinding.tab",
|
||||
Comma = "KeyBinding.comma",
|
||||
Save = "KeyBinding.save",
|
||||
|
||||
/** Toggle visibility of hidden events */
|
||||
ToggleHiddenEventVisibility = "KeyBinding.toggleHiddenEventVisibility",
|
||||
@ -269,6 +270,7 @@ export const CATEGORIES: Record<CategoryName, ICategory> = {
|
||||
KeyBindingAction.ArrowRight,
|
||||
KeyBindingAction.ArrowDown,
|
||||
KeyBindingAction.Comma,
|
||||
KeyBindingAction.Save,
|
||||
],
|
||||
},
|
||||
[CategoryName.NAVIGATION]: {
|
||||
@ -621,6 +623,13 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = {
|
||||
},
|
||||
displayName: _td("keyboard|composer_redo"),
|
||||
},
|
||||
[KeyBindingAction.Save]: {
|
||||
default: {
|
||||
key: Key.S,
|
||||
ctrlOrCmdKey: true,
|
||||
},
|
||||
displayName: _td("keyboard|save"),
|
||||
},
|
||||
[KeyBindingAction.PreviousVisitedRoomOrSpace]: {
|
||||
default: {
|
||||
metaKey: IS_MAC,
|
||||
|
@ -8,10 +8,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX, createRef, type CSSProperties, useRef, useState, useMemo, useEffect } from "react";
|
||||
import React, { type JSX, createRef, type CSSProperties, useEffect } from "react";
|
||||
import FocusLock from "react-focus-lock";
|
||||
import { type MatrixEvent, parseErrorResponse } from "matrix-js-sdk/src/matrix";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import MemberAvatar from "../avatars/MemberAvatar";
|
||||
@ -31,11 +30,7 @@ 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";
|
||||
import { MediaEventHelper } from "../../../utils/MediaEventHelper.ts";
|
||||
import ModuleApi from "../../../modules/Api";
|
||||
import { useDownloadMedia } from "../../../hooks/useDownloadMedia.ts";
|
||||
|
||||
// Max scale to keep gaps around the image
|
||||
const MAX_SCALE = 0.95;
|
||||
@ -123,6 +118,8 @@ export default class ImageView extends React.Component<IProps, IState> {
|
||||
private imageWrapper = createRef<HTMLDivElement>();
|
||||
private image = createRef<HTMLImageElement>();
|
||||
|
||||
private downloadFunction?: () => Promise<void>;
|
||||
|
||||
private initX = 0;
|
||||
private initY = 0;
|
||||
private previousX = 0;
|
||||
@ -302,6 +299,13 @@ export default class ImageView extends React.Component<IProps, IState> {
|
||||
ev.preventDefault();
|
||||
this.props.onFinished();
|
||||
break;
|
||||
case KeyBindingAction.Save:
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
if (this.downloadFunction) {
|
||||
this.downloadFunction();
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
@ -327,6 +331,10 @@ export default class ImageView extends React.Component<IProps, IState> {
|
||||
});
|
||||
};
|
||||
|
||||
private onDownloadFunctionReady = (download: () => Promise<void>): void => {
|
||||
this.downloadFunction = download;
|
||||
};
|
||||
|
||||
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.
|
||||
@ -552,7 +560,12 @@ export default class ImageView extends React.Component<IProps, IState> {
|
||||
title={_t("lightbox|rotate_right")}
|
||||
onClick={this.onRotateClockwiseClick}
|
||||
/>
|
||||
<DownloadButton url={this.props.src} fileName={this.props.name} mxEvent={this.props.mxEvent} />
|
||||
<DownloadButton
|
||||
url={this.props.src}
|
||||
fileName={this.props.name}
|
||||
mxEvent={this.props.mxEvent}
|
||||
onDownloadReady={this.onDownloadFunctionReady}
|
||||
/>
|
||||
{contextMenuButton}
|
||||
<AccessibleButton
|
||||
className="mx_ImageView_button mx_ImageView_button_close"
|
||||
@ -585,99 +598,28 @@ export default class ImageView extends React.Component<IProps, IState> {
|
||||
}
|
||||
}
|
||||
|
||||
function DownloadButton({
|
||||
url,
|
||||
fileName,
|
||||
mxEvent,
|
||||
}: {
|
||||
interface DownloadButtonProps {
|
||||
url: string;
|
||||
fileName?: string;
|
||||
mxEvent?: MatrixEvent;
|
||||
}): JSX.Element | null {
|
||||
const downloader = useRef(new FileDownloader()).current;
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [canDownload, setCanDownload] = useState<boolean>(false);
|
||||
const blobRef = useRef<Blob>(undefined);
|
||||
const mediaEventHelper = useMemo(() => (mxEvent ? new MediaEventHelper(mxEvent) : undefined), [mxEvent]);
|
||||
onDownloadReady?: (download: () => Promise<void>) => void;
|
||||
}
|
||||
|
||||
export const DownloadButton: React.FC<DownloadButtonProps> = ({ url, fileName, mxEvent, onDownloadReady }) => {
|
||||
const { download, loading, canDownload } = useDownloadMedia(url, fileName, mxEvent);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mxEvent) {
|
||||
// If we have no event, we assume this is safe to download.
|
||||
setCanDownload(true);
|
||||
return;
|
||||
}
|
||||
const hints = ModuleApi.customComponents.getHintsForMessage(mxEvent);
|
||||
if (hints?.allowDownloadingMedia) {
|
||||
// Disable downloading as soon as we know there is a hint.
|
||||
setCanDownload(false);
|
||||
hints
|
||||
.allowDownloadingMedia()
|
||||
.then((downloadable) => {
|
||||
setCanDownload(downloadable);
|
||||
})
|
||||
.catch((ex) => {
|
||||
logger.error(`Failed to check if media from ${mxEvent.getId()} could be downloaded`, ex);
|
||||
// Err on the side of safety.
|
||||
setCanDownload(false);
|
||||
});
|
||||
} else {
|
||||
setCanDownload(true);
|
||||
}
|
||||
}, [mxEvent]);
|
||||
if (onDownloadReady) onDownloadReady(download);
|
||||
}, [download, onDownloadReady]);
|
||||
|
||||
function showError(e: unknown): void {
|
||||
Modal.createDialog(ErrorDialog, {
|
||||
title: _t("timeline|download_failed"),
|
||||
description: (
|
||||
<>
|
||||
<div>{_t("timeline|download_failed_description")}</div>
|
||||
<div>{e instanceof Error ? e.toString() : ""}</div>
|
||||
</>
|
||||
),
|
||||
});
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
const onDownloadClick = async (): Promise<void> => {
|
||||
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<void> {
|
||||
await downloader.download({
|
||||
blob,
|
||||
name: mediaEventHelper?.fileName ?? fileName ?? _t("common|image"),
|
||||
});
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
if (!canDownload) {
|
||||
return null;
|
||||
}
|
||||
if (!canDownload) return null;
|
||||
|
||||
return (
|
||||
<AccessibleButton
|
||||
className="mx_ImageView_button mx_ImageView_button_download"
|
||||
title={loading ? _t("timeline|download_action_downloading") : _t("action|download")}
|
||||
onClick={onDownloadClick}
|
||||
onClick={download}
|
||||
disabled={loading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
@ -7,19 +7,15 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
import React, { type JSX } from "react";
|
||||
import React, { type ReactElement, useMemo } from "react";
|
||||
import classNames from "classnames";
|
||||
import { DownloadIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { type MediaEventHelper } from "../../../utils/MediaEventHelper";
|
||||
import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex";
|
||||
import Spinner from "../elements/Spinner";
|
||||
import { _t, _td, type TranslationKey } from "../../../languageHandler";
|
||||
import { FileDownloader } from "../../../utils/FileDownloader";
|
||||
import Modal from "../../../Modal";
|
||||
import ErrorDialog from "../dialogs/ErrorDialog";
|
||||
import ModuleApi from "../../../modules/Api";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { useDownloadMedia } from "../../../hooks/useDownloadMedia";
|
||||
|
||||
interface IProps {
|
||||
mxEvent: MatrixEvent;
|
||||
@ -30,121 +26,32 @@ interface IProps {
|
||||
mediaEventHelperGet: () => MediaEventHelper | undefined;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
canDownload: null | boolean;
|
||||
loading: boolean;
|
||||
blob?: Blob;
|
||||
tooltip: TranslationKey;
|
||||
}
|
||||
|
||||
export default class DownloadActionButton extends React.PureComponent<IProps, IState> {
|
||||
private downloader = new FileDownloader();
|
||||
|
||||
public constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
const moduleHints = ModuleApi.customComponents.getHintsForMessage(props.mxEvent);
|
||||
const downloadState: Pick<IState, "canDownload"> = { canDownload: true };
|
||||
if (moduleHints?.allowDownloadingMedia) {
|
||||
downloadState.canDownload = null;
|
||||
moduleHints
|
||||
.allowDownloadingMedia()
|
||||
.then((canDownload) => {
|
||||
this.setState({
|
||||
canDownload: canDownload,
|
||||
});
|
||||
})
|
||||
.catch((ex) => {
|
||||
logger.error(`Failed to check if media from ${props.mxEvent.getId()} could be downloaded`, ex);
|
||||
this.setState({
|
||||
canDownload: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
this.state = {
|
||||
loading: false,
|
||||
tooltip: _td("timeline|download_action_downloading"),
|
||||
...downloadState,
|
||||
};
|
||||
}
|
||||
|
||||
private onDownloadClick = async (): Promise<void> => {
|
||||
try {
|
||||
await this.doDownload();
|
||||
} catch (e) {
|
||||
Modal.createDialog(ErrorDialog, {
|
||||
title: _t("timeline|download_failed"),
|
||||
description: (
|
||||
<>
|
||||
<div>{_t("timeline|download_failed_description")}</div>
|
||||
<div>{e instanceof Error ? e.toString() : ""}</div>
|
||||
</>
|
||||
),
|
||||
});
|
||||
this.setState({ loading: false });
|
||||
}
|
||||
};
|
||||
|
||||
private async doDownload(): Promise<void> {
|
||||
const mediaEventHelper = this.props.mediaEventHelperGet();
|
||||
if (this.state.loading || !mediaEventHelper) return;
|
||||
|
||||
if (mediaEventHelper.media.isEncrypted) {
|
||||
this.setState({ tooltip: _td("timeline|download_action_decrypting") });
|
||||
}
|
||||
|
||||
this.setState({ loading: true });
|
||||
|
||||
if (this.state.blob) {
|
||||
// Cheat and trigger a download, again.
|
||||
return this.downloadBlob(this.state.blob);
|
||||
}
|
||||
|
||||
const blob = await mediaEventHelper.sourceBlob.value;
|
||||
this.setState({ blob });
|
||||
await this.downloadBlob(blob);
|
||||
}
|
||||
|
||||
private async downloadBlob(blob: Blob): Promise<void> {
|
||||
await this.downloader.download({
|
||||
blob,
|
||||
name: this.props.mediaEventHelperGet()!.fileName,
|
||||
});
|
||||
this.setState({ loading: false });
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
let spinner: JSX.Element | undefined;
|
||||
if (this.state.loading) {
|
||||
spinner = <Spinner w={18} h={18} />;
|
||||
}
|
||||
|
||||
if (this.state.canDownload === null) {
|
||||
spinner = <Spinner w={18} h={18} />;
|
||||
}
|
||||
|
||||
if (this.state.canDownload === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const classes = classNames({
|
||||
mx_MessageActionBar_iconButton: true,
|
||||
mx_MessageActionBar_downloadButton: true,
|
||||
mx_MessageActionBar_downloadSpinnerButton: !!spinner,
|
||||
});
|
||||
|
||||
return (
|
||||
<RovingAccessibleButton
|
||||
className={classes}
|
||||
title={spinner ? _t(this.state.tooltip) : _t("action|download")}
|
||||
onClick={this.onDownloadClick}
|
||||
disabled={!!spinner}
|
||||
placement="left"
|
||||
>
|
||||
<DownloadIcon />
|
||||
{spinner}
|
||||
</RovingAccessibleButton>
|
||||
);
|
||||
}
|
||||
export default function DownloadActionButton({ mxEvent, mediaEventHelperGet }: IProps): ReactElement | null {
|
||||
const mediaEventHelper = useMemo(() => mediaEventHelperGet(), [mediaEventHelperGet]);
|
||||
const downloadUrl = mediaEventHelper?.media.srcHttp ?? "";
|
||||
const fileName = mediaEventHelper?.fileName;
|
||||
|
||||
const { download, loading, canDownload } = useDownloadMedia(downloadUrl, fileName, mxEvent);
|
||||
|
||||
if (!canDownload) return null;
|
||||
|
||||
const spinner = loading ? <Spinner w={18} h={18} /> : undefined;
|
||||
const classes = classNames({
|
||||
mx_MessageActionBar_iconButton: true,
|
||||
mx_MessageActionBar_downloadButton: true,
|
||||
mx_MessageActionBar_downloadSpinnerButton: !!spinner,
|
||||
});
|
||||
|
||||
return (
|
||||
<RovingAccessibleButton
|
||||
className={classes}
|
||||
title={loading ? _t("timeline|download_action_downloading") : _t("action|download")}
|
||||
onClick={download}
|
||||
disabled={loading}
|
||||
placement="left"
|
||||
>
|
||||
<DownloadIcon />
|
||||
{spinner}
|
||||
</RovingAccessibleButton>
|
||||
);
|
||||
}
|
||||
|
93
src/hooks/useDownloadMedia.ts
Normal file
93
src/hooks/useDownloadMedia.ts
Normal file
@ -0,0 +1,93 @@
|
||||
/*
|
||||
Copyright 2024 New Vector 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 { parseErrorResponse } from "matrix-js-sdk/src/matrix";
|
||||
import { useRef, useState, useMemo, useEffect } from "react";
|
||||
import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import ErrorDialog from "../components/views/dialogs/ErrorDialog";
|
||||
import { _t } from "../languageHandler";
|
||||
import Modal from "../Modal";
|
||||
import { FileDownloader } from "../utils/FileDownloader";
|
||||
import { MediaEventHelper } from "../utils/MediaEventHelper";
|
||||
import ModuleApi from "../modules/Api";
|
||||
|
||||
export interface UseDownloadMediaReturn {
|
||||
download: () => Promise<void>;
|
||||
loading: boolean;
|
||||
canDownload: boolean;
|
||||
}
|
||||
|
||||
export function useDownloadMedia(url: string, fileName?: string, mxEvent?: MatrixEvent): UseDownloadMediaReturn {
|
||||
const downloader = useRef(new FileDownloader()).current;
|
||||
const blobRef = useRef<Blob>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [canDownload, setCanDownload] = useState<boolean>(true);
|
||||
|
||||
const mediaEventHelper = useMemo(() => (mxEvent ? new MediaEventHelper(mxEvent) : undefined), [mxEvent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mxEvent) return;
|
||||
|
||||
const hints = ModuleApi.customComponents.getHintsForMessage(mxEvent);
|
||||
if (hints?.allowDownloadingMedia) {
|
||||
setCanDownload(false);
|
||||
hints
|
||||
.allowDownloadingMedia()
|
||||
.then(setCanDownload)
|
||||
.catch((err: any) => {
|
||||
logger.error(`Failed to check media download permission for ${mxEvent.event.event_id}`, err);
|
||||
|
||||
setCanDownload(false);
|
||||
});
|
||||
} else {
|
||||
setCanDownload(true);
|
||||
}
|
||||
}, [mxEvent]);
|
||||
|
||||
const download = async (): Promise<void> => {
|
||||
if (loading) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
if (blobRef.current) {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadBlob = async (blob: Blob): Promise<void> => {
|
||||
await downloader.download({
|
||||
blob,
|
||||
name: mediaEventHelper?.fileName ?? fileName ?? _t("common|image"),
|
||||
});
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const showError = (e: unknown): void => {
|
||||
Modal.createDialog(ErrorDialog, {
|
||||
title: _t("timeline|download_failed"),
|
||||
description: `${_t("timeline|download_failed_description")}\n\n${String(e)}`,
|
||||
});
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return { download, loading, canDownload };
|
||||
}
|
@ -1450,6 +1450,7 @@
|
||||
"room_list_navigate_down": "Navigate down in the room list",
|
||||
"room_list_navigate_up": "Navigate up in the room list",
|
||||
"room_list_select_room": "Select room from the room list",
|
||||
"save": "Save",
|
||||
"scroll_down_timeline": "Scroll down in the timeline",
|
||||
"scroll_up_timeline": "Scroll up in the timeline",
|
||||
"search": "Search (must be enabled)",
|
||||
@ -3370,7 +3371,6 @@
|
||||
"unable_to_decrypt": "Unable to decrypt message"
|
||||
},
|
||||
"disambiguated_profile": "%(displayName)s (%(matrixId)s)",
|
||||
"download_action_decrypting": "Decrypting",
|
||||
"download_action_downloading": "Downloading",
|
||||
"download_failed": "Download failed",
|
||||
"download_failed_description": "An error occurred while downloading this file",
|
||||
|
@ -44,6 +44,28 @@ describe("<ImageView />", () => {
|
||||
expect(fetchMock).toHaveFetched("https://example.com/image.png");
|
||||
});
|
||||
|
||||
it("should start download on Ctrl+S", async () => {
|
||||
fetchMock.get("https://example.com/image.png", "TESTFILE");
|
||||
|
||||
const { container } = render(
|
||||
<ImageView src="https://example.com/image.png" name="filename.png" onFinished={jest.fn()} />,
|
||||
);
|
||||
|
||||
const dialog = container.querySelector('[role="dialog"]') as HTMLElement;
|
||||
dialog?.focus();
|
||||
|
||||
fireEvent.keyDown(dialog!, { key: "s", code: "KeyS", ctrlKey: true });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocked(FileDownloader).mock.instances[0].download).toHaveBeenCalledWith({
|
||||
blob: expect.anything(),
|
||||
name: "filename.png",
|
||||
});
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveFetched("https://example.com/image.png");
|
||||
});
|
||||
|
||||
it("should handle download errors", async () => {
|
||||
const modalSpy = jest.spyOn(Modal, "createDialog");
|
||||
fetchMock.get("https://example.com/image.png", { status: 500 });
|
||||
|
@ -742,6 +742,26 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = `
|
||||
</kbd>
|
||||
</div>
|
||||
</li>
|
||||
<li
|
||||
class="mx_KeyboardShortcut_shortcutRow"
|
||||
>
|
||||
Save
|
||||
<div
|
||||
class="mx_KeyboardShortcut"
|
||||
>
|
||||
<kbd>
|
||||
|
||||
Ctrl
|
||||
|
||||
</kbd>
|
||||
+
|
||||
<kbd>
|
||||
|
||||
s
|
||||
|
||||
</kbd>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
Loading…
Reference in New Issue
Block a user