Просмотр исходного кода

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

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

GitOrigin-RevId: fcbeaa17174288ba627b3988ef5149d54a33fd4f
Malik Glossop 4 месяцев назад
Родитель
Сommit
504005aa74

+ 3 - 0
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": "",

+ 5 - 1
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<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])
 )

+ 1 - 1
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
   }
 }

+ 43 - 0
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 (
+    <>
+      <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 },
+    })
+  )
+}

+ 27 - 13
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,

+ 3 - 0
services/web/locales/en.json

@@ -1616,6 +1616,7 @@
   "opted_out_linking": "You’ve 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 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</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": "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__</0> 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",

+ 57 - 0
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<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()