Merge pull request #32264 from overleaf/mg-paste-blocked

Show toast when context menu paste fails due to blocked clipboard access

GitOrigin-RevId: fcbeaa17174288ba627b3988ef5149d54a33fd4f
This commit is contained in:
Malik Glossop
2026-03-20 09:05:44 +01:00
committed by Copybot
parent 3aa69c6ffa
commit 504005aa74
7 changed files with 139 additions and 15 deletions
@@ -1254,6 +1254,7 @@
"open_target": "",
"optional": "",
"or": "",
"or_enable_clipboard_access_in_your_browser_settings": "",
"organization_name": "",
"organize_tags": "",
"other": "",
@@ -2153,6 +2154,7 @@
"use_a_different_email": "",
"use_a_different_password": "",
"use_saml_metadata_to_configure_sso_with_idp": "",
"use_the_shortcut_key_to_paste": "",
"use_your_own_machine": "",
"used_latex_before": "",
"used_latex_response_familiar": "",
@@ -2326,6 +2328,7 @@
"your_account_is_managed_by_your_group_admin": "",
"your_add_on_has_been_cancelled_and_will_remain_active_until_your_billing_cycle_ends_on": "",
"your_affiliation_is_confirmed": "",
"your_browser_cant_access_the_clipboard_so_use_this_shortcut": "",
"your_changes_will_save": "",
"your_compile_timed_out": "",
"your_current_plan": "",
@@ -5,6 +5,7 @@ import { Fragment, memo, ReactElement, useCallback, useState } from 'react'
import { debugConsole } from '@/utils/debugging'
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
import { OLToastContainer } from '@/shared/components/ol/ol-toast-container'
import clipboardToastGenerators from '@/features/source-editor/components/clipboard-toasts'
const moduleGeneratorsImport = importOverleafModules('toastGenerators') as {
import: { default: GlobalToastGeneratorEntry[] }
@@ -23,7 +24,10 @@ type GlobalToastGenerator = (
args: Record<string, any>
) => Omit<OLToastProps, 'onDismiss'>
const GENERATOR_LIST: GlobalToastGeneratorEntry[] = moduleGenerators.flat()
const GENERATOR_LIST: GlobalToastGeneratorEntry[] = [
...moduleGenerators.flat(),
...clipboardToastGenerators,
]
const GENERATOR_MAP: Map<string, GlobalToastGenerator> = new Map(
GENERATOR_LIST.map(({ key, generator }) => [key, generator])
)
@@ -105,7 +105,7 @@ export const pasteWithoutFormatting = async (
pastePlainText(view, text)
return true
} catch {
// Clipboard access denied or empty
// Clipboard access denied
return false
}
}
@@ -0,0 +1,43 @@
import { GlobalToastGeneratorEntry } from '@/features/ide-react/components/global-toasts'
import { useTranslation } from 'react-i18next'
import { isMac } from '@/shared/utils/os'
const ClipboardPasteErrorToast = ({ shortcut }: { shortcut?: string }) => {
const { t } = useTranslation()
const resolvedShortcut = shortcut ?? (isMac ? '⌘V' : 'Ctrl+V')
return (
<>
<p>
<b>{t('use_the_shortcut_key_to_paste')}</b>
</p>
{t('your_browser_cant_access_the_clipboard_so_use_this_shortcut')}{' '}
<b>{resolvedShortcut}</b>
<br />
{t('or_enable_clipboard_access_in_your_browser_settings')}
</>
)
}
const generators: GlobalToastGeneratorEntry[] = [
{
key: 'clipboard:paste-error',
generator: ({ shortcut }: { shortcut?: string }) => ({
content: <ClipboardPasteErrorToast shortcut={shortcut} />,
type: 'warning',
autoHide: true,
delay: 6000,
isDismissible: true,
}),
},
]
export default generators
export const showClipboardPasteErrorToast = (shortcut?: string) => {
window.dispatchEvent(
new CustomEvent('ide:show-toast', {
detail: { key: 'clipboard:paste-error', shortcut },
})
)
}
@@ -23,6 +23,7 @@ import {
pasteWithoutFormatting,
pasteWithFormatting,
} from '../commands/clipboard'
import { showClipboardPasteErrorToast } from '../components/clipboard-toasts'
import { isVisual } from '../extensions/visual/visual'
import { useEditorContext } from '@/shared/context/editor-context'
import { useTrackingChangesMode } from '@/shared/hooks/use-tracking-changes-mode'
@@ -109,17 +110,38 @@ export const useContextMenuItems = () => {
[view, closeMenu]
)
const getShortcut = useCallback(
(id: string) => {
const shortcut = shortcuts[id]?.[0]
return shortcut ? formatShortcut(shortcut) : undefined
},
[shortcuts]
)
const inVisualMode = isVisual(view)
const handleCut = wrapForContextMenu('cut', () => cutSelection(view))
const handleCopy = wrapForContextMenu('copy', () => copySelection(view))
const handlePaste = wrapForContextMenu('paste', () =>
inVisualMode ? pasteWithFormatting(view) : pasteWithoutFormatting(view)
)
const handlePaste = wrapForContextMenu('paste', async () => {
const result = await (inVisualMode
? pasteWithFormatting(view)
: pasteWithoutFormatting(view))
if (result === false) {
showClipboardPasteErrorToast(getShortcut('paste'))
}
return result
})
const handlePasteSpecial = wrapForContextMenu(
inVisualMode ? 'paste-without-formatting' : 'paste-with-formatting',
() =>
inVisualMode ? pasteWithoutFormatting(view) : pasteWithFormatting(view)
async () => {
const result = await (inVisualMode
? pasteWithoutFormatting(view)
: pasteWithFormatting(view))
if (result === false) {
showClipboardPasteErrorToast(getShortcut('paste'))
}
return result
}
)
const handleDelete = wrapForContextMenu('delete', () =>
commands.deleteSelection(view)
@@ -166,14 +188,6 @@ export const useContextMenuItems = () => {
view.focus()
}, [syncToPdf, view, changeLayout, isEditorOnly])
const getShortcut = useCallback(
(id: string) => {
const shortcut = shortcuts[id]?.[0]
return shortcut ? formatShortcut(shortcut) : undefined
},
[shortcuts]
)
return {
closeMenu,
onToggle,
+3
View File
@@ -1616,6 +1616,7 @@
"opted_out_linking": "Youve opted out from linking your <b>__email__</b> <b>__appName__</b> account to your institutional account.",
"optional": "Optional",
"or": "or",
"or_enable_clipboard_access_in_your_browser_settings": "Or enable clipboard access in your browser settings.",
"organization": "Organization",
"organization_does_not_support_sso": "Your organization doesnt support SSO yet, but you can still register using your email address.",
"organization_name": "Organization name",
@@ -2705,6 +2706,7 @@
"use_a_different_email": "Use a <0>different email</0>.",
"use_a_different_password": "Please use a different password",
"use_saml_metadata_to_configure_sso_with_idp": "Use the Overleaf SAML metadata to configure SSO with your Identity Provider.",
"use_the_shortcut_key_to_paste": "Use the shortcut key to paste",
"use_your_own_machine": "Use your own machine, with your own setup",
"used_latex_before": "How familiar are you with LaTeX?",
"used_latex_response_familiar": "Ive heard of it, but never used it",
@@ -2901,6 +2903,7 @@
"your_account_is_suspended": "Your account is suspended",
"your_add_on_has_been_cancelled_and_will_remain_active_until_your_billing_cycle_ends_on": "Your add-on has been cancelled and will remain active until your billing cycle ends on __nextBillingDate__",
"your_affiliation_is_confirmed": "Your <0>__institutionName__</0> affiliation is confirmed.",
"your_browser_cant_access_the_clipboard_so_use_this_shortcut": "Your browser cant access the clipboard, so use this shortcut:",
"your_changes_will_save": "Your changes will save when we get the connection back.",
"your_compile_timed_out": "Your compile timed out",
"your_current_plan": "Your current plan",
@@ -13,6 +13,7 @@ import { DetachCompileContext } from '@/shared/context/detach-compile-context'
import { FileTreeDataContext } from '@/shared/context/file-tree-data-context'
import PackageVersions from '../../../../../app/src/infrastructure/PackageVersions'
import { mockProject } from '../helpers/mock-project'
import { GlobalToasts } from '@/features/ide-react/components/global-toasts'
const createPermissionsProvider = (
permissions: Partial<Permissions>
@@ -717,6 +718,62 @@ describe('editor context menu', { scrollBehavior: false }, function () {
})
})
describe('when clipboard access is blocked', function () {
beforeEach(function () {
cy.window().then(win => {
const blocked = new DOMException('Not allowed', 'NotAllowedError')
cy.stub(win.navigator.clipboard, 'read').rejects(blocked)
cy.stub(win.navigator.clipboard, 'readText').rejects(blocked)
})
})
it('should show a toast when clicking Paste', function () {
const scope = mockScope()
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<GlobalToasts />
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.get('.cm-line').eq(10).rightclick()
cy.findByRole('menu').within(() => {
cy.findByRole('menuitem', { name: pasteLabelMatcher }).click()
})
cy.get('.global-toasts').should(
'contain.text',
'Use the shortcut key to paste'
)
})
it('should show a toast when clicking Paste with formatting', function () {
const scope = mockScope()
cy.mount(
<TestContainer>
<EditorProviders scope={scope}>
<GlobalToasts />
<CodeMirrorEditor />
</EditorProviders>
</TestContainer>
)
cy.get('.cm-line').eq(10).rightclick()
cy.findByRole('menu').within(() => {
cy.findByRole('menuitem', { name: /paste with formatting/i }).click()
})
cy.get('.global-toasts').should(
'contain.text',
'Use the shortcut key to paste'
)
})
})
describe('when pasting an image', function () {
it('should open figure modal on pasting image', function () {
const scope = mockScope()