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(``); + expect(transformed).to.include('/blocks/edit/img/fpo.svg#'); + expect(transformed).to.not.include('data:image'); + } finally { + window.fetch = savedFetch; + } + }); + + describe('uploadBase64Image', () => { + it('uploads via source.uploadMedia (not source.save)', async () => { + const savedFetch = window.fetch; + let requestMethod = null; + window.fetch = (url, opts) => { + requestMethod = opts?.method; + return Promise.resolve(new Response( + JSON.stringify({ source: { contentUrl: './media_abc123.png' } }), + { status: 200 }, + )); + }; + try { + const fpoSrc = '/blocks/edit/img/fpo.svg#123'; + const image = editor.view.state.schema.nodes.image.create({ src: fpoSrc }); + editor.view.dispatch(editor.view.state.tr.replaceSelectionWith(image)); + + await uploadBase64Image(editor.view, { src: dataUrl, path: '/org/repo/.page/wp123.png', fpoSrc }); + + expect(requestMethod).to.equal('POST'); + } finally { + window.fetch = savedFetch; + } + }); + + it('swaps the FPO placeholder for the uploaded content URL, including relative media_ paths', async () => { + const savedFetch = window.fetch; + const contentUrl = './media_abc123.png'; + window.fetch = () => Promise.resolve(new Response( + JSON.stringify({ source: { contentUrl } }), + { status: 200 }, + )); + try { + const fpoSrc = '/blocks/edit/img/fpo.svg#123'; + const image = editor.view.state.schema.nodes.image.create({ src: fpoSrc }); + editor.view.dispatch(editor.view.state.tr.replaceSelectionWith(image)); + + await uploadBase64Image(editor.view, { src: dataUrl, path: '/org/repo/.page/wp123.png', fpoSrc }); + + let finalSrc = null; + editor.view.state.doc.descendants((node) => { + if (node.type.name === 'image') finalSrc = node.attrs.src; + }); + expect(finalSrc).to.equal(contentUrl); + } finally { + window.fetch = savedFetch; + } + }); + + it('leaves the FPO placeholder in place and logs when the upload fails', async () => { + const savedFetch = window.fetch; + const savedConsoleError = console.error; + let loggedArgs = null; + console.error = (...args) => { loggedArgs = args; }; + window.fetch = () => Promise.resolve(new Response('boom', { status: 403, statusText: 'Forbidden' })); + try { + const fpoSrc = '/blocks/edit/img/fpo.svg#123'; + const image = editor.view.state.schema.nodes.image.create({ src: fpoSrc }); + editor.view.dispatch(editor.view.state.tr.replaceSelectionWith(image)); + + await uploadBase64Image(editor.view, { src: dataUrl, path: '/org/repo/.page/wp123.png', fpoSrc }); + + let finalSrc = null; + editor.view.state.doc.descendants((node) => { + if (node.type.name === 'image') finalSrc = node.attrs.src; + }); + expect(finalSrc).to.equal(fpoSrc); + expect(loggedArgs).to.not.be.null; + expect(loggedArgs[0]).to.include('403'); + } finally { + window.fetch = savedFetch; + console.error = savedConsoleError; + } + }); + }); + + it('end to end: transformPastedHTML placeholder eventually gets uploaded and swapped', async () => { + const savedFetch = window.fetch; + const contentUrl = './media_end2end.png'; + window.fetch = () => Promise.resolve(new Response( + JSON.stringify({ source: { contentUrl } }), + { status: 200 }, + )); + try { + const plugin = base64UploaderFactory(); + const transformed = plugin.props.transformPastedHTML(``); + const fpoSrc = extractFpoSrc(transformed); + expect(fpoSrc).to.be.a('string'); + + // Mirrors what ProseMirror's own paste handling would do with the + // transformed HTML: insert an image node carrying the FPO placeholder. + const image = editor.view.state.schema.nodes.image.create({ src: fpoSrc }); + editor.view.dispatch(editor.view.state.tr.replaceSelectionWith(image)); + + function getImageSrc() { + let src = null; + editor.view.state.doc.descendants((node) => { + if (node.type.name === 'image') src = node.attrs.src; + }); + return src; + } + + // transformPastedHTML kicks the upload off fire-and-forget — poll + // briefly for the doc to settle rather than guessing a tick count. + let finalSrc = getImageSrc(); + for (let i = 0; i < 20 && finalSrc !== contentUrl; i += 1) { + // eslint-disable-next-line no-await-in-loop + await nextFrame(); + finalSrc = getImageSrc(); + } + expect(finalSrc).to.equal(contentUrl); + } finally { + window.fetch = savedFetch; + } + }); +});