Merge pull request #8082 from overleaf/ae-layout-shortcut

Add keyboard shortcuts to Layout menu

GitOrigin-RevId: f6ea369f26024280df401b0444ddccf38e19c005
This commit is contained in:
Miguel Serrano 2022-05-24 13:22:11 +02:00 committed by Copybot
parent 430b7528b2
commit 549a8497fb
4 changed files with 313 additions and 160 deletions

View file

@ -1,4 +1,4 @@
import { useCallback } from 'react'
import { memo, useCallback, useEffect, useMemo } from 'react'
import PropTypes from 'prop-types'
import { Dropdown, MenuItem } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
@ -11,6 +11,7 @@ import IconPdfOnly from './icon-pdf-only'
import { useLayoutContext } from '../../../shared/context/layout-context'
import * as eventTracking from '../../../infrastructure/event-tracking'
import useEventListener from '../../../shared/hooks/use-event-listener'
import Shortcut from './shortcut'
function IconPlaceholder() {
return <Icon type="" fw />
@ -47,27 +48,46 @@ function IconCheckmark({ iconFor, pdfLayout, view, detachRole }) {
return <IconPlaceholder />
}
function PdfDetachMenuItem({ handleDetach, children }) {
const { t } = useTranslation()
if (!('BroadcastChannel' in window)) {
return (
<Tooltip
id="detach-disabled"
description={t('your_browser_does_not_support_this_feature')}
overlayProps={{ placement: 'left' }}
>
<MenuItem disabled>{children}</MenuItem>
</Tooltip>
)
}
return <MenuItem onSelect={handleDetach}>{children}</MenuItem>
function LayoutMenuItem({ checkmark, icon, text, shortcut, ...props }) {
return (
<MenuItem {...props}>
<div className="layout-menu-item">
<div className="layout-menu-item-start">
<div>{checkmark}</div>
<div>{icon}</div>
<div>{text}</div>
</div>
<Shortcut shortcut={shortcut} />
</div>
</MenuItem>
)
}
LayoutMenuItem.propTypes = {
checkmark: PropTypes.node.isRequired,
icon: PropTypes.node.isRequired,
text: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
shortcut: PropTypes.string.isRequired,
onSelect: PropTypes.func,
}
PdfDetachMenuItem.propTypes = {
handleDetach: PropTypes.func.isRequired,
children: PropTypes.arrayOf(PropTypes.node).isRequired,
function DetachDisabled() {
const { t } = useTranslation()
return (
<Tooltip
id="detach-disabled"
description={t('your_browser_does_not_support_this_feature')}
overlayProps={{ placement: 'left' }}
>
<LayoutMenuItem
disabled
checkmark={<IconPlaceholder />}
icon={<IconDetach />}
text={t('pdf_in_separate_tab')}
shortcut="Control+Option+ArrowUp"
/>
</Tooltip>
)
}
function LayoutDropdownButton() {
@ -111,6 +131,31 @@ function LayoutDropdownButton() {
[changeLayout, handleReattach]
)
const keyMap = useMemo(() => {
return {
ArrowDown: () => handleChangeLayout('sideBySide', null),
ArrowRight: () => handleChangeLayout('flat', 'pdf'),
ArrowLeft: () => handleChangeLayout('flat', 'editor'),
ArrowUp: () => handleDetach(),
}
}, [handleChangeLayout, handleDetach])
useEffect(() => {
const listener = event => {
if (event.ctrlKey && event.altKey && event.key in keyMap) {
event.preventDefault()
event.stopImmediatePropagation()
keyMap[event.key]()
}
}
window.addEventListener('keydown', listener, true)
return () => {
window.removeEventListener('keydown', listener, true)
}
}, [keyMap])
const processing = !detachIsLinked && detachRole === 'detacher'
// bsStyle is required for Dropdown.Toggle, but we will override style
@ -137,76 +182,94 @@ function LayoutDropdownButton() {
<span className="beta-badge" />
</Tooltip>
</Dropdown.Toggle>
<Dropdown.Menu id="layout-dropdown-list">
<MenuItem onSelect={() => handleChangeLayout('sideBySide')}>
<IconCheckmark
iconFor="sideBySide"
pdfLayout={pdfLayout}
view={view}
detachRole={detachRole}
/>
<Icon type="columns" fw />
{t('editor_and_pdf')}
</MenuItem>
<Dropdown.Menu className="layout-dropdown-list">
<LayoutMenuItem
onSelect={() => handleChangeLayout('sideBySide')}
checkmark={
<IconCheckmark
iconFor="sideBySide"
pdfLayout={pdfLayout}
view={view}
detachRole={detachRole}
/>
}
icon={<Icon type="columns" fw />}
text={t('editor_and_pdf')}
shortcut="Control+Option+ArrowDown"
/>
<MenuItem
<LayoutMenuItem
onSelect={() => handleChangeLayout('flat', 'editor')}
className="menu-item-with-svg"
>
<IconCheckmark
iconFor="editorOnly"
pdfLayout={pdfLayout}
view={view}
detachRole={detachRole}
/>
<i className="fa fa-fw">
<IconEditorOnly />
</i>
<span>
checkmark={
<IconCheckmark
iconFor="editorOnly"
pdfLayout={pdfLayout}
view={view}
detachRole={detachRole}
/>
}
icon={
<i className="fa fa-fw">
<IconEditorOnly />
</i>
}
text={
<Trans
i18nKey="editor_only_hide_pdf"
components={[
<span key="editor_only_hide_pdf" className="subdued" />,
]}
/>
</span>
</MenuItem>
}
shortcut="Control+Option+ArrowLeft"
/>
<MenuItem
<LayoutMenuItem
onSelect={() => handleChangeLayout('flat', 'pdf')}
className="menu-item-with-svg"
>
<IconCheckmark
iconFor="pdfOnly"
pdfLayout={pdfLayout}
view={view}
detachRole={detachRole}
/>
<i className="fa fa-fw">
<IconPdfOnly />
</i>
<span>
checkmark={
<IconCheckmark
iconFor="pdfOnly"
pdfLayout={pdfLayout}
view={view}
detachRole={detachRole}
/>
}
icon={
<i className="fa fa-fw">
<IconPdfOnly />
</i>
}
text={
<Trans
i18nKey="pdf_only_hide_editor"
components={[
<span key="pdf_only_hide_editor" className="subdued" />,
]}
/>
</span>
</MenuItem>
}
shortcut="Control+Option+ArrowRight"
/>
{detachRole === 'detacher' ? (
<MenuItem>
{detachIsLinked ? <IconChecked /> : <IconRefresh />}
<IconDetach />
{t('pdf_in_separate_tab')}
</MenuItem>
{'BroadcastChannel' in window ? (
<LayoutMenuItem
onSelect={() => handleDetach()}
checkmark={
detachRole === 'detacher' ? (
detachIsLinked ? (
<IconChecked />
) : (
<IconRefresh />
)
) : (
<IconPlaceholder />
)
}
icon={<IconDetach />}
text={t('pdf_in_separate_tab')}
shortcut="Control+Option+ArrowUp"
/>
) : (
<PdfDetachMenuItem handleDetach={handleDetach}>
<IconPlaceholder />
<IconDetach />
{t('pdf_in_separate_tab')}
</PdfDetachMenuItem>
<DetachDisabled />
)}
<MenuItem divider />
@ -232,7 +295,7 @@ function LayoutDropdownButton() {
)
}
export default LayoutDropdownButton
export default memo(LayoutDropdownButton)
IconCheckmark.propTypes = {
iconFor: PropTypes.string.isRequired,

View file

@ -0,0 +1,53 @@
import { Fragment, memo } from 'react'
const isMac = /Mac/.test(window.navigator.platform)
const symbols = isMac
? {
CommandOrControl: '⌘',
Option: '⌥',
Control: '⌃',
Shift: '⇧',
ArrowRight: '→',
ArrowDown: '↓',
ArrowLeft: '←',
ArrowUp: '↑',
}
: {
CommandOrControl: 'Ctrl',
Control: 'Ctrl',
Option: 'Alt',
Shift: 'Shift',
ArrowRight: '→',
ArrowDown: '↓',
ArrowLeft: '←',
ArrowUp: '↑',
}
const separator = isMac ? '' : '+'
const chooseCharacter = (input: string): string =>
input in symbols ? symbols[input] : input
const Shortcut = ({ shortcut }: { shortcut: string }) => {
return (
<div className="shortcut" aria-hidden="true">
{shortcut.split('+').map((item, index) => {
const char = chooseCharacter(item)
return (
<Fragment key={item}>
{index > 0 && separator}
{char.length === 1 ? (
<span className="shortcut-symbol">{char}</span>
) : (
char
)}
</Fragment>
)
})}
</div>
)
}
export default memo(Shortcut)

View file

@ -161,30 +161,6 @@
}
}
#layout-dropdown {
// override style added by required bsStyle react-bootstrap prop
text-decoration: none !important;
}
.layout-dropdown {
.pdf-detach-survey {
display: flex;
font-size: @font-size-small;
}
.pdf-detach-survey-text {
margin-left: @margin-sm;
white-space: normal;
}
}
#layout-dropdown-list {
a {
i {
margin-right: @margin-xs;
}
}
}
.header-cobranding-logo {
display: block;
width: auto;
@ -462,66 +438,3 @@
.opacity(0.65);
.box-shadow(none);
}
.menu-item-with-svg {
a {
align-items: center;
display: flex !important;
}
svg {
line,
rect {
stroke: @dropdown-link-color;
}
path {
fill: @dropdown-link-color;
}
}
a:hover,
a:focus {
svg {
line,
rect {
stroke: @dropdown-link-hover-color;
}
path {
fill: @dropdown-link-hover-color;
}
}
}
&.disabled {
.subdued {
color: @dropdown-link-disabled-color;
}
svg {
line,
rect {
stroke: @dropdown-link-disabled-color;
}
path {
fill: @dropdown-link-disabled-color;
}
}
a:hover,
a:focus {
.subdued {
color: @dropdown-link-disabled-color;
}
svg {
line,
rect {
stroke: @dropdown-link-disabled-color;
}
path {
fill: @dropdown-link-disabled-color;
}
}
}
}
}

View file

@ -120,6 +120,9 @@ button.dropdown-toggle.dropdown-toggle-no-background {
.subdued {
color: @dropdown-link-hover-color;
}
div {
color: @dropdown-link-hover-color;
}
}
}
@ -252,3 +255,124 @@ button.dropdown-toggle.dropdown-toggle-no-background {
}
}
}
.layout-dropdown {
.dropdown-toggle {
// override style added by required bsStyle react-bootstrap prop
text-decoration: none !important;
}
.dropdown-menu > li {
svg {
line,
rect {
stroke: @dropdown-link-color;
}
path {
fill: @dropdown-link-color;
}
}
> a {
padding: 4px 10px;
}
> a:hover,
> a:focus {
svg {
line,
rect {
stroke: @dropdown-link-hover-color;
}
path {
fill: @dropdown-link-hover-color;
}
}
}
&.disabled {
.subdued {
color: @dropdown-link-disabled-color;
}
svg {
line,
rect {
stroke: @dropdown-link-disabled-color;
}
path {
fill: @dropdown-link-disabled-color;
}
}
> a:hover,
> a:focus {
.subdued {
color: @dropdown-link-disabled-color;
}
svg {
line,
rect {
stroke: @dropdown-link-disabled-color;
}
path {
fill: @dropdown-link-disabled-color;
}
}
}
}
}
.layout-menu-item {
display: flex;
align-items: center;
white-space: nowrap;
justify-content: space-between;
padding: 0;
.layout-menu-item-start {
display: flex;
align-items: center;
padding: 0;
> div {
padding: 0;
}
}
}
.shortcut {
color: @gray;
font-family: system-ui, -apple-system, monospace;
font-size: 14px;
padding-right: 0;
padding-left: 10px;
display: inline-flex;
align-items: center;
.shortcut-symbol {
display: inline-flex;
justify-content: center;
width: 1em;
}
}
.pdf-detach-survey {
display: flex;
font-size: @font-size-small;
}
.pdf-detach-survey-text {
margin-left: @margin-sm;
white-space: normal;
}
.layout-dropdown-list {
a {
i {
margin-right: @margin-xs;
}
}
}
}