Преглед изворни кода

Merge pull request #28978 from overleaf/mj-visual-editor-broken-image

[web] Add image loading fallback screen to visual editor

GitOrigin-RevId: 205c6355011802672e57123bb2a61cb708242e31
Mathias Jakobsen пре 10 месеци
родитељ
комит
f694da4443

+ 2 - 0
services/web/frontend/extracted-translations.json

@@ -272,6 +272,7 @@
   "clear_search": "",
   "clearing": "",
   "click_here_to_view_sl_in_lng": "",
+  "click_recompile_and_check_your_pdf_to_see_how_its_looking": "",
   "click_to_unpause": "",
   "clicking_delete_will_remove_sso_config_and_clear_saml_data": "",
   "clone_with_git": "",
@@ -1837,6 +1838,7 @@
   "the_primary_file_for_compiling_your_project": "",
   "the_target_folder_could_not_be_found": "",
   "the_version_of_tex_live_used_for_compiling": "",
+  "the_visual_editor_cant_preview_this_type_of_image_file": "",
   "the_width_you_choose_here_is_based_on_the_width_of_the_text_in_your_document": "",
   "their_projects_will_be_transferred_to_another_user": "",
   "then_x_price_per_month": "",

+ 34 - 0
services/web/frontend/js/features/source-editor/extensions/visual/visual-theme.ts

@@ -26,6 +26,15 @@ export const visualHighlightStyle = syntaxHighlighting(
   ])
 )
 
