Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion blocks/edit/prose/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ function handleAwarenessUpdates(wsProvider, daTitle, win, path) {
win.addEventListener('focus', () => {
// cancel any pending disconnect
if (disconnectTimeout) clearTimeout(disconnectTimeout);
wsProvider.connect();
if (!wsProvider.wsconnected) wsProvider.connect();
});
win.addEventListener('blur', () => {
if (disconnectTimeout) clearTimeout(disconnectTimeout);
Expand Down
29 changes: 18 additions & 11 deletions blocks/edit/prose/plugins/imageDrop.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// eslint-disable-next-line import/no-unresolved
import { Plugin, PluginKey, TextSelection } from 'da-y-wrapper';
import { Plugin, PluginKey } from 'da-y-wrapper';
import getPathDetails from '../../../shared/pathDetails.js';
import { daFetch } from '../../../shared/utils.js';

Expand All @@ -12,14 +12,15 @@ export async function uploadImageFile(view, file) {
if (!SUPPORTED_IMAGE_TYPES.some((type) => type === file.type)) return;

const { schema } = view.state;
const fpo = schema.nodes.image.create({ src: FPO_IMG_URL, style: 'width: 180px' });
view.dispatch(view.state.tr.replaceSelectionWith(fpo).scrollIntoView());

const { $from } = view.state.selection;

const details = getPathDetails();
const url = `${details.origin}/source${details.parent}/.${details.name}/${file.name}`;

// Use the upload URL as a unique FPO identifier so concurrent uploads can
// each find their own placeholder by content rather than by stale position.
const fpoSrc = `${FPO_IMG_URL}#${url}`;
const fpo = schema.nodes.image.create({ src: fpoSrc, style: 'width: 180px' });
view.dispatch(view.state.tr.replaceSelectionWith(fpo).scrollIntoView());

const formData = new FormData();
formData.append('data', file);
const opts = { method: 'PUT', body: formData };
Expand All @@ -30,11 +31,17 @@ export async function uploadImageFile(view, file) {
// Create a doc image to pre-download the image before showing it.
const docImg = document.createElement('img');
docImg.addEventListener('load', () => {
const fpoSelection = TextSelection.create(view.state.doc, $from.pos - 1, $from.pos);
const ts = view.state.tr.setSelection(fpoSelection);
const img = schema.nodes.image.create({ src: json.source.contentUrl });
const tr = ts.replaceSelectionWith(img).scrollIntoView();
view.dispatch(tr);
// Find the placeholder by its unique src rather than a stale position so
// concurrent uploads and collab updates cannot cause the wrong node to be
// replaced.
let replaced = false;
view.state.doc.descendants((node, pos) => {
if (!replaced && node.type.name === 'image' && node.attrs.src === fpoSrc) {
replaced = true;
const img = schema.nodes.image.create({ src: json.source.contentUrl });
view.dispatch(view.state.tr.replaceWith(pos, pos + node.nodeSize, img).scrollIntoView());
}
});
});
docImg.src = json.source.contentUrl;
}
Expand Down
64 changes: 64 additions & 0 deletions test/unit/blocks/edit/prose/plugins/imageDrop.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,4 +107,68 @@ describe('imageDrop plugin', () => {
});
expect(prevented).to.be.true;
});

it('uploadImageFile gives FPO a unique src containing the upload URL', async () => {
const savedFetch = window.fetch;
window.fetch = () => new Promise(() => {}); // never resolves — FPO stays in doc
try {
const file = new File(['x'], 'my-photo.png', { type: 'image/png' });
uploadImageFile(editor.view, file); // intentionally not awaited
await nextFrame();
let fpoSrc = null;
editor.view.state.doc.descendants((node) => {
if (node.type.name === 'image') fpoSrc = node.attrs.src;
});
expect(fpoSrc).to.be.a('string');
expect(fpoSrc).to.include('/blocks/edit/img/fpo.svg#');
expect(fpoSrc).to.include('my-photo.png');
} finally {
window.fetch = savedFetch;
}
});

it('concurrent uploads use distinct FPO srcs so they can be replaced independently', async () => {
const savedFetch = window.fetch;
window.fetch = () => new Promise(() => {}); // never resolves — both FPOs stay
try {
const file1 = new File(['a'], 'alpha.png', { type: 'image/png' });
const file2 = new File(['b'], 'beta.gif', { type: 'image/gif' });
uploadImageFile(editor.view, file1);
uploadImageFile(editor.view, file2);
await nextFrame();
const fpoSrcs = [];
editor.view.state.doc.descendants((node) => {
if (node.type.name === 'image') fpoSrcs.push(node.attrs.src);
});
expect(fpoSrcs).to.have.length(2);
expect(fpoSrcs[0]).to.not.equal(fpoSrcs[1]);
expect(fpoSrcs[0]).to.include('alpha.png');
expect(fpoSrcs[1]).to.include('beta.gif');
} finally {
window.fetch = savedFetch;
}
});

it('uploadImageFile replaces FPO with the real image URL after upload completes', async () => {
// Use a data URL so the browser fires the img load event in the test environment.
const dataUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
const savedFetch = window.fetch;
window.fetch = () => Promise.resolve(new Response(
JSON.stringify({ source: { contentUrl: dataUrl } }),
{ status: 200 },
));
try {
const file = new File(['x'], 'pic.png', { type: 'image/png' });
await uploadImageFile(editor.view, file);
// Give the img load event time to fire and dispatch the replacement transaction.
await new Promise((resolve) => { setTimeout(resolve, 200); });
let finalSrc = null;
editor.view.state.doc.descendants((node) => {
if (node.type.name === 'image') finalSrc = node.attrs.src;
});
expect(finalSrc).to.equal(dataUrl);
} finally {
window.fetch = savedFetch;
}
});
});
Loading