From 504005aa744d669c51422318b16fcdd570602ecf Mon Sep 17 00:00:00 2001 From: Malik Glossop Date: Fri, 20 Mar 2026 09:05:44 +0100 Subject: [PATCH] Merge pull request #32264 from overleaf/mg-paste-blocked Show toast when context menu paste fails due to blocked clipboard access GitOrigin-RevId: fcbeaa17174288ba627b3988ef5149d54a33fd4f --- .../web/frontend/extracted-translations.json | 3 + .../ide-react/components/global-toasts.tsx | 6 +- .../source-editor/commands/clipboard.ts | 2 +- .../components/clipboard-toasts.tsx | 43 ++++++++++++++ .../hooks/use-context-menu-items.tsx | 40 ++++++++----- services/web/locales/en.json | 3 + .../codemirror-editor-context-menu.spec.tsx | 57 +++++++++++++++++++ 7 files changed, 139 insertions(+), 15 deletions(-) create mode 100644 services/web/frontend/js/features/source-editor/components/clipboard-toasts.tsx diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index ef8afb3641..5137807719 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -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": "", diff --git a/services/web/frontend/js/features/ide-react/components/global-toasts.tsx b/services/web/frontend/js/features/ide-react/components/global-toasts.tsx index eedd88596d..7524459634 100644 --- a/services/web/frontend/js/features/ide-react/components/global-toasts.tsx +++ b/services/web/frontend/js/features/ide-react/components/global-toasts.tsx @@ -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 ) => Omit -const GENERATOR_LIST: GlobalToastGeneratorEntry[] = moduleGenerators.flat() +const GENERATOR_LIST: GlobalToastGeneratorEntry[] = [ + ...moduleGenerators.flat(), + ...clipboardToastGenerators, +] const GENERATOR_MAP: Map = new Map( GENERATOR_LIST.map(({ key, generator }) => [key, generator]) ) diff --git a/services/web/frontend/js/features/source-editor/commands/clipboard.ts b/services/web/frontend/js/features/source-editor/commands/clipboard.ts index af84e67f41..a881962058 100644 --- a/services/web/frontend/js/features/source-editor/commands/clipboard.ts +++ b/services/web/frontend/js/features/source-editor/commands/clipboard.ts @@ -105,7 +105,7 @@ export const pasteWithoutFormatting = async ( pastePlainText(view, text) return true } catch { - // Clipboard access denied or empty + // Clipboard access denied return false } } diff --git a/services/web/frontend/js/features/source-editor/components/clipboard-toasts.tsx b/services/web/frontend/js/features/source-editor/components/clipboard-toasts.tsx new file mode 100644 index 0000000000..51844fbb72 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/components/clipboard-toasts.tsx @@ -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 ( + <> +

+ {t('use_the_shortcut_key_to_paste')} +

+ {t('your_browser_cant_access_the_clipboard_so_use_this_shortcut')}{' '} + {resolvedShortcut} +
+ {t('or_enable_clipboard_access_in_your_browser_settings')} + + ) +} + +const generators: GlobalToastGeneratorEntry[] = [ + { + key: 'clipboard:paste-error', + generator: ({ shortcut }: { shortcut?: string }) => ({ + content: , + 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 }, + }) + ) +} diff --git a/services/web/frontend/js/features/source-editor/hooks/use-context-menu-items.tsx b/services/web/frontend/js/features/source-editor/hooks/use-context-menu-items.tsx index 310cde0667..8841ca710b 100644 --- a/services/web/frontend/js/features/source-editor/hooks/use-context-menu-items.tsx +++ b/services/web/frontend/js/features/source-editor/hooks/use-context-menu-items.tsx @@ -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, diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 52274069fe..14a7aec202 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -1616,6 +1616,7 @@ "opted_out_linking": "You’ve opted out from linking your __email__ __appName__ 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 doesn’t 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.", "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": "I’ve 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__ affiliation is confirmed.", + "your_browser_cant_access_the_clipboard_so_use_this_shortcut": "Your browser can’t 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", diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-context-menu.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-context-menu.spec.tsx index 816526e9f8..18b1d43d91 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-context-menu.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-context-menu.spec.tsx @@ -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 @@ -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( + + + + + + + ) + + 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( + + + + + + + ) + + 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()