mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-23 09:09:36 +02: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:
@@ -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);
|
||||
}
|
||||
@@ -102,16 +102,42 @@ describe('editor context menu', { scrollBehavior: false }, function () {
|
||||
</TestContainer>
|
||||
)
|
||||
|
||||
cy.get('.editor-context-menu').should('not.exist')
|
||||
cy.findByRole('menu').should('not.exist')
|
||||
|
||||
cy.get('.cm-line').eq(10).rightclick()
|
||||
cy.get('.editor-context-menu').should('be.visible')
|
||||
cy.findByRole('menu').should('be.visible')
|
||||
|
||||
cy.get('body').type('{esc}')
|
||||
cy.get('.editor-context-menu').should('not.exist')
|
||||
cy.findByRole('menu').should('not.exist')
|
||||
})
|
||||
|
||||
it('should close when clicking elsewhere', function () {
|
||||
it('should open on Shift+F10', { retries: 1 }, function () {
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<TestContainer>
|
||||
<EditorProviders scope={scope}>
|
||||
<CodeMirrorEditor />
|
||||
</EditorProviders>
|
||||
</TestContainer>
|
||||
)
|
||||
|
||||
cy.get('.cm-line').eq(8).click()
|
||||
cy.findByRole('menu').should('not.exist')
|
||||
|
||||
cy.get('.cm-line').eq(8).trigger('keydown', {
|
||||
key: 'F10',
|
||||
code: 'F10',
|
||||
shiftKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
force: true,
|
||||
})
|
||||
|
||||
cy.findByRole('menu').should('be.visible')
|
||||
})
|
||||
|
||||
it('should close when clicking elsewhere in the editor', function () {
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
@@ -123,10 +149,30 @@ describe('editor context menu', { scrollBehavior: false }, function () {
|
||||
)
|
||||
|
||||
cy.get('.cm-line').eq(10).rightclick()
|
||||
cy.get('.editor-context-menu').should('be.visible')
|
||||
cy.findByRole('menu').should('be.visible')
|
||||
|
||||
cy.get('.cm-line').eq(5).click()
|
||||
cy.get('.editor-context-menu').should('not.exist')
|
||||
cy.findByRole('menu').should('not.exist')
|
||||
})
|
||||
|
||||
it('should should close when clicking outside the editor', function () {
|
||||
const scope = mockScope()
|
||||
const outsideEditorButtonName = 'Recompile'
|
||||
cy.mount(
|
||||
<TestContainer>
|
||||
<EditorProviders scope={scope}>
|
||||
<button>{outsideEditorButtonName}</button>
|
||||
<CodeMirrorEditor />
|
||||
</EditorProviders>
|
||||
</TestContainer>
|
||||
)
|
||||
|
||||
// Open context menu
|
||||
cy.get('.cm-line').eq(10).rightclick()
|
||||
cy.findByRole('menu').should('be.visible')
|
||||
|
||||
cy.findByRole('button', { name: outsideEditorButtonName }).click()
|
||||
cy.findByRole('menu').should('not.exist')
|
||||
})
|
||||
|
||||
describe('when nothing is selected', function () {
|
||||
@@ -143,17 +189,25 @@ describe('editor context menu', { scrollBehavior: false }, function () {
|
||||
|
||||
cy.get('.cm-line').eq(10).rightclick()
|
||||
|
||||
cy.get('.editor-context-menu').within(() => {
|
||||
cy.findByRole('menu').within(() => {
|
||||
cy.findByRole('menuitem', { name: /cut/i }).should('be.enabled')
|
||||
cy.findByRole('menuitem', { name: /copy/i }).should('be.enabled')
|
||||
cy.findByRole('menuitem', { name: /delete/i }).should('be.disabled')
|
||||
cy.findByRole('menuitem', { name: /comment/i }).should('be.disabled')
|
||||
cy.findByRole('menuitem', { name: pasteLabelMatcher }).should(
|
||||
'be.enabled'
|
||||
)
|
||||
cy.findByRole('menuitem', {
|
||||
name: /paste with formatting/i,
|
||||
}).should('be.enabled')
|
||||
cy.findByRole('menuitem', { name: /delete/i }).should(
|
||||
'have.attr',
|
||||
'aria-disabled',
|
||||
'true'
|
||||
)
|
||||
cy.findByRole('menuitem', { name: /comment/i }).should(
|
||||
'have.attr',
|
||||
'aria-disabled',
|
||||
'true'
|
||||
)
|
||||
cy.findByRole('menuitem', { name: /suggest edits/i }).should(
|
||||
'be.enabled'
|
||||
)
|
||||
@@ -185,9 +239,9 @@ describe('editor context menu', { scrollBehavior: false }, function () {
|
||||
cy.get('@line').rightclick()
|
||||
|
||||
cy.get('.cm-selectionBackground').should('exist')
|
||||
cy.get('.editor-context-menu').should('be.visible')
|
||||
cy.findByRole('menu').should('be.visible')
|
||||
|
||||
cy.get('.editor-context-menu').within(() => {
|
||||
cy.findByRole('menu').within(() => {
|
||||
cy.findByRole('menuitem', { name: /cut/i }).should('be.enabled')
|
||||
cy.findByRole('menuitem', { name: /copy/i }).should('be.enabled')
|
||||
cy.findByRole('menuitem', { name: pasteLabelMatcher }).should(
|
||||
@@ -228,11 +282,11 @@ describe('editor context menu', { scrollBehavior: false }, function () {
|
||||
|
||||
cy.get('@line').rightclick()
|
||||
|
||||
cy.get('.editor-context-menu').within(() => {
|
||||
cy.findByRole('menu').within(() => {
|
||||
cy.findByRole('menuitem', { name: /copy/i }).click()
|
||||
})
|
||||
|
||||
cy.get('.editor-context-menu').should('not.exist')
|
||||
cy.findByRole('menu').should('not.exist')
|
||||
})
|
||||
|
||||
it('should cut and paste text', function () {
|
||||
@@ -259,11 +313,11 @@ describe('editor context menu', { scrollBehavior: false }, function () {
|
||||
|
||||
// Cut "world"
|
||||
cy.get('@line').rightclick()
|
||||
cy.get('.editor-context-menu').within(() => {
|
||||
cy.findByRole('menu').within(() => {
|
||||
cy.findByRole('menuitem', { name: /cut/i }).click()
|
||||
})
|
||||
|
||||
cy.get('.editor-context-menu').should('not.exist')
|
||||
cy.findByRole('menu').should('not.exist')
|
||||
cy.get('@line').should('contain', 'hello ')
|
||||
cy.get('@line').should('not.contain', 'world')
|
||||
|
||||
@@ -272,11 +326,11 @@ describe('editor context menu', { scrollBehavior: false }, function () {
|
||||
cy.get('@line').rightclick(0, 0)
|
||||
|
||||
// Paste "world" at the beginning
|
||||
cy.get('.editor-context-menu').within(() => {
|
||||
cy.findByRole('menu').within(() => {
|
||||
cy.findByRole('menuitem', { name: pasteLabelMatcher }).click()
|
||||
})
|
||||
|
||||
cy.get('.editor-context-menu').should('not.exist')
|
||||
cy.findByRole('menu').should('not.exist')
|
||||
cy.get('@line').should('contain', 'worldhello')
|
||||
})
|
||||
|
||||
@@ -302,11 +356,11 @@ describe('editor context menu', { scrollBehavior: false }, function () {
|
||||
|
||||
cy.get('@line').rightclick()
|
||||
|
||||
cy.get('.editor-context-menu').within(() => {
|
||||
cy.findByRole('menu').within(() => {
|
||||
cy.findByRole('menuitem', { name: /delete/i }).click()
|
||||
})
|
||||
|
||||
cy.get('.editor-context-menu').should('not.exist')
|
||||
cy.findByRole('menu').should('not.exist')
|
||||
cy.get('@line').should('contain', 'hello ')
|
||||
cy.get('@line').should('not.contain', 'world')
|
||||
})
|
||||
@@ -337,6 +391,7 @@ describe('editor context menu', { scrollBehavior: false }, function () {
|
||||
<TestContainer>
|
||||
<EditorProviders
|
||||
scope={scope}
|
||||
projectFeatures={{ trackChanges: true }}
|
||||
providers={{
|
||||
EditorPropertiesProvider: makeEditorPropertiesProvider({
|
||||
wantTrackChanges: false,
|
||||
@@ -350,7 +405,7 @@ describe('editor context menu', { scrollBehavior: false }, function () {
|
||||
|
||||
cy.get('.cm-line').eq(10).rightclick()
|
||||
|
||||
cy.get('.editor-context-menu').within(() => {
|
||||
cy.findByRole('menu').within(() => {
|
||||
// Verify we're showing the edit mode label
|
||||
cy.findByRole('menuitem', { name: /suggest edits/i }).should(
|
||||
'be.visible'
|
||||
@@ -361,7 +416,7 @@ describe('editor context menu', { scrollBehavior: false }, function () {
|
||||
cy.findByRole('menuitem', { name: /suggest edits/i }).click()
|
||||
})
|
||||
|
||||
cy.get('.editor-context-menu').should('not.exist')
|
||||
cy.findByRole('menu').should('not.exist')
|
||||
|
||||
// Verify the toggle event was dispatched
|
||||
cy.get('@toggleTrackChanges').should('have.been.calledOnce')
|
||||
@@ -374,6 +429,7 @@ describe('editor context menu', { scrollBehavior: false }, function () {
|
||||
<TestContainer>
|
||||
<EditorProviders
|
||||
scope={scope}
|
||||
projectFeatures={{ trackChanges: true }}
|
||||
providers={{
|
||||
EditorPropertiesProvider: makeEditorPropertiesProvider({
|
||||
wantTrackChanges: true,
|
||||
@@ -387,7 +443,7 @@ describe('editor context menu', { scrollBehavior: false }, function () {
|
||||
|
||||
cy.get('.cm-line').eq(10).rightclick()
|
||||
|
||||
cy.get('.editor-context-menu').within(() => {
|
||||
cy.findByRole('menu').within(() => {
|
||||
// Verify we're showing the review mode label
|
||||
cy.findByRole('menuitem', { name: /back to editing/i }).should(
|
||||
'be.visible'
|
||||
@@ -398,11 +454,36 @@ describe('editor context menu', { scrollBehavior: false }, function () {
|
||||
cy.findByRole('menuitem', { name: /back to editing/i }).click()
|
||||
})
|
||||
|
||||
cy.get('.editor-context-menu').should('not.exist')
|
||||
cy.findByRole('menu').should('not.exist')
|
||||
|
||||
// Verify the toggle event was dispatched
|
||||
cy.get('@toggleTrackChanges').should('have.been.calledOnce')
|
||||
})
|
||||
|
||||
it('should disable suggest edits when project does not support track changes', function () {
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<TestContainer>
|
||||
<EditorProviders
|
||||
scope={scope}
|
||||
projectFeatures={{ trackChanges: false }}
|
||||
>
|
||||
<CodeMirrorEditor />
|
||||
</EditorProviders>
|
||||
</TestContainer>
|
||||
)
|
||||
|
||||
cy.get('.cm-line').eq(10).rightclick()
|
||||
|
||||
cy.findByRole('menu').within(() => {
|
||||
cy.findByRole('menuitem', { name: /suggest edits/i }).should(
|
||||
'have.attr',
|
||||
'aria-disabled',
|
||||
'true'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('when feature flag is disabled', function () {
|
||||
@@ -422,7 +503,7 @@ describe('editor context menu', { scrollBehavior: false }, function () {
|
||||
)
|
||||
|
||||
cy.get('.cm-line').eq(10).rightclick()
|
||||
cy.get('.editor-context-menu').should('not.exist')
|
||||
cy.findByRole('menu').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -460,9 +541,9 @@ describe('editor context menu', { scrollBehavior: false }, function () {
|
||||
|
||||
cy.get('@line').rightclick()
|
||||
|
||||
cy.get('.editor-context-menu').should('be.visible')
|
||||
cy.findByRole('menu').should('be.visible')
|
||||
|
||||
cy.get('.editor-context-menu').within(() => {
|
||||
cy.findByRole('menu').within(() => {
|
||||
cy.findByRole('menuitem', { name: /cut/i }).should('not.exist')
|
||||
cy.findByRole('menuitem', { name: /copy/i }).should('be.enabled')
|
||||
cy.findByRole('menuitem', { name: pasteLabelMatcher }).should(
|
||||
@@ -515,9 +596,9 @@ describe('editor context menu', { scrollBehavior: false }, function () {
|
||||
|
||||
cy.get('@line').rightclick()
|
||||
|
||||
cy.get('.editor-context-menu').should('be.visible')
|
||||
cy.findByRole('menu').should('be.visible')
|
||||
|
||||
cy.get('.editor-context-menu').within(() => {
|
||||
cy.findByRole('menu').within(() => {
|
||||
cy.findByRole('menuitem', { name: /copy/i }).should('be.enabled')
|
||||
cy.findByRole('menuitem', { name: /comment/i }).should('not.exist')
|
||||
})
|
||||
@@ -554,10 +635,10 @@ describe('editor context menu', { scrollBehavior: false }, function () {
|
||||
|
||||
// Right-click to open context menu
|
||||
cy.get('.cm-line').eq(10).rightclick()
|
||||
cy.get('.editor-context-menu').should('be.visible')
|
||||
cy.findByRole('menu').should('be.visible')
|
||||
|
||||
// Click paste button
|
||||
cy.get('.editor-context-menu').within(() => {
|
||||
cy.findByRole('menu').within(() => {
|
||||
cy.findByRole('menuitem', { name: pasteLabelMatcher }).click()
|
||||
})
|
||||
|
||||
@@ -565,7 +646,7 @@ describe('editor context menu', { scrollBehavior: false }, function () {
|
||||
cy.findByText('Upload from computer').should('be.visible')
|
||||
|
||||
// Context menu should close
|
||||
cy.get('.editor-context-menu').should('not.exist')
|
||||
cy.findByRole('menu').should('not.exist')
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -612,13 +693,13 @@ describe('editor context menu', { scrollBehavior: false }, function () {
|
||||
)
|
||||
|
||||
cy.get('.cm-line').eq(10).rightclick()
|
||||
cy.get('.editor-context-menu').within(() => {
|
||||
cy.findByRole('menu').within(() => {
|
||||
cy.findByRole('menuitem', {
|
||||
name: /paste with formatting/i,
|
||||
}).click()
|
||||
})
|
||||
|
||||
cy.get('.editor-context-menu').should('not.exist')
|
||||
cy.findByRole('menu').should('not.exist')
|
||||
|
||||
cy.get('.cm-line').should($lines => {
|
||||
const text = $lines.text()
|
||||
@@ -642,11 +723,11 @@ describe('editor context menu', { scrollBehavior: false }, function () {
|
||||
)
|
||||
|
||||
cy.get('.cm-line').eq(10).rightclick()
|
||||
cy.get('.editor-context-menu').within(() => {
|
||||
cy.findByRole('menu').within(() => {
|
||||
cy.findByRole('menuitem', { name: pasteLabelMatcher }).click()
|
||||
})
|
||||
|
||||
cy.get('.editor-context-menu').should('not.exist')
|
||||
cy.findByRole('menu').should('not.exist')
|
||||
|
||||
cy.get('.cm-line').should($lines => {
|
||||
const text = $lines.text()
|
||||
@@ -688,13 +769,11 @@ describe('editor context menu', { scrollBehavior: false }, function () {
|
||||
|
||||
cy.get('.cm-line').eq(10).rightclick()
|
||||
|
||||
cy.get('.editor-context-menu').within(() => {
|
||||
cy.findByRole('menuitem', {
|
||||
name: /jump to location in pdf/i,
|
||||
}).click()
|
||||
cy.findByRole('menu').within(() => {
|
||||
cy.findByRole('menuitem', { name: /jump to location in pdf/i }).click()
|
||||
})
|
||||
|
||||
cy.get('.editor-context-menu').should('not.exist')
|
||||
cy.findByRole('menu').should('not.exist')
|
||||
|
||||
// Verify the sync API was called and returned expected response
|
||||
cy.wait('@syncToPdfRequest').then(interception => {
|
||||
@@ -721,10 +800,10 @@ describe('editor context menu', { scrollBehavior: false }, function () {
|
||||
|
||||
cy.get('.cm-line').eq(10).rightclick()
|
||||
|
||||
cy.get('.editor-context-menu').within(() => {
|
||||
cy.findByRole('menuitem', {
|
||||
name: /jump to location in pdf/i,
|
||||
}).should('not.exist')
|
||||
cy.findByRole('menu').within(() => {
|
||||
cy.findByRole('menuitem', { name: /jump to location in pdf/i }).should(
|
||||
'not.exist'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -744,7 +823,7 @@ describe('editor context menu', { scrollBehavior: false }, function () {
|
||||
</TestContainer>
|
||||
)
|
||||
|
||||
cy.get('.editor-context-menu').should('not.exist')
|
||||
cy.findByRole('menu').should('not.exist')
|
||||
|
||||
cy.get('.cm-line').eq(editorLine).as('targetLine')
|
||||
cy.get('@targetLine').click()
|
||||
@@ -756,7 +835,7 @@ describe('editor context menu', { scrollBehavior: false }, function () {
|
||||
cy.get('.cm-gutterElement').eq(gutterLineIndex).rightclick()
|
||||
|
||||
cy.get('.cm-selectionBackground').should('exist')
|
||||
cy.get('.editor-context-menu').should('be.visible')
|
||||
cy.findByRole('menu').should('be.visible')
|
||||
})
|
||||
|
||||
it('should work with cut/copy/delete operations on gutter-selected line', function () {
|
||||
@@ -783,9 +862,9 @@ describe('editor context menu', { scrollBehavior: false }, function () {
|
||||
cy.get('.cm-gutterElement').eq(gutterLineIndex).rightclick()
|
||||
|
||||
cy.get('.cm-selectionBackground').should('exist')
|
||||
cy.get('.editor-context-menu').should('be.visible')
|
||||
cy.findByRole('menu').should('be.visible')
|
||||
|
||||
cy.get('.editor-context-menu').within(() => {
|
||||
cy.findByRole('menu').within(() => {
|
||||
cy.findByRole('menuitem', { name: /cut/i }).should('be.enabled')
|
||||
cy.findByRole('menuitem', { name: /copy/i }).should('be.enabled')
|
||||
cy.findByRole('menuitem', { name: pasteLabelMatcher }).should(
|
||||
@@ -800,7 +879,7 @@ describe('editor context menu', { scrollBehavior: false }, function () {
|
||||
cy.findByRole('menuitem', { name: /copy/i }).click()
|
||||
})
|
||||
|
||||
cy.get('.editor-context-menu').should('not.exist')
|
||||
cy.findByRole('menu').should('not.exist')
|
||||
|
||||
cy.get('@writeText').should('have.been.calledOnce')
|
||||
cy.get('@writeText').should(
|
||||
@@ -823,10 +902,10 @@ describe('editor context menu', { scrollBehavior: false }, function () {
|
||||
)
|
||||
|
||||
cy.get('.cm-gutterElement').eq(5).rightclick()
|
||||
cy.get('.editor-context-menu').should('be.visible')
|
||||
cy.findByRole('menu').should('be.visible')
|
||||
|
||||
cy.get('.cm-line').eq(10).click()
|
||||
cy.get('.editor-context-menu').should('not.exist')
|
||||
cy.findByRole('menu').should('not.exist')
|
||||
})
|
||||
|
||||
it('should close menu on Escape after gutter right-click', function () {
|
||||
@@ -841,11 +920,11 @@ describe('editor context menu', { scrollBehavior: false }, function () {
|
||||
)
|
||||
|
||||
cy.get('.cm-gutterElement').eq(5).rightclick()
|
||||
cy.get('.editor-context-menu').should('be.visible')
|
||||
cy.findByRole('menu').should('be.visible')
|
||||
|
||||
cy.get('.cm-content').focus()
|
||||
cy.get('body').type('{esc}')
|
||||
cy.get('.editor-context-menu').should('not.exist')
|
||||
cy.findByRole('menu').should('not.exist')
|
||||
})
|
||||
|
||||
it('should not show context menu on gutter when feature flag is disabled', function () {
|
||||
@@ -864,7 +943,7 @@ describe('editor context menu', { scrollBehavior: false }, function () {
|
||||
)
|
||||
|
||||
cy.get('.cm-gutterElement').eq(5).rightclick()
|
||||
cy.get('.editor-context-menu').should('not.exist')
|
||||
cy.findByRole('menu').should('not.exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user