Implement hotkeys for web UI (#5164)
* Fix #2102 - Implement hotkeys Hotkeys on status list: - r to reply - m to mention author - f to favourite - b to boost - enter to open status - p to open author's profile - up or k to move up in the list - down or j to move down in the list - 1-9 to focus a status in one of the columns - n to focus the compose textarea - alt+n to start a brand new toot - backspace to navigate back * Add navigational hotkeys The key g followed by: - s: start - h: home - n: notifications - l: local timeline - t: federated timeline - f: favourites - u: own profile - p: pinned toots - b: blocked users - m: muted users * Add hotkey for focusing search, make escape un-focus compose/search * Fix focusing notifications column, fix hotkeys in compose textarea
This commit is contained in:
parent
49cc0eb3e7
commit
7db0f8dcb2
16 changed files with 627 additions and 150 deletions
|
@ -125,6 +125,16 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
|||
this.props.onKeyDown(e);
|
||||
}
|
||||
|
||||
onKeyUp = e => {
|
||||
if (e.key === 'Escape' && this.state.suggestionsHidden) {
|
||||
document.querySelector('.ui').parentElement.focus();
|
||||
}
|
||||
|
||||
if (this.props.onKeyUp) {
|
||||
this.props.onKeyUp(e);
|
||||
}
|
||||
}
|
||||
|
||||
onBlur = () => {
|
||||
this.setState({ suggestionsHidden: true });
|
||||
}
|
||||
|
@ -173,7 +183,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
render () {
|
||||
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus } = this.props;
|
||||
const { value, suggestions, disabled, placeholder, autoFocus } = this.props;
|
||||
const { suggestionsHidden } = this.state;
|
||||
const style = { direction: 'ltr' };
|
||||
|
||||
|
@ -195,7 +205,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
|||
value={value}
|
||||
onChange={this.onChange}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onKeyUp={onKeyUp}
|
||||
onKeyUp={this.onKeyUp}
|
||||
onBlur={this.onBlur}
|
||||
onPaste={this.onPaste}
|
||||
style={style}
|
||||
|
|
|
@ -145,32 +145,6 @@ export default class ScrollableList extends PureComponent {
|
|||
return this._lastMouseMove !== null && ((new Date()) - this._lastMouseMove < 600);
|
||||
}
|
||||
|
||||
handleKeyDown = (e) => {
|
||||
if (['PageDown', 'PageUp'].includes(e.key) || (e.ctrlKey && ['End', 'Home'].includes(e.key))) {
|
||||
const article = (() => {
|
||||
switch (e.key) {
|
||||
case 'PageDown':
|
||||
return e.target.nodeName === 'ARTICLE' && e.target.nextElementSibling;
|
||||
case 'PageUp':
|
||||
return e.target.nodeName === 'ARTICLE' && e.target.previousElementSibling;
|
||||
case 'End':
|
||||
return this.node.querySelector('[role="feed"] > article:last-of-type');
|
||||
case 'Home':
|
||||
return this.node.querySelector('[role="feed"] > article:first-of-type');
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
if (article) {
|
||||
e.preventDefault();
|
||||
article.focus();
|
||||
article.scrollIntoView();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props;
|
||||
const { fullscreen } = this.state;
|
||||
|
@ -182,7 +156,7 @@ export default class ScrollableList extends PureComponent {
|
|||
if (isLoading || childrenCount > 0 || !emptyMessage) {
|
||||
scrollableArea = (
|
||||
<div className={classNames('scrollable', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove} onMouseLeave={this.handleMouseLeave}>
|
||||
<div role='feed' className='item-list' onKeyDown={this.handleKeyDown}>
|
||||
<div role='feed' className='item-list'>
|
||||
{prepend}
|
||||
|
||||
{React.Children.map(this.props.children, (child, index) => (
|
||||
|
|
|
@ -10,6 +10,8 @@ import StatusActionBar from './status_action_bar';
|
|||
import { FormattedMessage } from 'react-intl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { MediaGallery, Video } from '../features/ui/util/async-components';
|
||||
import { HotKeys } from 'react-hotkeys';
|
||||
import classNames from 'classnames';
|
||||
|
||||
// We use the component (and not the container) since we do not want
|
||||
// to use the progress bar to show download progress
|
||||
|
@ -39,6 +41,8 @@ export default class Status extends ImmutablePureComponent {
|
|||
autoPlayGif: PropTypes.bool,
|
||||
muted: PropTypes.bool,
|
||||
hidden: PropTypes.bool,
|
||||
onMoveUp: PropTypes.func,
|
||||
onMoveDown: PropTypes.func,
|
||||
};
|
||||
|
||||
state = {
|
||||
|
@ -89,16 +93,62 @@ export default class Status extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
handleOpenVideo = startTime => {
|
||||
this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime);
|
||||
this.props.onOpenVideo(this._properStatus().getIn(['media_attachments', 0]), startTime);
|
||||
}
|
||||
|
||||
handleHotkeyReply = e => {
|
||||
e.preventDefault();
|
||||
this.props.onReply(this._properStatus(), this.context.router.history);
|
||||
}
|
||||
|
||||
handleHotkeyFavourite = () => {
|
||||
this.props.onFavourite(this._properStatus());
|
||||
}
|
||||
|
||||
handleHotkeyBoost = e => {
|
||||
this.props.onReblog(this._properStatus(), e);
|
||||
}
|
||||
|
||||
handleHotkeyMention = e => {
|
||||
e.preventDefault();
|
||||
this.props.onMention(this._properStatus().get('account'), this.context.router.history);
|
||||
}
|
||||
|
||||
handleHotkeyOpen = () => {
|
||||
this.context.router.history.push(`/statuses/${this._properStatus().get('id')}`);
|
||||
}
|
||||
|
||||
handleHotkeyOpenProfile = () => {
|
||||
this.context.router.history.push(`/accounts/${this._properStatus().getIn(['account', 'id'])}`);
|
||||
}
|
||||
|
||||
handleHotkeyMoveUp = () => {
|
||||
this.props.onMoveUp(this.props.status.get('id'));
|
||||
}
|
||||
|
||||
handleHotkeyMoveDown = () => {
|
||||
this.props.onMoveDown(this.props.status.get('id'));
|
||||
}
|
||||
|
||||
_properStatus () {
|
||||
const { status } = this.props;
|
||||
|
||||
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
|
||||
return status.get('reblog');
|
||||
} else {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
let media = null;
|
||||
let statusAvatar;
|
||||
let statusAvatar, prepend;
|
||||
|
||||
const { status, account, hidden, ...other } = this.props;
|
||||
const { hidden } = this.props;
|
||||
const { isExpanded } = this.state;
|
||||
|
||||
let { status, account, ...other } = this.props;
|
||||
|
||||
if (status === null) {
|
||||
return null;
|
||||
}
|
||||
|
@ -115,16 +165,15 @@ export default class Status extends ImmutablePureComponent {
|
|||
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
|
||||
const display_name_html = { __html: status.getIn(['account', 'display_name_html']) };
|
||||
|
||||
return (
|
||||
<div className='status__wrapper' data-id={status.get('id')} >
|
||||
<div className='status__prepend'>
|
||||
<div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div>
|
||||
<FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={display_name_html} /></a> }} />
|
||||
</div>
|
||||
|
||||
<Status {...other} status={status.get('reblog')} account={status.get('account')} />
|
||||
prepend = (
|
||||
<div className='status__prepend'>
|
||||
<div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div>
|
||||
<FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={display_name_html} /></a> }} />
|
||||
</div>
|
||||
);
|
||||
|
||||
account = status.get('account');
|
||||
status = status.get('reblog');
|
||||
}
|
||||
|
||||
if (status.get('media_attachments').size > 0 && !this.props.muted) {
|
||||
|
@ -160,26 +209,43 @@ export default class Status extends ImmutablePureComponent {
|
|||
statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`status ${this.props.muted ? 'muted' : ''} status-${status.get('visibility')}`} data-id={status.get('id')}>
|
||||
<div className='status__info'>
|
||||
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
|
||||
const handlers = this.props.muted ? {} : {
|
||||
reply: this.handleHotkeyReply,
|
||||
favourite: this.handleHotkeyFavourite,
|
||||
boost: this.handleHotkeyBoost,
|
||||
mention: this.handleHotkeyMention,
|
||||
open: this.handleHotkeyOpen,
|
||||
openProfile: this.handleHotkeyOpenProfile,
|
||||
moveUp: this.handleHotkeyMoveUp,
|
||||
moveDown: this.handleHotkeyMoveDown,
|
||||
};
|
||||
|
||||
<a onClick={this.handleAccountClick} target='_blank' data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name'>
|
||||
<div className='status__avatar'>
|
||||
{statusAvatar}
|
||||
return (
|
||||
<HotKeys handlers={handlers}>
|
||||
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0}>
|
||||
{prepend}
|
||||
|
||||
<div className={classNames('status', `status-${status.get('visibility')}`, { muted: this.props.muted })} data-id={status.get('id')}>
|
||||
<div className='status__info'>
|
||||
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
|
||||
|
||||
<a onClick={this.handleAccountClick} target='_blank' data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name'>
|
||||
<div className='status__avatar'>
|
||||
{statusAvatar}
|
||||
</div>
|
||||
|
||||
<DisplayName account={status.get('account')} />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<DisplayName account={status.get('account')} />
|
||||
</a>
|
||||
<StatusContent status={status} onClick={this.handleClick} expanded={isExpanded} onExpandedToggle={this.handleExpandedToggle} />
|
||||
|
||||
{media}
|
||||
|
||||
<StatusActionBar status={status} account={account} {...other} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StatusContent status={status} onClick={this.handleClick} expanded={isExpanded} onExpandedToggle={this.handleExpandedToggle} />
|
||||
|
||||
{media}
|
||||
|
||||
<StatusActionBar {...this.props} />
|
||||
</div>
|
||||
</HotKeys>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -25,18 +25,45 @@ export default class StatusList extends ImmutablePureComponent {
|
|||
trackScroll: true,
|
||||
};
|
||||
|
||||
handleMoveUp = id => {
|
||||
const elementIndex = this.props.statusIds.indexOf(id) - 1;
|
||||
this._selectChild(elementIndex);
|
||||
}
|
||||
|
||||
handleMoveDown = id => {
|
||||
const elementIndex = this.props.statusIds.indexOf(id) + 1;
|
||||
this._selectChild(elementIndex);
|
||||
}
|
||||
|
||||
_selectChild (index) {
|
||||
const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
|
||||
|
||||
if (element) {
|
||||
element.focus();
|
||||
}
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
render () {
|
||||
const { statusIds, ...other } = this.props;
|
||||
const { isLoading } = other;
|
||||
|
||||
const scrollableContent = (isLoading || statusIds.size > 0) ? (
|
||||
statusIds.map((statusId) => (
|
||||
<StatusContainer key={statusId} id={statusId} />
|
||||
<StatusContainer
|
||||
key={statusId}
|
||||
id={statusId}
|
||||
onMoveUp={this.handleMoveUp}
|
||||
onMoveDown={this.handleMoveDown}
|
||||
/>
|
||||
))
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<ScrollableList {...other}>
|
||||
<ScrollableList {...other} ref={this.setRef}>
|
||||
{scrollableContent}
|
||||
</ScrollableList>
|
||||
);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue