mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-14 22:23:42 +01:00
Merge pull request #30490 from overleaf/mg-context-menu-a11y
[web] Add a11y support for context menu GitOrigin-RevId: 3cbe66ee3ee8345dd0e89f89561928889832a50d
This commit is contained in:
+50
-44
@@ -1,7 +1,12 @@
|
||||
import { FC, Fragment, memo } from 'react'
|
||||
import { FC, Fragment, memo, useEffect, useRef } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getTooltip } from '@codemirror/view'
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownMenu,
|
||||
DropdownItem,
|
||||
DropdownDivider,
|
||||
} from '@/shared/components/dropdown/dropdown-menu'
|
||||
import {
|
||||
useCodeMirrorStateContext,
|
||||
useCodeMirrorViewContext,
|
||||
@@ -9,6 +14,7 @@ import {
|
||||
import { contextMenuStateField } from '../extensions/context-menu'
|
||||
import { useFeatureFlag } from '@/shared/context/split-test-context'
|
||||
import { useContextMenuItems } from '../hooks/use-context-menu-items'
|
||||
import DropdownListItem from '@/shared/components/dropdown/dropdown-list-item'
|
||||
|
||||
const EditorContextMenu: FC = () => {
|
||||
const state = useCodeMirrorStateContext()
|
||||
@@ -29,52 +35,52 @@ const EditorContextMenu: FC = () => {
|
||||
}
|
||||
|
||||
const EditorContextMenuContent: FC = memo(function EditorContextMenuContent() {
|
||||
const { t } = useTranslation()
|
||||
const { menuItems, closeMenu, onToggle } = useContextMenuItems()
|
||||
const menuRef = useRef<any>(null)
|
||||
|
||||
const menuItems = useContextMenuItems()
|
||||
useEffect(() => {
|
||||
menuRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="editor-context-menu" role="menu" aria-label={t('menu')}>
|
||||
{menuItems.map((menuItem, index) => (
|
||||
<Fragment key={index}>
|
||||
{menuItem.separatorAbove && (
|
||||
<div className="editor-context-menu-separator" />
|
||||
)}
|
||||
<ContextMenuItem
|
||||
label={menuItem.label}
|
||||
onClick={() => menuItem.handler()}
|
||||
disabled={menuItem.disabled}
|
||||
shortcut={menuItem.shortcut}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
<Dropdown show onToggle={onToggle}>
|
||||
<DropdownMenu
|
||||
ref={menuRef}
|
||||
show
|
||||
tabIndex={0}
|
||||
className="dropdown-menu-unpositioned"
|
||||
onKeyDown={event => {
|
||||
switch (event.code) {
|
||||
case 'Escape':
|
||||
case 'Tab':
|
||||
event.preventDefault()
|
||||
closeMenu()
|
||||
break
|
||||
}
|
||||
}}
|
||||
>
|
||||
{menuItems.map((menuItem, index) => (
|
||||
<Fragment key={index}>
|
||||
{menuItem.separatorAbove && <DropdownDivider />}
|
||||
<DropdownListItem>
|
||||
<DropdownItem
|
||||
as="button"
|
||||
onClick={() => menuItem.handler()}
|
||||
disabled={menuItem.disabled}
|
||||
trailingIcon={
|
||||
menuItem.shortcut ? (
|
||||
<span>{menuItem.shortcut}</span>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{menuItem.label}
|
||||
</DropdownItem>
|
||||
</DropdownListItem>
|
||||
</Fragment>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
)
|
||||
})
|
||||
|
||||
type ContextMenuItemProps = {
|
||||
label: string
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
shortcut?: string
|
||||
}
|
||||
|
||||
const ContextMenuItem: FC<ContextMenuItemProps> = ({
|
||||
label,
|
||||
shortcut,
|
||||
onClick,
|
||||
disabled,
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="editor-context-menu-item"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className="editor-context-menu-item-label">{label}</span>
|
||||
<span className="editor-context-menu-item-shortcut">{shortcut ?? ''}</span>
|
||||
</button>
|
||||
)
|
||||
|
||||
export default EditorContextMenu
|
||||
|
||||
@@ -38,7 +38,7 @@ export const contextMenuStateField = StateField.define<ContextMenuState>({
|
||||
if (effect.is(openContextMenuEffect)) {
|
||||
const { pos, x, y } = effect.value
|
||||
return {
|
||||
tooltip: buildContextMenuTooltip(pos),
|
||||
tooltip: buildContextMenuTooltip(pos, { x, y }),
|
||||
mousePosition: { x, y },
|
||||
}
|
||||
}
|
||||
@@ -61,20 +61,71 @@ export const contextMenuStateField = StateField.define<ContextMenuState>({
|
||||
],
|
||||
})
|
||||
|
||||
function buildContextMenuTooltip(pos: number): Tooltip {
|
||||
function buildContextMenuTooltip(
|
||||
pos: number,
|
||||
mousePosition: { x: number; y: number }
|
||||
): Tooltip {
|
||||
return {
|
||||
pos,
|
||||
above: false,
|
||||
strictSide: false,
|
||||
arrow: false,
|
||||
create: createTooltipView,
|
||||
create: () => createTooltipView(mousePosition),
|
||||
}
|
||||
}
|
||||
|
||||
const createTooltipView = (): TooltipView => {
|
||||
const createTooltipView = (mousePosition: {
|
||||
x: number
|
||||
y: number
|
||||
}): TooltipView => {
|
||||
const dom = document.createElement('div')
|
||||
dom.className = 'editor-context-menu-container'
|
||||
return { dom, overlap: true, offset: { x: 0, y: 0 } }
|
||||
|
||||
// Watch for size changes and reposition accordingly
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
requestAnimationFrame(() => positionMenu(dom, mousePosition))
|
||||
})
|
||||
resizeObserver.observe(dom)
|
||||
|
||||
return {
|
||||
dom,
|
||||
overlap: true,
|
||||
offset: { x: 0, y: 0 },
|
||||
destroy() {
|
||||
resizeObserver.disconnect()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function positionMenu(
|
||||
dom: HTMLElement,
|
||||
mousePosition: { x: number; y: number }
|
||||
) {
|
||||
const bounds = dom.getBoundingClientRect()
|
||||
|
||||
// Wait for menu to render
|
||||
if (bounds.width === 0 || bounds.height === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const viewportWidth = window.innerWidth
|
||||
const viewportHeight = window.innerHeight
|
||||
const y = mousePosition.y
|
||||
|
||||
// Adjust horizontal position if menu would overflow right edge
|
||||
let left = mousePosition.x
|
||||
if (mousePosition.x + bounds.width > viewportWidth) {
|
||||
left = viewportWidth - bounds.width
|
||||
}
|
||||
dom.style.setProperty('--context-menu-left', `${left}px`)
|
||||
const spaceBelow = viewportHeight - y
|
||||
let top = y
|
||||
if (bounds.height > spaceBelow) {
|
||||
// Show above if menu won't fit below
|
||||
top = y - bounds.height
|
||||
}
|
||||
|
||||
dom.style.setProperty('--context-menu-top', `${top}px`)
|
||||
}
|
||||
|
||||
function isPositionInsideSelection(pos: number, from: number, to: number) {
|
||||
@@ -138,6 +189,21 @@ function openContextMenuAtPosition(
|
||||
})
|
||||
}
|
||||
|
||||
function openContextMenuAtSelection(view: EditorView): boolean {
|
||||
const { main } = view.state.selection
|
||||
const pos = main.head
|
||||
const coords = view.coordsAtPos(pos)
|
||||
if (!coords) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Keep the current selection; actions should apply to it
|
||||
const selection = view.state.selection
|
||||
|
||||
openContextMenuAtPosition(view, pos, selection, coords.left, coords.top)
|
||||
return true
|
||||
}
|
||||
|
||||
function isClickOnGutter(target: HTMLElement): boolean {
|
||||
return !!target.closest('.cm-gutters')
|
||||
}
|
||||
@@ -246,6 +312,11 @@ const contextMenuKeymap = (): Extension =>
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'Shift-F10',
|
||||
// Accessibility standard shortcut to open context menu
|
||||
run: view => openContextMenuAtSelection(view),
|
||||
},
|
||||
])
|
||||
)
|
||||
|
||||
@@ -265,5 +336,8 @@ const contextMenuContainerTheme = EditorView.baseTheme({
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
zIndex: 100,
|
||||
position: 'fixed !important',
|
||||
top: 'var(--context-menu-top) !important',
|
||||
left: 'var(--context-menu-left) !important',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -168,12 +168,19 @@ export const addComment = () => {
|
||||
}
|
||||
|
||||
export const deleteSelection: Command = view => {
|
||||
const { from, to } = view.state.selection.main
|
||||
if (from === to) return false
|
||||
if (view.state.selection.ranges.every(range => range.empty)) return false
|
||||
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: '' },
|
||||
selection: { anchor: from },
|
||||
const transaction = view.state.changeByRange(range => {
|
||||
if (range.empty) {
|
||||
return { changes: [], range }
|
||||
}
|
||||
|
||||
return {
|
||||
changes: { from: range.from, to: range.to, insert: '' },
|
||||
range: EditorSelection.cursor(range.from),
|
||||
}
|
||||
})
|
||||
|
||||
view.dispatch(transaction)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -4,12 +4,13 @@ import {
|
||||
} from '@/features/source-editor/components/codemirror-context'
|
||||
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
|
||||
import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import useSynctex from '@/features/pdf-preview/hooks/use-synctex'
|
||||
import { useDetachCompileContext } from '@/shared/context/detach-compile-context'
|
||||
import { useLayoutContext } from '@/shared/context/layout-context'
|
||||
import { useFeatureFlag } from '@/shared/context/split-test-context'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import {
|
||||
formatShortcut,
|
||||
useCommandRegistry,
|
||||
@@ -35,20 +36,33 @@ export const useContextMenuItems = () => {
|
||||
const visualPreviewEnabled = useFeatureFlag('visual-preview')
|
||||
const { t } = useTranslation()
|
||||
const { shortcuts } = useCommandRegistry()
|
||||
const { features } = useProjectContext()
|
||||
const requestedPdfSyncRef = useRef(false)
|
||||
|
||||
const closeMenu = useCallback(() => {
|
||||
view.dispatch({ effects: closeContextMenuEffect.of(null) })
|
||||
}, [view])
|
||||
|
||||
const [pendingClose, setPendingClose] = useState(false)
|
||||
// Handle closing the menu when it loses focus, e.g. click outside the editor
|
||||
const onToggle = (show: boolean) => {
|
||||
if (!show) {
|
||||
// Skip closing if a sync to PDF is in flight
|
||||
if (requestedPdfSyncRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
closeMenu()
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for syncToPdf to finish before closing the menu
|
||||
useEffect(() => {
|
||||
if (pendingClose && !syncToPdfInFlight) {
|
||||
if (requestedPdfSyncRef.current && !syncToPdfInFlight) {
|
||||
closeMenu()
|
||||
setPendingClose(false)
|
||||
// Clear the synchronous flag when the close completes
|
||||
requestedPdfSyncRef.current = false
|
||||
}
|
||||
}, [pendingClose, syncToPdfInFlight, closeMenu])
|
||||
}, [syncToPdfInFlight, closeMenu])
|
||||
|
||||
const hasSelection = !state.selection.main.empty
|
||||
const canEdit = permissions.write || permissions.trackedWrite
|
||||
@@ -94,8 +108,8 @@ export const useContextMenuItems = () => {
|
||||
|
||||
// Sync-to-PDF is special: it needs to wait for async completion before closing
|
||||
const handleSyncToPdf = useCallback(() => {
|
||||
requestedPdfSyncRef.current = true
|
||||
syncToPdf()
|
||||
setPendingClose(true)
|
||||
view.focus()
|
||||
}, [syncToPdf, view])
|
||||
|
||||
@@ -107,66 +121,71 @@ export const useContextMenuItems = () => {
|
||||
[shortcuts]
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
label: t('cut'),
|
||||
handler: handleCut,
|
||||
disabled: false,
|
||||
show: canEdit,
|
||||
shortcut: getShortcut('cut'),
|
||||
},
|
||||
{
|
||||
label: t('copy'),
|
||||
handler: handleCopy,
|
||||
disabled: false,
|
||||
show: true,
|
||||
shortcut: getShortcut('copy'),
|
||||
},
|
||||
{
|
||||
label: t('paste'),
|
||||
handler: handlePaste,
|
||||
disabled: false,
|
||||
show: canEdit,
|
||||
shortcut: getShortcut('paste'),
|
||||
},
|
||||
{
|
||||
label: inVisualMode
|
||||
? t('paste_without_formatting')
|
||||
: t('paste_with_formatting'),
|
||||
handler: handlePasteSpecial,
|
||||
disabled: false,
|
||||
show: canEdit,
|
||||
shortcut: inVisualMode ? getShortcut('paste-special') : undefined,
|
||||
},
|
||||
{
|
||||
label: t('delete'),
|
||||
handler: handleDelete,
|
||||
disabled: !hasSelection,
|
||||
show: canEdit,
|
||||
shortcut: undefined,
|
||||
},
|
||||
{
|
||||
label: t('jump_to_location_in_pdf'),
|
||||
handler: handleSyncToPdf,
|
||||
disabled: syncToPdfInFlight,
|
||||
separatorAbove: true,
|
||||
show: jumpToLocationInPdfEnabled,
|
||||
shortcut: undefined,
|
||||
},
|
||||
{
|
||||
label: wantTrackChanges ? t('back_to_editing') : t('suggest_edits'),
|
||||
handler: handleToggleTrackChanges,
|
||||
disabled: false,
|
||||
separatorAbove: true,
|
||||
show: canEdit,
|
||||
shortcut: getShortcut('toggle-track-changes'),
|
||||
},
|
||||
{
|
||||
label: t('comment'),
|
||||
handler: handleComment,
|
||||
disabled: !hasSelection,
|
||||
show: permissions.comment,
|
||||
shortcut: getShortcut('insert-comment'),
|
||||
},
|
||||
].filter(item => item.show)
|
||||
return {
|
||||
closeMenu,
|
||||
onToggle,
|
||||
menuItems: [
|
||||
{
|
||||
label: t('cut'),
|
||||
handler: handleCut,
|
||||
disabled: false,
|
||||
show: canEdit,
|
||||
shortcut: getShortcut('cut'),
|
||||
},
|
||||
{
|
||||
label: t('copy'),
|
||||
handler: handleCopy,
|
||||
disabled: false,
|
||||
show: true,
|
||||
shortcut: getShortcut('copy'),
|
||||
},
|
||||
{
|
||||
label: t('paste'),
|
||||
handler: handlePaste,
|
||||
disabled: false,
|
||||
show: canEdit,
|
||||
shortcut: getShortcut('paste'),
|
||||
},
|
||||
{
|
||||
label: inVisualMode
|
||||
? t('paste_without_formatting')
|
||||
: t('paste_with_formatting'),
|
||||
handler: handlePasteSpecial,
|
||||
disabled: false,
|
||||
show: canEdit,
|
||||
shortcut: inVisualMode ? getShortcut('paste-special') : undefined,
|
||||
},
|
||||
{
|
||||
label: t('delete'),
|
||||
handler: handleDelete,
|
||||
disabled: !hasSelection,
|
||||
show: canEdit,
|
||||
shortcut: undefined,
|
||||
},
|
||||
{
|
||||
label: t('jump_to_location_in_pdf'),
|
||||
handler: handleSyncToPdf,
|
||||
disabled: syncToPdfInFlight,
|
||||
separatorAbove: true,
|
||||
show: jumpToLocationInPdfEnabled,
|
||||
shortcut: undefined,
|
||||
},
|
||||
{
|
||||
label: wantTrackChanges ? t('back_to_editing') : t('suggest_edits'),
|
||||
handler: handleToggleTrackChanges,
|
||||
// disable for now, future work opens upgrade modal
|
||||
disabled: !features.trackChanges,
|
||||
separatorAbove: true,
|
||||
show: canEdit,
|
||||
shortcut: getShortcut('toggle-track-changes'),
|
||||
},
|
||||
{
|
||||
label: t('comment'),
|
||||
handler: handleComment,
|
||||
disabled: !hasSelection,
|
||||
show: permissions.comment,
|
||||
shortcut: getShortcut('insert-comment'),
|
||||
},
|
||||
].filter(item => item.show),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,8 @@ export type DropdownMenuProps = PropsWithChildren<{
|
||||
id?: string
|
||||
renderOnMount?: boolean
|
||||
popperConfig?: BS5DropdownMenuProps['popperConfig']
|
||||
tabIndex?: number
|
||||
onKeyDown?: (event: React.KeyboardEvent) => void
|
||||
}>
|
||||
|
||||
export type DropdownDividerProps = PropsWithChildren<{
|
||||
|
||||
@@ -38,7 +38,9 @@
|
||||
|
||||
@include theme('default') {
|
||||
.ide-redesign-main,
|
||||
.project-ds-nav-page {
|
||||
.project-ds-nav-page,
|
||||
// Codemirror tooltips are rendered outside of the main app container
|
||||
.cm-tooltip .dropdown-menu {
|
||||
@include dark-dropdown-menu;
|
||||
}
|
||||
}
|
||||
@@ -80,6 +82,10 @@ $dropdown-item-min-height: 36px;
|
||||
z-index: unset;
|
||||
display: block;
|
||||
float: unset;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
|
||||
@@ -31,7 +31,6 @@
|
||||
@import 'editor/share';
|
||||
@import 'editor/tags-input';
|
||||
@import 'editor/review-panel';
|
||||
@import 'editor/context-menu';
|
||||
@import 'editor/table-generator-column-width-modal';
|
||||
@import 'editor/math-preview';
|
||||
@import 'editor/references-search';
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
.editor-context-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 180px;
|
||||
border-radius: var(--border-radius-base);
|
||||
padding: var(--spacing-02);
|
||||
gap: var(--spacing-01);
|
||||
box-shadow: 0 2px 4px 0 #1e253029;
|
||||
border: 1px solid var(--border-divider);
|
||||
background-color: var(--bg-light-primary);
|
||||
}
|
||||
|
||||
.editor-context-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-04);
|
||||
padding: var(--spacing-02) var(--spacing-04);
|
||||
border: none;
|
||||
border-radius: var(--border-radius-base);
|
||||
background-color: transparent;
|
||||
color: var(--content-primary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: var(--font-size-02);
|
||||
line-height: var(--line-height-02);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--bg-light-secondary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: var(--content-disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&-shortcut {
|
||||
min-width: var(--spacing-10);
|
||||
text-align: right;
|
||||
color: var(--content-secondary);
|
||||
|
||||
.editor-context-menu-item:disabled & {
|
||||
color: var(--content-disabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.editor-context-menu-separator {
|
||||
height: 1px;
|
||||
margin: var(--spacing-01) var(--spacing-04);
|
||||
background-color: var(--border-divider);
|
||||
}
|
||||
Reference in New Issue
Block a user