Add Keyboard shortcuts dialog

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski 2020-03-18 16:40:21 +00:00
parent fac4561ac8
commit cad28c81c0
10 changed files with 444 additions and 6 deletions

View file

@ -118,6 +118,7 @@
"@babel/preset-typescript": "^7.7.4", "@babel/preset-typescript": "^7.7.4",
"@babel/register": "^7.7.4", "@babel/register": "^7.7.4",
"@peculiar/webcrypto": "^1.0.22", "@peculiar/webcrypto": "^1.0.22",
"@types/classnames": "^2.2.10",
"@types/react": "16.9", "@types/react": "16.9",
"babel-eslint": "^10.0.3", "babel-eslint": "^10.0.3",
"babel-jest": "^24.9.0", "babel-jest": "^24.9.0",

View file

@ -65,6 +65,7 @@
@import "./views/dialogs/_GroupAddressPicker.scss"; @import "./views/dialogs/_GroupAddressPicker.scss";
@import "./views/dialogs/_IncomingSasDialog.scss"; @import "./views/dialogs/_IncomingSasDialog.scss";
@import "./views/dialogs/_InviteDialog.scss"; @import "./views/dialogs/_InviteDialog.scss";
@import "./views/dialogs/_KeyboardShortcutsDialog.scss";
@import "./views/dialogs/_MessageEditHistoryDialog.scss"; @import "./views/dialogs/_MessageEditHistoryDialog.scss";
@import "./views/dialogs/_NewSessionReviewDialog.scss"; @import "./views/dialogs/_NewSessionReviewDialog.scss";
@import "./views/dialogs/_RoomSettingsDialog.scss"; @import "./views/dialogs/_RoomSettingsDialog.scss";

View file

@ -0,0 +1,65 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_KeyboardShortcutsDialog {
display: flex;
flex-wrap: wrap;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
flex-direction: column;
margin-bottom: -50px;
max-height: 700px; // XXX: this may need adjusting when adding new shortcuts
.mx_KeyboardShortcutsDialog_category {
width: 33.3333%; // 3 columns
margin: 0 0 40px;
& > div {
padding-left: 5px;
}
}
h3 {
margin: 0 0 10px;
}
h5 {
margin: 15px 0 5px;
font-weight: normal;
}
kbd {
padding: 5px;
border-radius: 4px;
background: $roomheader-addroom-bg-color;
margin-right: 5px;
min-width: 20px;
text-align: center;
display: inline-block;
border: 1px solid black;
box-shadow: 0 2px black;
margin-bottom: 4px;
text-transform: capitalize;
& + kbd {
margin-left: 5px;
}
}
.mx_KeyboardShortcutsDialog_inline div {
display: inline;
}
}

View file

