diff --git a/blocks/edit/prose/plugins/base64uploader.js b/blocks/edit/prose/plugins/base64uploader.js index 2b97ac287..c1c95216a 100644 --- a/blocks/edit/prose/plugins/base64uploader.js +++ b/blocks/edit/prose/plugins/base64uploader.js @@ -1,7 +1,6 @@ import { Plugin, PluginKey } from 'da-y-wrapper'; import getPathDetails from '../../../shared/pathDetails.js'; import { getNx2Api } from '../../../../scripts/utils.js'; -import { CON_ORIGIN } from '../../../shared/constants.js'; const base64UploaderKey = new PluginKey('base64Uploader'); @@ -15,6 +14,31 @@ function makeHash(string) { )); } +// Uploads a single pasted base64 image and swaps its FPO placeholder for the +// real content URL. uploadMedia content-addresses the file, so the final URL +// (possibly a relative ./media_... path) is only known from the response — +// unlike source.save, it can't be derived from the upload path upfront. +export async function uploadBase64Image(view, { src, path, fpoSrc }) { + const resp = await fetch(src); + const blob = await resp.blob(); + const { source } = await getNx2Api(); + const uploadResp = await source.uploadMedia(path, { body: blob }); + if (!uploadResp.ok) { + // eslint-disable-next-line no-console + console.error(`Failed to upload pasted image: ${uploadResp.status} ${uploadResp.statusText}`); + return; + } + const { source: { contentUrl } } = await uploadResp.json(); + + view.state.doc.descendants((node, pos) => { + if (node.type.name === 'image' && node.attrs.src === fpoSrc) { + view.dispatch(view.state.tr.setNodeMarkup(pos, null, { ...node.attrs, src: contentUrl })); + return false; + } + return true; + }); +} + /** * Base 64 Uploader * @returns {Plugin} the base64 uploader plugin @@ -32,39 +56,16 @@ export default function base64Uploader() { return html; } - const imagePaths = []; - const uploadPromises = []; - dataImgs.forEach((img) => { const src = img.getAttribute('src'); let ext = src.replace('data:image/', '').split(';base64')[0]; if (ext === 'jpeg') ext = 'jpg'; const { parent, name } = getPathDetails(); const path = `${parent}/.${name}/wp${makeHash(src)}.${ext}`; // WP = Word Paste - const fpoSrc = `${FPO_IMG_URL}#${CON_ORIGIN}${path}`; + const fpoSrc = `${FPO_IMG_URL}#${makeHash(src)}`; img.setAttribute('src', fpoSrc); - imagePaths.push(fpoSrc); - - uploadPromises.push((async () => { - const resp = await fetch(src); - const blob = await resp.blob(); - const { source } = await getNx2Api(); - await source.save(path, { body: blob }); - })()); - }); - - Promise.all(uploadPromises).then(() => { - const { view } = window; - const { tr } = view.state; - - view.state.doc.descendants((node, pos) => { - if (node.type.name === 'image' && imagePaths.includes(node.attrs.src)) { - const newAttrs = { src: node.attrs.src.split('#')[1] }; - tr.setNodeMarkup(pos, null, { ...node.attrs, ...newAttrs }); - } - }); - view.dispatch(tr); + uploadBase64Image(window.view, { src, path, fpoSrc }); }); const serializer = new XMLSerializer(); diff --git a/test/unit/blocks/edit/prose/plugins/base64uploader.test.js b/test/unit/blocks/edit/prose/plugins/base64uploader.test.js new file mode 100644 index 000000000..b970279d3 --- /dev/null +++ b/test/unit/blocks/edit/prose/plugins/base64uploader.test.js @@ -0,0 +1,170 @@ +import { expect } from '@esm-bundle/chai'; +import base64UploaderFactory, { uploadBase64Image } from '../../../../../../blocks/edit/prose/plugins/base64uploader.js'; +import { createTestEditor, destroyEditor } from '../test-helpers.js'; + +const { setNx } = await import('../../../../../../scripts/utils.js'); +setNx('/test/fixtures/nx', { hostname: 'example.com' }); + +const nextFrame = () => new Promise((resolve) => { setTimeout(resolve, 0); }); + +const dataUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + +function extractFpoSrc(transformedHtml) { + const match = transformedHtml.match(/src="([^"]*fpo\.svg#[^"]*)"/); + return match?.[1]; +} + +describe('base64Uploader plugin', () => { + let editor; + + beforeEach(async () => { + editor = await createTestEditor({ additionalPlugins: [base64UploaderFactory()] }); + window.view = editor.view; + // The plugin uses getPathDetails() — set a hash so it returns valid details. + window.history.replaceState(null, '', '/edit#/org/repo/page'); + await nextFrame(); + }); + + afterEach(() => { + destroyEditor(editor); + delete window.view; + window.history.replaceState(null, '', '/'); + }); + + it('returns html unchanged when there are no data: images', () => { + const plugin = base64UploaderFactory(); + const html = '
hello
'; + expect(plugin.props.transformPastedHTML(html)).to.equal(html); + }); + + it('replaces data: image srcs with an FPO placeholder synchronously', () => { + // transformPastedHTML fires the upload off fire-and-forget as a side + // effect — mock fetch so that stray call can't reach the real network + // and bleed into other tests. + const savedFetch = window.fetch; + window.fetch = () => new Promise(() => {}); + try { + const plugin = base64UploaderFactory(); + const transformed = plugin.props.transformPastedHTML(`