Просмотр исходного кода

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

Add keyboard shortcuts to Layout menu

GitOrigin-RevId: f6ea369f26024280df401b0444ddccf38e19c005
Miguel Serrano 4 лет назад
Родитель
Сommit
549a8497fb

+ 137 - 74
services/web/frontend/js/features/editor-navigation-toolbar/components/layout-dropdown-button.js

@@ -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>
-
-          {detachRole === 'detacher' ? (
-            <MenuItem>
-              {detachIsLinked ? <IconChecked /> : <IconRefresh />}
-              <IconDetach />
-              {t('pdf_in_separate_tab')}
-            </MenuItem>
+            }
+            shortcut="Control+Option+ArrowRight"
+          />
+
+          {'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,

+ 53 - 0
services/web/frontend/js/features/editor-navigation-toolbar/components/shortcut.tsx

@@ -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)

+ 0 - 87
services/web/frontend/stylesheets/app/editor/toolbar.less

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

+ 124 - 0
services/web/frontend/stylesheets/components/dropdowns.less

@@ -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;
+      }
+    }
+  }
+}