mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 13:13:45 +01:00
Merge pull request #30495 from overleaf/mg-context-menu-paste
[web] Support paste with formatting in context menu GitOrigin-RevId: 551ed1d49ca423395bd9bfc756e10e8d59d71ecd
This commit is contained in:
@@ -60,6 +60,7 @@ export const CommandRegistryProvider: React.FC<React.PropsWithChildren> = ({
|
|||||||
cut: [{ key: 'Mod-x' }],
|
cut: [{ key: 'Mod-x' }],
|
||||||
copy: [{ key: 'Mod-c' }],
|
copy: [{ key: 'Mod-c' }],
|
||||||
paste: [{ key: 'Mod-v' }],
|
paste: [{ key: 'Mod-v' }],
|
||||||
|
'paste-special': [{ key: 'Mod-Shift-V' }],
|
||||||
'toggle-track-changes': [{ key: 'Mod-Shift-A' }],
|
'toggle-track-changes': [{ key: 'Mod-Shift-A' }],
|
||||||
undo: [
|
undo: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { EditorView } from '@codemirror/view'
|
import { EditorView } from '@codemirror/view'
|
||||||
|
import { EditorSelection } from '@codemirror/state'
|
||||||
|
import { handleImagePaste } from '../utils/paste-image'
|
||||||
|
import { convertHtmlStringToLatex } from '../extensions/visual/paste-html'
|
||||||
import {
|
import {
|
||||||
findImageInClipboard,
|
insertPastedContent,
|
||||||
dispatchFigureModalPasteEvent,
|
storePastedContent,
|
||||||
} from '../utils/paste-image'
|
} from '../extensions/visual/pasted-content'
|
||||||
|
|
||||||
const getEntireLineText = (view: EditorView, pos: number): string => {
|
const getEntireLineText = (view: EditorView, pos: number): string => {
|
||||||
const line = view.state.doc.lineAt(pos)
|
const line = view.state.doc.lineAt(pos)
|
||||||
@@ -10,6 +13,36 @@ const getEntireLineText = (view: EditorView, pos: number): string => {
|
|||||||
return atDocumentEnd ? line.text : line.text + view.state.lineBreak
|
return atDocumentEnd ? line.text : line.text + view.state.lineBreak
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pastePlainText = (view: EditorView, text: string): void => {
|
||||||
|
// Detect line-wise paste: single line of text with trailing linebreak
|
||||||
|
const textWithoutTrailingBreak = text.slice(0, -view.state.lineBreak.length)
|
||||||
|
const isLineWise =
|
||||||
|
text.endsWith(view.state.lineBreak) &&
|
||||||
|
!textWithoutTrailingBreak.includes(view.state.lineBreak)
|
||||||
|
|
||||||
|
// Use changeByRange to apply paste to each selection/range
|
||||||
|
const changes = view.state.changeByRange(range => {
|
||||||
|
const { from, to } = range
|
||||||
|
const noSelection = from === to
|
||||||
|
const shouldInsertAtLineStart = noSelection && isLineWise
|
||||||
|
|
||||||
|
if (shouldInsertAtLineStart) {
|
||||||
|
const line = view.state.doc.lineAt(from)
|
||||||
|
return {
|
||||||
|
changes: { from: line.from, to: line.from, insert: text },
|
||||||
|
range: EditorSelection.cursor(line.from + text.length),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
changes: { from, to, insert: text },
|
||||||
|
range: EditorSelection.cursor(from + text.length),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
view.dispatch(changes)
|
||||||
|
}
|
||||||
|
|
||||||
export const cutSelection = async (view: EditorView): Promise<boolean> => {
|
export const cutSelection = async (view: EditorView): Promise<boolean> => {
|
||||||
const selections = view.state.selection.ranges
|
const selections = view.state.selection.ranges
|
||||||
const changes = []
|
const changes = []
|
||||||
@@ -62,54 +95,59 @@ export const pasteWithoutFormatting = async (
|
|||||||
view: EditorView
|
view: EditorView
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
// Check for pasted images first
|
// Check for pasted images first
|
||||||
const imageFile = await findImageInClipboard()
|
if (await handleImagePaste()) {
|
||||||
if (imageFile) {
|
|
||||||
dispatchFigureModalPasteEvent({
|
|
||||||
name: imageFile.name,
|
|
||||||
type: imageFile.type,
|
|
||||||
data: imageFile,
|
|
||||||
})
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to plain text paste
|
// Fall back to plain text paste
|
||||||
try {
|
try {
|
||||||
const text = await navigator.clipboard.readText()
|
const text = await navigator.clipboard.readText()
|
||||||
const selections = view.state.selection.ranges
|
pastePlainText(view, text)
|
||||||
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
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
// Clipboard access denied or empty
|
// Clipboard access denied or empty
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const pasteWithFormatting = async (
|
||||||
|
view: EditorView
|
||||||
|
): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const clipboardItems = await navigator.clipboard.read()
|
||||||
|
|
||||||
|
let html = ''
|
||||||
|
let text = ''
|
||||||
|
let nonTextBlobCount = 0
|
||||||
|
for (const item of clipboardItems) {
|
||||||
|
for (const type of item.types) {
|
||||||
|
const blob = await item.getType(type)
|
||||||
|
if (type === 'text/html') {
|
||||||
|
html = (await blob.text()).trim()
|
||||||
|
} else if (type === 'text/plain') {
|
||||||
|
text = (await blob.text()).trim()
|
||||||
|
} else if (!type.startsWith('text/')) {
|
||||||
|
nonTextBlobCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!html) {
|
||||||
|
return await pasteWithoutFormatting(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
const latex = convertHtmlStringToLatex(html, nonTextBlobCount)
|
||||||
|
|
||||||
|
if (latex === null || (latex === text && nonTextBlobCount === 0)) {
|
||||||
|
// No latex or formatting detected, use plain text paste
|
||||||
|
return await pasteWithoutFormatting(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
view.dispatch(insertPastedContent(view, { latex, text }))
|
||||||
|
view.dispatch(storePastedContent({ latex, text }, true))
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
// Clipboard.read not available, or latex conversion failed, use standard paste behavior
|
||||||
|
return await pasteWithoutFormatting(view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -81,6 +81,27 @@ function isPositionInsideSelection(pos: number, from: number, to: number) {
|
|||||||
return from !== to && pos >= from && pos <= to
|
return from !== to && pos >= from && pos <= to
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPositionInsideAnyRangeOrCursor(view: EditorView, pos: number) {
|
||||||
|
for (const range of view.state.selection.ranges) {
|
||||||
|
// If it's a cursor, treat a right-click anywhere on the same line as "inside".
|
||||||
|
// This avoids collapsing multi-cursor selections when right-clicking on blank lines
|
||||||
|
// or to the right of the caret.
|
||||||
|
if (range.from === range.to) {
|
||||||
|
const clickedLine = view.state.doc.lineAt(pos)
|
||||||
|
const cursorLine = view.state.doc.lineAt(range.from)
|
||||||
|
if (clickedLine.number === cursorLine.number) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPositionInsideSelection(pos, range.from, range.to)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
function selectEntireLine(
|
function selectEntireLine(
|
||||||
view: EditorView,
|
view: EditorView,
|
||||||
pos: number
|
pos: number
|
||||||
@@ -171,8 +192,7 @@ const editorContextMenuHandlers = (): Extension =>
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const { from, to } = view.state.selection.main
|
const clickedInsideSelection = isPositionInsideAnyRangeOrCursor(view, pos)
|
||||||
const clickedInsideSelection = isPositionInsideSelection(pos, from, to)
|
|
||||||
|
|
||||||
// Set cursor to clicked position if outside selection
|
// Set cursor to clicked position if outside selection
|
||||||
let selection: TransactionSpec['selection'] = { anchor: pos }
|
let selection: TransactionSpec['selection'] = { anchor: pos }
|
||||||
|
|||||||
@@ -45,35 +45,17 @@ export const pasteHtml = [
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// convert the HTML to LaTeX
|
|
||||||
try {
|
try {
|
||||||
const parser = new DOMParser()
|
const latex = convertHtmlStringToLatex(
|
||||||
const { documentElement } = parser.parseFromString(html, 'text/html')
|
html,
|
||||||
|
clipboardData.files.length
|
||||||
|
)
|
||||||
|
|
||||||
// fall back to creating a figure when there's an image on the clipoard,
|
// if there's no latex conversion, use plain text version
|
||||||
// unless the HTML indicates that it came from an Office application
|
if (latex === null) {
|
||||||
// (which also puts an image on the clipboard)
|
|
||||||
if (
|
|
||||||
clipboardData.files.length > 0 &&
|
|
||||||
!hasProgId(documentElement) &&
|
|
||||||
!isOnlyTable(documentElement)
|
|
||||||
) {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const bodyElement = documentElement.querySelector('body')
|
|
||||||
// DOMParser should always create a body element, so this is mostly for TypeScript
|
|
||||||
if (!bodyElement) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// if the only content is in a code block, use the plain text version
|
|
||||||
if (onlyCode(bodyElement)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const latex = htmlToLaTeX(bodyElement)
|
|
||||||
|
|
||||||
// if there's no formatting, use the plain text version
|
// if there's no formatting, use the plain text version
|
||||||
if (latex === text && clipboardData.files.length === 0) {
|
if (latex === text && clipboardData.files.length === 0) {
|
||||||
return false
|
return false
|
||||||
@@ -95,6 +77,37 @@ export const pasteHtml = [
|
|||||||
pastedContent,
|
pastedContent,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export function convertHtmlStringToLatex(
|
||||||
|
html: string,
|
||||||
|
filesLength: number
|
||||||
|
): string | null {
|
||||||
|
const parser = new DOMParser()
|
||||||
|
const { documentElement } = parser.parseFromString(html, 'text/html')
|
||||||
|
|
||||||
|
// Do not process HTML as LaTeX when the clipboard contains files (e.g. images),
|
||||||
|
// unless the HTML is from an Office application or is a table-only selection.
|
||||||
|
if (
|
||||||
|
filesLength > 0 &&
|
||||||
|
!hasProgId(documentElement) &&
|
||||||
|
!isOnlyTable(documentElement)
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyElement = documentElement.querySelector('body')
|
||||||
|
// DOMParser should always create a body element, so this is mostly for TypeScript
|
||||||
|
if (!bodyElement) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the only content is a code block, skip latex conversion
|
||||||
|
if (onlyCode(bodyElement)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return htmlToLaTeX(bodyElement)
|
||||||
|
}
|
||||||
|
|
||||||
const removeUnwantedElements = (
|
const removeUnwantedElements = (
|
||||||
documentElement: HTMLElement,
|
documentElement: HTMLElement,
|
||||||
selector: string
|
selector: string
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ import {
|
|||||||
cutSelection,
|
cutSelection,
|
||||||
copySelection,
|
copySelection,
|
||||||
pasteWithoutFormatting,
|
pasteWithoutFormatting,
|
||||||
|
pasteWithFormatting,
|
||||||
} from '../commands/clipboard'
|
} from '../commands/clipboard'
|
||||||
|
import { isVisual } from '../extensions/visual/visual'
|
||||||
|
|
||||||
export const useContextMenuItems = () => {
|
export const useContextMenuItems = () => {
|
||||||
const view = useCodeMirrorViewContext()
|
const view = useCodeMirrorViewContext()
|
||||||
@@ -68,9 +70,16 @@ export const useContextMenuItems = () => {
|
|||||||
[view, closeMenu]
|
[view, closeMenu]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const inVisualMode = isVisual(view)
|
||||||
|
|
||||||
const handleCut = wrapForContextMenu(() => cutSelection(view))
|
const handleCut = wrapForContextMenu(() => cutSelection(view))
|
||||||
const handleCopy = wrapForContextMenu(() => copySelection(view))
|
const handleCopy = wrapForContextMenu(() => copySelection(view))
|
||||||
const handlePaste = wrapForContextMenu(() => pasteWithoutFormatting(view))
|
const handlePaste = wrapForContextMenu(() =>
|
||||||
|
inVisualMode ? pasteWithFormatting(view) : pasteWithoutFormatting(view)
|
||||||
|
)
|
||||||
|
const handlePasteSpecial = wrapForContextMenu(() =>
|
||||||
|
inVisualMode ? pasteWithoutFormatting(view) : pasteWithFormatting(view)
|
||||||
|
)
|
||||||
const handleDelete = wrapForContextMenu(() => commands.deleteSelection(view))
|
const handleDelete = wrapForContextMenu(() => commands.deleteSelection(view))
|
||||||
|
|
||||||
const handleToggleTrackChanges = wrapForContextMenu(() => {
|
const handleToggleTrackChanges = wrapForContextMenu(() => {
|
||||||
@@ -120,6 +129,15 @@ export const useContextMenuItems = () => {
|
|||||||
show: canEdit,
|
show: canEdit,
|
||||||
shortcut: getShortcut('paste'),
|
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'),
|
label: t('delete'),
|
||||||
handler: handleDelete,
|
handler: handleDelete,
|
||||||
|
|||||||
@@ -45,3 +45,16 @@ export async function findImageInClipboard(): Promise<File | null> {
|
|||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const handleImagePaste = async (): Promise<boolean> => {
|
||||||
|
const imageFile = await findImageInClipboard()
|
||||||
|
if (imageFile) {
|
||||||
|
dispatchFigureModalPasteEvent({
|
||||||
|
name: imageFile.name,
|
||||||
|
type: imageFile.type,
|
||||||
|
data: imageFile,
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
copySelection,
|
copySelection,
|
||||||
cutSelection,
|
cutSelection,
|
||||||
pasteWithoutFormatting,
|
pasteWithoutFormatting,
|
||||||
|
pasteWithFormatting,
|
||||||
} from '../../../../../frontend/js/features/source-editor/commands/clipboard'
|
} from '../../../../../frontend/js/features/source-editor/commands/clipboard'
|
||||||
|
|
||||||
const createClipboardStub = () => {
|
const createClipboardStub = () => {
|
||||||
@@ -191,5 +192,80 @@ describe('clipboard behavior', function () {
|
|||||||
await pasteWithoutFormatting(view)
|
await pasteWithoutFormatting(view)
|
||||||
expect(view.state.doc.toString()).to.equal('new\nline1\nnew\nline2')
|
expect(view.state.doc.toString()).to.equal('new\nline1\nnew\nline2')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('preserves multiple cursors after pasting into multiple selections', async function () {
|
||||||
|
clipboard.reads.push('XX')
|
||||||
|
const view = createViewWithMultipleRanges('abcdefgh', [
|
||||||
|
{ anchor: 1, head: 2 }, // "b"
|
||||||
|
{ anchor: 5, head: 6 }, // "f"
|
||||||
|
])
|
||||||
|
await pasteWithoutFormatting(view)
|
||||||
|
const ranges = view.state.selection.ranges
|
||||||
|
expect(ranges).to.have.length(2)
|
||||||
|
expect(ranges.every(r => r.empty)).to.equal(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves multiple cursors when pasting line-wise content', async function () {
|
||||||
|
clipboard.reads.push('new\n')
|
||||||
|
const view = createViewWithMultipleRanges('line1\nline2', [
|
||||||
|
{ anchor: 2 }, // in "line1"
|
||||||
|
{ anchor: 8 }, // in "line2"
|
||||||
|
])
|
||||||
|
await pasteWithoutFormatting(view)
|
||||||
|
// After line-wise paste at line starts: cursor should be after each "new\n"
|
||||||
|
const ranges = view.state.selection.ranges
|
||||||
|
expect(ranges).to.have.length(2)
|
||||||
|
expect(ranges[0].anchor).to.equal(4) // after first "new\n"
|
||||||
|
expect(ranges[1].anchor).to.equal(14) // after second "new\n" (original pos 8 + 4 from first insert + 4 from second)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('pasteWithFormatting', function () {
|
||||||
|
// Helper to set clipboard.read with HTML + plain text
|
||||||
|
const setClipboardHtml = (html: string, text: string) => {
|
||||||
|
;(navigator as any).clipboard.read = async () => [
|
||||||
|
{
|
||||||
|
types: ['text/html', 'text/plain'],
|
||||||
|
// Avoid relying on DOM Blob in the Mocha/jsdom environment.
|
||||||
|
getType: async (type: string) =>
|
||||||
|
({
|
||||||
|
text: async () => (type === 'text/html' ? html : text),
|
||||||
|
}) as any,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
;(navigator as any).clipboard.readText = async () => text
|
||||||
|
}
|
||||||
|
|
||||||
|
it('pastes converted HTML into a single selection', async function () {
|
||||||
|
setClipboardHtml('<b>x</b>', 'x')
|
||||||
|
const view = createView('abcde', 2, 3) // replace 'c'
|
||||||
|
await pasteWithFormatting(view)
|
||||||
|
expect(view.state.doc.toString()).to.equal('ab\\textbf{x}de')
|
||||||
|
expect(view.state.selection.ranges).to.have.length(1)
|
||||||
|
expect(view.state.selection.main.empty).to.equal(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pastes converted HTML into all selected ranges (multi-range)', async function () {
|
||||||
|
setClipboardHtml('<strong>x</strong>', 'x')
|
||||||
|
const view = createViewWithMultipleRanges('abcdefgh', [
|
||||||
|
{ anchor: 1, head: 2 }, // b
|
||||||
|
{ anchor: 5, head: 6 }, // f
|
||||||
|
])
|
||||||
|
await pasteWithFormatting(view)
|
||||||
|
expect(view.state.doc.toString()).to.equal('a\\textbf{x}cde\\textbf{x}gh')
|
||||||
|
expect(view.state.selection.ranges).to.have.length(2)
|
||||||
|
expect(view.state.selection.ranges.every(r => r.empty)).to.equal(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to plain text when there is no formatting', async function () {
|
||||||
|
setClipboardHtml('x', 'x')
|
||||||
|
const view = createViewWithMultipleRanges('abcdefgh', [
|
||||||
|
{ anchor: 1, head: 2 },
|
||||||
|
{ anchor: 5, head: 6 },
|
||||||
|
])
|
||||||
|
await pasteWithFormatting(view)
|
||||||
|
expect(view.state.doc.toString()).to.equal('axcdexgh')
|
||||||
|
expect(view.state.selection.ranges).to.have.length(2)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+142
-45
@@ -66,6 +66,21 @@ const MockFileTreeDataProvider: FC<React.PropsWithChildren> = ({
|
|||||||
</FileTreeDataContext.Provider>
|
</FileTreeDataContext.Provider>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Regex to match plain "Paste" but exclude "Paste with formatting" and "Paste without formatting"
|
||||||
|
const pasteLabelMatcher = /^paste(?! with| without)/i
|
||||||
|
|
||||||
|
const grantClipboardPermissions = () => {
|
||||||
|
cy.wrap(
|
||||||
|
Cypress.automation('remote:debugger:protocol', {
|
||||||
|
command: 'Browser.grantPermissions',
|
||||||
|
params: {
|
||||||
|
permissions: ['clipboardReadWrite', 'clipboardSanitizedWrite'],
|
||||||
|
origin: window.location.origin,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
describe('editor context menu', { scrollBehavior: false }, function () {
|
describe('editor context menu', { scrollBehavior: false }, function () {
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
|
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
|
||||||
@@ -133,7 +148,12 @@ describe('editor context menu', { scrollBehavior: false }, function () {
|
|||||||
cy.findByRole('menuitem', { name: /copy/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: /delete/i }).should('be.disabled')
|
||||||
cy.findByRole('menuitem', { name: /comment/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: pasteLabelMatcher }).should(
|
||||||
|
'be.enabled'
|
||||||
|
)
|
||||||
|
cy.findByRole('menuitem', {
|
||||||
|
name: /paste with formatting/i,
|
||||||
|
}).should('be.enabled')
|
||||||
cy.findByRole('menuitem', { name: /suggest edits/i }).should(
|
cy.findByRole('menuitem', { name: /suggest edits/i }).should(
|
||||||
'be.enabled'
|
'be.enabled'
|
||||||
)
|
)
|
||||||
@@ -170,7 +190,12 @@ describe('editor context menu', { scrollBehavior: false }, function () {
|
|||||||
cy.get('.editor-context-menu').within(() => {
|
cy.get('.editor-context-menu').within(() => {
|
||||||
cy.findByRole('menuitem', { name: /cut/i }).should('be.enabled')
|
cy.findByRole('menuitem', { name: /cut/i }).should('be.enabled')
|
||||||
cy.findByRole('menuitem', { name: /copy/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: pasteLabelMatcher }).should(
|
||||||
|
'be.enabled'
|
||||||
|
)
|
||||||
|
cy.findByRole('menuitem', {
|
||||||
|
name: /paste with formatting/i,
|
||||||
|
}).should('be.enabled')
|
||||||
cy.findByRole('menuitem', { name: /delete/i }).should('be.enabled')
|
cy.findByRole('menuitem', { name: /delete/i }).should('be.enabled')
|
||||||
cy.findByRole('menuitem', { name: /suggest edits/i }).should(
|
cy.findByRole('menuitem', { name: /suggest edits/i }).should(
|
||||||
'be.enabled'
|
'be.enabled'
|
||||||
@@ -180,16 +205,7 @@ describe('editor context menu', { scrollBehavior: false }, function () {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should copy selected text and close menu', function () {
|
it('should copy selected text and close menu', function () {
|
||||||
// Grant clipboard permissions for this test
|
grantClipboardPermissions()
|
||||||
cy.wrap(
|
|
||||||
Cypress.automation('remote:debugger:protocol', {
|
|
||||||
command: 'Browser.grantPermissions',
|
|
||||||
params: {
|
|
||||||
permissions: ['clipboardReadWrite', 'clipboardSanitizedWrite'],
|
|
||||||
origin: window.location.origin,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
const scope = mockScope()
|
const scope = mockScope()
|
||||||
|
|
||||||
@@ -219,17 +235,8 @@ describe('editor context menu', { scrollBehavior: false }, function () {
|
|||||||
cy.get('.editor-context-menu').should('not.exist')
|
cy.get('.editor-context-menu').should('not.exist')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should cut and paste text via the context menu', function () {
|
it('should cut and paste text', function () {
|
||||||
// Grant clipboard permissions for this test
|
grantClipboardPermissions()
|
||||||
cy.wrap(
|
|
||||||
Cypress.automation('remote:debugger:protocol', {
|
|
||||||
command: 'Browser.grantPermissions',
|
|
||||||
params: {
|
|
||||||
permissions: ['clipboardReadWrite', 'clipboardSanitizedWrite'],
|
|
||||||
origin: window.location.origin,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
const scope = mockScope()
|
const scope = mockScope()
|
||||||
|
|
||||||
@@ -266,7 +273,7 @@ describe('editor context menu', { scrollBehavior: false }, function () {
|
|||||||
|
|
||||||
// Paste "world" at the beginning
|
// Paste "world" at the beginning
|
||||||
cy.get('.editor-context-menu').within(() => {
|
cy.get('.editor-context-menu').within(() => {
|
||||||
cy.findByRole('menuitem', { name: /paste/i }).click()
|
cy.findByRole('menuitem', { name: pasteLabelMatcher }).click()
|
||||||
})
|
})
|
||||||
|
|
||||||
cy.get('.editor-context-menu').should('not.exist')
|
cy.get('.editor-context-menu').should('not.exist')
|
||||||
@@ -458,7 +465,12 @@ describe('editor context menu', { scrollBehavior: false }, function () {
|
|||||||
cy.get('.editor-context-menu').within(() => {
|
cy.get('.editor-context-menu').within(() => {
|
||||||
cy.findByRole('menuitem', { name: /cut/i }).should('not.exist')
|
cy.findByRole('menuitem', { name: /cut/i }).should('not.exist')
|
||||||
cy.findByRole('menuitem', { name: /copy/i }).should('be.enabled')
|
cy.findByRole('menuitem', { name: /copy/i }).should('be.enabled')
|
||||||
cy.findByRole('menuitem', { name: /paste/i }).should('not.exist')
|
cy.findByRole('menuitem', { name: pasteLabelMatcher }).should(
|
||||||
|
'not.exist'
|
||||||
|
)
|
||||||
|
cy.findByRole('menuitem', { name: /paste with formatting/ }).should(
|
||||||
|
'not.exist'
|
||||||
|
)
|
||||||
cy.findByRole('menuitem', { name: /delete/i }).should('not.exist')
|
cy.findByRole('menuitem', { name: /delete/i }).should('not.exist')
|
||||||
cy.findByRole('menuitem', { name: /suggest edits/i }).should(
|
cy.findByRole('menuitem', { name: /suggest edits/i }).should(
|
||||||
'not.exist'
|
'not.exist'
|
||||||
@@ -512,8 +524,8 @@ describe('editor context menu', { scrollBehavior: false }, function () {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('pasting images via context menu', function () {
|
describe('when pasting an image', function () {
|
||||||
it('should open figure modal on pasting image via context menu', function () {
|
it('should open figure modal on pasting image', function () {
|
||||||
const scope = mockScope()
|
const scope = mockScope()
|
||||||
|
|
||||||
cy.mount(
|
cy.mount(
|
||||||
@@ -546,7 +558,7 @@ describe('editor context menu', { scrollBehavior: false }, function () {
|
|||||||
|
|
||||||
// Click paste button
|
// Click paste button
|
||||||
cy.get('.editor-context-menu').within(() => {
|
cy.get('.editor-context-menu').within(() => {
|
||||||
cy.findByRole('menuitem', { name: /paste/i }).click()
|
cy.findByRole('menuitem', { name: pasteLabelMatcher }).click()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Figure modal should open with the image
|
// Figure modal should open with the image
|
||||||
@@ -559,6 +571,95 @@ describe('editor context menu', { scrollBehavior: false }, function () {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('when a user has HTML content in the clipboard', function () {
|
||||||
|
const formattedHtml =
|
||||||
|
'<b>foo</b><sup>th</sup> <i>bar</i><sub>2</sub> baz <em>woo</em> <strong>woo</strong> woo'
|
||||||
|
const plainText = 'footh bar2 baz woo woo woo'
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
grantClipboardPermissions()
|
||||||
|
|
||||||
|
// Stub the clipboard API with formatted HTML
|
||||||
|
cy.window().then(win => {
|
||||||
|
const getTypeStub = cy.stub()
|
||||||
|
getTypeStub
|
||||||
|
.withArgs('text/html')
|
||||||
|
.resolves(new Blob([formattedHtml], { type: 'text/html' }))
|
||||||
|
getTypeStub
|
||||||
|
.withArgs('text/plain')
|
||||||
|
.resolves(new Blob([plainText], { type: 'text/plain' }))
|
||||||
|
|
||||||
|
cy.stub(win.navigator.clipboard, 'read').resolves([
|
||||||
|
{
|
||||||
|
types: ['text/html', 'text/plain'],
|
||||||
|
getType: getTypeStub,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
cy.stub(win.navigator.clipboard, 'readText').resolves(plainText)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('when pasting with formatting', function () {
|
||||||
|
it('should paste formatted HTML with LaTeX commands', 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: /paste with formatting/i,
|
||||||
|
}).click()
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.get('.editor-context-menu').should('not.exist')
|
||||||
|
|
||||||
|
cy.get('.cm-line').should($lines => {
|
||||||
|
const text = $lines.text()
|
||||||
|
expect(text).to.include(
|
||||||
|
'\\textbf{foo}\\textsuperscript{th} \\textit{bar}\\textsubscript{2} baz \\textit{woo} \\textbf{woo} woo'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('when pasting without formatting', function () {
|
||||||
|
it('should paste plain text without LaTeX commands', 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: pasteLabelMatcher }).click()
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.get('.editor-context-menu').should('not.exist')
|
||||||
|
|
||||||
|
cy.get('.cm-line').should($lines => {
|
||||||
|
const text = $lines.text()
|
||||||
|
expect(text).to.include('footh bar2 baz woo woo woo')
|
||||||
|
expect(text).to.not.include('\\textbf{foo}')
|
||||||
|
expect(text).to.not.include('\\textsuperscript{th}')
|
||||||
|
expect(text).to.not.include('\\textit{bar}')
|
||||||
|
expect(text).to.not.include('\\textsubscript{2}')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('sync to PDF button', function () {
|
describe('sync to PDF button', function () {
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
// Stub the sync API call
|
// Stub the sync API call
|
||||||
@@ -588,7 +689,9 @@ describe('editor context menu', { scrollBehavior: false }, function () {
|
|||||||
cy.get('.cm-line').eq(10).rightclick()
|
cy.get('.cm-line').eq(10).rightclick()
|
||||||
|
|
||||||
cy.get('.editor-context-menu').within(() => {
|
cy.get('.editor-context-menu').within(() => {
|
||||||
cy.findByRole('menuitem', { name: /jump to location in pdf/i }).click()
|
cy.findByRole('menuitem', {
|
||||||
|
name: /jump to location in pdf/i,
|
||||||
|
}).click()
|
||||||
})
|
})
|
||||||
|
|
||||||
cy.get('.editor-context-menu').should('not.exist')
|
cy.get('.editor-context-menu').should('not.exist')
|
||||||
@@ -619,18 +722,18 @@ describe('editor context menu', { scrollBehavior: false }, function () {
|
|||||||
cy.get('.cm-line').eq(10).rightclick()
|
cy.get('.cm-line').eq(10).rightclick()
|
||||||
|
|
||||||
cy.get('.editor-context-menu').within(() => {
|
cy.get('.editor-context-menu').within(() => {
|
||||||
cy.findByRole('menuitem', { name: /jump to location in pdf/i }).should(
|
cy.findByRole('menuitem', {
|
||||||
'not.exist'
|
name: /jump to location in pdf/i,
|
||||||
)
|
}).should('not.exist')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('gutter context menu', function () {
|
describe('when right-clicking on the gutter', function () {
|
||||||
const editorLine = 2
|
const editorLine = 2
|
||||||
const gutterLineIndex = editorLine + 1 // extra hidden gutter line
|
const gutterLineIndex = editorLine + 1 // extra hidden gutter line
|
||||||
|
|
||||||
it('should select entire line when right-clicking on gutter', function () {
|
it('should select entire line', function () {
|
||||||
const scope = mockScope()
|
const scope = mockScope()
|
||||||
|
|
||||||
cy.mount(
|
cy.mount(
|
||||||
@@ -657,15 +760,7 @@ describe('editor context menu', { scrollBehavior: false }, function () {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should work with cut/copy/delete operations on gutter-selected line', function () {
|
it('should work with cut/copy/delete operations on gutter-selected line', function () {
|
||||||
cy.wrap(
|
grantClipboardPermissions()
|
||||||
Cypress.automation('remote:debugger:protocol', {
|
|
||||||
command: 'Browser.grantPermissions',
|
|
||||||
params: {
|
|
||||||
permissions: ['clipboardReadWrite', 'clipboardSanitizedWrite'],
|
|
||||||
origin: window.location.origin,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
const scope = mockScope()
|
const scope = mockScope()
|
||||||
|
|
||||||
@@ -693,7 +788,9 @@ describe('editor context menu', { scrollBehavior: false }, function () {
|
|||||||
cy.get('.editor-context-menu').within(() => {
|
cy.get('.editor-context-menu').within(() => {
|
||||||
cy.findByRole('menuitem', { name: /cut/i }).should('be.enabled')
|
cy.findByRole('menuitem', { name: /cut/i }).should('be.enabled')
|
||||||
cy.findByRole('menuitem', { name: /copy/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: pasteLabelMatcher }).should(
|
||||||
|
'be.enabled'
|
||||||
|
)
|
||||||
cy.findByRole('menuitem', { name: /delete/i }).should('be.enabled')
|
cy.findByRole('menuitem', { name: /delete/i }).should('be.enabled')
|
||||||
cy.findByRole('menuitem', { name: /suggest edits/i }).should(
|
cy.findByRole('menuitem', { name: /suggest edits/i }).should(
|
||||||
'be.enabled'
|
'be.enabled'
|
||||||
@@ -714,7 +811,7 @@ describe('editor context menu', { scrollBehavior: false }, function () {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should close menu when clicking elsewhere after gutter right-click', function () {
|
it('should close menu when clicking elsewhere', function () {
|
||||||
const scope = mockScope()
|
const scope = mockScope()
|
||||||
|
|
||||||
cy.mount(
|
cy.mount(
|
||||||
|
|||||||
Reference in New Issue
Block a user