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