diff --git a/ghost/admin/app/components/gh-editor-feature-image.js b/ghost/admin/app/components/gh-editor-feature-image.js index 23f102c2f42..a8a3897a968 100644 --- a/ghost/admin/app/components/gh-editor-feature-image.js +++ b/ghost/admin/app/components/gh-editor-feature-image.js @@ -11,6 +11,30 @@ function hasParagraphWrapper(html) { return doc.body?.firstElementChild?.tagName === 'P'; } +function cleanCaptionHtml(html) { + return cleanBasicHtml(html || '', {firstChildInnerContent: true}); +} + +function isLexicalPlainTextSpan(element) { + return element.tagName === 'SPAN' && element.style.length === 1 && element.style.whiteSpace === 'pre-wrap'; +} + +function normalizeCaptionHtml(html) { + // Lexical wraps plain text in spans with `white-space: pre-wrap` on load. + // Ignore those wrappers so API-loaded captions do not mark the post as unsaved. + const cleanedHtml = cleanCaptionHtml(html); + const domParser = new DOMParser(); + const doc = domParser.parseFromString(cleanedHtml, 'text/html'); + + doc.body.querySelectorAll('span').forEach((element) => { + if (isLexicalPlainTextSpan(element)) { + element.replaceWith(...element.childNodes); + } + }); + + return doc.body.innerHTML.trim(); +} + export default class GhEditorFeatureImageComponent extends Component { @service settings; @@ -31,7 +55,11 @@ export default class GhEditorFeatureImageComponent extends Component { @action setCaption(html) { - const cleanedHtml = cleanBasicHtml(html || '', {firstChildInnerContent: true}); + const cleanedHtml = cleanCaptionHtml(html); + if (normalizeCaptionHtml(cleanedHtml) === normalizeCaptionHtml(this.caption)) { + return; + } + this.args.updateCaption(cleanedHtml); } diff --git a/ghost/admin/tests/acceptance/editor/feature-image-test.js b/ghost/admin/tests/acceptance/editor/feature-image-test.js index adbf8675671..25bda0329c6 100644 --- a/ghost/admin/tests/acceptance/editor/feature-image-test.js +++ b/ghost/admin/tests/acceptance/editor/feature-image-test.js @@ -21,6 +21,19 @@ describe('Acceptance: Feature Image', function () { expect(await find('.gh-editor-feature-image-caption').textContent).to.contain('Hello dogggos'); }); + it('does not enable update button when a feature image caption is loaded from the API', async function () { + const post = this.server.create('post', { + status: 'published', + featureImage: 'https://static.ghost.org/v4.0.0/images/feature-image.jpg', + featureImageCaption: 'Feature image caption from the API' + }); + + await visit(`/editor/post/${post.id}`); + + expect(find('.gh-editor-feature-image-caption')).to.have.rendered.text('Feature image caption from the API'); + expect(find('[data-test-button="publish-save"]').disabled).to.be.true; + }); + it('does not attempt to save if already deleted and goes back to posts', async function () { // avoids an infinite loop when the post is deleted and the save button is clicked, potential race condition const post = this.server.create('post', {status: 'published', featureImage: 'https://static.ghost.org/v4.0.0/images/feature-image.jpg', featureImageCaption: 'Hello dogggos'}); @@ -34,4 +47,4 @@ describe('Acceptance: Feature Image', function () { expect(currentURL()).to.equal('/posts'); }); -}); \ No newline at end of file +});