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

[web] Add image loading fallback screen to visual editor

GitOrigin-RevId: 205c6355011802672e57123bb2a61cb708242e31
This commit is contained in:
Mathias Jakobsen
2025-10-15 08:06:32 +00:00
committed by Copybot
parent fe3ace423c
commit f694da4443
7 changed files with 146 additions and 21 deletions
@@ -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": "",
@@ -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,
]
@@ -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) {
+2
View File
@@ -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",
@@ -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
@@ -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 () {
@@ -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='