Commit 6900b532 authored by Marcia Ramos's avatar Marcia Ramos

Merge branch '216642-embed-youtube-video' into 'master'

Add the ability to insert a YouTube video

See merge request gitlab-org/gitlab!44102
parents 0add15d5 9401b60f
......@@ -15,7 +15,7 @@ const markPrefix = `${marker}-${Date.now()}`;
const reHelpers = {
template: `.| |\\t|\\n(?!(\\n|${markPrefix}))`,
openTag: '<(?!iframe)[a-zA-Z]+.*?>',
openTag: '<(?!figure|iframe)[a-zA-Z]+.*?>',
closeTag: '</.+>',
};
const reTemplated = new RegExp(`(^${wrapPrefix}(${reHelpers.template})+?${wrapPostfix}$)`, 'gm');
......
......@@ -2,9 +2,14 @@ import { __ } from '~/locale';
export const CUSTOM_EVENTS = {
openAddImageModal: 'gl_openAddImageModal',
openInsertVideoModal: 'gl_openInsertVideoModal',
};
export const ALLOWED_VIDEO_ORIGINS = ['https://www.youtube.com'];
export const YOUTUBE_URL = 'https://www.youtube.com';
export const YOUTUBE_EMBED_URL = `${YOUTUBE_URL}/embed`;
export const ALLOWED_VIDEO_ORIGINS = [YOUTUBE_URL];
/* eslint-disable @gitlab/require-i18n-strings */
export const TOOLBAR_ITEM_CONFIGS = [
......@@ -25,6 +30,7 @@ export const TOOLBAR_ITEM_CONFIGS = [
{ icon: 'dash', command: 'HR', tooltip: __('Add a line') },
{ icon: 'table', event: 'openPopupAddTable', classes: 'tui-table', tooltip: __('Add a table') },
{ icon: 'doc-image', event: CUSTOM_EVENTS.openAddImageModal, tooltip: __('Insert an image') },
{ icon: 'live-preview', event: CUSTOM_EVENTS.openInsertVideoModal, tooltip: __('Insert video') },
{ isDivider: true },
{ icon: 'code', command: 'Code', tooltip: __('Insert inline code') },
{ icon: 'doc-code', command: 'CodeBlock', tooltip: __('Insert a code block') },
......@@ -42,3 +48,10 @@ export const EDITOR_PREVIEW_STYLE = 'horizontal';
export const IMAGE_TABS = { UPLOAD_TAB: 0, URL_TAB: 1 };
export const MAX_FILE_SIZE = 2097152; // 2Mb
export const VIDEO_ATTRIBUTES = {
width: '560',
height: '315',
frameBorder: '0',
allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture',
};
<script>
import { GlModal, GlFormGroup, GlFormInput, GlSprintf } from '@gitlab/ui';
import { isSafeURL } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import { YOUTUBE_URL, YOUTUBE_EMBED_URL } from '../constants';
export default {
components: {
GlModal,
GlFormGroup,
GlFormInput,
GlSprintf,
},
data() {
return {
url: null,
urlError: null,
description: __(
'If the YouTube URL is https://www.youtube.com/watch?v=0t1DgySidms then the video ID is %{id}',
),
};
},
modalTitle: __('Insert a video'),
okTitle: __('Insert video'),
label: __('YouTube URL or ID'),
methods: {
show() {
this.urlError = null;
this.url = null;
this.$refs.modal.show();
},
onPrimary(event) {
this.submitURL(event);
},
submitURL(event) {
const url = this.generateUrl();
if (!url) {
event.preventDefault();
return;
}
this.$emit('insertVideo', url);
},
generateUrl() {
let { url } = this;
const reYouTubeId = /^[A-z0-9]*$/;
const reYouTubeUrl = RegExp(`${YOUTUBE_URL}/(embed/|watch\\?v=)([A-z0-9]+)`);
if (reYouTubeId.test(url)) {
url = `${YOUTUBE_EMBED_URL}/${url}`;
} else if (reYouTubeUrl.test(url)) {
url = `${YOUTUBE_EMBED_URL}/${reYouTubeUrl.exec(url)[2]}`;
}
if (!isSafeURL(url) || !reYouTubeUrl.test(url)) {
this.urlError = __('Please provide a valid YouTube URL or ID');
this.$refs.urlInput.$el.focus();
return null;
}
return url;
},
},
};
</script>
<template>
<gl-modal
ref="modal"
size="sm"
modal-id="insert-video-modal"
:title="$options.modalTitle"
:ok-title="$options.okTitle"
@primary="onPrimary"
>
<gl-form-group
:label="$options.label"
label-for="video-modal-url-input"
:state="!Boolean(urlError)"
:invalid-feedback="urlError"
>
<gl-form-input id="video-modal-url-input" ref="urlInput" v-model="url" />
<gl-sprintf slot="description" :message="description" class="text-gl-muted">
<template #id>
<strong>{{ __('0t1DgySidms') }}</strong>
</template>
</gl-sprintf>
</gl-form-group>
</gl-modal>
</template>
......@@ -3,6 +3,7 @@ import 'codemirror/lib/codemirror.css';
import '@toast-ui/editor/dist/toastui-editor.css';
import AddImageModal from './modals/add_image/add_image_modal.vue';
import InsertVideoModal from './modals/insert_video_modal.vue';
import { EDITOR_TYPES, EDITOR_HEIGHT, EDITOR_PREVIEW_STYLE, CUSTOM_EVENTS } from './constants';
import {
......@@ -12,6 +13,7 @@ import {
removeCustomEventListener,
addImage,
getMarkdown,
insertVideo,
} from './services/editor_service';
export default {
......@@ -21,6 +23,7 @@ export default {
toast => toast.Editor,
),
AddImageModal,
InsertVideoModal,
},
props: {
content: {
......@@ -63,6 +66,12 @@ export default {
editorInstance() {
return this.$refs.editor;
},
customEventListeners() {
return [
{ event: CUSTOM_EVENTS.openAddImageModal, listener: this.onOpenAddImageModal },
{ event: CUSTOM_EVENTS.openInsertVideoModal, listener: this.onOpenInsertVideoModal },
];
},
},
created() {
this.editorOptions = getEditorOptions(this.options);
......@@ -72,16 +81,16 @@ export default {
},
methods: {
addListeners(editorApi) {
addCustomEventListener(editorApi, CUSTOM_EVENTS.openAddImageModal, this.onOpenAddImageModal);
this.customEventListeners.forEach(({ event, listener }) => {
addCustomEventListener(editorApi, event, listener);
});
editorApi.eventManager.listen('changeMode', this.onChangeMode);
},
removeListeners() {
removeCustomEventListener(
this.editorApi,
CUSTOM_EVENTS.openAddImageModal,
this.onOpenAddImageModal,
);
this.customEventListeners.forEach(({ event, listener }) => {
removeCustomEventListener(this.editorApi, event, listener);
});
this.editorApi.eventManager.removeEventHandler('changeMode', this.onChangeMode);
},
......@@ -111,6 +120,12 @@ export default {
addImage(this.editorInstance, image);
},
onOpenInsertVideoModal() {
this.$refs.insertVideoModal.show();
},
onInsertVideo(url) {
insertVideo(this.editorInstance, url);
},
onChangeMode(newMode) {
this.$emit('modeChange', newMode);
},
......@@ -130,5 +145,6 @@ export default {
@load="onLoad"
/>
<add-image-modal ref="addImageModal" :image-root="imageRoot" @addImage="onAddImage" />
<insert-video-modal ref="insertVideoModal" @insertVideo="onInsertVideo" />
</div>
</template>
......@@ -3,7 +3,7 @@ import { defaults } from 'lodash';
import ToolbarItem from '../toolbar_item.vue';
import buildHtmlToMarkdownRenderer from './build_html_to_markdown_renderer';
import buildCustomHTMLRenderer from './build_custom_renderer';
import { TOOLBAR_ITEM_CONFIGS } from '../constants';
import { TOOLBAR_ITEM_CONFIGS, VIDEO_ATTRIBUTES } from '../constants';
import sanitizeHTML from './sanitize_html';
const buildWrapper = propsData => {
......@@ -17,6 +17,23 @@ const buildWrapper = propsData => {
return instance.$el;
};
const buildVideoIframe = src => {
const wrapper = document.createElement('figure');
const iframe = document.createElement('iframe');
const videoAttributes = { ...VIDEO_ATTRIBUTES, src };
const wrapperClasses = ['gl-relative', 'gl-h-0', 'video_container'];
const iframeClasses = ['gl-absolute', 'gl-top-0', 'gl-left-0', 'gl-w-full', 'gl-h-full'];
wrapper.setAttribute('contenteditable', 'false');
wrapper.classList.add(...wrapperClasses);
iframe.classList.add(...iframeClasses);
Object.assign(iframe, videoAttributes);
wrapper.appendChild(iframe);
return wrapper;
};
export const generateToolbarItem = config => {
const { icon, classes, event, command, tooltip, isDivider } = config;
......@@ -44,6 +61,16 @@ export const removeCustomEventListener = (editorApi, event, handler) =>
export const addImage = ({ editor }, image) => editor.exec('AddImage', image);
export const insertVideo = ({ editor }, url) => {
const videoIframe = buildVideoIframe(url);
if (editor.isWysiwygMode()) {
editor.getSquire().insertElement(videoIframe);
} else {
editor.insertText(videoIframe.outerHTML);
}
};
export const getMarkdown = editorInstance => editorInstance.invoke('getMarkdown');
/**
......
......@@ -44,3 +44,11 @@
@include gl-line-height-20;
}
}
/**
* Styling below ensures that YouTube videos are displayed in the editor the same as they would in about.gitlab.com
* https://gitlab.com/gitlab-com/www-gitlab-com/-/blob/master/source/stylesheets/_base.scss#L977
*/
.video_container {
padding-bottom: 56.25%;
}
---
title: Add the ability to insert a YouTube video
merge_request: 44102
author:
type: added
......@@ -115,6 +115,17 @@ company and a new feature has been added to the company product.
1. You edit the file right there and click **Submit changes**.
1. A new merge request is automatically created and you assign it to your colleague for review.
## Videos
> - Support for embedding YouTube videos through the WYSIWYG editor [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/216642) in GitLab 13.5.
You can embed YouTube videos on the WYSIWYG mode by clicking the video icon (**{live-preview}**).
The following URL/ID formats are supported:
- YouTube watch URL (e.g. `https://www.youtube.com/watch?v=0t1DgySidms`)
- YouTube embed URL (e.g. `https://www.youtube.com/embed/0t1DgySidms`)
- YouTube video ID (e.g. `0t1DgySidms`)
## Limitations
- The Static Site Editor still cannot be quickly added to existing Middleman sites. Follow this [epic](https://gitlab.com/groups/gitlab-org/-/epics/2784) for updates.
......@@ -1051,6 +1051,9 @@ msgstr ""
msgid "0 for unlimited, only effective with remote storage enabled."
msgstr ""
msgid "0t1DgySidms"
msgstr ""
msgid "1 %{type} addition"
msgid_plural "%{count} %{type} additions"
msgstr[0] ""
......@@ -13549,6 +13552,9 @@ msgstr ""
msgid "If enabled, access to projects will be validated on an external service using their classification label."
msgstr ""
msgid "If the YouTube URL is https://www.youtube.com/watch?v=0t1DgySidms then the video ID is %{id}"
msgstr ""
msgid "If the number of active users exceeds the user limit, you will be charged for the number of %{users_over_license_link} at your next license reconciliation."
msgstr ""
......@@ -14021,6 +14027,9 @@ msgstr ""
msgid "Insert a quote"
msgstr ""
msgid "Insert a video"
msgstr ""
msgid "Insert an image"
msgstr ""
......@@ -14036,6 +14045,9 @@ msgstr ""
msgid "Insert suggestion"
msgstr ""
msgid "Insert video"
msgstr ""
msgid "Insights"
msgstr ""
......@@ -19546,6 +19558,9 @@ msgstr ""
msgid "Please provide a valid URL"
msgstr ""
msgid "Please provide a valid YouTube URL or ID"
msgstr ""
msgid "Please provide a valid email address."
msgstr ""
......@@ -30300,6 +30315,9 @@ msgstr ""
msgid "YouTube"
msgstr ""
msgid "YouTube URL or ID"
msgstr ""
msgid "Your %{host} account was signed in to from a new location"
msgstr ""
......
......@@ -4,6 +4,7 @@ import {
removeCustomEventListener,
registerHTMLToMarkdownRenderer,
addImage,
insertVideo,
getMarkdown,
getEditorOptions,
} from '~/vue_shared/components/rich_content_editor/services/editor_service';
......@@ -19,11 +20,21 @@ describe('Editor Service', () => {
let mockInstance;
let event;
let handler;
const parseHtml = str => {
const wrapper = document.createElement('div');
wrapper.innerHTML = str;
return wrapper.firstChild;
};
beforeEach(() => {
mockInstance = {
eventManager: { addEventType: jest.fn(), removeEventHandler: jest.fn(), listen: jest.fn() },
editor: { exec: jest.fn() },
editor: {
exec: jest.fn(),
isWysiwygMode: jest.fn(),
getSquire: jest.fn(),
insertText: jest.fn(),
},
invoke: jest.fn(),
toMarkOptions: {
renderer: {
......@@ -89,6 +100,38 @@ describe('Editor Service', () => {
});
});
describe('insertVideo', () => {
const mockUrl = 'some/url';
const htmlString = `<figure contenteditable="false" class="gl-relative gl-h-0 video_container"><iframe class="gl-absolute gl-top-0 gl-left-0 gl-w-full gl-h-full" width="560" height="315" frameborder="0" src="some/url"></iframe></figure>`;
const mockInsertElement = jest.fn();
beforeEach(() =>
mockInstance.editor.getSquire.mockReturnValue({ insertElement: mockInsertElement }),
);
describe('WYSIWYG mode', () => {
it('calls the insertElement method on the squire instance with an iFrame element', () => {
mockInstance.editor.isWysiwygMode.mockReturnValue(true);
insertVideo(mockInstance, mockUrl);
expect(mockInstance.editor.getSquire().insertElement).toHaveBeenCalledWith(
parseHtml(htmlString),
);
});
});
describe('Markdown mode', () => {
it('calls the insertText method on the editor instance with the iFrame element HTML', () => {
mockInstance.editor.isWysiwygMode.mockReturnValue(false);
insertVideo(mockInstance, mockUrl);
expect(mockInstance.editor.insertText).toHaveBeenCalledWith(htmlString);
});
});
});
describe('getMarkdown', () => {
it('calls the invoke method on the instance', () => {
getMarkdown(mockInstance);
......
import { shallowMount } from '@vue/test-utils';
import { GlModal } from '@gitlab/ui';
import InsertVideoModal from '~/vue_shared/components/rich_content_editor/modals/insert_video_modal.vue';
describe('Insert Video Modal', () => {
let wrapper;
const findModal = () => wrapper.find(GlModal);
const findUrlInput = () => wrapper.find({ ref: 'urlInput' });
const triggerInsertVideo = url => {
const preventDefault = jest.fn();
findUrlInput().vm.$emit('input', url);
findModal().vm.$emit('primary', { preventDefault });
};
beforeEach(() => {
wrapper = shallowMount(InsertVideoModal);
});
afterEach(() => wrapper.destroy());
describe('when content is loaded', () => {
it('renders a modal component', () => {
expect(findModal().exists()).toBe(true);
});
it('renders an input to add a URL', () => {
expect(findUrlInput().exists()).toBe(true);
});
});
describe('insert video', () => {
it.each`
url | emitted
${'https://www.youtube.com/embed/someId'} | ${[['https://www.youtube.com/embed/someId']]}
${'https://www.youtube.com/watch?v=1234'} | ${[['https://www.youtube.com/embed/1234']]}
${'::youtube.com/invalid/url'} | ${undefined}
`('formats the url correctly', ({ url, emitted }) => {
triggerInsertVideo(url);
expect(wrapper.emitted('insertVideo')).toEqual(emitted);
});
});
});
import { shallowMount } from '@vue/test-utils';
import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_content_editor.vue';
import AddImageModal from '~/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue';
import InsertVideoModal from '~/vue_shared/components/rich_content_editor/modals/insert_video_modal.vue';
import {
EDITOR_TYPES,
EDITOR_HEIGHT,
......@@ -12,6 +13,7 @@ import {
addCustomEventListener,
removeCustomEventListener,
addImage,
insertVideo,
registerHTMLToMarkdownRenderer,
getEditorOptions,
} from '~/vue_shared/components/rich_content_editor/services/editor_service';
......@@ -21,6 +23,7 @@ jest.mock('~/vue_shared/components/rich_content_editor/services/editor_service',
addCustomEventListener: jest.fn(),
removeCustomEventListener: jest.fn(),
addImage: jest.fn(),
insertVideo: jest.fn(),
registerHTMLToMarkdownRenderer: jest.fn(),
getEditorOptions: jest.fn(),
}));
......@@ -32,6 +35,7 @@ describe('Rich Content Editor', () => {
const imageRoot = 'path/to/root/';
const findEditor = () => wrapper.find({ ref: 'editor' });
const findAddImageModal = () => wrapper.find(AddImageModal);
const findInsertVideoModal = () => wrapper.find(InsertVideoModal);
const buildWrapper = () => {
wrapper = shallowMount(RichContentEditor, {
......@@ -122,6 +126,14 @@ describe('Rich Content Editor', () => {
);
});
it('adds the CUSTOM_EVENTS.openInsertVideoModal custom event listener', () => {
expect(addCustomEventListener).toHaveBeenCalledWith(
wrapper.vm.editorApi,
CUSTOM_EVENTS.openInsertVideoModal,
wrapper.vm.onOpenInsertVideoModal,
);
});
it('registers HTML to markdown renderer', () => {
expect(registerHTMLToMarkdownRenderer).toHaveBeenCalledWith(wrapper.vm.editorApi);
});
......@@ -141,6 +153,16 @@ describe('Rich Content Editor', () => {
wrapper.vm.onOpenAddImageModal,
);
});
it('removes the CUSTOM_EVENTS.openInsertVideoModal custom event listener', () => {
wrapper.vm.$destroy();
expect(removeCustomEventListener).toHaveBeenCalledWith(
wrapper.vm.editorApi,
CUSTOM_EVENTS.openInsertVideoModal,
wrapper.vm.onOpenInsertVideoModal,
);
});
});
describe('add image modal', () => {
......@@ -161,4 +183,23 @@ describe('Rich Content Editor', () => {
expect(addImage).toHaveBeenCalledWith(mockInstance, mockImage);
});
});
describe('insert video modal', () => {
beforeEach(() => {
buildWrapper();
});
it('renders an insertVideoModal component', () => {
expect(findInsertVideoModal().exists()).toBe(true);
});
it('calls the onInsertVideo method when the insertVideo event is emitted', () => {
const mockUrl = 'https://www.youtube.com/embed/someId';
const mockInstance = { exec: jest.fn() };
wrapper.vm.$refs.editor = mockInstance;
findInsertVideoModal().vm.$emit('insertVideo', mockUrl);
expect(insertVideo).toHaveBeenCalledWith(mockInstance, mockUrl);
});
});
});
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