summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/vue_shared/components/rich_content_editor
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-06-18 11:18:50 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2020-06-18 11:18:50 +0000
commit8c7f4e9d5f36cff46365a7f8c4b9c21578c1e781 (patch)
treea77e7fe7a93de11213032ed4ab1f33a3db51b738 /app/assets/javascripts/vue_shared/components/rich_content_editor
parent00b35af3db1abfe813a778f643dad221aad51fca (diff)
downloadgitlab-ce-8c7f4e9d5f36cff46365a7f8c4b9c21578c1e781.tar.gz
Add latest changes from gitlab-org/gitlab@13-1-stable-ee
Diffstat (limited to 'app/assets/javascripts/vue_shared/components/rich_content_editor')
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js10
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/editor_service.js42
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image_modal.vue74
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue97
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue13
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_service.js32
6 files changed, 218 insertions, 50 deletions
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js
index 457f1806452..1566c2c784b 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js
@@ -1,5 +1,9 @@
import { __ } from '~/locale';
-import { generateToolbarItem } from './toolbar_service';
+import { generateToolbarItem } from './editor_service';
+
+export const CUSTOM_EVENTS = {
+ openAddImageModal: 'gl_openAddImageModal',
+};
/* eslint-disable @gitlab/require-i18n-strings */
const TOOLBAR_ITEM_CONFIGS = [
@@ -10,7 +14,6 @@ const TOOLBAR_ITEM_CONFIGS = [
{ isDivider: true },
{ icon: 'quote', command: 'Blockquote', tooltip: __('Insert a quote') },
{ icon: 'link', event: 'openPopupAddLink', tooltip: __('Add a link') },
- { icon: 'doc-code', command: 'CodeBlock', tooltip: __('Insert a code block') },
{ isDivider: true },
{ icon: 'list-bulleted', command: 'UL', tooltip: __('Add a bullet list') },
{ icon: 'list-numbered', command: 'OL', tooltip: __('Add a numbered list') },
@@ -20,8 +23,10 @@ const TOOLBAR_ITEM_CONFIGS = [
{ isDivider: true },
{ 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') },
{ isDivider: true },
{ icon: 'code', command: 'Code', tooltip: __('Insert inline code') },
+ { icon: 'doc-code', command: 'CodeBlock', tooltip: __('Insert a code block') },
];
export const EDITOR_OPTIONS = {
@@ -29,6 +34,7 @@ export const EDITOR_OPTIONS = {
};
export const EDITOR_TYPES = {
+ markdown: 'markdown',
wysiwyg: 'wysiwyg',
};
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/editor_service.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/editor_service.js
new file mode 100644
index 00000000000..278cd50a947
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/editor_service.js
@@ -0,0 +1,42 @@
+import Vue from 'vue';
+import ToolbarItem from './toolbar_item.vue';
+
+const buildWrapper = propsData => {
+ const instance = new Vue({
+ render(createElement) {
+ return createElement(ToolbarItem, propsData);
+ },
+ });
+
+ instance.$mount();
+ return instance.$el;
+};
+
+export const generateToolbarItem = config => {
+ const { icon, classes, event, command, tooltip, isDivider } = config;
+
+ if (isDivider) {
+ return 'divider';
+ }
+
+ return {
+ type: 'button',
+ options: {
+ el: buildWrapper({ props: { icon, tooltip }, class: classes }),
+ event,
+ command,
+ },
+ };
+};
+
+export const addCustomEventListener = (editorApi, event, handler) => {
+ editorApi.eventManager.addEventType(event);
+ editorApi.eventManager.listen(event, handler);
+};
+
+export const removeCustomEventListener = (editorApi, event, handler) =>
+ editorApi.eventManager.removeEventHandler(event, handler);
+
+export const addImage = ({ editor }, image) => editor.exec('AddImage', image);
+
+export const getMarkdown = editorInstance => editorInstance.invoke('getMarkdown');
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image_modal.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image_modal.vue
new file mode 100644
index 00000000000..40063065926
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image_modal.vue
@@ -0,0 +1,74 @@
+<script>
+import { isSafeURL } from '~/lib/utils/url_utility';
+import { GlModal, GlFormGroup, GlFormInput } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ components: {
+ GlModal,
+ GlFormGroup,
+ GlFormInput,
+ },
+ data() {
+ return {
+ error: null,
+ imageUrl: null,
+ altText: null,
+ modalTitle: __('Image Details'),
+ okTitle: __('Insert'),
+ urlLabel: __('Image URL'),
+ descriptionLabel: __('Description'),
+ };
+ },
+ methods: {
+ show() {
+ this.error = null;
+ this.imageUrl = null;
+ this.altText = null;
+
+ this.$refs.modal.show();
+ },
+ onOk(event) {
+ if (!this.isValid()) {
+ event.preventDefault();
+ return;
+ }
+
+ const { imageUrl, altText } = this;
+
+ this.$emit('addImage', { imageUrl, altText: altText || __('image') });
+ },
+ isValid() {
+ if (!isSafeURL(this.imageUrl)) {
+ this.error = __('Please provide a valid URL');
+ this.$refs.urlInput.$el.focus();
+ return false;
+ }
+
+ return true;
+ },
+ },
+};
+</script>
+<template>
+ <gl-modal
+ ref="modal"
+ modal-id="add-image-modal"
+ :title="modalTitle"
+ :ok-title="okTitle"
+ @ok="onOk"
+ >
+ <gl-form-group
+ :label="urlLabel"
+ label-for="url-input"
+ :state="!Boolean(error)"
+ :invalid-feedback="error"
+ >
+ <gl-form-input id="url-input" ref="urlInput" v-model="imageUrl" />
+ </gl-form-group>
+
+ <gl-form-group :label="descriptionLabel" label-for="description-input">
+ <gl-form-input id="description-input" ref="descriptionInput" v-model="altText" />
+ </gl-form-group>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue
index ba3696c8ad1..5c310fc059b 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue
@@ -2,7 +2,21 @@
import 'codemirror/lib/codemirror.css';
import '@toast-ui/editor/dist/toastui-editor.css';
-import { EDITOR_OPTIONS, EDITOR_TYPES, EDITOR_HEIGHT, EDITOR_PREVIEW_STYLE } from './constants';
+import AddImageModal from './modals/add_image_modal.vue';
+import {
+ EDITOR_OPTIONS,
+ EDITOR_TYPES,
+ EDITOR_HEIGHT,
+ EDITOR_PREVIEW_STYLE,
+ CUSTOM_EVENTS,
+} from './constants';
+
+import {
+ addCustomEventListener,
+ removeCustomEventListener,
+ addImage,
+ getMarkdown,
+} from './editor_service';
export default {
components: {
@@ -10,6 +24,7 @@ export default {
import(/* webpackChunkName: 'toast_editor' */ '@toast-ui/vue-editor').then(
toast => toast.Editor,
),
+ AddImageModal,
},
props: {
value: {
@@ -37,29 +52,85 @@ export default {
default: EDITOR_PREVIEW_STYLE,
},
},
+ data() {
+ return {
+ editorApi: null,
+ previousMode: null,
+ };
+ },
computed: {
editorOptions() {
return { ...EDITOR_OPTIONS, ...this.options };
},
+ editorInstance() {
+ return this.$refs.editor;
+ },
+ },
+ watch: {
+ value(newVal) {
+ const isSameMode = this.previousMode === this.editorApi.currentMode;
+ if (!isSameMode) {
+ /*
+ The ToastUI Editor consumes its content via the `initial-value` prop and then internally
+ manages changes. If we desire the `v-model` to work as expected, we need to manually call
+ `setMarkdown`. However, if we do this in each v-model change we'll continually prevent
+ the editor from internally managing changes. Thus we use the `previousMode` flag as
+ confirmation to actually update its internals. This is initially designed so that front
+ matter is excluded from editing in wysiwyg mode, but included in markdown mode.
+ */
+ this.editorInstance.invoke('setMarkdown', newVal);
+ this.previousMode = this.editorApi.currentMode;
+ }
+ },
+ },
+ beforeDestroy() {
+ removeCustomEventListener(
+ this.editorApi,
+ CUSTOM_EVENTS.openAddImageModal,
+ this.onOpenAddImageModal,
+ );
+
+ this.editorApi.eventManager.removeEventHandler('changeMode', this.onChangeMode);
},
methods: {
onContentChanged() {
- this.$emit('input', this.getMarkdown());
+ this.$emit('input', getMarkdown(this.editorInstance));
+ },
+ onLoad(editorApi) {
+ this.editorApi = editorApi;
+
+ addCustomEventListener(
+ this.editorApi,
+ CUSTOM_EVENTS.openAddImageModal,
+ this.onOpenAddImageModal,
+ );
+
+ this.editorApi.eventManager.listen('changeMode', this.onChangeMode);
+ },
+ onOpenAddImageModal() {
+ this.$refs.addImageModal.show();
+ },
+ onAddImage(image) {
+ addImage(this.editorInstance, image);
},
- getMarkdown() {
- return this.$refs.editor.invoke('getMarkdown');
+ onChangeMode(newMode) {
+ this.$emit('modeChange', newMode);
},
},
};
</script>
<template>
- <toast-editor
- ref="editor"
- :initial-value="value"
- :options="editorOptions"
- :preview-style="previewStyle"
- :initial-edit-type="initialEditType"
- :height="height"
- @change="onContentChanged"
- />
+ <div>
+ <toast-editor
+ ref="editor"
+ :initial-value="value"
+ :options="editorOptions"
+ :preview-style="previewStyle"
+ :initial-edit-type="initialEditType"
+ :height="height"
+ @change="onContentChanged"
+ @load="onLoad"
+ />
+ <add-image-modal ref="addImageModal" @addImage="onAddImage" />
+ </div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue
index 58aaeef45f2..4271f6053ed 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue
@@ -1,20 +1,27 @@
<script>
-import { GlIcon } from '@gitlab/ui';
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
export default {
components: {
GlIcon,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
props: {
icon: {
type: String,
required: true,
},
+ tooltip: {
+ type: String,
+ required: true,
+ },
},
};
</script>
<template>
- <button class="p-0 gl-display-flex toolbar-button">
- <gl-icon class="gl-mx-auto" :name="icon" />
+ <button v-gl-tooltip="{ title: tooltip }" class="p-0 gl-display-flex toolbar-button">
+ <gl-icon class="gl-mx-auto gl-align-self-center" :name="icon" />
</button>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_service.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_service.js
deleted file mode 100644
index fff90f3e3fb..00000000000
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_service.js
+++ /dev/null
@@ -1,32 +0,0 @@
-import Vue from 'vue';
-import ToolbarItem from './toolbar_item.vue';
-
-const buildWrapper = propsData => {
- const instance = new Vue({
- render(createElement) {
- return createElement(ToolbarItem, propsData);
- },
- });
-
- instance.$mount();
- return instance.$el;
-};
-
-// eslint-disable-next-line import/prefer-default-export
-export const generateToolbarItem = config => {
- const { icon, classes, event, command, tooltip, isDivider } = config;
-
- if (isDivider) {
- return 'divider';
- }
-
- return {
- type: 'button',
- options: {
- el: buildWrapper({ props: { icon }, class: classes }),
- event,
- command,
- tooltip,
- },
- };
-};