From 7ca8b8adee452bc58c14f42d809c7a3e6d654201 Mon Sep 17 00:00:00 2001 From: Tobias Bocanegra Date: Mon, 10 Aug 2026 14:38:47 +0200 Subject: [PATCH] fix: upload pasted base64 images via source.uploadMedia Mirrors #1219's switch for dropped images: source.save assumed the upload path itself was the final addressable URL, but uploadMedia content-addresses the file, so the real URL (now possibly a relative ./media_... path) is only known from the response. Swap each pasted image's FPO placeholder for that response URL as soon as its own upload resolves, instead of waiting for every pasted image to finish. Co-Authored-By: Claude Sonnet 5 --- blocks/edit/prose/plugins/base64uploader.js | 53 +++--- .../edit/prose/plugins/base64uploader.test.js | 170 ++++++++++++++++++ 2 files changed, 197 insertions(+), 26 deletions(-) create mode 100644 test/unit/blocks/edit/prose/plugins/base64uploader.test.js 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; + } + }); +});