@ -40,6 +40,7 @@ export const Key = {
GREATER_THAN: ">", GREATER_THAN: ">",
BACKTICK: "`", BACKTICK: "`",
SPACE: " ", SPACE: " ",
SLASH: "/",
A: "a", A: "a",
B: "b", B: "b",
C: "c", C: "c",
@ -68,8 +69,9 @@ export const Key = {
Z: "z", Z: "z",
}; };
export const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
export function isOnlyCtrlOrCmdKeyEvent(ev) { export function isOnlyCtrlOrCmdKeyEvent(ev) {
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
if (isMac) { if (isMac) {
return ev.metaKey && !ev.altKey && !ev.ctrlKey && !ev.shiftKey; return ev.metaKey && !ev.altKey && !ev.ctrlKey && !ev.shiftKey;
} else { } else {
@ -78,7 +80,6 @@ export function isOnlyCtrlOrCmdKeyEvent(ev) {
} }
export function isOnlyCtrlOrCmdIgnoreShiftKeyEvent(ev) { export function isOnlyCtrlOrCmdIgnoreShiftKeyEvent(ev) {
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
if (isMac) { if (isMac) {
return ev.metaKey && !ev.altKey && !ev.ctrlKey; return ev.metaKey && !ev.altKey && !ev.ctrlKey;
} else { } else {

View file

@ -0,0 +1,313 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import classNames from "classnames";
import * as sdk from "../index";
import Modal from "../Modal";
import { _t, _td } from "../languageHandler";
import {isMac, Key} from "../Keyboard";
// TS: once languageHandler is TS we can probably inline this into the enum
_td("Navigation");
_td("Calls");
_td("Composer");
_td("Room List");
_td("Autocomplete");
export enum Categories {
NAVIGATION="Navigation",
CALLS="Calls",
COMPOSER="Composer",
ROOM_LIST="Room List",
AUTOCOMPLETE="Autocomplete",
}
// TS: once languageHandler is TS we can probably inline this into the enum
_td("Alt");
_td("Alt Gr");
_td("Shift");
_td("Super");
_td("Ctrl");
export enum Modifiers {
ALT="Alt",
OPTION="Option", // This gets displayed as an Icon
ALT_GR="Alt Gr",
SHIFT="Shift",
SUPER="Super", // should this be "Windows"?
// Instead of using below, consider CMD_OR_CTRL
COMMAND="Command", // This gets displayed as an Icon
CONTROL="Ctrl",
}
// Meta-modifier: isMac ? CMD : CONTROL
export const CMD_OR_CTRL = isMac ? Modifiers.COMMAND : Modifiers.CONTROL;
interface IKeybind {
modifiers?: Modifiers[];
key: string; // TS: fix this once Key is an enum
}
interface IShortcut {
keybinds: IKeybind[];
description: string;
}
const shortcuts: Record<Categories, IShortcut[]> = {
[Categories.COMPOSER]: [
{
keybinds: [{
modifiers: [CMD_OR_CTRL],
key: Key.B,
}],
description: _td("Toggle Bold"),
}, {
keybinds: [{
modifiers: [CMD_OR_CTRL],
key: Key.I,
}],
description: _td("Toggle Italics"),
}, {
keybinds: [{
modifiers: [CMD_OR_CTRL],
key: Key.GREATER_THAN,
}],
description: _td("Toggle Quote"),
}, {
keybinds: [{
modifiers: [CMD_OR_CTRL],
key: Key.M,
}],
description: _td("Toggle Markdown"),
}, {
keybinds: [{
modifiers: [Modifiers.SHIFT],
key: Key.ENTER,
}],
description: _td("New line"),
}, {
keybinds: [{
key: Key.ARROW_UP,
}, {
key: Key.ARROW_DOWN,
}],
description: _td("Navigate recent messages to edit"),
}, {
keybinds: [{
modifiers: [CMD_OR_CTRL],
key: Key.HOME,
}, {
modifiers: [CMD_OR_CTRL],
key: Key.END,
}],
description: _td("Jump to start/end of the composer"),
},
],
[Categories.CALLS]: [
{
keybinds: [{
modifiers: [CMD_OR_CTRL],
key: Key.D,
}],
description: _td("Toggle microphone mute"),
}, {
keybinds: [{
modifiers: [CMD_OR_CTRL],
key: Key.E,
}],
description: _td("Toggle video on/off"),
},
],
[Categories.ROOM_LIST]: [
{
keybinds: [{
modifiers: [CMD_OR_CTRL],
key: Key.K,
}],
description: _td("Jump to room search"),
}, {
keybinds: [{
key: Key.ARROW_UP,
}, {
key: Key.ARROW_DOWN,
}],
description: _td("Navigate up/down in the room list"),
}, {
keybinds: [{
key: Key.ENTER,
}],
description: _td("Select room from the room list"),
}, {
keybinds: [{
key: Key.ARROW_LEFT,
}],
description: _td("Collapse room list section"),
}, {
keybinds: [{
key: Key.ARROW_RIGHT,
}],
description: _td("Expand room list section"),
}, {
keybinds: [{
key: Key.ESCAPE,
}],
description: _td("Clear room list filter field"),
},
],
[Categories.NAVIGATION]: [
{
keybinds: [{
key: Key.PAGE_UP,
}, {
key: Key.PAGE_DOWN,
}],
description: _td("Scroll up/down in the timeline"),
}, {
keybinds: [{
modifiers: [CMD_OR_CTRL],
key: Key.BACKTICK,
}],
description: _td("Toggle the top left menu"),
}, {
keybinds: [{
key: Key.ESCAPE,
}],
description: _td("Close dialog or context menu"),
}, {
keybinds: [{
key: Key.ENTER,
}, {
key: Key.SPACE,
}],
description: _td("Activate selected button"),
}, {
keybinds: [{
modifiers: [CMD_OR_CTRL],
key: Key.SLASH,
}],
description: _td("Toggle this dialog"),
},
],
[Categories.AUTOCOMPLETE]: [
{
keybinds: [{
key: Key.ARROW_UP,
}, {
key: Key.ARROW_DOWN,
}],
description: _td("Move autocomplete selection up/down"),
}, {
keybinds: [{
key: Key.ESCAPE,
}],
description: _td("Cancel autocomplete"),
},
],
};
interface IModal {
close: () => void;
finished: Promise<any[]>;
}
const modifierIcon: Record<Modifiers, string> = {
[Modifiers.COMMAND]: "⌘",
[Modifiers.OPTION]: "⌥",
};
const alternateKeyName: Record<string, string> = { // TS: fix this once Key is an enum
[Key.PAGE_UP]: _td("Page Up"),
[Key.PAGE_DOWN]: _td("Page Down"),
[Key.ESCAPE]: _td("Esc"),
[Key.ENTER]: _td("Enter"),
[Key.SPACE]: _td("Space"),
[Key.HOME]: _td("Home"),
[Key.END]: _td("End"),
};
const keyIcon: Record<string, string> = { // TS: fix this once Key is an enum
[Key.ARROW_UP]: "↑",
[Key.ARROW_DOWN]: "↓",
[Key.ARROW_LEFT]: "←",
[Key.ARROW_RIGHT]: "→",
};
const Shortcut: React.FC<{
shortcut: IShortcut;
}> = ({shortcut}) => {
const classes = classNames({
"mx_KeyboardShortcutsDialog_inline": shortcut.keybinds.every(k => !k.modifiers || k.modifiers.length === 0),
});
return <div className={classes}>
<h5>{ _t(shortcut.description) }</h5>
{ shortcut.keybinds.map(s => {
let text = s.key;
if (alternateKeyName[s.key]) {
text = _t(alternateKeyName[s.key]);
} else if (keyIcon[s.key]) {
text = keyIcon[s.key];
}
return <div key={s.key}>
{ s.modifiers && s.modifiers.map(m => {
return <React.Fragment key={m}>
<kbd>{ modifierIcon[m] || _t(m) }</kbd>+
</React.Fragment>;
}) }
<kbd>{ text }</kbd>
</div>;
}) }
</div>;
};
let activeModal: IModal = null;
export const toggleDialog = () => {
if (activeModal) {
activeModal.close();
activeModal = null;
return;
}
const sections = Object.entries(shortcuts).map(([category, list]) => {
return <div className="mx_KeyboardShortcutsDialog_category" key={category}>
<h3>{_t(category)}</h3>
<div>{list.map(shortcut => <Shortcut key={shortcut.description} shortcut={shortcut} />)}</div>
</div>;
});
const InfoDialog = sdk.getComponent('dialogs.InfoDialog');
activeModal = Modal.createTrackedDialog("Keyboard Shortcuts", "", InfoDialog, {
className: "mx_KeyboardShortcutsDialog",
title: _t("Keyboard Shortcuts"),
description: sections,
hasCloseButton: true,
onKeyDown: (ev) => {
if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey && ev.key === Key.SLASH) { // Ctrl + /
ev.stopPropagation();
activeModal.close();
}
},
onFinished: () => {
activeModal = null;
},
});
};

View file

@ -39,6 +39,7 @@ import RoomListActions from '../../actions/RoomListActions';
import ResizeHandle from '../views/elements/ResizeHandle'; import ResizeHandle from '../views/elements/ResizeHandle';
import {Resizer, CollapseDistributor} from '../../resizer'; import {Resizer, CollapseDistributor} from '../../resizer';
import MatrixClientContext from "../../contexts/MatrixClientContext"; import MatrixClientContext from "../../contexts/MatrixClientContext";
import * as KeyboardShortcuts from "../../accessibility/KeyboardShortcuts";
// We need to fetch each pinned message individually (if we don't already have it) // We need to fetch each pinned message individually (if we don't already have it)
// so each pinned message may trigger a request. Limit the number per room for sanity. // so each pinned message may trigger a request. Limit the number per room for sanity.
// NB. this is just for server notices rather than pinned messages in general. // NB. this is just for server notices rather than pinned messages in general.
@ -365,8 +366,6 @@ const LoggedInView = createReactClass({
} }
break; break;
case Key.BACKTICK: case Key.BACKTICK:
if (ev.key !== "`") break;
// Ideally this would be CTRL+P for "Profile", but that's // Ideally this would be CTRL+P for "Profile", but that's
// taken by the print dialog. CTRL+I for "Information" // taken by the print dialog. CTRL+I for "Information"
// was previously chosen but conflicted with italics in // was previously chosen but conflicted with italics in
@ -379,6 +378,13 @@ const LoggedInView = createReactClass({
handled = true; handled = true;
} }
break; break;
case Key.SLASH:
if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {
KeyboardShortcuts.toggleDialog();
handled = true;
}
break;
} }
if (handled) { if (handled) {

View file

@ -32,6 +32,7 @@ export default createReactClass({
button: PropTypes.string, button: PropTypes.string,
onFinished: PropTypes.func, onFinished: PropTypes.func,
hasCloseButton: PropTypes.bool, hasCloseButton: PropTypes.bool,
onKeyDown: PropTypes.func,
}, },
getDefaultProps: function() { getDefaultProps: function() {
@ -50,10 +51,13 @@ export default createReactClass({
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return ( return (
<BaseDialog className="mx_InfoDialog" onFinished={this.props.onFinished} <BaseDialog
className="mx_InfoDialog"
onFinished={this.props.onFinished}
title={this.props.title} title={this.props.title}
contentId='mx_Dialog_content' contentId='mx_Dialog_content'
hasCancel={this.props.hasCloseButton} hasCancel={this.props.hasCloseButton}
onKeyDown={this.props.onKeyDown}
> >
<div className={classNames("mx_Dialog_content", this.props.className)} id="mx_Dialog_content"> <div className={classNames("mx_Dialog_content", this.props.className)} id="mx_Dialog_content">
{ this.props.description } { this.props.description }

View file

@ -24,6 +24,7 @@ import createRoom from "../../../../../createRoom";
import Modal from "../../../../../Modal"; import Modal from "../../../../../Modal";
import * as sdk from "../../../../../"; import * as sdk from "../../../../../";
import PlatformPeg from "../../../../../PlatformPeg"; import PlatformPeg from "../../../../../PlatformPeg";
import * as KeyboardShortcuts from "../../../../../accessibility/KeyboardShortcuts";
export default class HelpUserSettingsTab extends React.Component { export default class HelpUserSettingsTab extends React.Component {
static propTypes = { static propTypes = {
@ -224,6 +225,9 @@ export default class HelpUserSettingsTab extends React.Component {
<div className='mx_SettingsTab_subsectionText'> <div className='mx_SettingsTab_subsectionText'>
{faqText} {faqText}
</div> </div>
<AccessibleButton kind="primary" onClick={KeyboardShortcuts.toggleDialog}>
{ _t("Keyboard Shortcuts") }
</AccessibleButton>
</div> </div>
<div className='mx_SettingsTab_section mx_HelpUserSettingsTab_versions'> <div className='mx_SettingsTab_section mx_HelpUserSettingsTab_versions'>
<span className='mx_SettingsTab_subheading'>{_t("Versions")}</span> <span className='mx_SettingsTab_subheading'>{_t("Versions")}</span>

View file

@ -742,6 +742,7 @@
"Clear cache and reload": "Clear cache and reload", "Clear cache and reload": "Clear cache and reload",
"To report a Matrix-related security issue, please read the Matrix.org <a>Security Disclosure Policy</a>.": "To report a Matrix-related security issue, please read the Matrix.org <a>Security Disclosure Policy</a>.", "To report a Matrix-related security issue, please read the Matrix.org <a>Security Disclosure Policy</a>.": "To report a Matrix-related security issue, please read the Matrix.org <a>Security Disclosure Policy</a>.",
"FAQ": "FAQ", "FAQ": "FAQ",
"Keyboard Shortcuts": "Keyboard Shortcuts",
"Versions": "Versions", "Versions": "Versions",
"riot-web version:": "riot-web version:", "riot-web version:": "riot-web version:",
"olm version:": "olm version:", "olm version:": "olm version:",
@ -2155,5 +2156,42 @@
"Message downloading sleep time(ms)": "Message downloading sleep time(ms)", "Message downloading sleep time(ms)": "Message downloading sleep time(ms)",
"Failed to set direct chat tag": "Failed to set direct chat tag", "Failed to set direct chat tag": "Failed to set direct chat tag",
"Failed to remove tag %(tagName)s from room": "Failed to remove tag %(tagName)s from room", "Failed to remove tag %(tagName)s from room": "Failed to remove tag %(tagName)s from room",
"Failed to add tag %(tagName)s to room": "Failed to add tag %(tagName)s to room" "Failed to add tag %(tagName)s to room": "Failed to add tag %(tagName)s to room",
"Navigation": "Navigation",
"Calls": "Calls",
"Room List": "Room List",
"Autocomplete": "Autocomplete",
"Alt": "Alt",
"Alt Gr": "Alt Gr",
"Shift": "Shift",
"Super": "Super",
"Ctrl": "Ctrl",
"Toggle Bold": "Toggle Bold",
"Toggle Italics": "Toggle Italics",
"Toggle Quote": "Toggle Quote",
"Toggle Markdown": "Toggle Markdown",
"New line": "New line",
"Navigate recent messages to edit": "Navigate recent messages to edit",
"Jump to start/end of the composer": "Jump to start/end of the composer",
"Toggle microphone mute": "Toggle microphone mute",
"Toggle video on/off": "Toggle video on/off",
"Jump to room search": "Jump to room search",
"Navigate up/down in the room list": "Navigate up/down in the room list",
"Select room from the room list": "Select room from the room list",
"Collapse room list section": "Collapse room list section",
"Expand room list section": "Expand room list section",
"Clear room list filter field": "Clear room list filter field",
"Scroll up/down in the timeline": "Scroll up/down in the timeline",
"Toggle the top left menu": "Toggle the top left menu",
"Close dialog or context menu": "Close dialog or context menu",
"Activate selected button": "Activate selected button",
"Toggle this dialog": "Toggle this dialog",
"Move autocomplete selection up/down": "Move autocomplete selection up/down",
"Cancel autocomplete": "Cancel autocomplete",
"Page Up": "Page Up",
"Page Down": "Page Down",
"Esc": "Esc",
"Enter": "Enter",
"Space": "Space",
"End": "End"
} }

View file

@ -1178,6 +1178,11 @@
dependencies: dependencies:
"@babel/types" "^7.3.0" "@babel/types" "^7.3.0"
"@types/classnames@^2.2.10":
version "2.2.10"
resolved "https://registry.yarnpkg.com/@types/classnames/-/classnames-2.2.10.tgz#cc658ca319b6355399efc1f5b9e818f1a24bf999"
integrity sha512-1UzDldn9GfYYEsWWnn/P4wkTlkZDH7lDb0wBMGbtIQc9zXEQq7FlKBdZUn6OBqD8sKZZ2RQO2mAjGpXiDGoRmQ==
"@types/events@*": "@types/events@*":
version "3.0.0" version "3.0.0"
resolved "https://registry.yarnpkg.com/@types/events/-/events-3.0.0.tgz#2862f3f58a9a7f7c3e78d79f130dd4d71c25c2a7" resolved "https://registry.yarnpkg.com/@types/events/-/events-3.0.0.tgz#2862f3f58a9a7f7c3e78d79f130dd4d71c25c2a7"