Add keyboard shortcuts for Layout actions (#30494)

GitOrigin-RevId: 7978a579e719c4cd593c02662406e364b19f87e0
This commit is contained in:
Alf Eaton
2026-01-09 09:05:56 +00:00
committed by Copybot
parent 6de0273d45
commit ce5f66c21b
9 changed files with 176 additions and 97 deletions
@@ -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'}
@@ -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,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
@@ -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>
)
}
@@ -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,
]
)
@@ -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;
}
@@ -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;
@@ -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 }) => {
@@ -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,
]
)