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:
ioalexander 2025-07-17 11:53:11 +02:00 committed by GitHub
parent 3e11a62a3f
commit 77cb4b3157
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 212 additions and 216 deletions

View File

@ -19,3 +19,6 @@ include:
* Thom Cleary (https://github.com/thomcatdotrocks) * Thom Cleary (https://github.com/thomcatdotrocks)
Small update for tarball deployment Small update for tarball deployment
* Alexander (https://github.com/ioalexander)
Save image on CTRL + S shortcut

View File

@ -146,6 +146,7 @@ export enum KeyBindingAction {
ArrowDown = "KeyBinding.arrowDown", ArrowDown = "KeyBinding.arrowDown",
Tab = "KeyBinding.tab", Tab = "KeyBinding.tab",
Comma = "KeyBinding.comma", Comma = "KeyBinding.comma",
Save = "KeyBinding.save",
/** Toggle visibility of hidden events */ /** Toggle visibility of hidden events */
ToggleHiddenEventVisibility = "KeyBinding.toggleHiddenEventVisibility", ToggleHiddenEventVisibility = "KeyBinding.toggleHiddenEventVisibility",
@ -269,6 +270,7 @@ export const CATEGORIES: Record<CategoryName, ICategory> = {
KeyBindingAction.ArrowRight, KeyBindingAction.ArrowRight,
KeyBindingAction.ArrowDown, KeyBindingAction.ArrowDown,
KeyBindingAction.Comma, KeyBindingAction.Comma,
KeyBindingAction.Save,
], ],
}, },
[CategoryName.NAVIGATION]: { [CategoryName.NAVIGATION]: {
@ -621,6 +623,13 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = {
}, },
displayName: _td("keyboard|composer_redo"), displayName: _td("keyboard|composer_redo"),
}, },
[KeyBindingAction.Save]: {
default: {
key: Key.S,
ctrlOrCmdKey: true,
},
displayName: _td("keyboard|save"),
},
[KeyBindingAction.PreviousVisitedRoomOrSpace]: { [KeyBindingAction.PreviousVisitedRoomOrSpace]: {
default: { default: {
metaKey: IS_MAC, metaKey: IS_MAC,

View File

@ -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. 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 FocusLock from "react-focus-lock";
import { type MatrixEvent, parseErrorResponse } from "matrix-js-sdk/src/matrix"; import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import MemberAvatar from "../avatars/MemberAvatar"; import MemberAvatar from "../avatars/MemberAvatar";
@ -31,11 +30,7 @@ import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import { presentableTextForFile } from "../../../utils/FileUtils"; import { presentableTextForFile } from "../../../utils/FileUtils";
import AccessibleButton from "./AccessibleButton"; import AccessibleButton from "./AccessibleButton";
import Modal from "../../../Modal"; import { useDownloadMedia } from "../../../hooks/useDownloadMedia.ts";
import ErrorDialog from "../dialogs/ErrorDialog";
import { FileDownloader } from "../../../utils/FileDownloader";
import { MediaEventHelper } from "../../../utils/MediaEventHelper.ts";
import ModuleApi from "../../../modules/Api";
// 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;
@ -123,6 +118,8 @@ export default class ImageView extends React.Component<IProps, IState> {
private imageWrapper = createRef<HTMLDivElement>(); private imageWrapper = createRef<HTMLDivElement>();
private image = createRef<HTMLImageElement>(); private image = createRef<HTMLImageElement>();
private downloadFunction?: () => Promise<void>;
private initX = 0; private initX = 0;
private initY = 0; private initY = 0;
private previousX = 0; private previousX = 0;
@ -302,6 +299,13 @@ export default class ImageView extends React.Component<IProps, IState> {
ev.preventDefault(); ev.preventDefault();
this.props.onFinished(); this.props.onFinished();
break; 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 => { private onPermalinkClicked = (ev: React.MouseEvent): void => {
// This allows the permalink to be opened in a new tab/window or copied as // 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. // 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")} title={_t("lightbox|rotate_right")}
onClick={this.onRotateClockwiseClick} 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} {contextMenuButton}
<AccessibleButton <AccessibleButton
className="mx_ImageView_button mx_ImageView_button_close" className="mx_ImageView_button mx_ImageView_button_close"
@ -585,99 +598,28 @@ export default class ImageView extends React.Component<IProps, IState> {
} }
} }
function DownloadButton({ interface DownloadButtonProps {
url,
fileName,
mxEvent,
}: {
url: string; url: string;
fileName?: string; fileName?: string;
mxEvent?: MatrixEvent; mxEvent?: MatrixEvent;
}): JSX.Element | null { onDownloadReady?: (download: () => Promise<void>) => void;
const downloader = useRef(new FileDownloader()).current; }
const [loading, setLoading] = useState(false);
const [canDownload, setCanDownload] = useState<boolean>(false); export const DownloadButton: React.FC<DownloadButtonProps> = ({ url, fileName, mxEvent, onDownloadReady }) => {
const blobRef = useRef<Blob>(undefined); const { download, loading, canDownload } = useDownloadMedia(url, fileName, mxEvent);
const mediaEventHelper = useMemo(() => (mxEvent ? new MediaEventHelper(mxEvent) : undefined), [mxEvent]);
useEffect(() => { useEffect(() => {
if (!mxEvent) { if (onDownloadReady) onDownloadReady(download);
// If we have no event, we assume this is safe to download. }, [download, onDownloadReady]);
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]);
function showError(e: unknown): void { if (!canDownload) return null;
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;
}
return ( return (
<AccessibleButton <AccessibleButton
className="mx_ImageView_button mx_ImageView_button_download" className="mx_ImageView_button mx_ImageView_button_download"
title={loading ? _t("timeline|download_action_downloading") : _t("action|download")} title={loading ? _t("timeline|download_action_downloading") : _t("action|download")}
onClick={onDownloadClick} onClick={download}
disabled={loading} disabled={loading}
/> />
); );
} };

View File

@ -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 { 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 classNames from "classnames";
import { DownloadIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; 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 { type MediaEventHelper } from "../../../utils/MediaEventHelper";
import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex"; import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex";
import Spinner from "../elements/Spinner"; import Spinner from "../elements/Spinner";
import { _t, _td, type TranslationKey } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { FileDownloader } from "../../../utils/FileDownloader"; import { useDownloadMedia } from "../../../hooks/useDownloadMedia";
import Modal from "../../../Modal";
import ErrorDialog from "../dialogs/ErrorDialog";
import ModuleApi from "../../../modules/Api";
interface IProps { interface IProps {
mxEvent: MatrixEvent; mxEvent: MatrixEvent;
@ -30,121 +26,32 @@ interface IProps {
mediaEventHelperGet: () => MediaEventHelper | undefined; mediaEventHelperGet: () => MediaEventHelper | undefined;
} }
interface IState { export default function DownloadActionButton({ mxEvent, mediaEventHelperGet }: IProps): ReactElement | null {
canDownload: null | boolean; const mediaEventHelper = useMemo(() => mediaEventHelperGet(), [mediaEventHelperGet]);
loading: boolean; const downloadUrl = mediaEventHelper?.media.srcHttp ?? "";
blob?: Blob; const fileName = mediaEventHelper?.fileName;
tooltip: TranslationKey;
} const { download, loading, canDownload } = useDownloadMedia(downloadUrl, fileName, mxEvent);
export default class DownloadActionButton extends React.PureComponent<IProps, IState> { if (!canDownload) return null;
private downloader = new FileDownloader();
const spinner = loading ? <Spinner w={18} h={18} /> : undefined;
public constructor(props: IProps) { const classes = classNames({
super(props); mx_MessageActionBar_iconButton: true,
mx_MessageActionBar_downloadButton: true,
const moduleHints = ModuleApi.customComponents.getHintsForMessage(props.mxEvent); mx_MessageActionBar_downloadSpinnerButton: !!spinner,
const downloadState: Pick<IState, "canDownload"> = { canDownload: true }; });
if (moduleHints?.allowDownloadingMedia) {
downloadState.canDownload = null; return (
moduleHints <RovingAccessibleButton
.allowDownloadingMedia() className={classes}
.then((canDownload) => { title={loading ? _t("timeline|download_action_downloading") : _t("action|download")}
this.setState({ onClick={download}
canDownload: canDownload, disabled={loading}
}); placement="left"
}) >
.catch((ex) => { <DownloadIcon />
logger.error(`Failed to check if media from ${props.mxEvent.getId()} could be downloaded`, ex); {spinner}
this.setState({ </RovingAccessibleButton>
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>
);
}
} }

View 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 };
}

View File

@ -1450,6 +1450,7 @@
"room_list_navigate_down": "Navigate down in the room list", "room_list_navigate_down": "Navigate down in the room list",
"room_list_navigate_up": "Navigate up in the room list", "room_list_navigate_up": "Navigate up in the room list",
"room_list_select_room": "Select room from the room list", "room_list_select_room": "Select room from the room list",
"save": "Save",
"scroll_down_timeline": "Scroll down in the timeline", "scroll_down_timeline": "Scroll down in the timeline",
"scroll_up_timeline": "Scroll up in the timeline", "scroll_up_timeline": "Scroll up in the timeline",
"search": "Search (must be enabled)", "search": "Search (must be enabled)",
@ -3370,7 +3371,6 @@
"unable_to_decrypt": "Unable to decrypt message" "unable_to_decrypt": "Unable to decrypt message"
}, },
"disambiguated_profile": "%(displayName)s (%(matrixId)s)", "disambiguated_profile": "%(displayName)s (%(matrixId)s)",
"download_action_decrypting": "Decrypting",
"download_action_downloading": "Downloading", "download_action_downloading": "Downloading",
"download_failed": "Download failed", "download_failed": "Download failed",
"download_failed_description": "An error occurred while downloading this file", "download_failed_description": "An error occurred while downloading this file",

View File

@ -44,6 +44,28 @@ describe("<ImageView />", () => {
expect(fetchMock).toHaveFetched("https://example.com/image.png"); 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 () => { it("should handle download errors", async () => {
const modalSpy = jest.spyOn(Modal, "createDialog"); const modalSpy = jest.spyOn(Modal, "createDialog");
fetchMock.get("https://example.com/image.png", { status: 500 }); fetchMock.get("https://example.com/image.png", { status: 500 });

View File

@ -742,6 +742,26 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = `
</kbd> </kbd>
</div> </div>
</li> </li>
<li
class="mx_KeyboardShortcut_shortcutRow"
>
Save
<div
class="mx_KeyboardShortcut"
>
<kbd>
Ctrl
</kbd>
+
<kbd>
s
</kbd>
</div>
</li>
</ul> </ul>
</div> </div>
</div> </div>