Commit 4287fcf3 authored by Olena Horal-Koretska's avatar Olena Horal-Koretska

Merge branch '328641-insert-images/enhanced-image-extension' into 'master'

Enhance image extension to support image uploads

See merge request gitlab-org/gitlab!65763
parents d7cc3459 9b558f53
import { Image } from '@tiptap/extension-image';
import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
import { VueNodeViewRenderer } from '@tiptap/vue-2';
import { Plugin, PluginKey } from 'prosemirror-state';
import { __ } from '~/locale';
import ImageWrapper from '../components/wrappers/image.vue';
import { uploadFile } from '../services/upload_file';
import { getImageAlt, readFileAsDataURL } from '../services/utils';
export const acceptedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/jpg'];
const resolveImageEl = (element) =>
element.nodeName === 'IMG' ? element : element.querySelector('img');
const startFileUpload = async ({ editor, file, uploadsPath, renderMarkdown }) => {
const encodedSrc = await readFileAsDataURL(file);
const { view } = editor;
editor.commands.setImage({ uploading: true, src: encodedSrc });
const { state } = view;
const position = state.selection.from - 1;
const { tr } = state;
try {
const { src, canonicalSrc } = await uploadFile({ file, uploadsPath, renderMarkdown });
view.dispatch(
tr.setNodeMarkup(position, undefined, {
uploading: false,
src: encodedSrc,
alt: getImageAlt(src),
canonicalSrc,
}),
);
} catch (e) {
editor.commands.deleteRange({ from: position, to: position + 1 });
editor.emit('error', __('An error occurred while uploading the image. Please try again.'));
}
};
const handleFileEvent = ({ editor, file, uploadsPath, renderMarkdown }) => {
if (acceptedMimes.includes(file?.type)) {
startFileUpload({ editor, file, uploadsPath, renderMarkdown });
return true;
}
return false;
};
const ExtendedImage = Image.extend({
defaultOptions: {
...Image.options,
uploadsPath: null,
renderMarkdown: null,
},
addAttributes() {
return {
...this.parent?.(),
uploading: {
default: false,
},
src: {
default: null,
/*
......@@ -14,17 +69,25 @@ const ExtendedImage = Image.extend({
* attribute.
*/
parseHTML: (element) => {
const img = element.querySelector('img');
const img = resolveImageEl(element);
return {
src: img.dataset.src || img.getAttribute('src'),
};
},
},
canonicalSrc: {
default: null,
parseHTML: (element) => {
return {
canonicalSrc: element.dataset.canonicalSrc,
};
},
},
alt: {
default: null,
parseHTML: (element) => {
const img = element.querySelector('img');
const img = resolveImageEl(element);
return {
alt: img.getAttribute('alt'),
......@@ -44,9 +107,58 @@ const ExtendedImage = Image.extend({
},
];
},
addCommands() {
return {
...this.parent(),
uploadImage: ({ file }) => () => {
const { uploadsPath, renderMarkdown } = this.options;
handleFileEvent({ file, uploadsPath, renderMarkdown, editor: this.editor });
},
};
},
addProseMirrorPlugins() {
const { editor } = this;
return [
new Plugin({
key: new PluginKey('handleDropAndPasteImages'),
props: {
handlePaste: (_, event) => {
const { uploadsPath, renderMarkdown } = this.options;
return handleFileEvent({
editor,
file: event.clipboardData.files[0],
uploadsPath,
renderMarkdown,
});
},
handleDrop: (_, event) => {
const { uploadsPath, renderMarkdown } = this.options;
return handleFileEvent({
editor,
file: event.dataTransfer.files[0],
uploadsPath,
renderMarkdown,
});
},
},
}),
];
},
addNodeView() {
return VueNodeViewRenderer(ImageWrapper);
},
});
const serializer = defaultMarkdownSerializer.nodes.image;
const serializer = (state, node) => {
const { alt, canonicalSrc, src, title } = node.attrs;
const quotedTitle = title ? ` ${state.quote(title)}` : '';
state.write(`![${state.esc(alt || '')}](${state.esc(canonicalSrc || src)}${quotedTitle})`);
};
export const configure = ({ renderMarkdown, uploadsPath }) => {
return {
......
......@@ -3,3 +3,15 @@ export const hasSelection = (tiptapEditor) => {
return from < to;
};
export const getImageAlt = (src) => {
return src.replace(/^.*\/|\..*$/g, '').replace(/\W+/g, ' ');
};
export const readFileAsDataURL = (file) => {
return new Promise((resolve) => {
const reader = new FileReader();
reader.addEventListener('load', (e) => resolve(e.target.result), { once: true });
reader.readAsDataURL(file);
});
};
......@@ -3855,6 +3855,9 @@ msgstr ""
msgid "An error occurred while updating the notification settings. Please try again."
msgstr ""
msgid "An error occurred while uploading the image. Please try again."
msgstr ""
msgid "An error occurred while validating group path"
msgstr ""
......
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { once } from 'lodash';
import waitForPromises from 'helpers/wait_for_promises';
import * as Image from '~/content_editor/extensions/image';
import httpStatus from '~/lib/utils/http_status';
import { loadMarkdownApiResult } from '../markdown_processing_examples';
import { createTestEditor, createDocBuilder } from '../test_utils';
describe('content_editor/extensions/image', () => {
let tiptapEditor;
let eq;
let doc;
let p;
let image;
let renderMarkdown;
let mock;
const uploadsPath = '/uploads/';
const validFile = new File(['foo'], 'foo.png', { type: 'image/png' });
const invalidFile = new File(['foo'], 'bar.html', { type: 'text/html' });
beforeEach(() => {
renderMarkdown = jest
.fn()
.mockResolvedValue(loadMarkdownApiResult('project_wiki_attachment_image').body);
const { tiptapExtension } = Image.configure({ renderMarkdown, uploadsPath });
tiptapEditor = createTestEditor({ extensions: [tiptapExtension] });
({
builders: { doc, p, image },
eq,
} = createDocBuilder({
tiptapEditor,
names: { image: { nodeType: tiptapExtension.name } },
}));
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.reset();
});
it.each`
file | valid | description
${validFile} | ${true} | ${'handles paste event when mime type is valid'}
${invalidFile} | ${false} | ${'does not handle paste event when mime type is invalid'}
`('$description', ({ file, valid }) => {
const pasteEvent = Object.assign(new Event('paste'), {
clipboardData: {
files: [file],
},
});
let handled;
tiptapEditor.view.someProp('handlePaste', (eventHandler) => {
handled = eventHandler(tiptapEditor.view, pasteEvent);
});
expect(handled).toBe(valid);
});
it.each`
file | valid | description
${validFile} | ${true} | ${'handles drop event when mime type is valid'}
${invalidFile} | ${false} | ${'does not handle drop event when mime type is invalid'}
`('$description', ({ file, valid }) => {
const dropEvent = Object.assign(new Event('drop'), {
dataTransfer: {
files: [file],
},
});
let handled;
tiptapEditor.view.someProp('handleDrop', (eventHandler) => {
handled = eventHandler(tiptapEditor.view, dropEvent);
});
expect(handled).toBe(valid);
});
it('handles paste event when mime type is correct', () => {
const pasteEvent = Object.assign(new Event('paste'), {
clipboardData: {
files: [new File(['foo'], 'foo.png', { type: 'image/png' })],
},
});
const handled = tiptapEditor.view.someProp('handlePaste', (eventHandler) => {
return eventHandler(tiptapEditor.view, pasteEvent);
});
expect(handled).toBe(true);
});
describe('uploadImage command', () => {
describe('when file has correct mime type', () => {
let initialDoc;
const base64EncodedFile = 'data:image/png;base64,Zm9v';
beforeEach(() => {
initialDoc = doc(p(''));
tiptapEditor.commands.setContent(initialDoc.toJSON());
});
describe('when uploading image succeeds', () => {
const successResponse = {
link: {
markdown: '[image](/uploads/25265/image.png)',
},
};
beforeEach(() => {
mock.onPost().reply(httpStatus.OK, successResponse);
});
it('inserts an image with src set to the encoded image file and uploading true', (done) => {
const expectedDoc = doc(p(image({ uploading: true, src: base64EncodedFile })));
tiptapEditor.on(
'update',
once(() => {
expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true);
done();
}),
);
tiptapEditor.commands.uploadImage({ file: validFile });
});
it('updates the inserted image with canonicalSrc when upload is successful', async () => {
const expectedDoc = doc(
p(
image({
canonicalSrc: 'test-file.png',
src: base64EncodedFile,
alt: 'test file',
uploading: false,
}),
),
);
tiptapEditor.commands.uploadImage({ file: validFile });
await waitForPromises();
expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true);
});
});
describe('when uploading image request fails', () => {
beforeEach(() => {
mock.onPost().reply(httpStatus.INTERNAL_SERVER_ERROR);
});
it('resets the doc to orginal state', async () => {
const expectedDoc = doc(p(''));
tiptapEditor.commands.uploadImage({ file: validFile });
await waitForPromises();
expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true);
});
it('emits an error event that includes an error message', (done) => {
tiptapEditor.commands.uploadImage({ file: validFile });
tiptapEditor.on('error', (message) => {
expect(message).toBe('An error occurred while uploading the image. Please try again.');
done();
});
});
});
});
describe('when file does not have correct mime type', () => {
let initialDoc;
beforeEach(() => {
initialDoc = doc(p(''));
tiptapEditor.commands.setContent(initialDoc.toJSON());
});
it('does not start the upload image process', () => {
tiptapEditor.commands.uploadImage({ file: invalidFile });
expect(eq(tiptapEditor.state.doc, initialDoc)).toBe(true);
});
});
});
});
......@@ -23,6 +23,15 @@
- name: attachment_link
context: group
markdown: '[test-file](/uploads/aa45a38ec2cfe97433281b10bbff042c/test-file.zip)'
- name: attachment_image
context: project_wiki
markdown: '![test-file](test-file.png)'
- name: attachment_image
context: project
markdown: '![test-file](/uploads/aa45a38ec2cfe97433281b10bbff042c/test-file.png)'
- name: attachment_image
context: group
markdown: '![test-file](/uploads/aa45a38ec2cfe97433281b10bbff042c/test-file.png)'
- name: code_block
markdown: |-
```javascript
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment