Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@
"@types/history": "^4.7.5",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.14.149",
"@types/mousetrap": "^1.6.15",
"@types/node": "^22.18.10",
"@types/pikaday": "^1.7.4",
"@types/puppeteer": "^2.0.1",
Expand Down
7 changes: 7 additions & 0 deletions src/components/hotkeys/HotkeyContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import * as React from 'react';

import type HotkeyService from './HotkeyService';

export const HotkeyContext = React.createContext<HotkeyService | null>(null);

HotkeyContext.displayName = 'HotkeyContext';
32 changes: 32 additions & 0 deletions src/components/hotkeys/HotkeyFriendlyModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import * as React from 'react';

import HotkeyLayer from './HotkeyLayer';
// @ts-ignore flow import
import { Modal } from '../modal';

export interface HotkeyFriendlyModalProps {
/** Modal contents */
children: React.ReactNode;
/** Additional CSS classname of the `.modal` element */
className?: string;
/** Whether the modal is open; when false nothing is rendered */
isOpen?: boolean;
/** Called when the modal requests to close */
onRequestClose?: Function;
/** Modal title */
title?: React.ReactNode;
}

const HotkeyFriendlyModal = ({ isOpen, ...rest }: HotkeyFriendlyModalProps) => {
if (!isOpen) {
return null;
}

return (
<HotkeyLayer>
<Modal isOpen {...rest} />
</HotkeyLayer>
);
};

export default HotkeyFriendlyModal;
31 changes: 31 additions & 0 deletions src/components/hotkeys/HotkeyFriendlyOverlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import * as React from 'react';

// @ts-ignore flow import
import { Overlay } from '../flyout';

import HotkeyLayer from './HotkeyLayer';

export interface HotkeyFriendlyOverlayProps {
/** Overlay contents */
children: React.ReactNode;
/** Component class names */
className?: string;
/** Click handler for the overlay */
onClick?: Function;
/** Called when the overlay requests to close */
onClose?: Function;
/** Whether the overlay should focus the first focusable element by default */
shouldDefaultFocus?: boolean;
}

/*
* Note that this is expected to be used within a Flyout component that only renders this
* when it is actually to be put on screen.
*/
const HotkeyFriendlyOverlay = ({ ...props }: HotkeyFriendlyOverlayProps) => (
<HotkeyLayer>
<Overlay {...props} />
</HotkeyLayer>
);

export default HotkeyFriendlyOverlay;
221 changes: 221 additions & 0 deletions src/components/hotkeys/HotkeyHelpModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import * as React from 'react';
import { Component } from 'react';
import { FormattedMessage } from 'react-intl';

// @ts-ignore flow import
import { ModalActions } from '../modal';
import Button, { ButtonType } from '../button';
import PlainButton from '../plain-button';
import DropdownMenu, { MenuToggle } from '../dropdown-menu';
// @ts-ignore flow import
import { Menu, MenuItem } from '../menu';
import { HotkeyContext } from './HotkeyContext';
import HotkeyFriendlyModal from './HotkeyFriendlyModal';
import type { HotkeyConfig } from './HotkeyRecord';
import type HotkeyService from './HotkeyService';

// @ts-ignore flow import
import commonMessages from '../../common/messages';
import messages from './messages';

import './HotkeyHelpModal.scss';

export interface HotkeyHelpModalProps {
/** Whether the help modal is open */
isOpen?: boolean;
/** Called when the modal requests to close */
onRequestClose: Function;
}

interface HotkeyHelpModalState {
currentType: string | null;
}

const specialCharacters: { [key: string]: React.ReactNode } = {
backspace: '\u232b',
down: '\u2193',
left: '\u2190',
meta: '\u2318',
right: '\u2192',
up: '\u2191',
enter: <FormattedMessage {...messages.enterKey} />,
spacebar: <FormattedMessage {...messages.spacebarKey} />,
shift: '\u21e7',
ctrl: <FormattedMessage {...messages.ctrlKey} />,
alt: <FormattedMessage {...messages.altKey} />,
esc: <FormattedMessage {...messages.escKey} />,
};

