From ce5f66c21b93c0b0c0eda9c982d9f4c9866c4975 Mon Sep 17 00:00:00 2001 From: Alf Eaton Date: Thu, 8 Jan 2026 11:41:59 +0000 Subject: [PATCH] Add keyboard shortcuts for Layout actions (#30494) GitOrigin-RevId: 7978a579e719c4cd593c02662406e364b19f87e0 --- .../components/layout-dropdown-button.tsx | 36 +-------- .../toolbar/change-layout-options.tsx | 79 +++++++++---------- .../components/pdf-zoom-dropdown.tsx | 19 +---- .../js/shared/components/shortcut.tsx | 18 +++++ .../js/shared/context/layout-context.tsx | 70 ++++++++++++++++ .../stylesheets/components/dropdown-menu.scss | 10 +++ .../stylesheets/pages/editor/pdf.scss | 6 -- .../components/full-project-search.spec.tsx | 2 + .../frontend/helpers/editor-providers.tsx | 33 ++++++++ 9 files changed, 176 insertions(+), 97 deletions(-) create mode 100644 services/web/frontend/js/shared/components/shortcut.tsx diff --git a/services/web/frontend/js/features/editor-navigation-toolbar/components/layout-dropdown-button.tsx b/services/web/frontend/js/features/editor-navigation-toolbar/components/layout-dropdown-button.tsx index 71a4b2c7af..60e732ad40 100644 --- a/services/web/frontend/js/features/editor-navigation-toolbar/components/layout-dropdown-button.tsx +++ b/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 ( void children: React.ReactNode processing?: boolean disabled?: boolean }) => { - let trailingIcon: string | React.ReactNode | null = null if (processing) { - trailingIcon = + leadingIcon = } else if (active) { - trailingIcon = 'check' + leadingIcon = 'check' } return ( {children} ) } +const shortcuts: Record = 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 && + } > {t('split_view')} @@ -150,6 +140,9 @@ export default function ChangeLayoutOptions() { onClick={() => handleChangeLayout('flat', 'editor')} active={activeLayoutOption === 'editorOnly'} leadingIcon="edit" + trailingIcon={ + shortcuts.editorOnly && + } > {t('editor_only')} @@ -157,6 +150,9 @@ export default function ChangeLayoutOptions() { onClick={() => handleChangeLayout('flat', 'pdf')} active={activeLayoutOption === 'pdfOnly'} leadingIcon="picture_as_pdf" + trailingIcon={ + shortcuts.pdfOnly && + } > {t('pdf_only')} @@ -165,6 +161,9 @@ export default function ChangeLayoutOptions() { active={activeLayoutOption === 'detachedPdf' && detachIsLinked} disabled={!detachable} leadingIcon="open_in_new" + trailingIcon={ + shortcuts.detachedPdf && + } processing={waitingForDetachedLink} > {t('open_pdf_in_separate_tab')} diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-zoom-dropdown.tsx b/services/web/frontend/js/features/pdf-preview/components/pdf-zoom-dropdown.tsx index 40120ded5a..fb9761ed9f 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-zoom-dropdown.tsx +++ b/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 ( - - {keys.map((key, idx) => ( - - {key} - - ))} - - ) -} - export default PdfZoomDropdown diff --git a/services/web/frontend/js/shared/components/shortcut.tsx b/services/web/frontend/js/shared/components/shortcut.tsx new file mode 100644 index 0000000000..ffd8fc3ce0 --- /dev/null +++ b/services/web/frontend/js/shared/components/shortcut.tsx @@ -0,0 +1,18 @@ +import classNames from 'classnames' + +export function Shortcut({ keys }: { keys: string[] }) { + return ( + + {keys.map((key, idx) => ( + + {key} + + ))} + + ) +} diff --git a/services/web/frontend/js/shared/context/layout-context.tsx b/services/web/frontend/js/shared/context/layout-context.tsx index c3647d3926..2419a0210e 100644 --- a/services/web/frontend/js/shared/context/layout-context.tsx +++ b/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> setOpenFile: Dispatch> restoreView: () => void + handleChangeLayout: (newLayout: IdeLayout, newView?: IdeView) => void + handleDetach: () => void } const debugPdfDetach = getMeta('ol-debugPdfDetach') @@ -89,6 +92,7 @@ export const LayoutProvider: FC = ({ children }) => { const [prevRailIsOpen, setPrevRailIsOpen] = useState(railIsOpen) // Whether we came from a file or a document when we left the ide const lastIdeView = useRef('editor') + const { sendEvent } = useEditorAnalytics() const setView = useCallback( (value: IdeView | null) => { @@ -258,6 +262,68 @@ export const LayoutProvider: FC = ({ 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( () => ({ reattach, @@ -285,6 +351,8 @@ export const LayoutProvider: FC = ({ children }) => { setView, view, restoreView, + handleChangeLayout, + handleDetach, }), [ reattach, @@ -312,6 +380,8 @@ export const LayoutProvider: FC = ({ children }) => { setView, view, restoreView, + handleChangeLayout, + handleDetach, ] ) diff --git a/services/web/frontend/stylesheets/components/dropdown-menu.scss b/services/web/frontend/stylesheets/components/dropdown-menu.scss index d053d4de14..c3fdaaa95a 100644 --- a/services/web/frontend/stylesheets/components/dropdown-menu.scss +++ b/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; +} diff --git a/services/web/frontend/stylesheets/pages/editor/pdf.scss b/services/web/frontend/stylesheets/pages/editor/pdf.scss index 4a5e3385fd..9b8983ae4a 100644 --- a/services/web/frontend/stylesheets/pages/editor/pdf.scss +++ b/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; diff --git a/services/web/test/frontend/features/full-project-search/components/full-project-search.spec.tsx b/services/web/test/frontend/features/full-project-search/components/full-project-search.spec.tsx index 6e04a9cf3f..1486be140f 100644 --- a/services/web/test/frontend/features/full-project-search/components/full-project-search.spec.tsx +++ b/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 = ({ children }) => { diff --git a/services/web/test/frontend/helpers/editor-providers.tsx b/services/web/test/frontend/helpers/editor-providers.tsx index 764b0cb43d..cab6721853 100644 --- a/services/web/test/frontend/helpers/editor-providers.tsx +++ b/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, ] )