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 { import {
Dropdown, Dropdown,
DropdownItem, DropdownItem,
@@ -13,7 +13,6 @@ import {
useLayoutContext, useLayoutContext,
} from '../../../shared/context/layout-context' } from '../../../shared/context/layout-context'
import * as eventTracking from '../../../infrastructure/event-tracking' import * as eventTracking from '../../../infrastructure/event-tracking'
import useEventListener from '../../../shared/hooks/use-event-listener'
import { DetachRole } from '@/shared/context/detach-context' import { DetachRole } from '@/shared/context/detach-context'
import MaterialIcon from '@/shared/components/material-icon' import MaterialIcon from '@/shared/components/material-icon'
import OLTooltip from '@/shared/components/ol/ol-tooltip' import OLTooltip from '@/shared/components/ol/ol-tooltip'
@@ -96,43 +95,14 @@ function BS5DetachDisabled() {
function LayoutDropdownButton() { function LayoutDropdownButton() {
const { const {
reattach,
detach,
detachIsLinked, detachIsLinked,
detachRole, detachRole,
changeLayout,
view, view,
pdfLayout, pdfLayout,
handleChangeLayout,
handleDetach,
} = useLayoutContext() } = 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 ( return (
<LayoutDropdownButtonUi <LayoutDropdownButtonUi
processing={!detachIsLinked && detachRole === 'detacher'} processing={!detachIsLinked && detachRole === 'detacher'}
@@ -7,12 +7,13 @@ import {
IdeView, IdeView,
useLayoutContext, useLayoutContext,
} from '@/shared/context/layout-context' } from '@/shared/context/layout-context'
import React, { useCallback } from 'react' import React from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import useEventListener from '@/shared/hooks/use-event-listener'
import { DetachRole } from '@/shared/context/detach-context' import { DetachRole } from '@/shared/context/detach-context'
import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics'
import OLSpinner from '@/shared/components/ol/ol-spinner' 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' type LayoutOption = 'sideBySide' | 'editorOnly' | 'pdfOnly' | 'detachedPdf'
@@ -53,77 +54,63 @@ const LayoutDropdownItem = ({
disabled = false, disabled = false,
processing = false, processing = false,
leadingIcon, leadingIcon,
trailingIcon,
onClick, onClick,
children, children,
}: { }: {
active: boolean active: boolean
leadingIcon: string leadingIcon: React.ReactNode
trailingIcon?: React.ReactNode
onClick: () => void onClick: () => void
children: React.ReactNode children: React.ReactNode
processing?: boolean processing?: boolean
disabled?: boolean disabled?: boolean
}) => { }) => {
let trailingIcon: string | React.ReactNode | null = null
if (processing) { if (processing) {
trailingIcon = <OLSpinner size="sm" /> leadingIcon = <OLSpinner size="sm" />
} else if (active) { } else if (active) {
trailingIcon = 'check' leadingIcon = 'check'
} }
return ( return (
<DropdownItem <DropdownItem
active={active} active={active}
aria-current={active} aria-current={active}
trailingIcon={trailingIcon}
disabled={disabled} disabled={disabled}
onClick={onClick} onClick={onClick}
leadingIcon={leadingIcon} leadingIcon={leadingIcon}
trailingIcon={trailingIcon}
className={classNames({ 'dropdown-item-wide': isMac })}
> >
{children} {children}
</DropdownItem> </DropdownItem>
) )
} }
const shortcuts: Record<LayoutOption, string[] | null> = isMac
? {
editorOnly: ['⌃', '⌘', '←'],
pdfOnly: ['⌃', '⌘', '→'],
sideBySide: ['⌃', '⌘', '↓'],
detachedPdf: ['⌃', '⌘', '↑'],
}
: {
editorOnly: null,
pdfOnly: null,
sideBySide: null,
detachedPdf: null,
}
export default function ChangeLayoutOptions() { export default function ChangeLayoutOptions() {
const { sendEvent } = useEditorAnalytics()
const { const {
reattach,
detach,
detachIsLinked, detachIsLinked,
detachRole, detachRole,
changeLayout,
view, view,
pdfLayout, pdfLayout,
handleChangeLayout,
handleDetach,
} = useLayoutContext() } = 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 { t } = useTranslation()
const detachable = 'BroadcastChannel' in window const detachable = 'BroadcastChannel' in window
@@ -143,6 +130,9 @@ export default function ChangeLayoutOptions() {
onClick={() => handleChangeLayout('sideBySide')} onClick={() => handleChangeLayout('sideBySide')}
active={activeLayoutOption === 'sideBySide'} active={activeLayoutOption === 'sideBySide'}
leadingIcon="splitscreen_right" leadingIcon="splitscreen_right"
trailingIcon={
shortcuts.sideBySide && <Shortcut keys={shortcuts.sideBySide} />
}
> >
{t('split_view')} {t('split_view')}
</LayoutDropdownItem> </LayoutDropdownItem>
@@ -150,6 +140,9 @@ export default function ChangeLayoutOptions() {
onClick={() => handleChangeLayout('flat', 'editor')} onClick={() => handleChangeLayout('flat', 'editor')}
active={activeLayoutOption === 'editorOnly'} active={activeLayoutOption === 'editorOnly'}
leadingIcon="edit" leadingIcon="edit"
trailingIcon={
shortcuts.editorOnly && <Shortcut keys={shortcuts.editorOnly} />
}
> >
{t('editor_only')} {t('editor_only')}
</LayoutDropdownItem> </LayoutDropdownItem>
@@ -157,6 +150,9 @@ export default function ChangeLayoutOptions() {
onClick={() => handleChangeLayout('flat', 'pdf')} onClick={() => handleChangeLayout('flat', 'pdf')}
active={activeLayoutOption === 'pdfOnly'} active={activeLayoutOption === 'pdfOnly'}
leadingIcon="picture_as_pdf" leadingIcon="picture_as_pdf"
trailingIcon={
shortcuts.pdfOnly && <Shortcut keys={shortcuts.pdfOnly} />
}
> >
{t('pdf_only')} {t('pdf_only')}
</LayoutDropdownItem> </LayoutDropdownItem>
@@ -165,6 +161,9 @@ export default function ChangeLayoutOptions() {
active={activeLayoutOption === 'detachedPdf' && detachIsLinked} active={activeLayoutOption === 'detachedPdf' && detachIsLinked}
disabled={!detachable} disabled={!detachable}
leadingIcon="open_in_new" leadingIcon="open_in_new"
trailingIcon={
shortcuts.detachedPdf && <Shortcut keys={shortcuts.detachedPdf} />
}
processing={waitingForDetachedLink} processing={waitingForDetachedLink}
> >
{t('open_pdf_in_separate_tab')} {t('open_pdf_in_separate_tab')}
@@ -1,6 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import classNames from 'classnames'
import { import {
Dropdown, Dropdown,
DropdownDivider, DropdownDivider,
@@ -11,6 +10,7 @@ import {
} from '@/shared/components/dropdown/dropdown-menu' } from '@/shared/components/dropdown/dropdown-menu'
import FormControl from '@/shared/components/form/form-control' import FormControl from '@/shared/components/form/form-control'
import { isMac } from '@/shared/utils/os' import { isMac } from '@/shared/utils/os'
import { Shortcut } from '@/shared/components/shortcut'
const shortcuts = isMac 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 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 { useRailContext } from '@/features/ide-redesign/contexts/rail-context'
import usePersistedState from '@/shared/hooks/use-persisted-state' import usePersistedState from '@/shared/hooks/use-persisted-state'
import { repositionAllTooltips } from '@/features/source-editor/extensions/tooltips-reposition' import { repositionAllTooltips } from '@/features/source-editor/extensions/tooltips-reposition'
import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics'
export type IdeLayout = 'sideBySide' | 'flat' export type IdeLayout = 'sideBySide' | 'flat'
export type IdeView = 'editor' | 'file' | 'pdf' | 'history' export type IdeView = 'editor' | 'file' | 'pdf' | 'history'
@@ -63,6 +64,8 @@ export type LayoutContextValue = LayoutContextOwnStates & {
setProjectSearchIsOpen: Dispatch<SetStateAction<boolean>> setProjectSearchIsOpen: Dispatch<SetStateAction<boolean>>
setOpenFile: Dispatch<SetStateAction<BinaryFile | null>> setOpenFile: Dispatch<SetStateAction<BinaryFile | null>>
restoreView: () => void restoreView: () => void
handleChangeLayout: (newLayout: IdeLayout, newView?: IdeView) => void
handleDetach: () => void
} }
const debugPdfDetach = getMeta('ol-debugPdfDetach') const debugPdfDetach = getMeta('ol-debugPdfDetach')
@@ -89,6 +92,7 @@ export const LayoutProvider: FC<React.PropsWithChildren> = ({ children }) => {
const [prevRailIsOpen, setPrevRailIsOpen] = useState(railIsOpen) const [prevRailIsOpen, setPrevRailIsOpen] = useState(railIsOpen)
// Whether we came from a file or a document when we left the ide // Whether we came from a file or a document when we left the ide
const lastIdeView = useRef<IdeView>('editor') const lastIdeView = useRef<IdeView>('editor')
const { sendEvent } = useEditorAnalytics()
const setView = useCallback( const setView = useCallback(
(value: IdeView | null) => { (value: IdeView | null) => {
@@ -258,6 +262,68 @@ export const LayoutProvider: FC<React.PropsWithChildren> = ({ children }) => {
changeLayout, 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>( const value = useMemo<LayoutContextValue>(
() => ({ () => ({
reattach, reattach,
@@ -285,6 +351,8 @@ export const LayoutProvider: FC<React.PropsWithChildren> = ({ children }) => {
setView, setView,
view, view,
restoreView, restoreView,
handleChangeLayout,
handleDetach,
}), }),
[ [
reattach, reattach,
@@ -312,6 +380,8 @@ export const LayoutProvider: FC<React.PropsWithChildren> = ({ children }) => {
setView, setView,
view, view,
restoreView, restoreView,
handleChangeLayout,
handleDetach,
] ]
) )
@@ -272,3 +272,13 @@ $dropdown-item-min-height: 36px;
display: flex; display: flex;
justify-content: space-between; 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; font-weight: normal;
} }
.pdfjs-zoom-dropdown-mac-shortcut-char {
display: inline-block;
width: 1em;
text-align: center;
}
.pdfjs-custom-zoom-menu-item { .pdfjs-custom-zoom-menu-item {
display: block; display: block;
pointer-events: initial !important; pointer-events: initial !important;
@@ -96,6 +96,8 @@ const createInitialValue = () =>
openFile: null, openFile: null,
setOpenFile: cy.stub(), setOpenFile: cy.stub(),
restoreView: cy.stub(), restoreView: cy.stub(),
handleChangeLayout: cy.stub(),
handleDetach: cy.stub(),
}) satisfies LayoutContextValue }) satisfies LayoutContextValue
const LayoutProvider: FC<React.PropsWithChildren> = ({ children }) => { const LayoutProvider: FC<React.PropsWithChildren> = ({ children }) => {
@@ -48,6 +48,7 @@ import {
import { UserId } from '../../../types/user' import { UserId } from '../../../types/user'
import { ProjectCompiler } from '../../../types/project-settings' import { ProjectCompiler } from '../../../types/project-settings'
import { ReferencesContext } from '@/features/ide-react/context/references-context' 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 // these constants can be imported in tests instead of
// using magic strings // using magic strings
@@ -429,6 +430,8 @@ const makeLayoutProvider = (
layout.loadingStyleSheet layout.loadingStyleSheet
) )
const { sendEvent } = useEditorAnalytics()
useEventListener( useEventListener(
'ui.toggle-review-panel', 'ui.toggle-review-panel',
useCallback(() => { useCallback(() => {
@@ -453,8 +456,34 @@ const makeLayoutProvider = (
isLinked: detachIsLinked, isLinked: detachIsLinked,
role: detachRole, role: detachRole,
} = useDetachLayout() } = 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 = const pdfPreviewOpen =
pdfLayout === 'sideBySide' || view === 'pdf' || detachRole === 'detacher' pdfLayout === 'sideBySide' || view === 'pdf' || detachRole === 'detacher'
const value = useMemo( const value = useMemo(
() => ({ () => ({
reattach, reattach,
@@ -482,6 +511,8 @@ const makeLayoutProvider = (
setView, setView,
view, view,
restoreView, restoreView,
handleChangeLayout,
handleDetach,
}), }),
[ [
reattach, reattach,
@@ -509,6 +540,8 @@ const makeLayoutProvider = (
setView, setView,
view, view,
restoreView, restoreView,
handleChangeLayout,
handleDetach,
] ]
) )