class HotkeyHelpModal extends Component<HotkeyHelpModalProps, HotkeyHelpModalState> {
static contextType = HotkeyContext;

context: HotkeyService | null;

hotkeys: { [type: string]: HotkeyConfig[] };

types: string[];

constructor(props: HotkeyHelpModalProps) {
super(props);

this.hotkeys = {};
this.types = [];
this.state = {
currentType: null,
};
}

componentDidMount() {
const hotkeyLayer = this.context;
if (hotkeyLayer) {
this.hotkeys = hotkeyLayer.getActiveHotkeys();
this.types = hotkeyLayer.getActiveTypes();
this.setState({
currentType: this.types.length ? this.types[0] : null,
});
}
}

componentDidUpdate({ isOpen: prevIsOpen }: HotkeyHelpModalProps, { currentType: prevType }: HotkeyHelpModalState) {
const { isOpen } = this.props;
const hotkeyLayer = this.context;

if (!isOpen || !hotkeyLayer) {
return;
}

// modal is being opened; refresh hotkeys
if (!prevIsOpen && isOpen) {
this.hotkeys = hotkeyLayer.getActiveHotkeys();
this.types = hotkeyLayer.getActiveTypes();
}

if (!prevType && this.types.length) {
this.setState({
currentType: this.types[0],
});
}
Comment thread
bonchevskyi marked this conversation as resolved.
}

/**
* Converts a "raw" hotkey to translated JSX version
*/
prettyPrintHotkey = (hotkeyConfig: HotkeyConfig) => {
const hotkeys = Array.isArray(hotkeyConfig.key) ? hotkeyConfig.key : [hotkeyConfig.key];

const prettyHotkeys = hotkeys
.map(hotkey =>
hotkey.split(' ').reduce((prettyHotkey: React.ReactNode, combo, i) => {
// Convert a "raw" combo to a "pretty" combo:
// e.g. "shift+g" => [ <kbd>Shift</kbd>, '+', <kbd>G</kbd> ]
const prettyCombo = combo
.split('+')
.map(key => {
// Convert special key characters into their respective icons or translated components:
// e.g. "shift" => "Shift", "meta" => "⌘"
if (key in specialCharacters) {
return specialCharacters[key];
}
// If it's not a special character, just return the uppercased key:
// e.g. "g" => "G"
return key.length === 1 ? key.toUpperCase() : key;
})
.map((key, j) => <kbd key={j}>{key}</kbd>);
// If this hotkey is a sequence of keys, return a translated message to combine them:
// e.g. "Shift+G Shift+A" => "Shift+G then Shift+A"
return i === 0 ? (
prettyCombo
) : (
<FormattedMessage
values={{
key1: <span>{prettyHotkey}</span>,
key2: <span>{prettyCombo}</span>,
}}
{...messages.hotkeySequence}
/>
);
}, [] as React.ReactNode),
)
.reduce(
(finalHotkey: React.ReactNode[], hotkey, i) =>
// For shortcuts with multiple hotkeys, separate each hotkey with a "/" joiner:
// e.g. "Cmd+S Ctrl+S" => "Cmd+S / Ctrl+S"
i === 0 ? [hotkey] : [...finalHotkey, ' / ', hotkey],
[] as React.ReactNode[],
) as React.ReactNode[];

return prettyHotkeys.map((element, i) => <span key={i}>{element}</span>);
};

renderDropdownMenu() {
const { currentType } = this.state;

if (!currentType) {
return null;
}

return (
<div className="hotkey-dropdown">
<DropdownMenu>
<PlainButton className="lnk" type={ButtonType.BUTTON}>
<MenuToggle>{currentType}</MenuToggle>
</PlainButton>
<Menu>
{this.types.map((hotkeyType, i) => (
<MenuItem key={i} onClick={() => this.setState({ currentType: hotkeyType })}>
{hotkeyType}
</MenuItem>
))}
</Menu>
</DropdownMenu>
</div>
);
}

renderHotkey = (hotkey: HotkeyConfig, i: number) => (
<li key={i} className="hotkey-item">
<div className="hotkey-description">{hotkey.description}</div>
<div className="hotkey-key">{this.prettyPrintHotkey(hotkey)}</div>
</li>
);

renderHotkeyList() {
const { currentType } = this.state;

if (!currentType) {
return null;
}

const hotkeys = this.hotkeys[currentType];

return <ul className="hotkey-list">{hotkeys.map(this.renderHotkey)}</ul>;
}

render() {
const { isOpen, onRequestClose } = this.props;
const { currentType } = this.state;

if (!currentType) {
return null;
}

return (
<HotkeyFriendlyModal
className="hotkey-modal"
isOpen={isOpen}
onRequestClose={onRequestClose}
title={<FormattedMessage {...messages.hotkeyModalTitle} />}
>
{this.renderDropdownMenu()}
{this.renderHotkeyList()}
<ModalActions>
<Button onClick={onRequestClose}>
<FormattedMessage {...commonMessages.cancel} />
</Button>
</ModalActions>
</HotkeyFriendlyModal>
);
}
}

export default HotkeyHelpModal;
Loading
Loading