Commit 0c41d773 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '332088-audio-video-content-editor' into 'master'

Embed video and audio in the Content Editor

See merge request gitlab-org/gitlab!84594
parents b149d28f 7da260ce
......@@ -2,8 +2,14 @@
import { GlLoadingIcon } from '@gitlab/ui';
import { NodeViewWrapper } from '@tiptap/vue-2';
const tagNameMap = {
image: 'img',
video: 'video',
audio: 'audio',
};
export default {
name: 'ImageWrapper',
name: 'MediaWrapper',
components: {
NodeViewWrapper,
GlLoadingIcon,
......@@ -14,19 +20,32 @@ export default {
required: true,
},
},
computed: {
tagName() {
return tagNameMap[this.node.type.name] || 'img';
},
},
};
</script>
<template>
<node-view-wrapper class="gl-display-inline-block">
<span class="gl-relative">
<img
data-testid="image"
class="gl-max-w-full gl-h-auto"
:title="node.attrs.title"
:class="{ 'gl-opacity-5': node.attrs.uploading }"
<span class="gl-relative" :class="{ [`media-container ${tagName}-container`]: true }">
<gl-loading-icon v-if="node.attrs.uploading" class="gl-absolute gl-left-50p gl-top-half" />
<component
:is="tagName"
data-testid="media"
:class="{
'gl-max-w-full gl-h-auto': tagName !== 'audio',
'gl-opacity-5': node.attrs.uploading,
}"
:title="node.attrs.title || node.attrs.alt"
:alt="node.attrs.alt"
:src="node.attrs.src"
controls="true"
/>
<gl-loading-icon v-if="node.attrs.uploading" class="gl-absolute gl-left-50p gl-top-half" />
<a v-if="tagName !== 'img'" :href="node.attrs.canonicalSrc || node.attrs.src" @click.prevent>
{{ node.attrs.title || node.attrs.alt }}
</a>
</span>
</node-view-wrapper>
</template>
import { Image } from '@tiptap/extension-image';
import { VueNodeViewRenderer } from '@tiptap/vue-2';
import ImageWrapper from '../components/wrappers/image.vue';
import MediaWrapper from '../components/wrappers/media.vue';
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
const resolveImageEl = (element) =>
......@@ -78,6 +78,6 @@ export default Image.extend({
];
},
addNodeView() {
return VueNodeViewRenderer(ImageWrapper);
return VueNodeViewRenderer(MediaWrapper);
},
});
/* eslint-disable @gitlab/require-i18n-strings */
import { Node } from '@tiptap/core';
import { VueNodeViewRenderer } from '@tiptap/vue-2';
import MediaWrapper from '../components/wrappers/media.vue';
const queryPlayableElement = (element, mediaType) => element.querySelector(mediaType);
......@@ -11,6 +13,9 @@ export default Node.create({
addAttributes() {
return {
uploading: {
default: false,
},
src: {
default: null,
parseHTML: (element) => {
......@@ -60,7 +65,11 @@ export default Node.create({
...this.extraElementAttrs,
},
],
['a', { href: node.attrs.src }, node.attrs.alt],
['a', { href: node.attrs.src }, node.attrs.title || node.attrs.alt || ''],
];
},
addNodeView() {
return VueNodeViewRenderer(MediaWrapper);
},
});
......@@ -5,6 +5,16 @@ import { extractFilename, readFileAsDataURL } from './utils';
export const acceptedMimes = {
image: ['image/jpeg', 'image/png', 'image/gif', 'image/jpg'],
audio: [
'audio/basic',
'audio/mid',
'audio/mpeg',
'audio/x-aiff',
'audio/ogg',
'audio/vorbis',
'audio/vnd.wav',
],
video: ['video/mp4', 'video/quicktime'],
};
const extractAttachmentLinkUrl = (html) => {
......@@ -50,11 +60,11 @@ export const uploadFile = async ({ uploadsPath, renderMarkdown, file }) => {
return extractAttachmentLinkUrl(rendered);
};
const uploadImage = async ({ editor, file, uploadsPath, renderMarkdown, eventHub }) => {
const uploadContent = async ({ type, editor, file, uploadsPath, renderMarkdown, eventHub }) => {
const encodedSrc = await readFileAsDataURL(file);
const { view } = editor;
editor.commands.setImage({ uploading: true, src: encodedSrc });
editor.commands.insertContent({ type, attrs: { uploading: true, src: encodedSrc } });
const { state } = view;
const position = state.selection.from - 1;
......@@ -74,7 +84,7 @@ const uploadImage = async ({ editor, file, uploadsPath, renderMarkdown, eventHub
} catch (e) {
editor.commands.deleteRange({ from: position, to: position + 1 });
eventHub.$emit('alert', {
message: __('An error occurred while uploading the image. Please try again.'),
message: __('An error occurred while uploading the file. Please try again.'),
variant: VARIANT_DANGER,
});
}
......@@ -114,10 +124,12 @@ const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown, eve
export const handleFileEvent = ({ editor, file, uploadsPath, renderMarkdown, eventHub }) => {
if (!file) return false;
if (acceptedMimes.image.includes(file?.type)) {
uploadImage({ editor, file, uploadsPath, renderMarkdown, eventHub });
for (const [type, mimes] of Object.entries(acceptedMimes)) {
if (mimes.includes(file?.type)) {
uploadContent({ type, editor, file, uploadsPath, renderMarkdown, eventHub });
return true;
return true;
}
}
uploadAttachment({ editor, file, uploadsPath, renderMarkdown, eventHub });
......
......@@ -22,7 +22,7 @@ module Gitlab
'frame_src' => ContentSecurityPolicy::Directives.frame_src,
'img_src' => "'self' data: blob: http: https:",
'manifest_src' => "'self'",
'media_src' => "'self'",
'media_src' => "'self' data:",
'script_src' => ContentSecurityPolicy::Directives.script_src,
'style_src' => "'self' 'unsafe-inline'",
'worker_src' => "#{Gitlab::Utils.append_path(Gitlab.config.gitlab.url, 'assets/')} blob: data:",
......
......@@ -4178,9 +4178,6 @@ msgstr ""
msgid "An error occurred while uploading the file. 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 { GlLoadingIcon } from '@gitlab/ui';
import { NodeViewWrapper } from '@tiptap/vue-2';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ImageWrapper from '~/content_editor/components/wrappers/image.vue';
import MediaWrapper from '~/content_editor/components/wrappers/media.vue';
describe('content/components/wrappers/image', () => {
describe('content/components/wrappers/media', () => {
let wrapper;
const createWrapper = async (nodeAttrs = {}) => {
wrapper = shallowMountExtended(ImageWrapper, {
wrapper = shallowMountExtended(MediaWrapper, {
propsData: {
node: {
attrs: nodeAttrs,
type: {
name: 'image',
},
},
},
});
};
const findImage = () => wrapper.findByTestId('image');
const findMedia = () => wrapper.findByTestId('media');
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
afterEach(() => {
......@@ -33,7 +36,7 @@ describe('content/components/wrappers/image', () => {
createWrapper({ src });
expect(findImage().attributes().src).toBe(src);
expect(findMedia().attributes().src).toBe(src);
});
describe('when uploading', () => {
......@@ -45,8 +48,8 @@ describe('content/components/wrappers/image', () => {
expect(findLoadingIcon().exists()).toBe(true);
});
it('adds gl-opacity-5 class selector to image', () => {
expect(findImage().classes()).toContain('gl-opacity-5');
it('adds gl-opacity-5 class selector to the media tag', () => {
expect(findMedia().classes()).toContain('gl-opacity-5');
});
});
......@@ -59,8 +62,8 @@ describe('content/components/wrappers/image', () => {
expect(findLoadingIcon().exists()).toBe(false);
});
it('does not add gl-opacity-5 class selector to image', () => {
expect(findImage().classes()).not.toContain('gl-opacity-5');
it('does not add gl-opacity-5 class selector to the media tag', () => {
expect(findMedia().classes()).not.toContain('gl-opacity-5');
});
});
});
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import waitForPromises from 'helpers/wait_for_promises';
import Attachment from '~/content_editor/extensions/attachment';
import Image from '~/content_editor/extensions/image';
import Audio from '~/content_editor/extensions/audio';
import Video from '~/content_editor/extensions/video';
import Link from '~/content_editor/extensions/link';
import Loading from '~/content_editor/extensions/loading';
import { VARIANT_DANGER } from '~/flash';
......@@ -14,6 +17,23 @@ const PROJECT_WIKI_ATTACHMENT_IMAGE_HTML = `<p data-sourcepos="1:1-1:27" dir="au
<img alt="test-file" class="lazy" data-src="/group1/project1/-/wikis/test-file.png" data-canonical-src="test-file.png">
</a>
</p>`;
const PROJECT_WIKI_ATTACHMENT_VIDEO_HTML = `<p data-sourcepos="1:1-1:132" dir="auto">
<span class="media-container video-container">
<video src="/group1/project1/-/wikis/test-file.mp4" controls="true" data-setup="{}" data-title="test-file" width="400" preload="metadata" data-canonical-src="test-file.mp4">
</video>
<a href="/himkp/test/-/wikis/test-file.mp4" target="_blank" rel="noopener noreferrer" title="Download 'test-file'" data-canonical-src="test-file.mp4">test-file</a>
</span>
</p>`;
const PROJECT_WIKI_ATTACHMENT_AUDIO_HTML = `<p data-sourcepos="3:1-3:74" dir="auto">
<span class="media-container audio-container">
<audio src="/himkp/test/-/wikis/test-file.mp3" controls="true" data-setup="{}" data-title="test-file" data-canonical-src="test-file.mp3">
</audio>
<a href="/himkp/test/-/wikis/test-file.mp3" target="_blank" rel="noopener noreferrer" title="Download 'test-file'" data-canonical-src="test-file.mp3">test-file</a>
</span>
</p>`;
const PROJECT_WIKI_ATTACHMENT_LINK_HTML = `<p data-sourcepos="1:1-1:26" dir="auto">
<a href="/group1/project1/-/wikis/test-file.zip" data-canonical-src="test-file.zip">test-file</a>
</p>`;
......@@ -23,6 +43,8 @@ describe('content_editor/extensions/attachment', () => {
let doc;
let p;
let image;
let audio;
let video;
let loading;
let link;
let renderMarkdown;
......@@ -31,15 +53,18 @@ describe('content_editor/extensions/attachment', () => {
const uploadsPath = '/uploads/';
const imageFile = new File(['foo'], 'test-file.png', { type: 'image/png' });
const audioFile = new File(['foo'], 'test-file.mp3', { type: 'audio/mpeg' });
const videoFile = new File(['foo'], 'test-file.mp4', { type: 'video/mp4' });
const attachmentFile = new File(['foo'], 'test-file.zip', { type: 'application/zip' });
const expectDocumentAfterTransaction = ({ number, expectedDoc, action }) => {
return new Promise((resolve) => {
let counter = 1;
const handleTransaction = () => {
const handleTransaction = async () => {
if (counter === number) {
expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON());
tiptapEditor.off('update', handleTransaction);
await waitForPromises();
resolve();
}
......@@ -60,18 +85,22 @@ describe('content_editor/extensions/attachment', () => {
Loading,
Link,
Image,
Audio,
Video,
Attachment.configure({ renderMarkdown, uploadsPath, eventHub }),
],
});
({
builders: { doc, p, image, loading, link },
builders: { doc, p, image, audio, video, loading, link },
} = createDocBuilder({
tiptapEditor,
names: {
loading: { markType: Loading.name },
image: { nodeType: Image.name },
link: { nodeType: Link.name },
audio: { nodeType: Audio.name },
video: { nodeType: Video.name },
},
}));
......@@ -103,17 +132,22 @@ describe('content_editor/extensions/attachment', () => {
tiptapEditor.commands.setContent(initialDoc.toJSON());
});
describe('when the file has image mime type', () => {
const base64EncodedFile = 'data:image/png;base64,Zm9v';
describe.each`
nodeType | mimeType | html | file | mediaType
${'image'} | ${'image/png'} | ${PROJECT_WIKI_ATTACHMENT_IMAGE_HTML} | ${imageFile} | ${(attrs) => image(attrs)}
${'audio'} | ${'audio/mpeg'} | ${PROJECT_WIKI_ATTACHMENT_AUDIO_HTML} | ${audioFile} | ${(attrs) => audio(attrs)}
${'video'} | ${'video/mp4'} | ${PROJECT_WIKI_ATTACHMENT_VIDEO_HTML} | ${videoFile} | ${(attrs) => video(attrs)}
`('when the file has $nodeType mime type', ({ mimeType, html, file, mediaType }) => {
const base64EncodedFile = `data:${mimeType};base64,Zm9v`;
beforeEach(() => {
renderMarkdown.mockResolvedValue(PROJECT_WIKI_ATTACHMENT_IMAGE_HTML);
renderMarkdown.mockResolvedValue(html);
});
describe('when uploading succeeds', () => {
const successResponse = {
link: {
markdown: '![test-file](test-file.png)',
markdown: `![test-file](${file.name})`,
},
};
......@@ -121,21 +155,21 @@ describe('content_editor/extensions/attachment', () => {
mock.onPost().reply(httpStatus.OK, successResponse);
});
it('inserts an image with src set to the encoded image file and uploading true', async () => {
const expectedDoc = doc(p(image({ uploading: true, src: base64EncodedFile })));
it('inserts a media content with src set to the encoded content and uploading true', async () => {
const expectedDoc = doc(p(mediaType({ uploading: true, src: base64EncodedFile })));
await expectDocumentAfterTransaction({
number: 1,
expectedDoc,
action: () => tiptapEditor.commands.uploadAttachment({ file: imageFile }),
action: () => tiptapEditor.commands.uploadAttachment({ file }),
});
});
it('updates the inserted image with canonicalSrc when upload is successful', async () => {
it('updates the inserted content with canonicalSrc when upload is successful', async () => {
const expectedDoc = doc(
p(
image({
canonicalSrc: 'test-file.png',
mediaType({
canonicalSrc: file.name,
src: base64EncodedFile,
alt: 'test-file',
uploading: false,
......@@ -146,7 +180,7 @@ describe('content_editor/extensions/attachment', () => {
await expectDocumentAfterTransaction({
number: 2,
expectedDoc,
action: () => tiptapEditor.commands.uploadAttachment({ file: imageFile }),
action: () => tiptapEditor.commands.uploadAttachment({ file }),
});
});
});
......@@ -162,16 +196,16 @@ describe('content_editor/extensions/attachment', () => {
await expectDocumentAfterTransaction({
number: 2,
expectedDoc,
action: () => tiptapEditor.commands.uploadAttachment({ file: imageFile }),
action: () => tiptapEditor.commands.uploadAttachment({ file }),
});
});
it('emits an alert event that includes an error message', (done) => {
tiptapEditor.commands.uploadAttachment({ file: imageFile });
tiptapEditor.commands.uploadAttachment({ file });
eventHub.$on('alert', ({ message, variant }) => {
expect(variant).toBe(VARIANT_DANGER);
expect(message).toBe('An error occurred while uploading the image. Please try again.');
expect(message).toBe('An error occurred while uploading the file. Please try again.');
done();
});
});
......
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