mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-06-02 05:41:33 +02:00
Merge pull request #30168 from overleaf/mg-context-menu
Add context menu (right click) to editor GitOrigin-RevId: f2e567b51b04170ba1a46b0ab4659f3481b05bfe
This commit is contained in:
@@ -459,6 +459,7 @@ const _ProjectController = {
|
||||
'wf-citations-checker-on-selection',
|
||||
'writefull-asymetric-queue-size-per-model',
|
||||
'writefull-encourage-prompt-for-paraphrase',
|
||||
'editor-context-menu',
|
||||
'email-notifications',
|
||||
'editor-redesign-no-opt-out',
|
||||
].filter(Boolean)
|
||||
|
||||
@@ -180,6 +180,7 @@
|
||||
"available_with_group_professional": "",
|
||||
"back": "",
|
||||
"back_to_configuration": "",
|
||||
"back_to_editing": "",
|
||||
"back_to_editor": "",
|
||||
"back_to_subscription": "",
|
||||
"back_to_your_projects": "",
|
||||
@@ -382,6 +383,7 @@
|
||||
"customize_your_group_subscription": "",
|
||||
"customizing_figures": "",
|
||||
"customizing_tables": "",
|
||||
"cut": "",
|
||||
"dark_mode_pdf_preview": "",
|
||||
"dark_themes": "",
|
||||
"date_and_owner": "",
|
||||
@@ -940,6 +942,7 @@
|
||||
"join_x_enterprise_group": "",
|
||||
"joined_team": "",
|
||||
"joining": "",
|
||||
"jump_to_location_in_pdf": "",
|
||||
"justify": "",
|
||||
"kb_suggestions_enquiry": "",
|
||||
"keep_current_plan": "",
|
||||
@@ -1262,6 +1265,7 @@
|
||||
"password": "",
|
||||
"password_managed_externally": "",
|
||||
"password_was_detected_on_a_public_list_of_known_compromised_passwords": "",
|
||||
"paste": "",
|
||||
"paste_options": "",
|
||||
"paste_with_formatting": "",
|
||||
"paste_without_formatting": "",
|
||||
@@ -1804,6 +1808,7 @@
|
||||
"success_sso_set_up": "",
|
||||
"success_youve_successfully_joined_group": "",
|
||||
"suggest_a_different_fix": "",
|
||||
"suggest_edits": "",
|
||||
"suggest_fix": "",
|
||||
"suggested": "",
|
||||
"suggested_code": "",
|
||||
|
||||
@@ -57,6 +57,10 @@ export const CommandRegistryProvider: React.FC<React.PropsWithChildren> = ({
|
||||
// NOTE: This is where we'd add functionality for customising shortcuts.
|
||||
const shortcuts: Record<string, Shortcut[]> = useMemo(
|
||||
() => ({
|
||||
cut: [{ key: 'Mod-x' }],
|
||||
copy: [{ key: 'Mod-c' }],
|
||||
paste: [{ key: 'Mod-v' }],
|
||||
'toggle-track-changes': [{ key: 'Mod-Shift-A' }],
|
||||
undo: [
|
||||
{
|
||||
key: 'Mod-z',
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import {
|
||||
findImageInClipboard,
|
||||
dispatchFigureModalPasteEvent,
|
||||
} from '../utils/paste-image'
|
||||
|
||||
const getEntireLineText = (view: EditorView, pos: number): string => {
|
||||
const line = view.state.doc.lineAt(pos)
|
||||
const atDocumentEnd = line.to === view.state.doc.length
|
||||
return atDocumentEnd ? line.text : line.text + view.state.lineBreak
|
||||
}
|
||||
|
||||
export const cutSelection = async (view: EditorView): Promise<boolean> => {
|
||||
const selections = view.state.selection.ranges
|
||||
const changes = []
|
||||
const texts = []
|
||||
|
||||
for (const range of selections) {
|
||||
const { from, to } = range
|
||||
if (from === to) {
|
||||
const text = getEntireLineText(view, from)
|
||||
texts.push(text)
|
||||
const line = view.state.doc.lineAt(from)
|
||||
const atDocumentEnd = line.to === view.state.doc.length
|
||||
const deleteTo = atDocumentEnd
|
||||
? line.to
|
||||
: line.to + view.state.lineBreak.length
|
||||
changes.push({ from: line.from, to: deleteTo, insert: '' })
|
||||
} else {
|
||||
const text = view.state.sliceDoc(from, to)
|
||||
texts.push(text)
|
||||
changes.push({ from, to, insert: '' })
|
||||
}
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(texts.join(''))
|
||||
view.dispatch({
|
||||
changes,
|
||||
selection: { anchor: changes[0]?.from ?? view.state.selection.main.from },
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
export const copySelection = async (view: EditorView): Promise<boolean> => {
|
||||
const selections = view.state.selection.ranges
|
||||
const texts = []
|
||||
|
||||
for (const range of selections) {
|
||||
const { from, to } = range
|
||||
const text =
|
||||
from === to
|
||||
? getEntireLineText(view, from)
|
||||
: view.state.sliceDoc(from, to)
|
||||
texts.push(text)
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(texts.join(''))
|
||||
return true
|
||||
}
|
||||
|
||||
export const pasteWithoutFormatting = async (
|
||||
view: EditorView
|
||||
): Promise<boolean> => {
|
||||
// Check for pasted images first
|
||||
const imageFile = await findImageInClipboard()
|
||||
if (imageFile) {
|
||||
dispatchFigureModalPasteEvent({
|
||||
name: imageFile.name,
|
||||
type: imageFile.type,
|
||||
data: imageFile,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
// Fall back to plain text paste
|
||||
try {
|
||||
const text = await navigator.clipboard.readText()
|
||||
const selections = view.state.selection.ranges
|
||||
const changes = []
|
||||
let lastChangeTo = 0
|
||||
|
||||
// Detect line-wise paste: single line of text with trailing linebreak
|
||||
const textWithoutTrailingBreak = text.slice(0, -view.state.lineBreak.length)
|
||||
const isSingleLineWithTrailingBreak =
|
||||
text.endsWith(view.state.lineBreak) &&
|
||||
!textWithoutTrailingBreak.includes(view.state.lineBreak)
|
||||
|
||||
// Apply paste to each selection/range
|
||||
for (const range of selections) {
|
||||
const { from, to } = range
|
||||
const noSelection = from === to
|
||||
const shouldInsertAtLineStart =
|
||||
noSelection && isSingleLineWithTrailingBreak
|
||||
|
||||
if (shouldInsertAtLineStart) {
|
||||
const line = view.state.doc.lineAt(from)
|
||||
changes.push({ from: line.from, to: line.from, insert: text })
|
||||
lastChangeTo = line.from + text.length
|
||||
} else {
|
||||
changes.push({ from, to, insert: text })
|
||||
lastChangeTo = from + text.length
|
||||
}
|
||||
}
|
||||
|
||||
view.dispatch({
|
||||
changes,
|
||||
selection: { anchor: lastChangeTo },
|
||||
})
|
||||
|
||||
return true
|
||||
} catch {
|
||||
// Clipboard access denied or empty
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from './codemirror-context'
|
||||
import MathPreviewTooltip from './math-preview-tooltip'
|
||||
import { getVisualEditorComponent } from '../utils/visual-editor'
|
||||
import EditorContextMenu from './editor-context-menu'
|
||||
import { useToolbarMenuBarEditorCommands } from '@/features/ide-redesign/hooks/use-toolbar-menu-editor-commands'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import { useFeatureFlag } from '@/shared/context/split-test-context'
|
||||
@@ -90,6 +91,7 @@ function CodeMirrorEditorComponents() {
|
||||
<CodeMirrorCommandTooltip />
|
||||
|
||||
<MathPreviewTooltip />
|
||||
<EditorContextMenu />
|
||||
{features.trackChangesVisible && <ReviewTooltipMenu />}
|
||||
{features.trackChangesVisible && <ReviewPanelRoot />}
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { FC, Fragment, memo } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getTooltip } from '@codemirror/view'
|
||||
import {
|
||||
useCodeMirrorStateContext,
|
||||
useCodeMirrorViewContext,
|
||||
} from './codemirror-context'
|
||||
import { contextMenuStateField } from '../extensions/context-menu'
|
||||
import { useFeatureFlag } from '@/shared/context/split-test-context'
|
||||
import { useContextMenuItems } from '../hooks/use-context-menu-items'
|
||||
|
||||
const EditorContextMenu: FC = () => {
|
||||
const state = useCodeMirrorStateContext()
|
||||
const view = useCodeMirrorViewContext()
|
||||
const editorContextMenuEnabled = useFeatureFlag('editor-context-menu')
|
||||
|
||||
const menuState = state.field(contextMenuStateField, false)
|
||||
if (!editorContextMenuEnabled || !menuState?.tooltip) {
|
||||
return null
|
||||
}
|
||||
|
||||
const tooltipView = getTooltip(view, menuState.tooltip)
|
||||
if (!tooltipView) {
|
||||
return null
|
||||
}
|
||||
|
||||
return ReactDOM.createPortal(<EditorContextMenuContent />, tooltipView.dom)
|
||||
}
|
||||
|
||||
const EditorContextMenuContent: FC = memo(function EditorContextMenuContent() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const menuItems = useContextMenuItems()
|
||||
|
||||
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>
|
||||
)
|
||||
})
|
||||
|
||||
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
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FC, createContext, useContext, useReducer } from 'react'
|
||||
import { PastedImageData } from '../../extensions/figure-modal'
|
||||
import { PastedImageData } from '../../utils/paste-image'
|
||||
|
||||
/* eslint-disable no-unused-vars */
|
||||
export enum FigureModalSource {
|
||||
|
||||
@@ -18,7 +18,6 @@ import { ChangeSpec } from '@codemirror/state'
|
||||
import { snippet } from '@codemirror/autocomplete'
|
||||
import {
|
||||
FigureData,
|
||||
PastedImageData,
|
||||
editFigureData,
|
||||
editFigureDataEffect,
|
||||
} from '../../extensions/figure-modal'
|
||||
@@ -28,6 +27,7 @@ import useEventListener from '../../../../shared/hooks/use-event-listener'
|
||||
import { prepareLines } from '../../utils/prepare-lines'
|
||||
import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner'
|
||||
import { isSvgFile } from '../../utils/file'
|
||||
import { PastedImageData } from '../../utils/paste-image'
|
||||
|
||||
const FigureModalBody = lazy(() => import('./figure-modal-body'))
|
||||
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import {
|
||||
EditorView,
|
||||
showTooltip,
|
||||
Tooltip,
|
||||
TooltipView,
|
||||
keymap,
|
||||
} from '@codemirror/view'
|
||||
import {
|
||||
Extension,
|
||||
StateField,
|
||||
StateEffect,
|
||||
TransactionSpec,
|
||||
EditorSelection,
|
||||
Prec,
|
||||
} from '@codemirror/state'
|
||||
|
||||
export const openContextMenuEffect = StateEffect.define<{
|
||||
pos: number
|
||||
x: number
|
||||
y: number
|
||||
}>()
|
||||
|
||||
export const closeContextMenuEffect = StateEffect.define()
|
||||
|
||||
type ContextMenuState = {
|
||||
tooltip: Tooltip | null
|
||||
mousePosition: { x: number; y: number } | null
|
||||
}
|
||||
|
||||
export const contextMenuStateField = StateField.define<ContextMenuState>({
|
||||
create() {
|
||||
return { tooltip: null, mousePosition: null }
|
||||
},
|
||||
|
||||
update(field, tr) {
|
||||
// Process state effects to open/close menu
|
||||
for (const effect of tr.effects) {
|
||||
if (effect.is(openContextMenuEffect)) {
|
||||
const { pos, x, y } = effect.value
|
||||
return {
|
||||
tooltip: buildContextMenuTooltip(pos),
|
||||
mousePosition: { x, y },
|
||||
}
|
||||
}
|
||||
if (effect.is(closeContextMenuEffect)) {
|
||||
return { tooltip: null, mousePosition: null }
|
||||
}
|
||||
}
|
||||
|
||||
// Close menu on document changes
|
||||
if (tr.docChanged && field.tooltip) {
|
||||
return { tooltip: null, mousePosition: null }
|
||||
}
|
||||
|
||||
return field
|
||||
},
|
||||
|
||||
// Connect state field to tooltip system
|
||||
provide: field => [
|
||||
showTooltip.compute([field], state => state.field(field).tooltip),
|
||||
],
|
||||
})
|
||||
|
||||
function buildContextMenuTooltip(pos: number): Tooltip {
|
||||
return {
|
||||
pos,
|
||||
above: false,
|
||||
strictSide: false,
|
||||
arrow: false,
|
||||
create: createTooltipView,
|
||||
}
|
||||
}
|
||||
|
||||
const createTooltipView = (): TooltipView => {
|
||||
const dom = document.createElement('div')
|
||||
dom.className = 'editor-context-menu-container'
|
||||
return { dom, overlap: true, offset: { x: 0, y: 0 } }
|
||||
}
|
||||
|
||||
function isPositionInsideSelection(pos: number, from: number, to: number) {
|
||||
return from !== to && pos >= from && pos <= to
|
||||
}
|
||||
|
||||
function selectEntireLine(
|
||||
view: EditorView,
|
||||
pos: number
|
||||
): EditorSelection | null {
|
||||
if (pos === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
const line = view.state.doc.lineAt(pos)
|
||||
return EditorSelection.single(line.from, line.to)
|
||||
}
|
||||
|
||||
function closeContextMenu(view: EditorView): void {
|
||||
const menuState = view.state.field(contextMenuStateField, false)
|
||||
if (menuState?.tooltip) {
|
||||
view.dispatch({ effects: closeContextMenuEffect.of(null) })
|
||||
}
|
||||
}
|
||||
|
||||
function openContextMenuAtPosition(
|
||||
view: EditorView,
|
||||
pos: number,
|
||||
selection: EditorSelection | TransactionSpec['selection'],
|
||||
clientX: number,
|
||||
clientY: number
|
||||
): void {
|
||||
view.dispatch({
|
||||
selection,
|
||||
effects: openContextMenuEffect.of({
|
||||
pos,
|
||||
x: clientX,
|
||||
y: clientY,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
function isClickOnGutter(target: HTMLElement): boolean {
|
||||
return !!target.closest('.cm-gutters')
|
||||
}
|
||||
|
||||
// Gutter context menu plugin
|
||||
const gutterContextMenuPlugin = (): Extension =>
|
||||
EditorView.updateListener.of(update => {
|
||||
if (!update.view.dom.parentElement) {
|
||||
return
|
||||
}
|
||||
|
||||
const gutters = update.view.dom.parentElement.querySelector('.cm-gutters')
|
||||
// Attach listener only once per editor instance
|
||||
if (!gutters || gutters.hasAttribute('data-context-menu-attached')) {
|
||||
return
|
||||
}
|
||||
|
||||
gutters.setAttribute('data-context-menu-attached', 'true')
|
||||
gutters.addEventListener('contextmenu', (event: Event) => {
|
||||
const mouseEvent = event as MouseEvent
|
||||
event.preventDefault()
|
||||
|
||||
const pos = update.view.posAtCoords({
|
||||
x: mouseEvent.clientX,
|
||||
y: mouseEvent.clientY,
|
||||
})
|
||||
if (pos === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const selection = selectEntireLine(update.view, pos)
|
||||
if (selection) {
|
||||
openContextMenuAtPosition(
|
||||
update.view,
|
||||
pos,
|
||||
selection,
|
||||
mouseEvent.clientX,
|
||||
mouseEvent.clientY
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Editor view context menu handlers
|
||||
const editorContextMenuHandlers = (): Extension =>
|
||||
EditorView.domEventHandlers({
|
||||
contextmenu(event: MouseEvent, view: EditorView) {
|
||||
event.preventDefault()
|
||||
|
||||
const pos = view.posAtCoords({ x: event.clientX, y: event.clientY })
|
||||
if (pos === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
const { from, to } = view.state.selection.main
|
||||
const clickedInsideSelection = isPositionInsideSelection(pos, from, to)
|
||||
|
||||
// Set cursor to clicked position if outside selection
|
||||
let selection: TransactionSpec['selection'] = { anchor: pos }
|
||||
if (clickedInsideSelection) {
|
||||
// Keep current selection if inside selection
|
||||
// so actions apply to the existing selection
|
||||
selection = view.state.selection
|
||||
}
|
||||
|
||||
openContextMenuAtPosition(
|
||||
view,
|
||||
pos,
|
||||
selection,
|
||||
event.clientX,
|
||||
event.clientY
|
||||
)
|
||||
return true
|
||||
},
|
||||
|
||||
mousedown(event: MouseEvent, view: EditorView) {
|
||||
const target = event.target as HTMLElement
|
||||
const isGutter = isClickOnGutter(target)
|
||||
const isRightClick = event.button === 2 || event.ctrlKey
|
||||
|
||||
// Close menu on any click except right-click on non-gutter
|
||||
if (!isRightClick || isGutter) {
|
||||
closeContextMenu(view)
|
||||
}
|
||||
|
||||
// Prevent default on right-click to preserve selection
|
||||
if (isRightClick) {
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
})
|
||||
|
||||
// High-priority keymap to handle Escape before default handlers
|
||||
const contextMenuKeymap = (): Extension =>
|
||||
Prec.high(
|
||||
keymap.of([
|
||||
{
|
||||
key: 'Escape',
|
||||
run: view => {
|
||||
const menuState = view.state.field(contextMenuStateField, false)
|
||||
if (menuState?.tooltip) {
|
||||
closeContextMenu(view)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
])
|
||||
)
|
||||
|
||||
export const contextMenu = (enabled: boolean): Extension =>
|
||||
enabled
|
||||
? [
|
||||
contextMenuContainerTheme,
|
||||
contextMenuStateField,
|
||||
gutterContextMenuPlugin(),
|
||||
editorContextMenuHandlers(),
|
||||
contextMenuKeymap(),
|
||||
]
|
||||
: []
|
||||
|
||||
const contextMenuContainerTheme = EditorView.baseTheme({
|
||||
'.editor-context-menu-container.cm-tooltip': {
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
zIndex: 100,
|
||||
},
|
||||
})
|
||||
@@ -8,6 +8,10 @@ import { EditorView } from '@codemirror/view'
|
||||
import { addEffectListener, removeEffectListener } from './effect-listeners'
|
||||
import { setMetadataEffect } from './language'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import {
|
||||
dispatchFigureModalPasteEvent,
|
||||
isAllowedImageType,
|
||||
} from '../utils/paste-image'
|
||||
|
||||
type NestedReadonly<T> = {
|
||||
readonly [P in keyof T]: NestedReadonly<T[P]>
|
||||
@@ -160,18 +164,6 @@ export function waitForFileTreeUpdate(view: EditorView) {
|
||||
}
|
||||
}
|
||||
|
||||
const ALLOWED_MIME_TYPES = new Set([
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'application/pdf',
|
||||
])
|
||||
|
||||
export type PastedImageData = {
|
||||
name: string
|
||||
type: string
|
||||
data: Blob
|
||||
}
|
||||
|
||||
export const figureModalPasteHandler = (): Extension => {
|
||||
return EditorView.domEventHandlers({
|
||||
drop: evt => {
|
||||
@@ -179,18 +171,14 @@ export const figureModalPasteHandler = (): Extension => {
|
||||
return
|
||||
}
|
||||
const file = evt.dataTransfer.files[0]
|
||||
if (!ALLOWED_MIME_TYPES.has(file.type)) {
|
||||
if (!isAllowedImageType(file.type)) {
|
||||
return
|
||||
}
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<PastedImageData>('figure-modal:paste-image', {
|
||||
detail: {
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
data: file,
|
||||
},
|
||||
})
|
||||
)
|
||||
dispatchFigureModalPasteEvent({
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
data: file,
|
||||
})
|
||||
},
|
||||
paste: evt => {
|
||||
if (!evt.clipboardData || evt.clipboardData.files.length === 0) {
|
||||
@@ -200,18 +188,14 @@ export const figureModalPasteHandler = (): Extension => {
|
||||
return // allow pasted text to be handled even if there's also a file on the clipboard
|
||||
}
|
||||
const file = evt.clipboardData.files[0]
|
||||
if (!ALLOWED_MIME_TYPES.has(file.type)) {
|
||||
if (!isAllowedImageType(file.type)) {
|
||||
return
|
||||
}
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<PastedImageData>('figure-modal:paste-image', {
|
||||
detail: {
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
data: file,
|
||||
},
|
||||
})
|
||||
)
|
||||
dispatchFigureModalPasteEvent({
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
data: file,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ import { trackDetachedComments } from './track-detached-comments'
|
||||
import { reviewTooltip } from './review-tooltip'
|
||||
import { tooltipsReposition } from './tooltips-reposition'
|
||||
import { selectionListener } from '@/features/source-editor/extensions/selection-listener'
|
||||
import { contextMenu } from './context-menu'
|
||||
|
||||
const moduleExtensions: Array<(options: Record<string, any>) => Extension> =
|
||||
importOverleafModules('sourceEditorExtensions').map(
|
||||
@@ -156,6 +157,7 @@ export const createExtensions = (options: Record<string, any>): Extension[] => [
|
||||
visual(options.visual),
|
||||
mathPreview(options.settings.mathPreview),
|
||||
reviewTooltip(),
|
||||
contextMenu(options.editorContextMenuEnabled),
|
||||
toolbarPanel(),
|
||||
breadcrumbPanel(),
|
||||
verticalOverflow(),
|
||||
|
||||
@@ -166,3 +166,14 @@ export const toggleSearch: Command = view => {
|
||||
export const addComment = () => {
|
||||
window.dispatchEvent(new Event('add-new-review-comment'))
|
||||
}
|
||||
|
||||
export const deleteSelection: Command = view => {
|
||||
const { from, to } = view.state.selection.main
|
||||
if (from === to) return false
|
||||
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: '' },
|
||||
selection: { anchor: from },
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ import { useActiveOverallTheme } from '@/shared/hooks/use-active-overall-theme'
|
||||
import { useEditorSelectionContext } from '@/shared/context/editor-selection-context'
|
||||
import { useActiveEditorTheme } from '@/shared/hooks/use-active-editor-theme'
|
||||
import { isVisualEditorAvailable } from '../utils/visual-editor'
|
||||
import { useFeatureFlag } from '@/shared/context/split-test-context'
|
||||
|
||||
function useCodeMirrorScope(view: EditorView) {
|
||||
const { fileTreeData } = useFileTreeData()
|
||||
@@ -96,6 +97,7 @@ function useCodeMirrorScope(view: EditorView) {
|
||||
const { onlineUserCursorHighlights } = useOnlineUsersContext()
|
||||
|
||||
const { project, features: projectFeatures } = useProjectContext()
|
||||
const editorContextMenuEnabled = useFeatureFlag('editor-context-menu')
|
||||
let spellCheckLanguage = project?.spellCheckLanguage || ''
|
||||
// spell check is off when read-only
|
||||
if (!permissions.write && !permissions.trackedWrite) {
|
||||
@@ -210,6 +212,7 @@ function useCodeMirrorScope(view: EditorView) {
|
||||
}, [view, spellCheckLanguage, hunspellManager])
|
||||
|
||||
const projectFeaturesRef = useRef(projectFeatures)
|
||||
const editorContextMenuEnabledRef = useRef(editorContextMenuEnabled)
|
||||
|
||||
// listen to doc:after-opened, and focus the editor if it's not a new doc
|
||||
useEffect(() => {
|
||||
@@ -337,6 +340,7 @@ function useCodeMirrorScope(view: EditorView) {
|
||||
spelling: spellingRef.current,
|
||||
visual: visualRef.current,
|
||||
projectFeatures: projectFeaturesRef.current,
|
||||
editorContextMenuEnabled: editorContextMenuEnabledRef.current,
|
||||
initialSearchQuery: searchQueryRef.current,
|
||||
showBoundary,
|
||||
handleException,
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
useCodeMirrorStateContext,
|
||||
useCodeMirrorViewContext,
|
||||
} 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 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 {
|
||||
formatShortcut,
|
||||
useCommandRegistry,
|
||||
} from '@/features/ide-react/context/command-registry-context'
|
||||
import { closeContextMenuEffect } from '../extensions/context-menu'
|
||||
import * as commands from '../extensions/toolbar/commands'
|
||||
import {
|
||||
cutSelection,
|
||||
copySelection,
|
||||
pasteWithoutFormatting,
|
||||
} from '../commands/clipboard'
|
||||
|
||||
export const useContextMenuItems = () => {
|
||||
const view = useCodeMirrorViewContext()
|
||||
const state = useCodeMirrorStateContext()
|
||||
const permissions = usePermissionsContext()
|
||||
const { wantTrackChanges } = useEditorPropertiesContext()
|
||||
const { syncToPdf, syncToPdfInFlight, canSyncToPdf } = useSynctex()
|
||||
const { pdfUrl, pdfViewer } = useDetachCompileContext()
|
||||
const { detachRole } = useLayoutContext()
|
||||
const visualPreviewEnabled = useFeatureFlag('visual-preview')
|
||||
const { t } = useTranslation()
|
||||
const { shortcuts } = useCommandRegistry()
|
||||
|
||||
const closeMenu = useCallback(() => {
|
||||
view.dispatch({ effects: closeContextMenuEffect.of(null) })
|
||||
}, [view])
|
||||
|
||||
const [pendingClose, setPendingClose] = useState(false)
|
||||
|
||||
// Wait for syncToPdf to finish before closing the menu
|
||||
useEffect(() => {
|
||||
if (pendingClose && !syncToPdfInFlight) {
|
||||
closeMenu()
|
||||
setPendingClose(false)
|
||||
}
|
||||
}, [pendingClose, syncToPdfInFlight, closeMenu])
|
||||
|
||||
const hasSelection = !state.selection.main.empty
|
||||
const canEdit = permissions.write || permissions.trackedWrite
|
||||
const jumpToLocationInPdfEnabled =
|
||||
pdfUrl &&
|
||||
pdfViewer !== 'native' &&
|
||||
!detachRole &&
|
||||
!visualPreviewEnabled &&
|
||||
canSyncToPdf
|
||||
|
||||
const wrapForContextMenu = useCallback(
|
||||
(command: () => Promise<boolean> | boolean) => async () => {
|
||||
const result = await command()
|
||||
if (result !== false) {
|
||||
view.focus()
|
||||
closeMenu()
|
||||
}
|
||||
},
|
||||
[view, closeMenu]
|
||||
)
|
||||
|
||||
const handleCut = wrapForContextMenu(() => cutSelection(view))
|
||||
const handleCopy = wrapForContextMenu(() => copySelection(view))
|
||||
const handlePaste = wrapForContextMenu(() => pasteWithoutFormatting(view))
|
||||
const handleDelete = wrapForContextMenu(() => commands.deleteSelection(view))
|
||||
|
||||
const handleToggleTrackChanges = wrapForContextMenu(() => {
|
||||
window.dispatchEvent(new Event('toggle-track-changes'))
|
||||
return true
|
||||
})
|
||||
|
||||
const handleComment = wrapForContextMenu(() => {
|
||||
commands.addComment()
|
||||
return true
|
||||
})
|
||||
|
||||
// Sync-to-PDF is special: it needs to wait for async completion before closing
|
||||
const handleSyncToPdf = useCallback(() => {
|
||||
syncToPdf()
|
||||
setPendingClose(true)
|
||||
view.focus()
|
||||
}, [syncToPdf, view])
|
||||
|
||||
const getShortcut = useCallback(
|
||||
(id: string) => {
|
||||
const shortcut = shortcuts[id]?.[0]
|
||||
return shortcut ? formatShortcut(shortcut) : undefined
|
||||
},
|
||||
[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: 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)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
export const ALLOWED_IMAGE_TYPES = new Set([
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'application/pdf',
|
||||
])
|
||||
|
||||
export function isAllowedImageType(mimeType: string): boolean {
|
||||
return ALLOWED_IMAGE_TYPES.has(mimeType)
|
||||
}
|
||||
|
||||
export type PastedImageData = {
|
||||
name: string
|
||||
type: string
|
||||
data: Blob
|
||||
}
|
||||
|
||||
export function dispatchFigureModalPasteEvent(
|
||||
imageData: PastedImageData
|
||||
): void {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<PastedImageData>('figure-modal:paste-image', {
|
||||
detail: imageData,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export async function findImageInClipboard(): Promise<File | null> {
|
||||
try {
|
||||
const clipboardItems = await navigator.clipboard.read()
|
||||
|
||||
for (const item of clipboardItems) {
|
||||
for (const type of item.types) {
|
||||
if (isAllowedImageType(type)) {
|
||||
const blob = await item.getType(type)
|
||||
const file = new File([blob], `image.${type.split('/')[1]}`, {
|
||||
type,
|
||||
})
|
||||
return file
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Clipboard.read() may fail in some browsers
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -33,7 +33,7 @@ const { buildFileTree, createFolder } =
|
||||
(importOverleafModules('snapshotUtils')[0]
|
||||
?.import as typeof StubSnapshotUtils) || StubSnapshotUtils
|
||||
|
||||
const FileTreeDataContext = createContext<
|
||||
export const FileTreeDataContext = createContext<
|
||||
| {
|
||||
// fileTreeData is the up-to-date representation of the files list, updated
|
||||
// by the file tree
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
@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';
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
.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);
|
||||
}
|
||||
@@ -226,6 +226,7 @@
|
||||
"back_to_account_settings": "Back to account settings",
|
||||
"back_to_all_posts": "Back to all posts",
|
||||
"back_to_configuration": "Back to configuration",
|
||||
"back_to_editing": "Back to editing",
|
||||
"back_to_editor": "Back to editor",
|
||||
"back_to_log_in": "Back to log in",
|
||||
"back_to_subscription": "Back to subscription",
|
||||
@@ -499,6 +500,7 @@
|
||||
"customize_your_group_subscription": "Customize your group subscription",
|
||||
"customizing_figures": "Customizing figures",
|
||||
"customizing_tables": "Customizing tables",
|
||||
"cut": "Cut",
|
||||
"da": "Danish",
|
||||
"dark_mode": "Dark mode",
|
||||
"dark_mode_pdf_preview": "Dark mode PDF preview",
|
||||
@@ -1204,6 +1206,7 @@
|
||||
"joined_team": "You have joined the group subscription managed by __inviterName__",
|
||||
"joining": "Joining",
|
||||
"july": "July",
|
||||
"jump_to_location_in_pdf": "Jump to location in PDF",
|
||||
"june": "June",
|
||||
"justify": "Justify",
|
||||
"kb_suggestions_enquiry": "Have you checked our <0>__kbLink__</0>?",
|
||||
@@ -1654,6 +1657,7 @@
|
||||
"password_too_long_please_reset": "Maximum password length exceeded. Please reset your password.",
|
||||
"password_updated": "Password updated.",
|
||||
"password_was_detected_on_a_public_list_of_known_compromised_passwords": "This password was detected on a <0>public list of known compromised passwords</0>.",
|
||||
"paste": "Paste",
|
||||
"paste_options": "Paste options",
|
||||
"paste_with_formatting": "Paste with formatting",
|
||||
"paste_without_formatting": "Paste without formatting",
|
||||
@@ -2302,6 +2306,7 @@
|
||||
"success_sso_set_up": "Success! Single sign-on is all set up for you.",
|
||||
"success_youve_successfully_joined_group": "Success! You’ve joined the <0>__groupName__</0> group subscription. Your group has SSO enabled.",
|
||||
"suggest_a_different_fix": "Suggest a different fix",
|
||||
"suggest_edits": "Suggest edits",
|
||||
"suggest_fix": "Suggest fix",
|
||||
"suggested": "Suggested",
|
||||
"suggested_code": "Suggested code",
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { expect } from 'chai'
|
||||
import { EditorState, EditorSelection } from '@codemirror/state'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import {
|
||||
copySelection,
|
||||
cutSelection,
|
||||
pasteWithoutFormatting,
|
||||
} from '../../../../../frontend/js/features/source-editor/commands/clipboard'
|
||||
|
||||
const createClipboardStub = () => {
|
||||
const stub = {
|
||||
written: null as string | null,
|
||||
reads: [] as string[],
|
||||
rejectRead: undefined as Error | undefined,
|
||||
writeText: async (text: string) => {
|
||||
stub.written = text
|
||||
},
|
||||
readText: async () => {
|
||||
if (stub.rejectRead) throw stub.rejectRead
|
||||
return stub.reads.shift() ?? ''
|
||||
},
|
||||
}
|
||||
return stub
|
||||
}
|
||||
|
||||
const createView = (doc: string, anchor = 0, head = anchor) => {
|
||||
const state = EditorState.create({ doc, selection: { anchor, head } })
|
||||
const parent = document.createElement('div')
|
||||
return new EditorView({ state, parent })
|
||||
}
|
||||
|
||||
const createViewWithMultipleRanges = (
|
||||
doc: string,
|
||||
ranges: Array<{ anchor: number; head?: number }>
|
||||
) => {
|
||||
const state = EditorState.create({
|
||||
doc,
|
||||
extensions: [EditorState.allowMultipleSelections.of(true)],
|
||||
selection: EditorSelection.create(
|
||||
ranges.map(({ anchor, head }) =>
|
||||
EditorSelection.range(anchor, head ?? anchor)
|
||||
)
|
||||
),
|
||||
})
|
||||
const parent = document.createElement('div')
|
||||
return new EditorView({ state, parent })
|
||||
}
|
||||
|
||||
describe('clipboard behavior', function () {
|
||||
let clipboard: ReturnType<typeof createClipboardStub>
|
||||
|
||||
beforeEach(function () {
|
||||
clipboard = createClipboardStub()
|
||||
;(navigator as any).clipboard = clipboard
|
||||
})
|
||||
|
||||
describe('copySelection', function () {
|
||||
it('copies only the selected range when a selection exists', async function () {
|
||||
const view = createView('abcde', 1, 4) // selects "bcd"
|
||||
await copySelection(view)
|
||||
expect(clipboard.written).to.equal('bcd')
|
||||
})
|
||||
|
||||
it('copies entire current line with trailing break when no selection (middle line)', async function () {
|
||||
const view = createView('one\ntwo\nthree', 5) // inside "two"
|
||||
await copySelection(view)
|
||||
expect(clipboard.written).to.equal('two\n')
|
||||
})
|
||||
|
||||
it('copies last line without an extra trailing break at document end', async function () {
|
||||
const view = createView('one\ntwo\nthree', 9) // inside "three"
|
||||
await copySelection(view)
|
||||
expect(clipboard.written).to.equal('three')
|
||||
})
|
||||
|
||||
it('copies all selected ranges when multiple selections exist', async function () {
|
||||
const view = createViewWithMultipleRanges('abcdefgh', [
|
||||
{ anchor: 0, head: 1 }, // "a"
|
||||
{ anchor: 3, head: 5 }, // "de"
|
||||
])
|
||||
await copySelection(view)
|
||||
expect(clipboard.written).to.equal('ade')
|
||||
})
|
||||
|
||||
it('copies entire lines for each cursor when multiple empty selections', async function () {
|
||||
const view = createViewWithMultipleRanges('line1\nline2\nline3', [
|
||||
{ anchor: 2 }, // in "line1"
|
||||
{ anchor: 8 }, // in "line2"
|
||||
])
|
||||
await copySelection(view)
|
||||
expect(clipboard.written).to.equal('line1\nline2\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cutSelection', function () {
|
||||
it('cuts only the selected range when a selection exists', async function () {
|
||||
const view = createView('abcde', 1, 4) // selects "bcd"
|
||||
await cutSelection(view)
|
||||
expect(clipboard.written).to.equal('bcd')
|
||||
expect(view.state.doc.toString()).to.equal('ae')
|
||||
})
|
||||
|
||||
it('cuts entire line with trailing break when no selection (middle line)', async function () {
|
||||
const view = createView('first\nsecond', 1)
|
||||
await cutSelection(view)
|
||||
expect(clipboard.written).to.equal('first\n')
|
||||
expect(view.state.doc.toString()).to.equal('second')
|
||||
})
|
||||
|
||||
it('cuts last line without removing the preceding newline when at document end', async function () {
|
||||
const view = createView('first\nsecond', 8) // inside last line
|
||||
await cutSelection(view)
|
||||
expect(clipboard.written).to.equal('second')
|
||||
expect(view.state.doc.toString()).to.equal('first\n')
|
||||
})
|
||||
|
||||
it('cuts all selected ranges when multiple selections exist', async function () {
|
||||
const view = createViewWithMultipleRanges('abcdefgh', [
|
||||
{ anchor: 0, head: 2 }, // "ab"
|
||||
{ anchor: 4, head: 6 }, // "ef"
|
||||
])
|
||||
await cutSelection(view)
|
||||
expect(clipboard.written).to.equal('abef')
|
||||
expect(view.state.doc.toString()).to.equal('cdgh')
|
||||
})
|
||||
|
||||
it('cuts entire lines for each cursor when multiple empty selections', async function () {
|
||||
const view = createViewWithMultipleRanges('line1\nline2\nline3', [
|
||||
{ anchor: 2 }, // in "line1"
|
||||
{ anchor: 14 }, // in "line3"
|
||||
])
|
||||
await cutSelection(view)
|
||||
expect(clipboard.written).to.equal('line1\nline3')
|
||||
expect(view.state.doc.toString()).to.equal('line2\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('pasteWithoutFormatting', function () {
|
||||
it('inserts a line-wise single line above current line when no selection', async function () {
|
||||
clipboard.reads.push('pasted\n')
|
||||
const view = createView('current', 0)
|
||||
await pasteWithoutFormatting(view)
|
||||
expect(view.state.doc.toString()).to.equal('pasted\ncurrent')
|
||||
})
|
||||
|
||||
it('replaces an existing selection inline with single-line text', async function () {
|
||||
clipboard.reads.push('XX')
|
||||
const view = createView('hello', 1, 3) // replace "el"
|
||||
await pasteWithoutFormatting(view)
|
||||
expect(view.state.doc.toString()).to.equal('hXXlo')
|
||||
})
|
||||
|
||||
it('replaces an existing selection even when clipboard text is line-wise (no insert-above)', async function () {
|
||||
clipboard.reads.push('line\n')
|
||||
const view = createView('abc', 0, 1) // replace "a"
|
||||
await pasteWithoutFormatting(view)
|
||||
expect(view.state.doc.toString()).to.equal('line\nbc')
|
||||
})
|
||||
|
||||
it('pastes multiline text inline (no line-wise handling)', async function () {
|
||||
clipboard.reads.push('lineA\nlineB\n')
|
||||
const view = createView('X', 0)
|
||||
await pasteWithoutFormatting(view)
|
||||
expect(view.state.doc.toString()).to.equal('lineA\nlineB\nX')
|
||||
})
|
||||
|
||||
it('returns false and leaves document unchanged if clipboard read fails', async function () {
|
||||
clipboard.rejectRead = new Error('denied')
|
||||
const view = createView('stay', 0)
|
||||
const result = await pasteWithoutFormatting(view)
|
||||
expect(result).to.equal(false)
|
||||
expect(view.state.doc.toString()).to.equal('stay')
|
||||
})
|
||||
|
||||
it('pastes into all selected ranges when multiple selections exist', async function () {
|
||||
clipboard.reads.push('XX')
|
||||
const view = createViewWithMultipleRanges('abcdefgh', [
|
||||
{ anchor: 1, head: 2 }, // "b"
|
||||
{ anchor: 5, head: 6 }, // "f"
|
||||
])
|
||||
await pasteWithoutFormatting(view)
|
||||
expect(view.state.doc.toString()).to.equal('aXXcdeXXgh')
|
||||
})
|
||||
|
||||
it('pastes line-wise content at line start for each cursor when multiple empty selections', async function () {
|
||||
clipboard.reads.push('new\n')
|
||||
const view = createViewWithMultipleRanges('line1\nline2', [
|
||||
{ anchor: 2 }, // in "line1"
|
||||
{ anchor: 8 }, // in "line2"
|
||||
])
|
||||
await pasteWithoutFormatting(view)
|
||||
expect(view.state.doc.toString()).to.equal('new\nline1\nnew\nline2')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,773 @@
|
||||
import { mockScope } from '../helpers/mock-scope'
|
||||
import {
|
||||
EditorProviders,
|
||||
makeEditorPropertiesProvider,
|
||||
} from '../../../helpers/editor-providers'
|
||||
import CodeMirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
|
||||
import { TestContainer } from '../helpers/test-container'
|
||||
import { FC } from 'react'
|
||||
import { PermissionsContext } from '@/features/ide-react/context/permissions-context'
|
||||
import { Permissions } from '@/features/ide-react/types/permissions'
|
||||
import { DetachCompileContext } from '@/shared/context/detach-compile-context'
|
||||
import { FileTreeDataContext } from '@/shared/context/file-tree-data-context'
|
||||
|
||||
const createPermissionsProvider = (
|
||||
permissions: Partial<Permissions>
|
||||
): FC<React.PropsWithChildren> => {
|
||||
const defaultPermissions: Permissions = {
|
||||
read: true,
|
||||
comment: true,
|
||||
resolveOwnComments: false,
|
||||
resolveAllComments: false,
|
||||
trackedWrite: false,
|
||||
write: false,
|
||||
admin: false,
|
||||
labelVersion: false,
|
||||
}
|
||||
|
||||
return function PermissionsProvider({ children }) {
|
||||
return (
|
||||
<PermissionsContext.Provider
|
||||
value={{ ...defaultPermissions, ...permissions }}
|
||||
>
|
||||
{children}
|
||||
</PermissionsContext.Provider>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const MockDetachCompileProvider: FC<React.PropsWithChildren> = ({
|
||||
children,
|
||||
}) => (
|
||||
<DetachCompileContext.Provider
|
||||
value={
|
||||
{
|
||||
pdfUrl: '/pdf/output.pdf',
|
||||
pdfViewer: 'pdfjs',
|
||||
compiling: false,
|
||||
} as any
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</DetachCompileContext.Provider>
|
||||
)
|
||||
|
||||
const MockFileTreeDataProvider: FC<React.PropsWithChildren> = ({
|
||||
children,
|
||||
}) => (
|
||||
<FileTreeDataContext.Provider
|
||||
value={
|
||||
{
|
||||
selectedEntities: [{ type: 'doc', id: '_root_doc_id' } as any],
|
||||
} as any
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</FileTreeDataContext.Provider>
|
||||
)
|
||||
|
||||
describe('editor context menu', { scrollBehavior: false }, function () {
|
||||
beforeEach(function () {
|
||||
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
|
||||
window.metaAttributesCache.set('ol-splitTestVariants', {
|
||||
'editor-context-menu': 'enabled',
|
||||
})
|
||||
cy.interceptEvents()
|
||||
cy.interceptMetadata()
|
||||
})
|
||||
|
||||
it('should open on right-click and close on Escape', function () {
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<TestContainer>
|
||||
<EditorProviders scope={scope}>
|
||||
<CodeMirrorEditor />
|
||||
</EditorProviders>
|
||||
</TestContainer>
|
||||
)
|
||||
|
||||
cy.get('.editor-context-menu').should('not.exist')
|
||||
|
||||
cy.get('.cm-line').eq(10).rightclick()
|
||||
cy.get('.editor-context-menu').should('be.visible')
|
||||
|
||||
cy.get('body').type('{esc}')
|
||||
cy.get('.editor-context-menu').should('not.exist')
|
||||
})
|
||||
|
||||
it('should close when clicking elsewhere', function () {
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<TestContainer>
|
||||
<EditorProviders scope={scope}>
|
||||
<CodeMirrorEditor />
|
||||
</EditorProviders>
|
||||
</TestContainer>
|
||||
)
|
||||
|
||||
cy.get('.cm-line').eq(10).rightclick()
|
||||
cy.get('.editor-context-menu').should('be.visible')
|
||||
|
||||
cy.get('.cm-line').eq(5).click()
|
||||
cy.get('.editor-context-menu').should('not.exist')
|
||||
})
|
||||
|
||||
describe('when nothing is selected', function () {
|
||||
it('should enable Cut, Copy, Paste, Suggest edits and disable Delete, Comment', function () {
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<TestContainer>
|
||||
<EditorProviders scope={scope}>
|
||||
<CodeMirrorEditor />
|
||||
</EditorProviders>
|
||||
</TestContainer>
|
||||
)
|
||||
|
||||
cy.get('.cm-line').eq(10).rightclick()
|
||||
|
||||
cy.get('.editor-context-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: /paste/i }).should('be.enabled')
|
||||
cy.findByRole('menuitem', { name: /suggest edits/i }).should(
|
||||
'be.enabled'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('when text is selected', function () {
|
||||
it('should enable Cut, Copy, Paste, Delete, Suggest edits, and Comment', function () {
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<TestContainer>
|
||||
<EditorProviders scope={scope}>
|
||||
<CodeMirrorEditor />
|
||||
</EditorProviders>
|
||||
</TestContainer>
|
||||
)
|
||||
|
||||
cy.get('.cm-line').eq(16).as('line')
|
||||
cy.get('@line').click()
|
||||
cy.get('@line').type('test text')
|
||||
cy.get('@line').type(
|
||||
'{shift}{leftArrow}{leftArrow}{leftArrow}{leftArrow}'
|
||||
)
|
||||
|
||||
cy.get('.cm-selectionBackground').should('exist')
|
||||
|
||||
cy.get('@line').rightclick()
|
||||
|
||||
cy.get('.cm-selectionBackground').should('exist')
|
||||
cy.get('.editor-context-menu').should('be.visible')
|
||||
|
||||
cy.get('.editor-context-menu').within(() => {
|
||||
cy.findByRole('menuitem', { name: /cut/i }).should('be.enabled')
|
||||
cy.findByRole('menuitem', { name: /copy/i }).should('be.enabled')
|
||||
cy.findByRole('menuitem', { name: /paste/i }).should('be.enabled')
|
||||
cy.findByRole('menuitem', { name: /delete/i }).should('be.enabled')
|
||||
cy.findByRole('menuitem', { name: /suggest edits/i }).should(
|
||||
'be.enabled'
|
||||
)
|
||||
cy.findByRole('menuitem', { name: /comment/i }).should('be.enabled')
|
||||
})
|
||||
})
|
||||
|
||||
it('should copy selected text and close menu', function () {
|
||||
// Grant clipboard permissions for this test
|
||||
cy.wrap(
|
||||
Cypress.automation('remote:debugger:protocol', {
|
||||
command: 'Browser.grantPermissions',
|
||||
params: {
|
||||
permissions: ['clipboardReadWrite', 'clipboardSanitizedWrite'],
|
||||
origin: window.location.origin,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<TestContainer>
|
||||
<EditorProviders scope={scope}>
|
||||
<CodeMirrorEditor />
|
||||
</EditorProviders>
|
||||
</TestContainer>
|
||||
)
|
||||
|
||||
cy.get('.cm-line').eq(16).as('line')
|
||||
cy.get('@line').click()
|
||||
cy.get('@line').type('test text')
|
||||
cy.get('@line').type(
|
||||
'{shift}{leftArrow}{leftArrow}{leftArrow}{leftArrow}'
|
||||
)
|
||||
|
||||
cy.get('.cm-selectionBackground').should('exist')
|
||||
|
||||
cy.get('@line').rightclick()
|
||||
|
||||
cy.get('.editor-context-menu').within(() => {
|
||||
cy.findByRole('menuitem', { name: /copy/i }).click()
|
||||
})
|
||||
|
||||
cy.get('.editor-context-menu').should('not.exist')
|
||||
})
|
||||
|
||||
it('should cut and paste text via the context menu', function () {
|
||||
// Grant clipboard permissions for this test
|
||||
cy.wrap(
|
||||
Cypress.automation('remote:debugger:protocol', {
|
||||
command: 'Browser.grantPermissions',
|
||||
params: {
|
||||
permissions: ['clipboardReadWrite', 'clipboardSanitizedWrite'],
|
||||
origin: window.location.origin,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<TestContainer>
|
||||
<EditorProviders scope={scope}>
|
||||
<CodeMirrorEditor />
|
||||
</EditorProviders>
|
||||
</TestContainer>
|
||||
)
|
||||
|
||||
cy.get('.cm-line').eq(16).as('line')
|
||||
cy.get('@line').click()
|
||||
cy.get('@line').type('hello world')
|
||||
|
||||
// Select "world"
|
||||
cy.get('@line').type(
|
||||
'{shift}{leftArrow}{leftArrow}{leftArrow}{leftArrow}{leftArrow}'
|
||||
)
|
||||
|
||||
// Cut "world"
|
||||
cy.get('@line').rightclick()
|
||||
cy.get('.editor-context-menu').within(() => {
|
||||
cy.findByRole('menuitem', { name: /cut/i }).click()
|
||||
})
|
||||
|
||||
cy.get('.editor-context-menu').should('not.exist')
|
||||
cy.get('@line').should('contain', 'hello ')
|
||||
cy.get('@line').should('not.contain', 'world')
|
||||
|
||||
// Move cursor to beginning of line and right-click at column 0 so paste starts there
|
||||
cy.get('@line').type('{home}')
|
||||
cy.get('@line').rightclick(0, 0)
|
||||
|
||||
// Paste "world" at the beginning
|
||||
cy.get('.editor-context-menu').within(() => {
|
||||
cy.findByRole('menuitem', { name: /paste/i }).click()
|
||||
})
|
||||
|
||||
cy.get('.editor-context-menu').should('not.exist')
|
||||
cy.get('@line').should('contain', 'worldhello')
|
||||
})
|
||||
|
||||
it('should delete selected text', function () {
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<TestContainer>
|
||||
<EditorProviders scope={scope}>
|
||||
<CodeMirrorEditor />
|
||||
</EditorProviders>
|
||||
</TestContainer>
|
||||
)
|
||||
|
||||
cy.get('.cm-line').eq(16).as('line')
|
||||
cy.get('@line').click()
|
||||
cy.get('@line').type('hello world')
|
||||
|
||||
// Select "world"
|
||||
cy.get('@line').type(
|
||||
'{shift}{leftArrow}{leftArrow}{leftArrow}{leftArrow}{leftArrow}'
|
||||
)
|
||||
|
||||
cy.get('@line').rightclick()
|
||||
|
||||
cy.get('.editor-context-menu').within(() => {
|
||||
cy.findByRole('menuitem', { name: /delete/i }).click()
|
||||
})
|
||||
|
||||
cy.get('.editor-context-menu').should('not.exist')
|
||||
cy.get('@line').should('contain', 'hello ')
|
||||
cy.get('@line').should('not.contain', 'world')
|
||||
})
|
||||
})
|
||||
|
||||
describe('track changes toggle', function () {
|
||||
let toggleTrackChangesListener: Cypress.Agent<sinon.SinonStub>
|
||||
|
||||
beforeEach(function () {
|
||||
toggleTrackChangesListener = cy.stub().as('toggleTrackChanges')
|
||||
window.addEventListener(
|
||||
'toggle-track-changes',
|
||||
toggleTrackChangesListener
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
window.removeEventListener(
|
||||
'toggle-track-changes',
|
||||
toggleTrackChangesListener
|
||||
)
|
||||
})
|
||||
|
||||
it('should show "Suggest edits" in edit mode and dispatch toggle event when clicked', function () {
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<TestContainer>
|
||||
<EditorProviders
|
||||
scope={scope}
|
||||
providers={{
|
||||
EditorPropertiesProvider: makeEditorPropertiesProvider({
|
||||
wantTrackChanges: false,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<CodeMirrorEditor />
|
||||
</EditorProviders>
|
||||
</TestContainer>
|
||||
)
|
||||
|
||||
cy.get('.cm-line').eq(10).rightclick()
|
||||
|
||||
cy.get('.editor-context-menu').within(() => {
|
||||
// Verify we're showing the edit mode label
|
||||
cy.findByRole('menuitem', { name: /suggest edits/i }).should(
|
||||
'be.visible'
|
||||
)
|
||||
cy.findByRole('menuitem', { name: /back to editing/i }).should(
|
||||
'not.exist'
|
||||
)
|
||||
cy.findByRole('menuitem', { name: /suggest edits/i }).click()
|
||||
})
|
||||
|
||||
cy.get('.editor-context-menu').should('not.exist')
|
||||
|
||||
// Verify the toggle event was dispatched
|
||||
cy.get('@toggleTrackChanges').should('have.been.calledOnce')
|
||||
})
|
||||
|
||||
it('should show "Back to editing" in review mode and dispatch toggle event when clicked', function () {
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<TestContainer>
|
||||
<EditorProviders
|
||||
scope={scope}
|
||||
providers={{
|
||||
EditorPropertiesProvider: makeEditorPropertiesProvider({
|
||||
wantTrackChanges: true,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<CodeMirrorEditor />
|
||||
</EditorProviders>
|
||||
</TestContainer>
|
||||
)
|
||||
|
||||
cy.get('.cm-line').eq(10).rightclick()
|
||||
|
||||
cy.get('.editor-context-menu').within(() => {
|
||||
// Verify we're showing the review mode label
|
||||
cy.findByRole('menuitem', { name: /back to editing/i }).should(
|
||||
'be.visible'
|
||||
)
|
||||
cy.findByRole('menuitem', { name: /suggest edits/i }).should(
|
||||
'not.exist'
|
||||
)
|
||||
cy.findByRole('menuitem', { name: /back to editing/i }).click()
|
||||
})
|
||||
|
||||
cy.get('.editor-context-menu').should('not.exist')
|
||||
|
||||
// Verify the toggle event was dispatched
|
||||
cy.get('@toggleTrackChanges').should('have.been.calledOnce')
|
||||
})
|
||||
})
|
||||
|
||||
describe('when feature flag is disabled', function () {
|
||||
it('should not show the context menu', function () {
|
||||
window.metaAttributesCache.set('ol-splitTestVariants', {
|
||||
'editor-context-menu': 'default',
|
||||
})
|
||||
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<TestContainer>
|
||||
<EditorProviders scope={scope}>
|
||||
<CodeMirrorEditor />
|
||||
</EditorProviders>
|
||||
</TestContainer>
|
||||
)
|
||||
|
||||
cy.get('.cm-line').eq(10).rightclick()
|
||||
cy.get('.editor-context-menu').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('when a user does not have edit permissions', function () {
|
||||
it('should only show Copy and Comment (hidden Cut, Paste, Delete, Suggest edits)', function () {
|
||||
const scope = mockScope()
|
||||
scope.permissions.write = false
|
||||
scope.permissions.trackedWrite = false
|
||||
|
||||
cy.mount(
|
||||
<TestContainer>
|
||||
<EditorProviders
|
||||
scope={scope}
|
||||
providers={{
|
||||
PermissionsProvider: createPermissionsProvider({
|
||||
write: false,
|
||||
trackedWrite: false,
|
||||
comment: true,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<CodeMirrorEditor />
|
||||
</EditorProviders>
|
||||
</TestContainer>
|
||||
)
|
||||
|
||||
// Select some existing text
|
||||
cy.get('.cm-line').eq(10).as('line')
|
||||
cy.get('@line').click()
|
||||
cy.get('@line').type(
|
||||
'{shift}{leftArrow}{leftArrow}{leftArrow}{leftArrow}{leftArrow}'
|
||||
)
|
||||
|
||||
cy.get('.cm-selectionBackground').should('exist')
|
||||
|
||||
cy.get('@line').rightclick()
|
||||
|
||||
cy.get('.editor-context-menu').should('be.visible')
|
||||
|
||||
cy.get('.editor-context-menu').within(() => {
|
||||
cy.findByRole('menuitem', { name: /cut/i }).should('not.exist')
|
||||
cy.findByRole('menuitem', { name: /copy/i }).should('be.enabled')
|
||||
cy.findByRole('menuitem', { name: /paste/i }).should('not.exist')
|
||||
cy.findByRole('menuitem', { name: /delete/i }).should('not.exist')
|
||||
cy.findByRole('menuitem', { name: /suggest edits/i }).should(
|
||||
'not.exist'
|
||||
)
|
||||
cy.findByRole('menuitem', { name: /comment/i }).should('be.enabled')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('when a user does not have comment permissions', function () {
|
||||
it('should hide the Comment button', function () {
|
||||
const scope = mockScope()
|
||||
scope.permissions.write = false
|
||||
scope.permissions.trackedWrite = false
|
||||
scope.permissions.comment = false
|
||||
|
||||
cy.mount(
|
||||
<TestContainer>
|
||||
<EditorProviders
|
||||
scope={scope}
|
||||
providers={{
|
||||
PermissionsProvider: createPermissionsProvider({
|
||||
write: false,
|
||||
trackedWrite: false,
|
||||
comment: false,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<CodeMirrorEditor />
|
||||
</EditorProviders>
|
||||
</TestContainer>
|
||||
)
|
||||
|
||||
// Select some existing text
|
||||
cy.get('.cm-line').eq(10).as('line')
|
||||
cy.get('@line').click()
|
||||
cy.get('@line').type(
|
||||
'{shift}{leftArrow}{leftArrow}{leftArrow}{leftArrow}{leftArrow}'
|
||||
)
|
||||
|
||||
cy.get('.cm-selectionBackground').should('exist')
|
||||
|
||||
cy.get('@line').rightclick()
|
||||
|
||||
cy.get('.editor-context-menu').should('be.visible')
|
||||
|
||||
cy.get('.editor-context-menu').within(() => {
|
||||
cy.findByRole('menuitem', { name: /copy/i }).should('be.enabled')
|
||||
cy.findByRole('menuitem', { name: /comment/i }).should('not.exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('pasting images via context menu', function () {
|
||||
it('should open figure modal on pasting image via context menu', function () {
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<TestContainer>
|
||||
<EditorProviders scope={scope}>
|
||||
<CodeMirrorEditor />
|
||||
</EditorProviders>
|
||||
</TestContainer>
|
||||
)
|
||||
|
||||
cy.fixture<Uint8Array<ArrayBuffer>>('images/gradient.png').then(
|
||||
gradientBuffer => {
|
||||
// Stub the clipboard API to return our test image
|
||||
cy.window().then(_win => {
|
||||
const readStub = cy.stub(navigator.clipboard, 'read')
|
||||
readStub.resolves([
|
||||
{
|
||||
types: ['image/png'],
|
||||
getType: cy
|
||||
.stub()
|
||||
.withArgs('image/png')
|
||||
.resolves(new Blob([gradientBuffer], { type: 'image/png' })),
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
// Right-click to open context menu
|
||||
cy.get('.cm-line').eq(10).rightclick()
|
||||
cy.get('.editor-context-menu').should('be.visible')
|
||||
|
||||
// Click paste button
|
||||
cy.get('.editor-context-menu').within(() => {
|
||||
cy.findByRole('menuitem', { name: /paste/i }).click()
|
||||
})
|
||||
|
||||
// Figure modal should open with the image
|
||||
cy.findByText('Upload from computer').should('be.visible')
|
||||
|
||||
// Context menu should close
|
||||
cy.get('.editor-context-menu').should('not.exist')
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sync to PDF button', function () {
|
||||
beforeEach(function () {
|
||||
// Stub the sync API call
|
||||
cy.intercept('GET', '/project/*/sync/code*', {
|
||||
statusCode: 200,
|
||||
body: { pdf: [] },
|
||||
}).as('syncToPdfRequest')
|
||||
})
|
||||
|
||||
it('should show jump to location in PDF button and call sync API when clicked', function () {
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<TestContainer>
|
||||
<EditorProviders
|
||||
scope={scope}
|
||||
providers={{
|
||||
DetachCompileProvider: MockDetachCompileProvider,
|
||||
FileTreeDataProvider: MockFileTreeDataProvider,
|
||||
}}
|
||||
>
|
||||
<CodeMirrorEditor />
|
||||
</EditorProviders>
|
||||
</TestContainer>
|
||||
)
|
||||
|
||||
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.get('.editor-context-menu').should('not.exist')
|
||||
|
||||
// Verify the sync API was called and returned expected response
|
||||
cy.wait('@syncToPdfRequest').then(interception => {
|
||||
expect(interception.response?.statusCode).to.equal(200)
|
||||
expect(interception.response?.body).to.deep.equal({ pdf: [] })
|
||||
})
|
||||
})
|
||||
|
||||
it('should hide button when visual preview is enabled', function () {
|
||||
window.metaAttributesCache.set('ol-splitTestVariants', {
|
||||
'editor-context-menu': 'enabled',
|
||||
'visual-preview': 'enabled',
|
||||
})
|
||||
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<TestContainer>
|
||||
<EditorProviders scope={scope}>
|
||||
<CodeMirrorEditor />
|
||||
</EditorProviders>
|
||||
</TestContainer>
|
||||
)
|
||||
|
||||
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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('gutter context menu', function () {
|
||||
const editorLine = 2
|
||||
const gutterLineIndex = editorLine + 1 // extra hidden gutter line
|
||||
|
||||
it('should select entire line when right-clicking on gutter', function () {
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<TestContainer>
|
||||
<EditorProviders scope={scope}>
|
||||
<CodeMirrorEditor />
|
||||
</EditorProviders>
|
||||
</TestContainer>
|
||||
)
|
||||
|
||||
cy.get('.editor-context-menu').should('not.exist')
|
||||
|
||||
cy.get('.cm-line').eq(editorLine).as('targetLine')
|
||||
cy.get('@targetLine').click()
|
||||
cy.get('@targetLine').type('This is a test line')
|
||||
|
||||
cy.get('@targetLine').click()
|
||||
cy.get('.cm-selectionBackground').should('not.exist')
|
||||
|
||||
cy.get('.cm-gutterElement').eq(gutterLineIndex).rightclick()
|
||||
|
||||
cy.get('.cm-selectionBackground').should('exist')
|
||||
cy.get('.editor-context-menu').should('be.visible')
|
||||
})
|
||||
|
||||
it('should work with cut/copy/delete operations on gutter-selected line', function () {
|
||||
cy.wrap(
|
||||
Cypress.automation('remote:debugger:protocol', {
|
||||
command: 'Browser.grantPermissions',
|
||||
params: {
|
||||
permissions: ['clipboardReadWrite', 'clipboardSanitizedWrite'],
|
||||
origin: window.location.origin,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<TestContainer>
|
||||
<EditorProviders scope={scope}>
|
||||
<CodeMirrorEditor />
|
||||
</EditorProviders>
|
||||
</TestContainer>
|
||||
)
|
||||
|
||||
cy.window().then(win => {
|
||||
cy.stub(win.navigator.clipboard, 'writeText').as('writeText')
|
||||
})
|
||||
|
||||
cy.get('.cm-line').eq(editorLine).as('testLine')
|
||||
cy.get('@testLine').click()
|
||||
cy.get('@testLine').type('Test line for gutter copy')
|
||||
|
||||
cy.get('.cm-gutterElement').eq(gutterLineIndex).rightclick()
|
||||
|
||||
cy.get('.cm-selectionBackground').should('exist')
|
||||
cy.get('.editor-context-menu').should('be.visible')
|
||||
|
||||
cy.get('.editor-context-menu').within(() => {
|
||||
cy.findByRole('menuitem', { name: /cut/i }).should('be.enabled')
|
||||
cy.findByRole('menuitem', { name: /copy/i }).should('be.enabled')
|
||||
cy.findByRole('menuitem', { name: /paste/i }).should('be.enabled')
|
||||
cy.findByRole('menuitem', { name: /delete/i }).should('be.enabled')
|
||||
cy.findByRole('menuitem', { name: /suggest edits/i }).should(
|
||||
'be.enabled'
|
||||
)
|
||||
cy.findByRole('menuitem', { name: /comment/i }).should('be.enabled')
|
||||
|
||||
cy.findByRole('menuitem', { name: /copy/i }).click()
|
||||
})
|
||||
|
||||
cy.get('.editor-context-menu').should('not.exist')
|
||||
|
||||
cy.get('@writeText').should('have.been.calledOnce')
|
||||
cy.get('@writeText').should(
|
||||
'have.been.calledWith',
|
||||
Cypress.sinon.match((text: string) =>
|
||||
text.includes('Test line for gutter copy')
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('should close menu when clicking elsewhere after gutter right-click', function () {
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<TestContainer>
|
||||
<EditorProviders scope={scope}>
|
||||
<CodeMirrorEditor />
|
||||
</EditorProviders>
|
||||
</TestContainer>
|
||||
)
|
||||
|
||||
cy.get('.cm-gutterElement').eq(5).rightclick()
|
||||
cy.get('.editor-context-menu').should('be.visible')
|
||||
|
||||
cy.get('.cm-line').eq(10).click()
|
||||
cy.get('.editor-context-menu').should('not.exist')
|
||||
})
|
||||
|
||||
it('should close menu on Escape after gutter right-click', function () {
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<TestContainer>
|
||||
<EditorProviders scope={scope}>
|
||||
<CodeMirrorEditor />
|
||||
</EditorProviders>
|
||||
</TestContainer>
|
||||
)
|
||||
|
||||
cy.get('.cm-gutterElement').eq(5).rightclick()
|
||||
cy.get('.editor-context-menu').should('be.visible')
|
||||
|
||||
cy.get('.cm-content').focus()
|
||||
cy.get('body').type('{esc}')
|
||||
cy.get('.editor-context-menu').should('not.exist')
|
||||
})
|
||||
|
||||
it('should not show context menu on gutter when feature flag is disabled', function () {
|
||||
window.metaAttributesCache.set('ol-splitTestVariants', {
|
||||
'editor-context-menu': 'default',
|
||||
})
|
||||
|
||||
const scope = mockScope()
|
||||
|
||||
cy.mount(
|
||||
<TestContainer>
|
||||
<EditorProviders scope={scope}>
|
||||
<CodeMirrorEditor />
|
||||
</EditorProviders>
|
||||
</TestContainer>
|
||||
)
|
||||
|
||||
cy.get('.cm-gutterElement').eq(5).rightclick()
|
||||
cy.get('.editor-context-menu').should('not.exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user