Преглед изворни кода

Add keyboard shortcuts for Layout actions (#30494)

GitOrigin-RevId: 7978a579e719c4cd593c02662406e364b19f87e0
Alf Eaton пре 7 месеци
родитељ
комит
ce5f66c21b

+ 3 - 33
services/web/frontend/js/features/editor-navigation-toolbar/components/layout-dropdown-button.tsx

@@ -1,4 +1,4 @@
-import { memo, useCallback, forwardRef } from 'react'
+import { memo, forwardRef } from 'react'
 import {
   Dropdown,
   DropdownItem,
@@ -13,7 +13,6 @@ import {
   useLayoutContext,
 } from '../../../shared/context/layout-context'
 import * as eventTracking from '../../../infrastructure/event-tracking'
-import useEventListener from '../../../shared/hooks/use-event-listener'
 import { DetachRole } from '@/shared/context/detach-context'
 import MaterialIcon from '@/shared/components/material-icon'
 import OLTooltip from '@/shared/components/ol/ol-tooltip'
@@ -96,43 +95,14 @@ function BS5DetachDisabled() {
 
 function LayoutDropdownButton() {
   const {
-    reattach,
-    detach,
     detachIsLinked,
     detachRole,
-    changeLayout,
     view,
     pdfLayout,
+    handleChangeLayout,
+    handleDetach,
   } = useLayoutContext()
 
-  const handleDetach = useCallback(() => {
-    detach()
-    eventTracking.sendMB('project-layout-detach')
-  }, [detach])
-
-  const handleReattach = useCallback(() => {
-    if (detachRole !== 'detacher') {
-      return
-    }
-    reattach()
-    eventTracking.sendMB('project-layout-reattach')
-  }, [detachRole, reattach])
-
-  // reattach when the PDF pane opens
-  useEventListener('ui:pdf-open', handleReattach)
-
-  const handleChangeLayout = useCallback(
-    (newLayout: IdeLayout, newView?: IdeView) => {
-      handleReattach()
-      changeLayout(newLayout, newView)
-      eventTracking.sendMB('project-layout-change', {
-        layout: newLayout,
-        view: newView,
-      })
-    },
-    [changeLayout, handleReattach]
-  )
-
   return (
     <LayoutDropdownButtonUi
       processing={!detachIsLinked && detachRole === 'detacher'}

+ 39 - 40
services/web/frontend/js/features/ide-redesign/components/toolbar/change-layout-options.tsx

@@ -7,12 +7,13 @@ import {
   IdeView,
   useLayoutContext,
 } from '@/shared/context/layout-context'
-import React, { useCallback } from 'react'
+import React from 'react'
 import { useTranslation } from 'react-i18next'
-import useEventListener from '@/shared/hooks/use-event-listener'
 import { DetachRole } from '@/shared/context/detach-context'
-import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics'
 import OLSpinner from '@/shared/components/ol/ol-spinner'
+import { isMac } from '@/shared/utils/os'
+import { Shortcut } from '@/shared/components/shortcut'
+import classNames from 'classnames'
 
 type LayoutOption = 'sideBySide' | 'editorOnly' | 'pdfOnly' | 'detachedPdf'
 
@@ -53,77 +54,63 @@ const LayoutDropdownItem = ({
   disabled = false,
   processing = false,
   leadingIcon,
+  trailingIcon,
   onClick,
   children,
 }: {
   active: boolean
-  leadingIcon: string
+  leadingIcon: React.ReactNode
+  trailingIcon?: React.ReactNode
   onClick: () => void
   children: React.ReactNode
   processing?: boolean
   disabled?: boolean
 }) => {
-  let trailingIcon: string | React.ReactNode | null = null
   if (processing) {
-    trailingIcon = <OLSpinner size="sm" />
+    leadingIcon = <OLSpinner size="sm" />
   } else if (active) {
-    trailingIcon = 'check'
+    leadingIcon = 'check'
   }
 
   return (
     <DropdownItem
       active={active}
       aria-current={active}
-      trailingIcon={trailingIcon}
       disabled={disabled}
       onClick={onClick}
       leadingIcon={leadingIcon}
+      trailingIcon={trailingIcon}
+      className={classNames({ 'dropdown-item-wide': isMac })}
     >
       {children}
     </DropdownItem>
   )
 }
 
+const shortcuts: Record<LayoutOption, string[] | null> = isMac
+  ? {
+      editorOnly: ['⌃', '⌘', '←'],
+      pdfOnly: ['⌃', '⌘', '→'],
+      sideBySide: ['⌃', '⌘', '↓'],
+      detachedPdf: ['⌃', '⌘', '↑'],
+    }
+  : {
+      editorOnly: null,
+      pdfOnly: null,
+      sideBySide: null,
+      detachedPdf: null,
+    }
+
 export default function ChangeLayoutOptions() {
-  const { sendEvent } = useEditorAnalytics()
   const {
-    reattach,
-    detach,
     detachIsLinked,
     detachRole,
-    changeLayout,
     view,
     pdfLayout,
+    handleChangeLayout,
+    handleDetach,
   } = useLayoutContext()
 
-  const handleDetach = useCallback(() => {
-    detach()
-    sendEvent('project-layout-detach')
-  }, [detach, sendEvent])
-
-  const handleReattach = useCallback(() => {
-    if (detachRole !== 'detacher') {
-      return
-    }
-    reattach()
-    sendEvent('project-layout-reattach')
-  }, [detachRole, reattach, sendEvent])
-
-  // reattach when the PDF pane opens
-  useEventListener('ui:pdf-open', handleReattach)
-
-  const handleChangeLayout = useCallback(
-    (newLayout: IdeLayout, newView?: IdeView) => {
-      handleReattach()
-      changeLayout(newLayout, newView)
-      sendEvent('project-layout-change', {
-        layout: newLayout,
-        view: newView,
-      })
-    },
-    [changeLayout, handleReattach, sendEvent]
-  )
-
   const { t } = useTranslation()
 
   const detachable = 'BroadcastChannel' in window
@@ -143,6 +130,9 @@ export default function ChangeLayoutOptions() {
         onClick={() => handleChangeLayout('sideBySide')}
         active={activeLayoutOption === 'sideBySide'}
         leadingIcon="splitscreen_right"
+        trailingIcon={
+          shortcuts.sideBySide && <Shortcut keys={shortcuts.sideBySide} />
+        }
       >
         {t('split_view')}
       </LayoutDropdownItem>
@@ -150,6 +140,9 @@ export default function ChangeLayoutOptions() {
         onClick={() => handleChangeLayout('flat', 'editor')}
         active={activeLayoutOption === 'editorOnly'}
         leadingIcon="edit"
+        trailingIcon={
+          shortcuts.editorOnly && <Shortcut keys={shortcuts.editorOnly} />
+        }
       >
         {t('editor_only')}
       </LayoutDropdownItem>
@@ -157,6 +150,9 @@ export default function ChangeLayoutOptions() {
         onClick={() => handleChangeLayout('flat', 'pdf')}
         active={activeLayoutOption === 'pdfOnly'}
         leadingIcon="picture_as_pdf"
+        trailingIcon={
+          shortcuts.pdfOnly && <Shortcut keys={shortcuts.pdfOnly} />
+        }
       >
         {t('pdf_only')}
       </LayoutDropdownItem>
@@ -165,6 +161,9 @@ export default function ChangeLayoutOptions() {
         active={activeLayoutOption === 'detachedPdf' && detachIsLinked}
         disabled={!detachable}
         leadingIcon="open_in_new"
+        trailingIcon={
+          shortcuts.detachedPdf && <Shortcut keys={shortcuts.detachedPdf} />
+        }
         processing={waitingForDetachedLink}
       >
         {t('open_pdf_in_separate_tab')}

+ 1 - 18
services/web/frontend/js/features/pdf-preview/components/pdf-zoom-dropdown.tsx

@@ -1,6 +1,5 @@
 import { useEffect, useState } from 'react'
 import { useTranslation } from 'react-i18next'
-import classNames from 'classnames'
 import {
   Dropdown,
   DropdownDivider,
@@ -11,6 +10,7 @@ import {
 } from '@/shared/components/dropdown/dropdown-menu'
 import FormControl from '@/shared/components/form/form-control'
 import { isMac } from '@/shared/utils/os'
+import { Shortcut } from '@/shared/components/shortcut'
 
 const shortcuts = isMac
   ? {
@@ -171,21 +171,4 @@ function PdfZoomDropdown({
   )
 }
 
-function Shortcut({ keys }: { keys: string[] }) {
-  return (
-    <span className="float-end">
-      {keys.map((key, idx) => (
-        <span
-          className={classNames({
-            'pdfjs-zoom-dropdown-mac-shortcut-char': key.length === 1,
-          })}
-          key={`${key}${idx}`}
-        >
-          {key}
-        </span>
-      ))}
-    </span>
-  )
-}
-
 export default PdfZoomDropdown

+ 18 - 0
services/web/frontend/js/shared/components/shortcut.tsx

@@ -0,0 +1,18 @@
+import classNames from 'classnames'
+
+export function Shortcut({ keys }: { keys: string[] }) {
+  return (
+    <span>
+      {keys.map((key, idx) => (
+        <span
+          className={classNames({
+            'dropdown-shortcut-char': key.length === 1,
+          })}
+          key={`${key}${idx}`}
+        >
+          {key}
+        </span>
+      ))}
+    </span>
+  )
+}

+ 70 - 0
services/web/frontend/js/shared/context/layout-context.tsx

@@ -23,6 +23,7 @@ import { sendSearchEvent } from '@/features/event-tracking/search-events'
 import { useRailContext } from '@/features/ide-redesign/contexts/rail-context'
 import usePersistedState from '@/shared/hooks/use-persisted-state'
 import { repositionAllTooltips } from '@/features/source-editor/extensions/tooltips-reposition'
+import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics'
 
 export type IdeLayout = 'sideBySide' | 'flat'
 export type IdeView = 'editor' | 'file' | 'pdf' | 'history'
@@ -63,6 +64,8 @@ export type LayoutContextValue = LayoutContextOwnStates & {
   setProjectSearchIsOpen: Dispatch<SetStateAction<boolean>>
   setOpenFile: Dispatch<SetStateAction<BinaryFile | null>>
   restoreView: () => void
+  handleChangeLayout: (newLayout: IdeLayout, newView?: IdeView) => void
+  handleDetach: () => void
 }
 
 const debugPdfDetach = getMeta('ol-debugPdfDetach')
@@ -89,6 +92,7 @@ export const LayoutProvider: FC<React.PropsWithChildren> = ({ children }) => {
   const [prevRailIsOpen, setPrevRailIsOpen] = useState(railIsOpen)
   // Whether we came from a file or a document when we left the ide
   const lastIdeView = useRef<IdeView>('editor')
+  const { sendEvent } = useEditorAnalytics()
 
   const setView = useCallback(
     (value: IdeView | null) => {
@@ -258,6 +262,68 @@ export const LayoutProvider: FC<React.PropsWithChildren> = ({ children }) => {
     changeLayout,
   ])
 
+  const handleDetach = useCallback(() => {
+    detach()
+    sendEvent('project-layout-detach')
+  }, [detach, sendEvent])
+
+  const handleReattach = useCallback(() => {
+    if (detachRole !== 'detacher') {
+      return
+    }
+    reattach()
+    sendEvent('project-layout-reattach')
+  }, [detachRole, reattach, sendEvent])
+
+  const handleChangeLayout = useCallback(
+    (newLayout: IdeLayout, newView?: IdeView) => {
+      handleReattach()
+      changeLayout(newLayout, newView)
+      sendEvent('project-layout-change', {
+        layout: newLayout,
+        view: newView,
+      })
+    },
+    [changeLayout, handleReattach, sendEvent]
+  )
+
+  useEventListener(
+    'keydown',
+    useCallback(
+      (event: KeyboardEvent) => {
+        if (
+          isMac &&
+          event.metaKey &&
+          event.ctrlKey &&
+          !event.shiftKey &&
+          !event.altKey
+        ) {
+          switch (event.code) {
+            case 'ArrowLeft': // Editor only
+              event.preventDefault()
+              handleChangeLayout('flat', 'editor')
+              break
+            case 'ArrowRight': // PDF only
+              event.preventDefault()
+              handleChangeLayout('flat', 'pdf')
+              break
+            case 'ArrowDown': // Split view
+              event.preventDefault()
+              handleChangeLayout('sideBySide')
+              break
+            case 'ArrowUp': // Open PDF in separate tab (detach)
+              event.preventDefault()
+              if ('BroadcastChannel' in window && detachRole !== 'detacher') {
+                handleDetach()
+              }
+              break
+          }
+        }
+      },
+      [detachRole, handleChangeLayout, handleDetach]
+    )
+  )
+
   const value = useMemo<LayoutContextValue>(
     () => ({
       reattach,
@@ -285,6 +351,8 @@ export const LayoutProvider: FC<React.PropsWithChildren> = ({ children }) => {
       setView,
       view,
       restoreView,
+      handleChangeLayout,
+      handleDetach,
     }),
     [
       reattach,
@@ -312,6 +380,8 @@ export const LayoutProvider: FC<React.PropsWithChildren> = ({ children }) => {
       setView,
       view,
       restoreView,
+      handleChangeLayout,
+      handleDetach,
     ]
   )
 

+ 10 - 0
services/web/frontend/stylesheets/components/dropdown-menu.scss

@@ -272,3 +272,13 @@ $dropdown-item-min-height: 36px;
   display: flex;
   justify-content: space-between;
 }
+
+.dropdown-shortcut-char {
+  display: inline-block;
+  width: 1em;
+  text-align: center;
+}
+
+.dropdown-item-wide {
+  min-width: 264px;
+}

+ 0 - 6
services/web/frontend/stylesheets/pages/editor/pdf.scss

@@ -385,12 +385,6 @@
   font-weight: normal;
 }
 
-.pdfjs-zoom-dropdown-mac-shortcut-char {
-  display: inline-block;
-  width: 1em;
-  text-align: center;
-}
-
 .pdfjs-custom-zoom-menu-item {
   display: block;
   pointer-events: initial !important;

+ 2 - 0
services/web/test/frontend/features/full-project-search/components/full-project-search.spec.tsx

@@ -96,6 +96,8 @@ const createInitialValue = () =>
     openFile: null,
     setOpenFile: cy.stub(),
     restoreView: cy.stub(),
+    handleChangeLayout: cy.stub(),
+    handleDetach: cy.stub(),
   }) satisfies LayoutContextValue
 
 const LayoutProvider: FC<React.PropsWithChildren> = ({ children }) => {

+ 33 - 0
services/web/test/frontend/helpers/editor-providers.tsx

@@ -48,6 +48,7 @@ import {
 import { UserId } from '../../../types/user'
 import { ProjectCompiler } from '../../../types/project-settings'
 import { ReferencesContext } from '@/features/ide-react/context/references-context'
+import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics'
 
 // these constants can be imported in tests instead of
 // using magic strings
@@ -429,6 +430,8 @@ const makeLayoutProvider = (
       layout.loadingStyleSheet
     )
 
+    const { sendEvent } = useEditorAnalytics()
+
     useEventListener(
       'ui.toggle-review-panel',
       useCallback(() => {
@@ -453,8 +456,34 @@ const makeLayoutProvider = (
       isLinked: detachIsLinked,
       role: detachRole,
     } = useDetachLayout()
+
+    const handleDetach = useCallback(() => {
+      detach()
+      sendEvent('project-layout-detach')
+    }, [detach, sendEvent])
+
+    const handleReattach = useCallback(() => {
+      if (detachRole !== 'detacher') {
+        return
+      }
+      reattach()
+      sendEvent('project-layout-reattach')
+    }, [detachRole, reattach, sendEvent])
+
+    const handleChangeLayout = useCallback(
+      (newLayout: IdeLayout, newView?: IdeView) => {
+        handleReattach()
+        changeLayout(newLayout, newView)
+        sendEvent('project-layout-change', {
+          layout: newLayout,
+          view: newView,
+        })
+      },
+      [changeLayout, handleReattach, sendEvent]
+    )
     const pdfPreviewOpen =
       pdfLayout === 'sideBySide' || view === 'pdf' || detachRole === 'detacher'
+
     const value = useMemo(
       () => ({
         reattach,
@@ -482,6 +511,8 @@ const makeLayoutProvider = (
         setView,
         view,
         restoreView,
+        handleChangeLayout,
+        handleDetach,
       }),
       [
         reattach,
@@ -509,6 +540,8 @@ const makeLayoutProvider = (
         setView,
         view,
         restoreView,
+        handleChangeLayout,
+        handleDetach,
       ]
     )