diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 5be4c50d05..bacd4ffc42 100644 --- a/services/web/frontend/extracted-translations.json +++ b/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": "", diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-theme.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual-theme.ts index 255acae0a6..2832cc042a 100644 --- a/services/web/frontend/js/features/source-editor/extensions/visual/visual-theme.ts +++ b/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, ] diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/graphics.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/graphics.ts index 2480f2235c..7c6ce50f2d 100644 --- a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/graphics.ts +++ b/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() + }) - return image + wrapper.appendChild(image) + return wrapper + } + + 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) { diff --git a/services/web/locales/en.json b/services/web/locales/en.json index b7b2617edb..dc4c9e4657 100644 --- a/services/web/locales/en.json +++ b/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__", "click_link_to_proceed": "Click __clickText__ 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 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", diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-figure-modal.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-figure-modal.spec.tsx index 696b6fd1d8..5a98ecb671 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-figure-modal.spec.tsx +++ b/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('', 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('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>('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 diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual.spec.tsx index 45f68f359b..f6f6cd6635 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual.spec.tsx +++ b/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(' in Visual mode', function () { beforeEach(function () { @@ -34,7 +35,16 @@ describe(' 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(' 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(' 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 () { diff --git a/services/web/test/frontend/features/source-editor/fixtures/image.ts b/services/web/test/frontend/features/source-editor/fixtures/image.ts new file mode 100644 index 0000000000..27fc9f09db --- /dev/null +++ b/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='