+const graphicsErrorColorTheme = EditorView.baseTheme({
+  '&dark .ol-cm-graphics-loading-error': {
+    '--graphics-loading-error-color': 'var(--content-placeholder-dark)',
+  },
+  '&light .ol-cm-graphics-loading-error': {
+    '--graphics-loading-error-color': 'var(--content-placeholder)',
+  },
+})
+
 const mainVisualTheme = EditorView.theme({
   '&.cm-editor': {
     '--visual-font-family':
@@ -351,6 +360,30 @@ const mainVisualTheme = EditorView.theme({
     border: '1px solid red',
     padding: '8px',
   },
+  '.ol-cm-graphics-loading-error': {
+    color: 'var(--graphics-loading-error-color)',
+    minHeight: '300px',
+    textAlign: 'left',
+    fontFamily: 'var(--font-sans)',
+    display: 'flex',
+    flexDirection: 'column',
+    justifyContent: 'center',
+    gap: 'var(--spacing-03)',
+    padding: '0 var(--spacing-10)',
+    maxWidth: '500px',
+    margin: '0 auto',
+  },
+  '.ol-cm-graphics-loading-error-title, .ol-cm-graphics-loading-error-subtitle':
+    {
+      display: 'block',
+    },
+  '.ol-cm-graphics-loading-error-title': {
+    fontSize: 'var(--font-size-04)',
+    fontWeight: '600',
+  },
+  '.ol-cm-graphics-loading-error-subtitle': {
+    fontSize: 'var(--font-size-02)',
+  },
   '.ol-cm-environment-centered .ol-cm-graphics': {
     margin: '0 auto',
   },
@@ -491,5 +524,6 @@ const contentWidthSetter = EditorView.updateListener.of(update => {
 export const visualTheme: Extension = [
   contentWidthThemeConf.of(createContentWidthTheme('100%')),
   mainVisualTheme,
+  graphicsErrorColorTheme,
   contentWidthSetter,
 ]

+ 27 - 1
services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/graphics.ts

@@ -122,6 +122,7 @@ export class GraphicsWidget extends WidgetType {
   }
 
   createImage(view: EditorView, url: string) {
+    const wrapper = document.createElement('div')
     const image = document.createElement('img')
     image.classList.add('ol-cm-graphics')
     image.classList.add('ol-cm-graphics-loading')
@@ -135,8 +136,33 @@ export class GraphicsWidget extends WidgetType {
       this.height = image.height // for estimatedHeight
       view.requestMeasure()
     })
+    image.addEventListener('error', () => {
+      const errorElement = this.createErrorElement(view)
+      wrapper.replaceChildren(errorElement)
+      this.height = wrapper.clientHeight
+      view.requestMeasure()
+    })
+
+    wrapper.appendChild(image)
+    return wrapper
+  }
 
-    return image
+  createErrorElement(view: EditorView): HTMLElement {
+    const wrapper = document.createElement('div')
+    wrapper.classList.add('ol-cm-graphics-loading-error')
+    const title = document.createElement('span')
+    title.classList.add('ol-cm-graphics-loading-error-title')
+    title.textContent = view.state.phrase(
+      'the_visual_editor_cant_preview_this_type_of_image_file'
+    )
+    const subtitle = document.createElement('span')
+    subtitle.classList.add('ol-cm-graphics-loading-error-subtitle')
+    subtitle.textContent = view.state.phrase(
+      'click_recompile_and_check_your_pdf_to_see_how_its_looking'
+    )
+    wrapper.appendChild(title)
+    wrapper.appendChild(subtitle)
+    return wrapper
   }
 
   async renderPDF(view: EditorView, canvas: HTMLCanvasElement, url: string) {

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

@@ -349,6 +349,7 @@
   "clearing": "Clearing",
   "click_here_to_view_sl_in_lng": "Click here to use __appName__ in <0>__lngName__</0>",
   "click_link_to_proceed": "Click <b>__clickText__</b> below to proceed.",
+  "click_recompile_and_check_your_pdf_to_see_how_its_looking": "Click Recompile and check your PDF to see how it’s looking.",
   "click_to_unpause": "Click to unpause and reactivate your Overleaf premium features.",
   "clicking_delete_will_remove_sso_config_and_clear_saml_data": "Clicking <0>Delete</0> will remove your SSO configuration and unlink all users. You can only do this when SSO is disabled in your group settings.",
   "clone_with_git": "Clone with Git",
@@ -2353,6 +2354,7 @@
   "the_supplied_uri_is_invalid": "The link to open this content on Overleaf included an invalid URI. If this keeps happening for links on a particular site, please report this to them.",
   "the_target_folder_could_not_be_found": "The target folder could not be found.",
   "the_version_of_tex_live_used_for_compiling": "The version of TeX Live used for compiling",
+  "the_visual_editor_cant_preview_this_type_of_image_file": "The Visual Editor can’t preview this type of image file.",
   "the_width_you_choose_here_is_based_on_the_width_of_the_text_in_your_document": "The width you choose here is based on the width of the text in your document. Alternatively, you can customize the image size directly in the LaTeX code.",
   "their_projects_will_be_transferred_to_another_user": "Their projects will all be transferred to another user of your choice",
   "theme": "Theme",

+ 18 - 15
services/web/test/frontend/features/source-editor/components/codemirror-editor-figure-modal.spec.tsx

@@ -11,6 +11,7 @@ import { FileTreePathContext } from '@/features/file-tree/contexts/file-tree-pat
 import { TestContainer } from '../helpers/test-container'
 import getMeta from '@/utils/meta'
 import { mockProject } from '../helpers/mock-project'
+import { base64image } from '../fixtures/image'
 
 const clickToolbarButton = (text: string) => {
   cy.findByLabelText(text).click()
@@ -64,7 +65,7 @@ describe('<FigureModal />', function () {
           previewByPath: cy
             .stub()
             .as('previewByPath')
-            .returns({ url: 'frog.jpg', extension: 'jpg' }),
+            .returns({ url: base64image, extension: 'png' }),
         }}
       >
         {children}
@@ -631,20 +632,22 @@ text below`,
     })
 
     it('Opens figure modal on pasting image', function () {
-      cy.fixture<Uint8Array>('images/gradient.png').then(gradientBuffer => {
-        const gradientFile = new File([gradientBuffer], 'gradient.png', {
-          type: 'image/png',
-        })
-        const clipboardData = new DataTransfer()
-        clipboardData.items.add(gradientFile)
-        cy.wrap(clipboardData.files).should('have.length', 1)
-        cy.get('.cm-content').trigger('paste', { clipboardData })
-        cy.findByText('Upload from computer').should('be.visible')
-        cy.findByLabelText('File name in this project').should(
-          'have.value',
-          'gradient.png'
-        )
-      })
+      cy.fixture<Uint8Array<ArrayBuffer>>('images/gradient.png').then(
+        gradientBuffer => {
+          const gradientFile = new File([gradientBuffer], 'gradient.png', {
+            type: 'image/png',
+          })
+          const clipboardData = new DataTransfer()
+          clipboardData.items.add(gradientFile)
+          cy.wrap(clipboardData.files).should('have.length', 1)
+          cy.get('.cm-content').trigger('paste', { clipboardData })
+          cy.findByText('Upload from computer').should('be.visible')
+          cy.findByLabelText('File name in this project').should(
+            'have.value',
+            'gradient.png'
+          )
+        }
+      )
     })
 
     // TODO: Add tests for replacing image when we can match on image path

+ 61 - 5
services/web/test/frontend/features/source-editor/components/codemirror-editor-visual.spec.tsx

@@ -10,6 +10,7 @@ import { mockScope } from '../helpers/mock-scope'
 import forEach from 'mocha-each'
 import { FileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
 import { TestContainer } from '../helpers/test-container'
+import { base64image } from '../fixtures/image'
 
 describe('<CodeMirrorEditor/> in Visual mode', function () {
   beforeEach(function () {
@@ -34,7 +35,16 @@ describe('<CodeMirrorEditor/> in Visual mode', function () {
           previewByPath: cy
             .stub()
             .as('previewByPath')
-            .callsFake(path => ({ url: path, extension: 'png' })),
+            .callsFake(path =>
+              path === 'valid.png'
+                ? { url: base64image, extension: 'png' }
+                : path === 'graphic.eps'
+                  ? {
+                      url: 'data:application/postscript,0 0 moveto (hello) show',
+                      extension: 'eps',
+                    }
+                  : null
+            ),
         }}
       >
         {children}
@@ -256,11 +266,11 @@ describe('<CodeMirrorEditor/> in Visual mode', function () {
     })
 
     it('loads figures', function () {
-      cy.get('@third-line').type('path/to/image')
+      cy.get('@third-line').type('valid.png')
 
       cy.get('@third-line').should(
         'contain.text',
-        '    \\includegraphics[width=0.5\\linewidth]{path/to/image}'
+        '    \\includegraphics[width=0.5\\linewidth]{valid.png}'
       )
 
       // move the cursor out of the figure
@@ -269,10 +279,56 @@ describe('<CodeMirrorEditor/> in Visual mode', function () {
       // Should be removed from dom when line is hidden
       cy.get('.cm-content').should(
         'not.contain.text',
-        '\\includegraphics[width=0.5\\linewidth]{path/to/image}'
+        '\\includegraphics[width=0.5\\linewidth]{valid.png}'
       )
 
-      cy.get('img.ol-cm-graphics').should('have.attr', 'src', 'path/to/image')
+      cy.get('img.ol-cm-graphics').should('have.attr', 'src', base64image)
+    })
+
+    it('shows error state for figures we cannot render', function () {
+      cy.get('@third-line').type('graphic.eps')
+
+      cy.get('@third-line').should(
+        'contain.text',
+        '    \\includegraphics[width=0.5\\linewidth]{graphic.eps}'
+      )
+
+      // move the cursor out of the figure
+      cy.get('@third-line').type('{DownArrow}{DownArrow}{DownArrow}{DownArrow}')
+
+      // Should be removed from dom when line is hidden
+      cy.get('.cm-content').should(
+        'not.contain.text',
+        '\\includegraphics[width=0.5\\linewidth]{graphic.eps}'
+      )
+
+      // Should show error state
+      cy.get('div.ol-cm-graphics-loading-error').should('be.visible')
+      cy.findByText(
+        /The Visual Editor can’t preview this type of image file./
+      ).should('be.visible')
+    })
+
+    it('shows error state for missing figures', function () {
+      cy.get('@third-line').type('missing.png')
+
+      cy.get('@third-line').should(
+        'contain.text',
+        '    \\includegraphics[width=0.5\\linewidth]{missing.png}'
+      )
+
+      // move the cursor out of the figure
+      cy.get('@third-line').type('{DownArrow}{DownArrow}{DownArrow}{DownArrow}')
+
+      // Should be removed from dom when line is hidden
+      cy.get('.cm-content').should(
+        'not.contain.text',
+        '\\includegraphics[width=0.5\\linewidth]{missing.png}'
+      )
+
+      // Should show error state
+      cy.get('div.ol-cm-graphics-error').should('be.visible')
+      cy.findByText(/missing.png/).should('be.visible')
     })
 
     it('marks lines as figure environments', function () {

+ 2 - 0
services/web/test/frontend/features/source-editor/fixtures/image.ts

@@ -0,0 +1,2 @@
+export const base64image =
+  'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAASwAAAEsCAYAAAB5fY51AAABhWlDQ1BJQ0MgcHJvZmlsZQAAKJF9kT1Iw0AcxV9TpaJVETuIOGSogmBBVMRRq1CECqFWaNXB5NIvaNKQpLg4Cq4FBz8Wqw4uzro6uAqC4AeIq4uToouU+L+k0CLWg+N+vLv3uHsHCNUi06y2cUDTbTMRi4qp9KoYeIWAPnShB6Mys4w5SYqj5fi6h4+vdxGe1frcn6NbzVgM8InEs8wwbeIN4ulN2+C8TxxieVklPiceM+mCxI9cVzx+45xzWeCZITOZmCcOEYu5JlaamOVNjXiKOKxqOuULKY9VzluctWKZ1e/JXxjM6CvLXKc5hBgWsQQJIhSUUUARNiK06qRYSNB+tIV/0PVL5FLIVQAjxwJK0CC7fvA/+N2tlZ2c8JKCUaD9xXE+hoHALlCrOM73sePUTgD/M3ClN/ylKjDzSXqloYWPgN5t4OK6oSl7wOUOMPBkyKbsSn6aQjYLvJ/RN6WB/lugc83rrb6P0wcgSV3Fb4CDQ2AkR9nrLd7d0dzbv2fq/f0ARfNylZJUgMQAAAAGYktHRABuAP8AAGHZRr4AAAAJcEhZcwAALiMAAC4jAXilP3YAAAAHdElNRQfnAhELEhgyPeVkAAAAGXRFWHRDb21tZW50AENyZWF0ZWQgd2l0aCBHSU1QV4EOFwAAAyVJREFUeNrt1rEJgDAURVGVNCmS2hS6PziCteIYWjuEbiEfOWeEV1xe35bt6QhjnlYjBJLzbYRABhMAggUgWIBgAQgWgGABggUgWACCBQgWgGABCBYgWACCBSBYgGABCBaAYAGCBSBYAIIFCBaAYAEIFiBYAIIFIFiAYAEIFiBYAIIFIFiAYAEIFoBgAYIFIFgAggUIFoBgAQgWIFgAggUgWIBgAQgWgGABggUgWACCBQgWgGABCBYgWACCBSBYgGABCBYgWACCBSBYgGABCBaAYAGCBSBYAIIFCBaAYAEIFiBYAIIFIFiAYAEIFoBgAYIFIFgAggUIFoBgAQgWIFgAggUgWIBgAQgWIFgAggUgWIBgAQgWgGABggUgWACCBQgWgGABCBbwX6m13QqB5HwbIZBSLyN4WACCBQgWgGABCBYgWACCBSBYgGABCBaAYAGCBSBYAIIFCBaAYAEIFiBYAIIFCBaAYAEIFiBYAIIFIFiAYAEIFoBgAYIFIFgAggUIFoBgAQgWIFgAggUgWIBgAQgWgGABggUgWACCBQgWgGABCBYgWACCBQgWgGABCBYgWACCBSBYgGABCBaAYAGCBSBYAIIFCBaAYAEIFiBYAIIFIFiAYAEIFoBgAYIFIFgAggUIFoBgAQgWIFgAggUIFoBgAQgWIFgAggUgWIBgAQgWgGABggUgWACCBQgWgGABCBYgWACCBSBYgGABCBaAYAGCBfCFVMtphUBKvYwQSBsPI3hYAIIFCBaAYAEIFiBYAIIFIFiAYAEIFoBgAYIFIFgAggUIFoBgAQgWIFgAggUIFoBgAQgWIFgAggUgWIBgAQgWgGABggUgWACCBQgWgGABCBYgWACCBSBYgGABCBaAYAGCBSBYAIIFCBaAYAEIFiBYAIIFCBaAYAEIFiBYAIIFIFiAYAEIFoBgAYIFIFgAggUIFoBgAQgWIFgAggUgWIBgAQgWgGABggUgWACCBQgWgGABCBYgWACCBQgWgGABCBYgWACCBSBYgGABCBaAYAGCBSBYAIIFCBaAYAEIFiBYAIIFIFiAYAEIFoBgAYIF8IUXjtUMuBMh1xAAAAAASUVORK5CYII='