summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/content_editor
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/content_editor')
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor.vue108
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor_error.vue31
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor_provider.vue24
-rw-r--r--app/assets/javascripts/content_editor/components/editor_state_observer.vue40
-rw-r--r--app/assets/javascripts/content_editor/components/formatting_bubble_menu.vue67
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_button.vue58
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_image_button.vue19
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_link_button.vue110
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_table_button.vue50
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue60
-rw-r--r--app/assets/javascripts/content_editor/components/top_toolbar.vue53
-rw-r--r--app/assets/javascripts/content_editor/constants.js5
-rw-r--r--app/assets/javascripts/content_editor/extensions/attachment.js53
-rw-r--r--app/assets/javascripts/content_editor/extensions/blockquote.js6
-rw-r--r--app/assets/javascripts/content_editor/extensions/bold.js6
-rw-r--r--app/assets/javascripts/content_editor/extensions/bullet_list.js6
-rw-r--r--app/assets/javascripts/content_editor/extensions/code.js6
-rw-r--r--app/assets/javascripts/content_editor/extensions/code_block_highlight.js18
-rw-r--r--app/assets/javascripts/content_editor/extensions/document.js4
-rw-r--r--app/assets/javascripts/content_editor/extensions/dropcursor.js4
-rw-r--r--app/assets/javascripts/content_editor/extensions/emoji.js93
-rw-r--r--app/assets/javascripts/content_editor/extensions/gapcursor.js4
-rw-r--r--app/assets/javascripts/content_editor/extensions/hard_break.js6
-rw-r--r--app/assets/javascripts/content_editor/extensions/heading.js6
-rw-r--r--app/assets/javascripts/content_editor/extensions/history.js4
-rw-r--r--app/assets/javascripts/content_editor/extensions/horizontal_rule.js4
-rw-r--r--app/assets/javascripts/content_editor/extensions/image.js103
-rw-r--r--app/assets/javascripts/content_editor/extensions/inline_diff.js50
-rw-r--r--app/assets/javascripts/content_editor/extensions/italic.js5
-rw-r--r--app/assets/javascripts/content_editor/extensions/link.js18
-rw-r--r--app/assets/javascripts/content_editor/extensions/list_item.js6
-rw-r--r--app/assets/javascripts/content_editor/extensions/loading.js24
-rw-r--r--app/assets/javascripts/content_editor/extensions/ordered_list.js6
-rw-r--r--app/assets/javascripts/content_editor/extensions/paragraph.js6
-rw-r--r--app/assets/javascripts/content_editor/extensions/reference.js78
-rw-r--r--app/assets/javascripts/content_editor/extensions/strike.js10
-rw-r--r--app/assets/javascripts/content_editor/extensions/subscript.js1
-rw-r--r--app/assets/javascripts/content_editor/extensions/superscript.js1
-rw-r--r--app/assets/javascripts/content_editor/extensions/table.js8
-rw-r--r--app/assets/javascripts/content_editor/extensions/table_cell.js6
-rw-r--r--app/assets/javascripts/content_editor/extensions/table_header.js6
-rw-r--r--app/assets/javascripts/content_editor/extensions/table_row.js48
-rw-r--r--app/assets/javascripts/content_editor/extensions/task_item.js33
-rw-r--r--app/assets/javascripts/content_editor/extensions/task_list.js30
-rw-r--r--app/assets/javascripts/content_editor/extensions/text.js6
-rw-r--r--app/assets/javascripts/content_editor/services/build_serializer_config.js22
-rw-r--r--app/assets/javascripts/content_editor/services/content_editor.js42
-rw-r--r--app/assets/javascripts/content_editor/services/create_content_editor.js78
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_serializer.js174
-rw-r--r--app/assets/javascripts/content_editor/services/track_ui_control.js9
-rw-r--r--app/assets/javascripts/content_editor/services/upload_file.js44
-rw-r--r--app/assets/javascripts/content_editor/services/upload_helpers.js123
-rw-r--r--app/assets/javascripts/content_editor/services/utils.js14
53 files changed, 1226 insertions, 570 deletions
diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue
index 9a51def7075..a372233e543 100644
--- a/app/assets/javascripts/content_editor/components/content_editor.vue
+++ b/app/assets/javascripts/content_editor/components/content_editor.vue
@@ -1,45 +1,111 @@
<script>
-import { GlAlert } from '@gitlab/ui';
+import { GlLoadingIcon } from '@gitlab/ui';
import { EditorContent as TiptapEditorContent } from '@tiptap/vue-2';
-import { ContentEditor } from '../services/content_editor';
+import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } from '../constants';
+import { createContentEditor } from '../services/create_content_editor';
+import ContentEditorError from './content_editor_error.vue';
+import ContentEditorProvider from './content_editor_provider.vue';
+import EditorStateObserver from './editor_state_observer.vue';
+import FormattingBubbleMenu from './formatting_bubble_menu.vue';
import TopToolbar from './top_toolbar.vue';
export default {
components: {
- GlAlert,
+ GlLoadingIcon,
+ ContentEditorError,
+ ContentEditorProvider,
TiptapEditorContent,
TopToolbar,
+ FormattingBubbleMenu,
+ EditorStateObserver,
},
props: {
- contentEditor: {
- type: ContentEditor,
+ renderMarkdown: {
+ type: Function,
required: true,
},
+ uploadsPath: {
+ type: String,
+ required: true,
+ },
+ extensions: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ serializerConfig: {
+ type: Object,
+ required: false,
+ default: () => {},
+ },
},
data() {
return {
- error: '',
+ isLoadingContent: false,
+ focused: false,
};
},
- mounted() {
- this.contentEditor.tiptapEditor.on('error', (error) => {
- this.error = error;
+ created() {
+ const { renderMarkdown, uploadsPath, extensions, serializerConfig } = this;
+
+ // This is a non-reactive attribute intentionally since this is a complex object.
+ this.contentEditor = createContentEditor({
+ renderMarkdown,
+ uploadsPath,
+ extensions,
+ serializerConfig,
});
+
+ this.contentEditor.on(LOADING_CONTENT_EVENT, this.displayLoadingIndicator);
+ this.contentEditor.on(LOADING_SUCCESS_EVENT, this.hideLoadingIndicator);
+ this.contentEditor.on(LOADING_ERROR_EVENT, this.hideLoadingIndicator);
+ this.$emit('initialized', this.contentEditor);
+ },
+ beforeDestroy() {
+ this.contentEditor.dispose();
+ this.contentEditor.off(LOADING_CONTENT_EVENT, this.displayLoadingIndicator);
+ this.contentEditor.off(LOADING_SUCCESS_EVENT, this.hideLoadingIndicator);
+ this.contentEditor.off(LOADING_ERROR_EVENT, this.hideLoadingIndicator);
+ },
+ methods: {
+ displayLoadingIndicator() {
+ this.isLoadingContent = true;
+ },
+ hideLoadingIndicator() {
+ this.isLoadingContent = false;
+ },
+ focus() {
+ this.focused = true;
+ },
+ blur() {
+ this.focused = false;
+ },
+ notifyChange() {
+ this.$emit('change', {
+ empty: this.contentEditor.empty,
+ });
+ },
},
};
</script>
<template>
- <div>
- <gl-alert v-if="error" class="gl-mb-6" variant="danger" @dismiss="error = ''">
- {{ error }}
- </gl-alert>
- <div
- data-testid="content-editor"
- class="md-area"
- :class="{ 'is-focused': contentEditor.tiptapEditor.isFocused }"
- >
- <top-toolbar ref="toolbar" class="gl-mb-4" :content-editor="contentEditor" />
- <tiptap-editor-content class="md" :editor="contentEditor.tiptapEditor" />
+ <content-editor-provider :content-editor="contentEditor">
+ <div>
+ <editor-state-observer @docUpdate="notifyChange" @focus="focus" @blur="blur" />
+ <content-editor-error />
+ <div
+ data-testid="content-editor"
+ data-qa-selector="content_editor_container"
+ class="md-area"
+ :class="{ 'is-focused': focused }"
+ >
+ <top-toolbar ref="toolbar" class="gl-mb-4" />
+ <formatting-bubble-menu />
+ <div v-if="isLoadingContent" class="gl-w-full gl-display-flex gl-justify-content-center">
+ <gl-loading-icon size="sm" />
+ </div>
+ <tiptap-editor-content v-else class="md" :editor="contentEditor.tiptapEditor" />
+ </div>
</div>
- </div>
+ </content-editor-provider>
</template>
diff --git a/app/assets/javascripts/content_editor/components/content_editor_error.vue b/app/assets/javascripts/content_editor/components/content_editor_error.vue
new file mode 100644
index 00000000000..031ea92a7e9
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/content_editor_error.vue
@@ -0,0 +1,31 @@
+<script>
+import { GlAlert } from '@gitlab/ui';
+import EditorStateObserver from './editor_state_observer.vue';
+
+export default {
+ components: {
+ GlAlert,
+ EditorStateObserver,
+ },
+ data() {
+ return {
+ error: null,
+ };
+ },
+ methods: {
+ displayError({ error }) {
+ this.error = error;
+ },
+ dismissError() {
+ this.error = null;
+ },
+ },
+};
+</script>
+<template>
+ <editor-state-observer @error="displayError">
+ <gl-alert v-if="error" class="gl-mb-6" variant="danger" @dismiss="dismissError">
+ {{ error }}
+ </gl-alert>
+ </editor-state-observer>
+</template>
diff --git a/app/assets/javascripts/content_editor/components/content_editor_provider.vue b/app/assets/javascripts/content_editor/components/content_editor_provider.vue
new file mode 100644
index 00000000000..630aff9858f
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/content_editor_provider.vue
@@ -0,0 +1,24 @@
+<script>
+export default {
+ provide() {
+ // We can't use this.contentEditor due to bug in vue-apollo when
+ // provide is called in beforeCreate
+ // See https://github.com/vuejs/vue-apollo/pull/1153 for details
+ const { contentEditor } = this.$options.propsData;
+
+ return {
+ contentEditor,
+ tiptapEditor: contentEditor.tiptapEditor,
+ };
+ },
+ props: {
+ contentEditor: {
+ type: Object,
+ required: true,
+ },
+ },
+ render() {
+ return this.$slots.default;
+ },
+};
+</script>
diff --git a/app/assets/javascripts/content_editor/components/editor_state_observer.vue b/app/assets/javascripts/content_editor/components/editor_state_observer.vue
new file mode 100644
index 00000000000..2eeb0719096
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/editor_state_observer.vue
@@ -0,0 +1,40 @@
+<script>
+import { debounce } from 'lodash';
+
+export const tiptapToComponentMap = {
+ update: 'docUpdate',
+ selectionUpdate: 'selectionUpdate',
+ transaction: 'transaction',
+ focus: 'focus',
+ blur: 'blur',
+ error: 'error',
+};
+
+const getComponentEventName = (tiptapEventName) => tiptapToComponentMap[tiptapEventName];
+
+export default {
+ inject: ['tiptapEditor'],
+ created() {
+ this.disposables = [];
+
+ Object.keys(tiptapToComponentMap).forEach((tiptapEvent) => {
+ const eventHandler = debounce((params) => this.handleTipTapEvent(tiptapEvent, params), 100);
+
+ this.tiptapEditor?.on(tiptapEvent, eventHandler);
+
+ this.disposables.push(() => this.tiptapEditor?.off(tiptapEvent, eventHandler));
+ });
+ },
+ beforeDestroy() {
+ this.disposables.forEach((dispose) => dispose());
+ },
+ methods: {
+ handleTipTapEvent(tiptapEvent, params) {
+ this.$emit(getComponentEventName(tiptapEvent), params);
+ },
+ },
+ render() {
+ return this.$slots.default;
+ },
+};
+</script>
diff --git a/app/assets/javascripts/content_editor/components/formatting_bubble_menu.vue b/app/assets/javascripts/content_editor/components/formatting_bubble_menu.vue
new file mode 100644
index 00000000000..6c00480b87e
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/formatting_bubble_menu.vue
@@ -0,0 +1,67 @@
+<script>
+import { GlButtonGroup } from '@gitlab/ui';
+import { BubbleMenu } from '@tiptap/vue-2';
+import { BUBBLE_MENU_TRACKING_ACTION } from '../constants';
+import trackUIControl from '../services/track_ui_control';
+import ToolbarButton from './toolbar_button.vue';
+
+export default {
+ components: {
+ BubbleMenu,
+ GlButtonGroup,
+ ToolbarButton,
+ },
+ inject: ['tiptapEditor'],
+ methods: {
+ trackToolbarControlExecution({ contentType, value }) {
+ trackUIControl({ action: BUBBLE_MENU_TRACKING_ACTION, property: contentType, value });
+ },
+ },
+};
+</script>
+<template>
+ <bubble-menu class="gl-shadow gl-rounded-base" :editor="tiptapEditor">
+ <gl-button-group>
+ <toolbar-button
+ data-testid="bold"
+ content-type="bold"
+ icon-name="bold"
+ editor-command="toggleBold"
+ category="primary"
+ size="medium"
+ :label="__('Bold text')"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="italic"
+ content-type="italic"
+ icon-name="italic"
+ editor-command="toggleItalic"
+ category="primary"
+ size="medium"
+ :label="__('Italic text')"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="strike"
+ content-type="strike"
+ icon-name="strikethrough"
+ editor-command="toggleStrike"
+ category="primary"
+ size="medium"
+ :label="__('Strikethrough')"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="code"
+ content-type="code"
+ icon-name="code"
+ editor-command="toggleCode"
+ category="primary"
+ size="medium"
+ :label="__('Code')"
+ @execute="trackToolbarControlExecution"
+ />
+ </gl-button-group>
+ </bubble-menu>
+</template>
diff --git a/app/assets/javascripts/content_editor/components/toolbar_button.vue b/app/assets/javascripts/content_editor/components/toolbar_button.vue
index 0af12812f3b..cdb877152d4 100644
--- a/app/assets/javascripts/content_editor/components/toolbar_button.vue
+++ b/app/assets/javascripts/content_editor/components/toolbar_button.vue
@@ -1,23 +1,21 @@
<script>
import { GlButton, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
-import { Editor as TiptapEditor } from '@tiptap/vue-2';
+import EditorStateObserver from './editor_state_observer.vue';
export default {
components: {
GlButton,
+ EditorStateObserver,
},
directives: {
GlTooltip,
},
+ inject: ['tiptapEditor'],
props: {
iconName: {
type: String,
required: true,
},
- tiptapEditor: {
- type: TiptapEditor,
- required: true,
- },
contentType: {
type: String,
required: true,
@@ -31,13 +29,31 @@ export default {
required: false,
default: '',
},
- },
- computed: {
- isActive() {
- return this.tiptapEditor.isActive(this.contentType) && this.tiptapEditor.isFocused;
+ variant: {
+ type: String,
+ required: false,
+ default: 'default',
},
+ category: {
+ type: String,
+ required: false,
+ default: 'tertiary',
+ },
+ size: {
+ type: String,
+ required: false,
+ default: 'small',
+ },
+ },
+ data() {
+ return {
+ isActive: null,
+ };
},
methods: {
+ updateActive({ editor }) {
+ this.isActive = editor.isActive(this.contentType) && editor.isFocused;
+ },
execute() {
const { contentType } = this;
@@ -51,15 +67,17 @@ export default {
};
</script>
<template>
- <gl-button
- v-gl-tooltip
- category="tertiary"
- size="small"
- class="gl-mx-2"
- :class="{ active: isActive }"
- :aria-label="label"
- :title="label"
- :icon="iconName"
- @click="execute"
- />
+ <editor-state-observer @transaction="updateActive">
+ <gl-button
+ v-gl-tooltip
+ :variant="variant"
+ :category="category"
+ :size="size"
+ :class="{ active: isActive }"
+ :aria-label="label"
+ :title="label"
+ :icon="iconName"
+ @click="execute"
+ />
+ </editor-state-observer>
</template>
diff --git a/app/assets/javascripts/content_editor/components/toolbar_image_button.vue b/app/assets/javascripts/content_editor/components/toolbar_image_button.vue
index ebeee16dbec..649e23c29aa 100644
--- a/app/assets/javascripts/content_editor/components/toolbar_image_button.vue
+++ b/app/assets/javascripts/content_editor/components/toolbar_image_button.vue
@@ -8,9 +8,8 @@ import {
GlDropdownItem,
GlTooltipDirective as GlTooltip,
} from '@gitlab/ui';
-import { Editor as TiptapEditor } from '@tiptap/vue-2';
-import { acceptedMimes } from '../extensions/image';
-import { getImageAlt } from '../services/utils';
+import { acceptedMimes } from '../services/upload_helpers';
+import { extractFilename } from '../services/utils';
export default {
components: {
@@ -24,12 +23,7 @@ export default {
directives: {
GlTooltip,
},
- props: {
- tiptapEditor: {
- type: TiptapEditor,
- required: true,
- },
- },
+ inject: ['tiptapEditor'],
data() {
return {
imgSrc: '',
@@ -47,7 +41,7 @@ export default {
.setImage({
src: this.imgSrc,
canonicalSrc: this.imgSrc,
- alt: getImageAlt(this.imgSrc),
+ alt: extractFilename(this.imgSrc),
})
.run();
@@ -64,7 +58,7 @@ export default {
this.tiptapEditor
.chain()
.focus()
- .uploadImage({
+ .uploadAttachment({
file: e.target.files[0],
})
.run();
@@ -73,7 +67,7 @@ export default {
this.emitExecute('upload');
},
},
- acceptedMimes,
+ acceptedMimes: acceptedMimes.image,
};
</script>
<template>
@@ -104,6 +98,7 @@ export default {
name="content_editor_image"
:accept="$options.acceptedMimes"
class="gl-display-none"
+ data-qa-selector="file_upload_field"
@change="onFileSelect"
/>
</gl-dropdown>
diff --git a/app/assets/javascripts/content_editor/components/toolbar_link_button.vue b/app/assets/javascripts/content_editor/components/toolbar_link_button.vue
index 8f57959a73f..ff525e52873 100644
--- a/app/assets/javascripts/content_editor/components/toolbar_link_button.vue
+++ b/app/assets/javascripts/content_editor/components/toolbar_link_button.vue
@@ -8,10 +8,9 @@ import {
GlDropdownItem,
GlTooltipDirective as GlTooltip,
} from '@gitlab/ui';
-import { Editor as TiptapEditor } from '@tiptap/vue-2';
+import Link from '../extensions/link';
import { hasSelection } from '../services/utils';
-
-export const linkContentType = 'link';
+import EditorStateObserver from './editor_state_observer.vue';
export default {
components: {
@@ -21,34 +20,32 @@ export default {
GlDropdownDivider,
GlDropdownItem,
GlButton,
+ EditorStateObserver,
},
directives: {
GlTooltip,
},
- props: {
- tiptapEditor: {
- type: TiptapEditor,
- required: true,
- },
- },
+ inject: ['tiptapEditor'],
data() {
return {
linkHref: '',
+ isActive: false,
};
},
- computed: {
- isActive() {
- return this.tiptapEditor.isActive(linkContentType);
+ methods: {
+ resetFields() {
+ this.imgSrc = '';
+ this.$refs.fileSelector.value = '';
},
- },
- mounted() {
- this.tiptapEditor.on('selectionUpdate', ({ editor }) => {
- const { canonicalSrc, href } = editor.getAttributes(linkContentType);
+ openFileUpload() {
+ this.$refs.fileSelector.click();
+ },
+ updateLinkState({ editor }) {
+ const { canonicalSrc, href } = editor.getAttributes(Link.name);
+ this.isActive = editor.isActive(Link.name);
this.linkHref = canonicalSrc || href;
- });
- },
- methods: {
+ },
updateLink() {
this.tiptapEditor
.chain()
@@ -60,45 +57,70 @@ export default {
})
.run();
- this.$emit('execute', { contentType: linkContentType });
+ this.$emit('execute', { contentType: Link.name });
},
selectLink() {
const { tiptapEditor } = this;
// a selection has already been made by the user, so do nothing
if (!hasSelection(tiptapEditor)) {
- tiptapEditor.chain().focus().extendMarkRange(linkContentType).run();
+ tiptapEditor.chain().focus().extendMarkRange(Link.name).run();
}
},
removeLink() {
this.tiptapEditor.chain().focus().unsetLink().run();
- this.$emit('execute', { contentType: linkContentType });
+ this.$emit('execute', { contentType: Link.name });
+ },
+ onFileSelect(e) {
+ this.tiptapEditor
+ .chain()
+ .focus()
+ .uploadAttachment({
+ file: e.target.files[0],
+ })
+ .run();
+
+ this.resetFields();
+ this.$emit('execute', { contentType: Link.name });
},
},
};
</script>
<template>
- <gl-dropdown
- v-gl-tooltip
- :aria-label="__('Insert link')"
- :title="__('Insert link')"
- :toggle-class="{ active: isActive }"
- size="small"
- category="tertiary"
- icon="link"
- @show="selectLink()"
- >
- <gl-dropdown-form class="gl-px-3!">
- <gl-form-input-group v-model="linkHref" :placeholder="__('Link URL')">
- <template #append>
- <gl-button variant="confirm" @click="updateLink()">{{ __('Apply') }}</gl-button>
- </template>
- </gl-form-input-group>
- </gl-dropdown-form>
- <gl-dropdown-divider v-if="isActive" />
- <gl-dropdown-item v-if="isActive" @click="removeLink()">
- {{ __('Remove link') }}
- </gl-dropdown-item>
- </gl-dropdown>
+ <editor-state-observer @transaction="updateLinkState">
+ <gl-dropdown
+ v-gl-tooltip
+ :aria-label="__('Insert link')"
+ :title="__('Insert link')"
+ :toggle-class="{ active: isActive }"
+ size="small"
+ category="tertiary"
+ icon="link"
+ @show="selectLink()"
+ >
+ <gl-dropdown-form class="gl-px-3!">
+ <gl-form-input-group v-model="linkHref" :placeholder="__('Link URL')">
+ <template #append>
+ <gl-button variant="confirm" @click="updateLink">{{ __('Apply') }}</gl-button>
+ </template>
+ </gl-form-input-group>
+ </gl-dropdown-form>
+ <gl-dropdown-divider />
+ <gl-dropdown-item v-if="isActive" @click="removeLink">
+ {{ __('Remove link') }}
+ </gl-dropdown-item>
+ <gl-dropdown-item v-else @click="openFileUpload">
+ {{ __('Upload file') }}
+ </gl-dropdown-item>
+
+ <input
+ ref="fileSelector"
+ type="file"
+ name="content_editor_attachment"
+ class="gl-display-none"
+ @change="onFileSelect"
+ />
+ </gl-dropdown>
+ </editor-state-observer>
</template>
diff --git a/app/assets/javascripts/content_editor/components/toolbar_table_button.vue b/app/assets/javascripts/content_editor/components/toolbar_table_button.vue
index 49d3006e9bf..46db806da94 100644
--- a/app/assets/javascripts/content_editor/components/toolbar_table_button.vue
+++ b/app/assets/javascripts/content_editor/components/toolbar_table_button.vue
@@ -1,29 +1,23 @@
<script>
import { GlDropdown, GlDropdownDivider, GlDropdownForm, GlButton } from '@gitlab/ui';
-import { Editor as TiptapEditor } from '@tiptap/vue-2';
import { __, sprintf } from '~/locale';
import { clamp } from '../services/utils';
export const tableContentType = 'table';
-const MIN_ROWS = 3;
-const MIN_COLS = 3;
-const MAX_ROWS = 8;
-const MAX_COLS = 8;
+const MIN_ROWS = 5;
+const MIN_COLS = 5;
+const MAX_ROWS = 10;
+const MAX_COLS = 10;
export default {
components: {
+ GlButton,
GlDropdown,
GlDropdownDivider,
GlDropdownForm,
- GlButton,
- },
- props: {
- tiptapEditor: {
- type: TiptapEditor,
- required: true,
- },
},
+ inject: ['tiptapEditor'],
data() {
return {
maxRows: MIN_ROWS,
@@ -68,22 +62,22 @@ export default {
};
</script>
<template>
- <gl-dropdown size="small" category="tertiary" icon="table">
- <gl-dropdown-form class="gl-px-3! gl-w-auto!">
- <div class="gl-w-auto!">
- <div v-for="r of list(maxRows)" :key="r" class="gl-display-flex">
- <gl-button
- v-for="c of list(maxCols)"
- :key="c"
- :data-testid="`table-${r}-${c}`"
- :class="{ 'gl-bg-blue-50!': r <= rows && c <= cols }"
- :aria-label="getButtonLabel(r, c)"
- class="gl-display-inline! gl-px-0! gl-w-5! gl-h-5! gl-rounded-0!"
- @mouseover="setRowsAndCols(r, c)"
- @click="insertTable()"
- />
- </div>
- <gl-dropdown-divider />
+ <gl-dropdown size="small" category="tertiary" icon="table" class="table-dropdown">
+ <gl-dropdown-form class="gl-px-3!">
+ <div v-for="r of list(maxRows)" :key="r" class="gl-display-flex">
+ <gl-button
+ v-for="c of list(maxCols)"
+ :key="c"
+ :data-testid="`table-${r}-${c}`"
+ :class="{ 'active gl-bg-blue-50!': r <= rows && c <= cols }"
+ :aria-label="getButtonLabel(r, c)"
+ class="table-creator-grid-item gl-display-inline gl-rounded-0! gl-w-6! gl-h-6! gl-p-0!"
+ @mouseover="setRowsAndCols(r, c)"
+ @click="insertTable()"
+ />
+ </div>
+ <gl-dropdown-divider class="gl-my-3! gl-mx-n3!" />
+ <div class="gl-px-1">
{{ getButtonLabel(rows, cols) }}
</div>
</gl-dropdown-form>
diff --git a/app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue b/app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue
index 473fc472c1b..13728d4001d 100644
--- a/app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue
+++ b/app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue
@@ -1,29 +1,25 @@
<script>
import { GlDropdown, GlDropdownItem, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
-import { Editor as TiptapEditor } from '@tiptap/vue-2';
import { __ } from '~/locale';
import { TEXT_STYLE_DROPDOWN_ITEMS } from '../constants';
+import EditorStateObserver from './editor_state_observer.vue';
export default {
components: {
GlDropdown,
GlDropdownItem,
+ EditorStateObserver,
},
directives: {
GlTooltip,
},
- props: {
- tiptapEditor: {
- type: TiptapEditor,
- required: true,
- },
+ inject: ['tiptapEditor'],
+ data() {
+ return {
+ activeItem: null,
+ };
},
computed: {
- activeItem() {
- return TEXT_STYLE_DROPDOWN_ITEMS.find((item) =>
- this.tiptapEditor.isActive(item.contentType, item.commandParams),
- );
- },
activeItemLabel() {
const { activeItem } = this;
@@ -31,6 +27,11 @@ export default {
},
},
methods: {
+ updateActiveItem({ editor }) {
+ this.activeItem = TEXT_STYLE_DROPDOWN_ITEMS.find((item) =>
+ editor.isActive(item.contentType, item.commandParams),
+ );
+ },
execute(item) {
const { editorCommand, contentType, commandParams } = item;
const value = commandParams?.level;
@@ -38,8 +39,8 @@ export default {
if (editorCommand) {
this.tiptapEditor
.chain()
- .focus()
[editorCommand](commandParams || {})
+ .focus()
.run();
}
@@ -56,20 +57,25 @@ export default {
};
</script>
<template>
- <gl-dropdown
- v-gl-tooltip="$options.i18n.placeholder"
- size="small"
- :disabled="!activeItem"
- :text="activeItemLabel"
- >
- <gl-dropdown-item
- v-for="(item, index) in $options.items"
- :key="index"
- is-check-item
- :is-checked="isActive(item)"
- @click="execute(item)"
+ <editor-state-observer @transaction="updateActiveItem">
+ <gl-dropdown
+ v-gl-tooltip="$options.i18n.placeholder"
+ size="small"
+ data-qa-selector="text_style_dropdown"
+ :disabled="!activeItem"
+ :text="activeItemLabel"
>
- {{ item.label }}
- </gl-dropdown-item>
- </gl-dropdown>
+ <gl-dropdown-item
+ v-for="(item, index) in $options.items"
+ :key="index"
+ is-check-item
+ :is-checked="isActive(item)"
+ data-qa-selector="text_style_menu_item"
+ :data-qa-text-style="item.label"
+ @click="execute(item)"
+ >
+ {{ item.label }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </editor-state-observer>
</template>
diff --git a/app/assets/javascripts/content_editor/components/top_toolbar.vue b/app/assets/javascripts/content_editor/components/top_toolbar.vue
index fafc7a660e7..82a449ae6af 100644
--- a/app/assets/javascripts/content_editor/components/top_toolbar.vue
+++ b/app/assets/javascripts/content_editor/components/top_toolbar.vue
@@ -1,7 +1,5 @@
<script>
-import Tracking from '~/tracking';
-import { CONTENT_EDITOR_TRACKING_LABEL, TOOLBAR_CONTROL_TRACKING_ACTION } from '../constants';
-import { ContentEditor } from '../services/content_editor';
+import trackUIControl from '../services/track_ui_control';
import Divider from './divider.vue';
import ToolbarButton from './toolbar_button.vue';
import ToolbarImageButton from './toolbar_image_button.vue';
@@ -9,10 +7,6 @@ import ToolbarLinkButton from './toolbar_link_button.vue';
import ToolbarTableButton from './toolbar_table_button.vue';
import ToolbarTextStyleDropdown from './toolbar_text_style_dropdown.vue';
-const trackingMixin = Tracking.mixin({
- label: CONTENT_EDITOR_TRACKING_LABEL,
-});
-
export default {
components: {
ToolbarButton,
@@ -22,19 +16,9 @@ export default {
ToolbarImageButton,
Divider,
},
- mixins: [trackingMixin],
- props: {
- contentEditor: {
- type: ContentEditor,
- required: true,
- },
- },
methods: {
- trackToolbarControlExecution({ contentType: property, value }) {
- this.track(TOOLBAR_CONTROL_TRACKING_ACTION, {
- property,
- value,
- });
+ trackToolbarControlExecution({ contentType, value }) {
+ trackUIControl({ property: contentType, value });
},
},
};
@@ -45,7 +29,6 @@ export default {
>
<toolbar-text-style-dropdown
data-testid="text-styles"
- :tiptap-editor="contentEditor.tiptapEditor"
@execute="trackToolbarControlExecution"
/>
<divider />
@@ -53,99 +36,91 @@ export default {
data-testid="bold"
content-type="bold"
icon-name="bold"
+ class="gl-mx-2"
editor-command="toggleBold"
:label="__('Bold text')"
- :tiptap-editor="contentEditor.tiptapEditor"
@execute="trackToolbarControlExecution"
/>
<toolbar-button
data-testid="italic"
content-type="italic"
icon-name="italic"
+ class="gl-mx-2"
editor-command="toggleItalic"
:label="__('Italic text')"
- :tiptap-editor="contentEditor.tiptapEditor"
@execute="trackToolbarControlExecution"
/>
<toolbar-button
data-testid="strike"
content-type="strike"
icon-name="strikethrough"
+ class="gl-mx-2"
editor-command="toggleStrike"
:label="__('Strikethrough')"
- :tiptap-editor="contentEditor.tiptapEditor"
@execute="trackToolbarControlExecution"
/>
<toolbar-button
data-testid="code"
content-type="code"
icon-name="code"
+ class="gl-mx-2"
editor-command="toggleCode"
:label="__('Code')"
- :tiptap-editor="contentEditor.tiptapEditor"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-link-button
- data-testid="link"
- :tiptap-editor="contentEditor.tiptapEditor"
@execute="trackToolbarControlExecution"
/>
+ <toolbar-link-button data-testid="link" @execute="trackToolbarControlExecution" />
<divider />
<toolbar-image-button
ref="imageButton"
data-testid="image"
- :tiptap-editor="contentEditor.tiptapEditor"
@execute="trackToolbarControlExecution"
/>
<toolbar-button
data-testid="blockquote"
content-type="blockquote"
icon-name="quote"
+ class="gl-mx-2"
editor-command="toggleBlockquote"
:label="__('Insert a quote')"
- :tiptap-editor="contentEditor.tiptapEditor"
@execute="trackToolbarControlExecution"
/>
<toolbar-button
data-testid="code-block"
content-type="codeBlock"
icon-name="doc-code"
+ class="gl-mx-2"
editor-command="toggleCodeBlock"
:label="__('Insert a code block')"
- :tiptap-editor="contentEditor.tiptapEditor"
@execute="trackToolbarControlExecution"
/>
<toolbar-button
data-testid="bullet-list"
content-type="bulletList"
icon-name="list-bulleted"
+ class="gl-mx-2"
editor-command="toggleBulletList"
:label="__('Add a bullet list')"
- :tiptap-editor="contentEditor.tiptapEditor"
@execute="trackToolbarControlExecution"
/>
<toolbar-button
data-testid="ordered-list"
content-type="orderedList"
icon-name="list-numbered"
+ class="gl-mx-2"
editor-command="toggleOrderedList"
:label="__('Add a numbered list')"
- :tiptap-editor="contentEditor.tiptapEditor"
@execute="trackToolbarControlExecution"
/>
<toolbar-button
data-testid="horizontal-rule"
content-type="horizontalRule"
icon-name="dash"
+ class="gl-mx-2"
editor-command="setHorizontalRule"
:label="__('Add a horizontal rule')"
- :tiptap-editor="contentEditor.tiptapEditor"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-table-button
- :tiptap-editor="contentEditor.tiptapEditor"
@execute="trackToolbarControlExecution"
/>
+ <toolbar-table-button @execute="trackToolbarControlExecution" />
</div>
</template>
<style>
diff --git a/app/assets/javascripts/content_editor/constants.js b/app/assets/javascripts/content_editor/constants.js
index 7a5f1d3ed1f..f277508f628 100644
--- a/app/assets/javascripts/content_editor/constants.js
+++ b/app/assets/javascripts/content_editor/constants.js
@@ -6,6 +6,7 @@ export const PROVIDE_SERIALIZER_OR_RENDERER_ERROR = s__(
export const CONTENT_EDITOR_TRACKING_LABEL = 'content_editor';
export const TOOLBAR_CONTROL_TRACKING_ACTION = 'execute_toolbar_control';
+export const BUBBLE_MENU_TRACKING_ACTION = 'execute_bubble_menu_control';
export const KEYBOARD_SHORTCUT_TRACKING_ACTION = 'execute_keyboard_shortcut';
export const INPUT_RULE_TRACKING_ACTION = 'execute_input_rule';
@@ -40,3 +41,7 @@ export const TEXT_STYLE_DROPDOWN_ITEMS = [
label: __('Normal text'),
},
];
+
+export const LOADING_CONTENT_EVENT = 'loadingContent';
+export const LOADING_SUCCESS_EVENT = 'loadingSuccess';
+export const LOADING_ERROR_EVENT = 'loadingError';
diff --git a/app/assets/javascripts/content_editor/extensions/attachment.js b/app/assets/javascripts/content_editor/extensions/attachment.js
new file mode 100644
index 00000000000..29ee282f2d2
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/attachment.js
@@ -0,0 +1,53 @@
+import { Extension } from '@tiptap/core';
+import { Plugin, PluginKey } from 'prosemirror-state';
+import { handleFileEvent } from '../services/upload_helpers';
+
+export default Extension.create({
+ name: 'attachment',
+
+ defaultOptions: {
+ uploadsPath: null,
+ renderMarkdown: null,
+ },
+
+ addCommands() {
+ return {
+ uploadAttachment: ({ file }) => () => {
+ const { uploadsPath, renderMarkdown } = this.options;
+
+ return handleFileEvent({ file, uploadsPath, renderMarkdown, editor: this.editor });
+ },
+ };
+ },
+ addProseMirrorPlugins() {
+ const { editor } = this;
+
+ return [
+ new Plugin({
+ key: new PluginKey('attachment'),
+ 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,
+ });
+ },
+ },
+ }),
+ ];
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/blockquote.js b/app/assets/javascripts/content_editor/extensions/blockquote.js
index a4297b4550c..45f53fe230b 100644
--- a/app/assets/javascripts/content_editor/extensions/blockquote.js
+++ b/app/assets/javascripts/content_editor/extensions/blockquote.js
@@ -1,5 +1 @@
-import { Blockquote } from '@tiptap/extension-blockquote';
-import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
-
-export const tiptapExtension = Blockquote;
-export const serializer = defaultMarkdownSerializer.nodes.blockquote;
+export { Blockquote as default } from '@tiptap/extension-blockquote';
diff --git a/app/assets/javascripts/content_editor/extensions/bold.js b/app/assets/javascripts/content_editor/extensions/bold.js
index e90e7b59da0..0b7b22265b6 100644
--- a/app/assets/javascripts/content_editor/extensions/bold.js
+++ b/app/assets/javascripts/content_editor/extensions/bold.js
@@ -1,5 +1 @@
-import { Bold } from '@tiptap/extension-bold';
-import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
-
-export const tiptapExtension = Bold;
-export const serializer = defaultMarkdownSerializer.marks.strong;
+export { Bold as default } from '@tiptap/extension-bold';
diff --git a/app/assets/javascripts/content_editor/extensions/bullet_list.js b/app/assets/javascripts/content_editor/extensions/bullet_list.js
index 178b798e2d4..01ead571fe1 100644
--- a/app/assets/javascripts/content_editor/extensions/bullet_list.js
+++ b/app/assets/javascripts/content_editor/extensions/bullet_list.js
@@ -1,5 +1 @@
-import { BulletList } from '@tiptap/extension-bullet-list';
-import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
-
-export const tiptapExtension = BulletList;
-export const serializer = defaultMarkdownSerializer.nodes.bullet_list;
+export { BulletList as default } from '@tiptap/extension-bullet-list';
diff --git a/app/assets/javascripts/content_editor/extensions/code.js b/app/assets/javascripts/content_editor/extensions/code.js
index 8be50dc39c5..f93c22ad10e 100644
--- a/app/assets/javascripts/content_editor/extensions/code.js
+++ b/app/assets/javascripts/content_editor/extensions/code.js
@@ -1,5 +1 @@
-import { Code } from '@tiptap/extension-code';
-import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
-
-export const tiptapExtension = Code;
-export const serializer = defaultMarkdownSerializer.marks.code;
+export { Code as default } from '@tiptap/extension-code';
diff --git a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
index 50d72f4089a..c6d32fb8547 100644
--- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
+++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
@@ -1,10 +1,9 @@
import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight';
import * as lowlight from 'lowlight';
-import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
const extractLanguage = (element) => element.getAttribute('lang');
-const ExtendedCodeBlockLowlight = CodeBlockLowlight.extend({
+export default CodeBlockLowlight.extend({
addAttributes() {
return {
language: {
@@ -15,18 +14,6 @@ const ExtendedCodeBlockLowlight = CodeBlockLowlight.extend({
};
},
},
- /* `params` is the name of the attribute that
- prosemirror-markdown uses to extract the language
- of a codeblock.
- https://github.com/ProseMirror/prosemirror-markdown/blob/master/src/to_markdown.js#L62
- */
- params: {
- parseHTML: (element) => {
- return {
- params: extractLanguage(element),
- };
- },
- },
class: {
default: 'code highlight js-syntax-highlight',
},
@@ -38,6 +25,3 @@ const ExtendedCodeBlockLowlight = CodeBlockLowlight.extend({
}).configure({
lowlight,
});
-
-export const tiptapExtension = ExtendedCodeBlockLowlight;
-export const serializer = defaultMarkdownSerializer.nodes.code_block;
diff --git a/app/assets/javascripts/content_editor/extensions/document.js b/app/assets/javascripts/content_editor/extensions/document.js
index 99aa8d6235a..27496fd60b7 100644
--- a/app/assets/javascripts/content_editor/extensions/document.js
+++ b/app/assets/javascripts/content_editor/extensions/document.js
@@ -1,3 +1 @@
-import Document from '@tiptap/extension-document';
-
-export const tiptapExtension = Document;
+export { Document as default } from '@tiptap/extension-document';
diff --git a/app/assets/javascripts/content_editor/extensions/dropcursor.js b/app/assets/javascripts/content_editor/extensions/dropcursor.js
index 44c378ac7db..825dc73b9d9 100644
--- a/app/assets/javascripts/content_editor/extensions/dropcursor.js
+++ b/app/assets/javascripts/content_editor/extensions/dropcursor.js
@@ -1,3 +1 @@
-import Dropcursor from '@tiptap/extension-dropcursor';
-
-export const tiptapExtension = Dropcursor;
+export { Dropcursor as default } from '@tiptap/extension-dropcursor';
diff --git a/app/assets/javascripts/content_editor/extensions/emoji.js b/app/assets/javascripts/content_editor/extensions/emoji.js
new file mode 100644
index 00000000000..d88b9f92215
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/emoji.js
@@ -0,0 +1,93 @@
+import { Node } from '@tiptap/core';
+import { InputRule } from 'prosemirror-inputrules';
+import { initEmojiMap, getAllEmoji } from '~/emoji';
+
+export const emojiInputRegex = /(?:^|\s)((?::)((?:\w+))(?::))$/;
+
+export default Node.create({
+ name: 'emoji',
+
+ inline: true,
+
+ group: 'inline',
+
+ draggable: true,
+
+ addAttributes() {
+ return {
+ moji: {
+ default: null,
+ parseHTML: (element) => {
+ return {
+ moji: element.textContent,
+ };
+ },
+ },
+ name: {
+ default: null,
+ parseHTML: (element) => {
+ return {
+ name: element.dataset.name,
+ };
+ },
+ },
+ title: {
+ default: null,
+ },
+ unicodeVersion: {
+ default: '6.0',
+ parseHTML: (element) => {
+ return {
+ unicodeVersion: element.dataset.unicodeVersion,
+ };
+ },
+ },
+ };
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: 'gl-emoji',
+ },
+ ];
+ },
+
+ renderHTML({ node }) {
+ return [
+ 'gl-emoji',
+ {
+ 'data-name': node.attrs.name,
+ title: node.attrs.title,
+ 'data-unicode-version': node.attrs.unicodeVersion,
+ },
+ node.attrs.moji,
+ ];
+ },
+
+ addInputRules() {
+ return [
+ new InputRule(emojiInputRegex, (state, match, start, end) => {
+ const [, , name] = match;
+ const emojis = getAllEmoji();
+ const emoji = emojis[name];
+ const { tr } = state;
+
+ if (emoji) {
+ tr.replaceWith(start, end, [
+ state.schema.text(' '),
+ this.type.create({ name, moji: emoji.e, unicodeVersion: emoji.u, title: emoji.d }),
+ ]);
+
+ return tr;
+ }
+
+ return null;
+ }),
+ ];
+ },
+
+ onCreate() {
+ initEmojiMap();
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/gapcursor.js b/app/assets/javascripts/content_editor/extensions/gapcursor.js
index 2db862e4580..ef88cd92b4e 100644
--- a/app/assets/javascripts/content_editor/extensions/gapcursor.js
+++ b/app/assets/javascripts/content_editor/extensions/gapcursor.js
@@ -1,3 +1 @@
-import Gapcursor from '@tiptap/extension-gapcursor';
-
-export const tiptapExtension = Gapcursor;
+export { Gapcursor as default } from '@tiptap/extension-gapcursor';
diff --git a/app/assets/javascripts/content_editor/extensions/hard_break.js b/app/assets/javascripts/content_editor/extensions/hard_break.js
index 756eefa875c..fb81c6b79b6 100644
--- a/app/assets/javascripts/content_editor/extensions/hard_break.js
+++ b/app/assets/javascripts/content_editor/extensions/hard_break.js
@@ -1,13 +1,9 @@
import { HardBreak } from '@tiptap/extension-hard-break';
-import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
-const ExtendedHardBreak = HardBreak.extend({
+export default HardBreak.extend({
addKeyboardShortcuts() {
return {
'Shift-Enter': () => this.editor.commands.setHardBreak(),
};
},
});
-
-export const tiptapExtension = ExtendedHardBreak;
-export const serializer = defaultMarkdownSerializer.nodes.hard_break;
diff --git a/app/assets/javascripts/content_editor/extensions/heading.js b/app/assets/javascripts/content_editor/extensions/heading.js
index f69869d1e09..48303cdeca4 100644
--- a/app/assets/javascripts/content_editor/extensions/heading.js
+++ b/app/assets/javascripts/content_editor/extensions/heading.js
@@ -1,5 +1 @@
-import { Heading } from '@tiptap/extension-heading';
-import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
-
-export const tiptapExtension = Heading;
-export const serializer = defaultMarkdownSerializer.nodes.heading;
+export { Heading as default } from '@tiptap/extension-heading';
diff --git a/app/assets/javascripts/content_editor/extensions/history.js b/app/assets/javascripts/content_editor/extensions/history.js
index 554d797d30a..7c9d92d7b4e 100644
--- a/app/assets/javascripts/content_editor/extensions/history.js
+++ b/app/assets/javascripts/content_editor/extensions/history.js
@@ -1,3 +1 @@
-import History from '@tiptap/extension-history';
-
-export const tiptapExtension = History;
+export { History as default } from '@tiptap/extension-history';
diff --git a/app/assets/javascripts/content_editor/extensions/horizontal_rule.js b/app/assets/javascripts/content_editor/extensions/horizontal_rule.js
index c287938af5c..c8ec45d835c 100644
--- a/app/assets/javascripts/content_editor/extensions/horizontal_rule.js
+++ b/app/assets/javascripts/content_editor/extensions/horizontal_rule.js
@@ -1,12 +1,10 @@
import { nodeInputRule } from '@tiptap/core';
import { HorizontalRule } from '@tiptap/extension-horizontal-rule';
-import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
export const hrInputRuleRegExp = /^---$/;
-export const tiptapExtension = HorizontalRule.extend({
+export default HorizontalRule.extend({
addInputRules() {
return [nodeInputRule(hrInputRuleRegExp, this.type)];
},
});
-export const serializer = defaultMarkdownSerializer.nodes.horizontal_rule;
diff --git a/app/assets/javascripts/content_editor/extensions/image.js b/app/assets/javascripts/content_editor/extensions/image.js
index 4dd8a1376ad..c9e8dfa4ad9 100644
--- a/app/assets/javascripts/content_editor/extensions/image.js
+++ b/app/assets/javascripts/content_editor/extensions/image.js
@@ -1,58 +1,14 @@
import { Image } from '@tiptap/extension-image';
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({
+export default Image.extend({
defaultOptions: {
...Image.options,
- uploadsPath: null,
- renderMarkdown: null,
+ inline: true,
},
addAttributes() {
return {
@@ -107,62 +63,7 @@ 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 = (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 {
- tiptapExtension: ExtendedImage.configure({ inline: true, renderMarkdown, uploadsPath }),
- serializer,
- };
-};
diff --git a/app/assets/javascripts/content_editor/extensions/inline_diff.js b/app/assets/javascripts/content_editor/extensions/inline_diff.js
new file mode 100644
index 00000000000..9471d324764
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/inline_diff.js
@@ -0,0 +1,50 @@
+import { Mark, markInputRule, mergeAttributes } from '@tiptap/core';
+
+export const inputRegexAddition = /(\{\+(.+?)\+\})$/gm;
+export const inputRegexDeletion = /(\{-(.+?)-\})$/gm;
+
+export default Mark.create({
+ name: 'inlineDiff',
+
+ defaultOptions: {
+ HTMLAttributes: {},
+ },
+
+ addAttributes() {
+ return {
+ type: {
+ default: 'addition',
+ parseHTML: (element) => {
+ return {
+ type: element.classList.contains('deletion') ? 'deletion' : 'addition',
+ };
+ },
+ },
+ };
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: 'span.idiff',
+ },
+ ];
+ },
+
+ renderHTML({ HTMLAttributes: { type, ...HTMLAttributes } }) {
+ return [
+ 'span',
+ mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
+ class: `idiff left right ${type}`,
+ }),
+ 0,
+ ];
+ },
+
+ addInputRules() {
+ return [
+ markInputRule(inputRegexAddition, this.type, () => ({ type: 'addition' })),
+ markInputRule(inputRegexDeletion, this.type, () => ({ type: 'deletion' })),
+ ];
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/italic.js b/app/assets/javascripts/content_editor/extensions/italic.js
index b8a7c4aba3e..99e9922044d 100644
--- a/app/assets/javascripts/content_editor/extensions/italic.js
+++ b/app/assets/javascripts/content_editor/extensions/italic.js
@@ -1,4 +1 @@
-import { Italic } from '@tiptap/extension-italic';
-
-export const tiptapExtension = Italic;
-export const serializer = { open: '_', close: '_', mixable: true, expelEnclosingWhitespace: true };
+export { Italic as default } from '@tiptap/extension-italic';
diff --git a/app/assets/javascripts/content_editor/extensions/link.js b/app/assets/javascripts/content_editor/extensions/link.js
index 12019ab4636..53104fe07a3 100644
--- a/app/assets/javascripts/content_editor/extensions/link.js
+++ b/app/assets/javascripts/content_editor/extensions/link.js
@@ -20,7 +20,11 @@ export const extractHrefFromMarkdownLink = (match) => {
return extractHrefFromMatch(match);
};
-export const tiptapExtension = Link.extend({
+export default Link.extend({
+ defaultOptions: {
+ ...Link.options,
+ openOnClick: false,
+ },
addInputRules() {
return [
markInputRule(markdownLinkSyntaxInputRuleRegExp, this.type, extractHrefFromMarkdownLink),
@@ -48,16 +52,4 @@ export const tiptapExtension = Link.extend({
},
};
},
-}).configure({
- openOnClick: false,
});
-
-export const serializer = {
- open() {
- return '[';
- },
- close(state, mark) {
- const href = mark.attrs.canonicalSrc || mark.attrs.href;
- return `](${state.esc(href)}${mark.attrs.title ? ` ${state.quote(mark.attrs.title)}` : ''})`;
- },
-};
diff --git a/app/assets/javascripts/content_editor/extensions/list_item.js b/app/assets/javascripts/content_editor/extensions/list_item.js
index 86da98f6df7..72454b0905d 100644
--- a/app/assets/javascripts/content_editor/extensions/list_item.js
+++ b/app/assets/javascripts/content_editor/extensions/list_item.js
@@ -1,5 +1 @@
-import { ListItem } from '@tiptap/extension-list-item';
-import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
-
-export const tiptapExtension = ListItem;
-export const serializer = defaultMarkdownSerializer.nodes.list_item;
+export { ListItem as default } from '@tiptap/extension-list-item';
diff --git a/app/assets/javascripts/content_editor/extensions/loading.js b/app/assets/javascripts/content_editor/extensions/loading.js
new file mode 100644
index 00000000000..2324e9b132d
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/loading.js
@@ -0,0 +1,24 @@
+import { Node } from '@tiptap/core';
+
+export default Node.create({
+ name: 'loading',
+ inline: true,
+ group: 'inline',
+
+ addAttributes() {
+ return {
+ label: {
+ default: null,
+ },
+ };
+ },
+
+ renderHTML({ node }) {
+ return [
+ 'span',
+ { class: 'gl-display-inline-flex gl-align-items-center' },
+ ['span', { class: 'gl-spinner gl-mx-2' }],
+ ['span', { class: 'gl-link' }, node.attrs.label],
+ ];
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/ordered_list.js b/app/assets/javascripts/content_editor/extensions/ordered_list.js
index d980ab8bf10..9a79187d9c1 100644
--- a/app/assets/javascripts/content_editor/extensions/ordered_list.js
+++ b/app/assets/javascripts/content_editor/extensions/ordered_list.js
@@ -1,5 +1 @@
-import { OrderedList } from '@tiptap/extension-ordered-list';
-import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
-
-export const tiptapExtension = OrderedList;
-export const serializer = defaultMarkdownSerializer.nodes.ordered_list;
+export { OrderedList as default } from '@tiptap/extension-ordered-list';
diff --git a/app/assets/javascripts/content_editor/extensions/paragraph.js b/app/assets/javascripts/content_editor/extensions/paragraph.js
index 6c9f204b8ac..33bf1c94003 100644
--- a/app/assets/javascripts/content_editor/extensions/paragraph.js
+++ b/app/assets/javascripts/content_editor/extensions/paragraph.js
@@ -1,5 +1 @@
-import { Paragraph } from '@tiptap/extension-paragraph';
-import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
-
-export const tiptapExtension = Paragraph;
-export const serializer = defaultMarkdownSerializer.nodes.paragraph;
+export { Paragraph as default } from '@tiptap/extension-paragraph';
diff --git a/app/assets/javascripts/content_editor/extensions/reference.js b/app/assets/javascripts/content_editor/extensions/reference.js
new file mode 100644
index 00000000000..5f4484af9c8
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/reference.js
@@ -0,0 +1,78 @@
+import { Node } from '@tiptap/core';
+
+export default Node.create({
+ name: 'reference',
+
+ inline: true,
+
+ group: 'inline',
+
+ atom: true,
+
+ addAttributes() {
+ return {
+ className: {
+ default: null,
+ parseHTML: (element) => {
+ return {
+ className: element.className,
+ };
+ },
+ },
+ referenceType: {
+ default: null,
+ parseHTML: (element) => {
+ return {
+ referenceType: element.dataset.referenceType,
+ };
+ },
+ },
+ originalText: {
+ default: null,
+ parseHTML: (element) => {
+ return {
+ originalText: element.dataset.original,
+ };
+ },
+ },
+ href: {
+ default: null,
+ parseHTML: (element) => {
+ return {
+ href: element.getAttribute('href'),
+ };
+ },
+ },
+ text: {
+ default: null,
+ parseHTML: (element) => {
+ return {
+ text: element.textContent,
+ };
+ },
+ },
+ };
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: 'a.gfm:not([data-link=true])',
+ priority: 51,
+ },
+ ];
+ },
+
+ renderHTML({ node }) {
+ return [
+ 'a',
+ {
+ class: node.attrs.className,
+ href: node.attrs.href,
+ 'data-reference-type': node.attrs.referenceType,
+ 'data-original': node.attrs.originalText,
+ },
+ node.attrs.text,
+ ];
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/strike.js b/app/assets/javascripts/content_editor/extensions/strike.js
index 6f228e00994..b6c9a968fc2 100644
--- a/app/assets/javascripts/content_editor/extensions/strike.js
+++ b/app/assets/javascripts/content_editor/extensions/strike.js
@@ -1,9 +1 @@
-import { Strike } from '@tiptap/extension-strike';
-
-export const tiptapExtension = Strike;
-export const serializer = {
- open: '~~',
- close: '~~',
- mixable: true,
- expelEnclosingWhitespace: true,
-};
+export { Strike as default } from '@tiptap/extension-strike';
diff --git a/app/assets/javascripts/content_editor/extensions/subscript.js b/app/assets/javascripts/content_editor/extensions/subscript.js
new file mode 100644
index 00000000000..4bf89796efe
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/subscript.js
@@ -0,0 +1 @@
+export { Subscript as default } from '@tiptap/extension-subscript';
diff --git a/app/assets/javascripts/content_editor/extensions/superscript.js b/app/assets/javascripts/content_editor/extensions/superscript.js
new file mode 100644
index 00000000000..3eb7d86d90d
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/superscript.js
@@ -0,0 +1 @@
+export { Superscript as default } from '@tiptap/extension-superscript';
diff --git a/app/assets/javascripts/content_editor/extensions/table.js b/app/assets/javascripts/content_editor/extensions/table.js
index 566f7a21a85..0f0477cba2e 100644
--- a/app/assets/javascripts/content_editor/extensions/table.js
+++ b/app/assets/javascripts/content_editor/extensions/table.js
@@ -1,7 +1 @@
-import { Table } from '@tiptap/extension-table';
-
-export const tiptapExtension = Table;
-
-export function serializer(state, node) {
- state.renderContent(node);
-}
+export { Table as default } from '@tiptap/extension-table';
diff --git a/app/assets/javascripts/content_editor/extensions/table_cell.js b/app/assets/javascripts/content_editor/extensions/table_cell.js
index 6c25b867466..5bdc39231a1 100644
--- a/app/assets/javascripts/content_editor/extensions/table_cell.js
+++ b/app/assets/javascripts/content_editor/extensions/table_cell.js
@@ -1,9 +1,5 @@
import { TableCell } from '@tiptap/extension-table-cell';
-export const tiptapExtension = TableCell.extend({
+export default TableCell.extend({
content: 'inline*',
});
-
-export function serializer(state, node) {
- state.renderInline(node);
-}
diff --git a/app/assets/javascripts/content_editor/extensions/table_header.js b/app/assets/javascripts/content_editor/extensions/table_header.js
index 3475857b9e6..23509706e4b 100644
--- a/app/assets/javascripts/content_editor/extensions/table_header.js
+++ b/app/assets/javascripts/content_editor/extensions/table_header.js
@@ -1,9 +1,5 @@
import { TableHeader } from '@tiptap/extension-table-header';
-export const tiptapExtension = TableHeader.extend({
+export default TableHeader.extend({
content: 'inline*',
});
-
-export function serializer(state, node) {
- state.renderInline(node);
-}
diff --git a/app/assets/javascripts/content_editor/extensions/table_row.js b/app/assets/javascripts/content_editor/extensions/table_row.js
index 07d2eb4faa2..541257a6cbf 100644
--- a/app/assets/javascripts/content_editor/extensions/table_row.js
+++ b/app/assets/javascripts/content_editor/extensions/table_row.js
@@ -1,51 +1,5 @@
import { TableRow } from '@tiptap/extension-table-row';
-export const tiptapExtension = TableRow.extend({
+export default TableRow.extend({
allowGapCursor: false,
});
-
-export function serializer(state, node) {
- const isHeaderRow = node.child(0).type.name === 'tableHeader';
-
- const renderRow = () => {
- const cellWidths = [];
-
- state.flushClose(1);
-
- state.write('| ');
- node.forEach((cell, _, i) => {
- if (i) state.write(' | ');
-
- const { length } = state.out;
- state.render(cell, node, i);
- cellWidths.push(state.out.length - length);
- });
- state.write(' |');
-
- state.closeBlock(node);
-
- return cellWidths;
- };
-
- const renderHeaderRow = (cellWidths) => {
- state.flushClose(1);
-
- state.write('|');
- node.forEach((cell, _, i) => {
- if (i) state.write('|');
-
- state.write(cell.attrs.align === 'center' ? ':' : '-');
- state.write(state.repeat('-', cellWidths[i]));
- state.write(cell.attrs.align === 'center' || cell.attrs.align === 'right' ? ':' : '-');
- });
- state.write('|');
-
- state.closeBlock(node);
- };
-
- if (isHeaderRow) {
- renderHeaderRow(renderRow());
- } else {
- renderRow();
- }
-}
diff --git a/app/assets/javascripts/content_editor/extensions/task_item.js b/app/assets/javascripts/content_editor/extensions/task_item.js
new file mode 100644
index 00000000000..6163c0e043b
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/task_item.js
@@ -0,0 +1,33 @@
+import { TaskItem } from '@tiptap/extension-task-item';
+
+export default TaskItem.extend({
+ defaultOptions: {
+ nested: true,
+ HTMLAttributes: {},
+ },
+
+ addAttributes() {
+ return {
+ checked: {
+ default: false,
+ parseHTML: (element) => {
+ const checkbox = element.querySelector('input[type=checkbox].task-list-item-checkbox');
+ return { checked: checkbox?.checked };
+ },
+ renderHTML: (attributes) => ({
+ 'data-checked': attributes.checked,
+ }),
+ keepOnSplit: false,
+ },
+ };
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: 'li.task-list-item',
+ priority: 100,
+ },
+ ];
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/task_list.js b/app/assets/javascripts/content_editor/extensions/task_list.js
new file mode 100644
index 00000000000..b7f6c857bc7
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/task_list.js
@@ -0,0 +1,30 @@
+import { mergeAttributes } from '@tiptap/core';
+import { TaskList } from '@tiptap/extension-task-list';
+
+export default TaskList.extend({
+ addAttributes() {
+ return {
+ type: {
+ default: 'ul',
+ parseHTML: (element) => {
+ return {
+ type: element.tagName.toLowerCase() === 'ol' ? 'ol' : 'ul',
+ };
+ },
+ },
+ };
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: '.task-list',
+ priority: 100,
+ },
+ ];
+ },
+
+ renderHTML({ HTMLAttributes: { type, ...HTMLAttributes } }) {
+ return [type, mergeAttributes(HTMLAttributes, { 'data-type': 'taskList' }), 0];
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/text.js b/app/assets/javascripts/content_editor/extensions/text.js
index 0d76aa1f1a7..a2865e7010b 100644
--- a/app/assets/javascripts/content_editor/extensions/text.js
+++ b/app/assets/javascripts/content_editor/extensions/text.js
@@ -1,5 +1 @@
-import { Text } from '@tiptap/extension-text';
-import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
-
-export const tiptapExtension = Text;
-export const serializer = defaultMarkdownSerializer.nodes.text;
+export { Text as default } from '@tiptap/extension-text';
diff --git a/app/assets/javascripts/content_editor/services/build_serializer_config.js b/app/assets/javascripts/content_editor/services/build_serializer_config.js
deleted file mode 100644
index 75e2b0f9eba..00000000000
--- a/app/assets/javascripts/content_editor/services/build_serializer_config.js
+++ /dev/null
@@ -1,22 +0,0 @@
-const buildSerializerConfig = (extensions = []) =>
- extensions
- .filter(({ serializer }) => serializer)
- .reduce(
- (serializers, { serializer, tiptapExtension: { name, type } }) => {
- const collection = `${type}s`;
-
- return {
- ...serializers,
- [collection]: {
- ...serializers[collection],
- [name]: serializer,
- },
- };
- },
- {
- nodes: {},
- marks: {},
- },
- );
-
-export default buildSerializerConfig;
diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js
index 29553f4c2ca..a387322bff7 100644
--- a/app/assets/javascripts/content_editor/services/content_editor.js
+++ b/app/assets/javascripts/content_editor/services/content_editor.js
@@ -1,8 +1,11 @@
+import eventHubFactory from '~/helpers/event_hub_factory';
+import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } from '../constants';
/* eslint-disable no-underscore-dangle */
export class ContentEditor {
constructor({ tiptapEditor, serializer }) {
this._tiptapEditor = tiptapEditor;
this._serializer = serializer;
+ this._eventHub = eventHubFactory();
}
get tiptapEditor() {
@@ -16,12 +19,45 @@ export class ContentEditor {
return doc.childCount === 0 || (doc.childCount === 1 && doc.child(0).childCount === 0);
}
+ dispose() {
+ this.tiptapEditor.destroy();
+ }
+
+ once(type, handler) {
+ this._eventHub.$once(type, handler);
+ }
+
+ on(type, handler) {
+ this._eventHub.$on(type, handler);
+ }
+
+ emit(type, params = {}) {
+ this._eventHub.$emit(type, params);
+ }
+
+ off(type, handler) {
+ this._eventHub.$off(type, handler);
+ }
+
+ disposeAllEvents() {
+ this._eventHub.dispose();
+ }
+
async setSerializedContent(serializedContent) {
const { _tiptapEditor: editor, _serializer: serializer } = this;
- editor.commands.setContent(
- await serializer.deserialize({ schema: editor.schema, content: serializedContent }),
- );
+ try {
+ this._eventHub.$emit(LOADING_CONTENT_EVENT);
+ const document = await serializer.deserialize({
+ schema: editor.schema,
+ content: serializedContent,
+ });
+ editor.commands.setContent(document);
+ this._eventHub.$emit(LOADING_SUCCESS_EVENT);
+ } catch (e) {
+ this._eventHub.$emit(LOADING_ERROR_EVENT, e);
+ throw e;
+ }
}
getSerializedContent() {
diff --git a/app/assets/javascripts/content_editor/services/create_content_editor.js b/app/assets/javascripts/content_editor/services/create_content_editor.js
index 9251fdbbdc5..8997960203a 100644
--- a/app/assets/javascripts/content_editor/services/create_content_editor.js
+++ b/app/assets/javascripts/content_editor/services/create_content_editor.js
@@ -1,38 +1,43 @@
import { Editor } from '@tiptap/vue-2';
import { isFunction } from 'lodash';
import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants';
-import * as Blockquote from '../extensions/blockquote';
-import * as Bold from '../extensions/bold';
-import * as BulletList from '../extensions/bullet_list';
-import * as Code from '../extensions/code';
-import * as CodeBlockHighlight from '../extensions/code_block_highlight';
-import * as Document from '../extensions/document';
-import * as Dropcursor from '../extensions/dropcursor';
-import * as Gapcursor from '../extensions/gapcursor';
-import * as HardBreak from '../extensions/hard_break';
-import * as Heading from '../extensions/heading';
-import * as History from '../extensions/history';
-import * as HorizontalRule from '../extensions/horizontal_rule';
-import * as Image from '../extensions/image';
-import * as Italic from '../extensions/italic';
-import * as Link from '../extensions/link';
-import * as ListItem from '../extensions/list_item';
-import * as OrderedList from '../extensions/ordered_list';
-import * as Paragraph from '../extensions/paragraph';
-import * as Strike from '../extensions/strike';
-import * as Table from '../extensions/table';
-import * as TableCell from '../extensions/table_cell';
-import * as TableHeader from '../extensions/table_header';
-import * as TableRow from '../extensions/table_row';
-import * as Text from '../extensions/text';
-import buildSerializerConfig from './build_serializer_config';
+import Attachment from '../extensions/attachment';
+import Blockquote from '../extensions/blockquote';
+import Bold from '../extensions/bold';
+import BulletList from '../extensions/bullet_list';
+import Code from '../extensions/code';
+import CodeBlockHighlight from '../extensions/code_block_highlight';
+import Document from '../extensions/document';
+import Dropcursor from '../extensions/dropcursor';
+import Emoji from '../extensions/emoji';
+import Gapcursor from '../extensions/gapcursor';
+import HardBreak from '../extensions/hard_break';
+import Heading from '../extensions/heading';
+import History from '../extensions/history';
+import HorizontalRule from '../extensions/horizontal_rule';
+import Image from '../extensions/image';
+import InlineDiff from '../extensions/inline_diff';
+import Italic from '../extensions/italic';
+import Link from '../extensions/link';
+import ListItem from '../extensions/list_item';
+import Loading from '../extensions/loading';
+import OrderedList from '../extensions/ordered_list';
+import Paragraph from '../extensions/paragraph';
+import Reference from '../extensions/reference';
+import Strike from '../extensions/strike';
+import Subscript from '../extensions/subscript';
+import Superscript from '../extensions/superscript';
+import Table from '../extensions/table';
+import TableCell from '../extensions/table_cell';
+import TableHeader from '../extensions/table_header';
+import TableRow from '../extensions/table_row';
+import TaskItem from '../extensions/task_item';
+import TaskList from '../extensions/task_list';
+import Text from '../extensions/text';
import { ContentEditor } from './content_editor';
import createMarkdownSerializer from './markdown_serializer';
import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts';
-const collectTiptapExtensions = (extensions = []) =>
- extensions.map(({ tiptapExtension }) => tiptapExtension);
-
const createTiptapEditor = ({ extensions = [], ...options } = {}) =>
new Editor({
extensions: [...extensions],
@@ -48,6 +53,7 @@ export const createContentEditor = ({
renderMarkdown,
uploadsPath,
extensions = [],
+ serializerConfig = { marks: {}, nodes: {} },
tiptapOptions,
} = {}) => {
if (!isFunction(renderMarkdown)) {
@@ -55,6 +61,7 @@ export const createContentEditor = ({
}
const builtInContentEditorExtensions = [
+ Attachment.configure({ uploadsPath, renderMarkdown }),
Blockquote,
Bold,
BulletList,
@@ -62,29 +69,36 @@ export const createContentEditor = ({
CodeBlockHighlight,
Document,
Dropcursor,
+ Emoji,
Gapcursor,
HardBreak,
Heading,
History,
HorizontalRule,
- Image.configure({ uploadsPath, renderMarkdown }),
+ Image,
+ InlineDiff,
Italic,
Link,
ListItem,
+ Loading,
OrderedList,
Paragraph,
+ Reference,
Strike,
+ Subscript,
+ Superscript,
TableCell,
TableHeader,
TableRow,
Table,
+ TaskItem,
+ TaskList,
Text,
];
const allExtensions = [...builtInContentEditorExtensions, ...extensions];
- const tiptapExtensions = collectTiptapExtensions(allExtensions).map(trackInputRulesAndShortcuts);
- const tiptapEditor = createTiptapEditor({ extensions: tiptapExtensions, ...tiptapOptions });
- const serializerConfig = buildSerializerConfig(allExtensions);
+ const trackedExtensions = allExtensions.map(trackInputRulesAndShortcuts);
+ const tiptapEditor = createTiptapEditor({ extensions: trackedExtensions, ...tiptapOptions });
const serializer = createMarkdownSerializer({ render: renderMarkdown, serializerConfig });
return new ContentEditor({ tiptapEditor, serializer });
diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js
index f121cc9affd..df4d31c3d7f 100644
--- a/app/assets/javascripts/content_editor/services/markdown_serializer.js
+++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js
@@ -1,5 +1,165 @@
-import { MarkdownSerializer as ProseMirrorMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
+import {
+ MarkdownSerializer as ProseMirrorMarkdownSerializer,
+ defaultMarkdownSerializer,
+} from 'prosemirror-markdown/src/to_markdown';
import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model';
+import Blockquote from '../extensions/blockquote';
+import Bold from '../extensions/bold';
+import BulletList from '../extensions/bullet_list';
+import Code from '../extensions/code';
+import CodeBlockHighlight from '../extensions/code_block_highlight';
+import Emoji from '../extensions/emoji';
+import HardBreak from '../extensions/hard_break';
+import Heading from '../extensions/heading';
+import HorizontalRule from '../extensions/horizontal_rule';
+import Image from '../extensions/image';
+import InlineDiff from '../extensions/inline_diff';
+import Italic from '../extensions/italic';
+import Link from '../extensions/link';
+import ListItem from '../extensions/list_item';
+import OrderedList from '../extensions/ordered_list';
+import Paragraph from '../extensions/paragraph';
+import Reference from '../extensions/reference';
+import Strike from '../extensions/strike';
+import Subscript from '../extensions/subscript';
+import Superscript from '../extensions/superscript';
+import Table from '../extensions/table';
+import TableCell from '../extensions/table_cell';
+import TableHeader from '../extensions/table_header';
+import TableRow from '../extensions/table_row';
+import TaskItem from '../extensions/task_item';
+import TaskList from '../extensions/task_list';
+import Text from '../extensions/text';
+
+const defaultSerializerConfig = {
+ marks: {
+ [Bold.name]: defaultMarkdownSerializer.marks.strong,
+ [Code.name]: defaultMarkdownSerializer.marks.code,
+ [Italic.name]: { open: '_', close: '_', mixable: true, expelEnclosingWhitespace: true },
+ [Subscript.name]: { open: '<sub>', close: '</sub>', mixable: true },
+ [Superscript.name]: { open: '<sup>', close: '</sup>', mixable: true },
+ [InlineDiff.name]: {
+ mixable: true,
+ open(state, mark) {
+ return mark.attrs.type === 'addition' ? '{+' : '{-';
+ },
+ close(state, mark) {
+ return mark.attrs.type === 'addition' ? '+}' : '-}';
+ },
+ },
+ [Link.name]: {
+ open() {
+ return '[';
+ },
+ close(state, mark) {
+ const href = mark.attrs.canonicalSrc || mark.attrs.href;
+ return `](${state.esc(href)}${
+ mark.attrs.title ? ` ${state.quote(mark.attrs.title)}` : ''
+ })`;
+ },
+ },
+ [Strike.name]: {
+ open: '~~',
+ close: '~~',
+ mixable: true,
+ expelEnclosingWhitespace: true,
+ },
+ },
+ nodes: {
+ [Blockquote.name]: defaultMarkdownSerializer.nodes.blockquote,
+ [BulletList.name]: defaultMarkdownSerializer.nodes.bullet_list,
+ [CodeBlockHighlight.name]: (state, node) => {
+ state.write(`\`\`\`${node.attrs.language || ''}\n`);
+ state.text(node.textContent, false);
+ state.ensureNewLine();
+ state.write('```');
+ state.closeBlock(node);
+ },
+ [Emoji.name]: (state, node) => {
+ const { name } = node.attrs;
+
+ state.write(`:${name}:`);
+ },
+ [HardBreak.name]: defaultMarkdownSerializer.nodes.hard_break,
+ [Heading.name]: defaultMarkdownSerializer.nodes.heading,
+ [HorizontalRule.name]: defaultMarkdownSerializer.nodes.horizontal_rule,
+ [Image.name]: (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})`);
+ },
+ [ListItem.name]: defaultMarkdownSerializer.nodes.list_item,
+ [OrderedList.name]: defaultMarkdownSerializer.nodes.ordered_list,
+ [Paragraph.name]: defaultMarkdownSerializer.nodes.paragraph,
+ [Reference.name]: (state, node) => {
+ state.write(node.attrs.originalText || node.attrs.text);
+ },
+ [Table.name]: (state, node) => {
+ state.renderContent(node);
+ },
+ [TableCell.name]: (state, node) => {
+ state.renderInline(node);
+ },
+ [TableHeader.name]: (state, node) => {
+ state.renderInline(node);
+ },
+ [TableRow.name]: (state, node) => {
+ const isHeaderRow = node.child(0).type.name === 'tableHeader';
+
+ const renderRow = () => {
+ const cellWidths = [];
+
+ state.flushClose(1);
+
+ state.write('| ');
+ node.forEach((cell, _, i) => {
+ if (i) state.write(' | ');
+
+ const { length } = state.out;
+ state.render(cell, node, i);
+ cellWidths.push(state.out.length - length);
+ });
+ state.write(' |');
+
+ state.closeBlock(node);
+
+ return cellWidths;
+ };
+
+ const renderHeaderRow = (cellWidths) => {
+ state.flushClose(1);
+
+ state.write('|');
+ node.forEach((cell, _, i) => {
+ if (i) state.write('|');
+
+ state.write(cell.attrs.align === 'center' ? ':' : '-');
+ state.write(state.repeat('-', cellWidths[i]));
+ state.write(cell.attrs.align === 'center' || cell.attrs.align === 'right' ? ':' : '-');
+ });
+ state.write('|');
+
+ state.closeBlock(node);
+ };
+
+ if (isHeaderRow) {
+ renderHeaderRow(renderRow());
+ } else {
+ renderRow();
+ }
+ },
+ [TaskItem.name]: (state, node) => {
+ state.write(`[${node.attrs.checked ? 'x' : ' '}] `);
+ state.renderContent(node);
+ },
+ [TaskList.name]: (state, node) => {
+ if (node.attrs.type === 'ul') defaultMarkdownSerializer.nodes.bullet_list(state, node);
+ else defaultMarkdownSerializer.nodes.ordered_list(state, node);
+ },
+ [Text.name]: defaultMarkdownSerializer.nodes.text,
+ },
+};
const wrapHtmlPayload = (payload) => `<div>${payload}</div>`;
@@ -50,8 +210,16 @@ export default ({ render = () => null, serializerConfig }) => ({
*/
serialize: ({ schema, content }) => {
const proseMirrorDocument = schema.nodeFromJSON(content);
- const { nodes, marks } = serializerConfig;
- const serializer = new ProseMirrorMarkdownSerializer(nodes, marks);
+ const serializer = new ProseMirrorMarkdownSerializer(
+ {
+ ...defaultSerializerConfig.nodes,
+ ...serializerConfig.nodes,
+ },
+ {
+ ...defaultSerializerConfig.marks,
+ ...serializerConfig.marks,
+ },
+ );
return serializer.serialize(proseMirrorDocument, {
tightLists: true,
diff --git a/app/assets/javascripts/content_editor/services/track_ui_control.js b/app/assets/javascripts/content_editor/services/track_ui_control.js
new file mode 100644
index 00000000000..61f130ea861
--- /dev/null
+++ b/app/assets/javascripts/content_editor/services/track_ui_control.js
@@ -0,0 +1,9 @@
+import Tracking from '~/tracking';
+import { CONTENT_EDITOR_TRACKING_LABEL, TOOLBAR_CONTROL_TRACKING_ACTION } from '../constants';
+
+export default ({ action = TOOLBAR_CONTROL_TRACKING_ACTION, property, value } = {}) =>
+ Tracking.event(undefined, action, {
+ label: CONTENT_EDITOR_TRACKING_LABEL,
+ property,
+ value,
+ });
diff --git a/app/assets/javascripts/content_editor/services/upload_file.js b/app/assets/javascripts/content_editor/services/upload_file.js
deleted file mode 100644
index 613c53144a1..00000000000
--- a/app/assets/javascripts/content_editor/services/upload_file.js
+++ /dev/null
@@ -1,44 +0,0 @@
-import axios from '~/lib/utils/axios_utils';
-
-const extractAttachmentLinkUrl = (html) => {
- const parser = new DOMParser();
- const { body } = parser.parseFromString(html, 'text/html');
- const link = body.querySelector('a');
- const src = link.getAttribute('href');
- const { canonicalSrc } = link.dataset;
-
- return { src, canonicalSrc };
-};
-
-/**
- * Uploads a file with a post request to the URL indicated
- * in the uploadsPath parameter. The expected response of the
- * uploads service is a JSON object that contains, at least, a
- * link property. The link property should contain markdown link
- * definition (i.e. [GitLab](https://gitlab.com)).
- *
- * This Markdown will be rendered to extract its canonical and full
- * URLs using GitLab Flavored Markdown renderer in the backend.
- *
- * @param {Object} params
- * @param {String} params.uploadsPath An absolute URL that points to a service
- * that allows sending a file for uploading via POST request.
- * @param {String} params.renderMarkdown A function that accepts a markdown string
- * and returns a rendered version in HTML format.
- * @param {File} params.file The file to upload
- *
- * @returns Returns an object with two properties:
- *
- * canonicalSrc: The URL as defined in the Markdown
- * src: The absolute URL that points to the resource in the server
- */
-export const uploadFile = async ({ uploadsPath, renderMarkdown, file }) => {
- const formData = new FormData();
- formData.append('file', file, file.name);
-
- const { data } = await axios.post(uploadsPath, formData);
- const { markdown } = data.link;
- const rendered = await renderMarkdown(markdown);
-
- return extractAttachmentLinkUrl(rendered);
-};
diff --git a/app/assets/javascripts/content_editor/services/upload_helpers.js b/app/assets/javascripts/content_editor/services/upload_helpers.js
new file mode 100644
index 00000000000..8ac3f719309
--- /dev/null
+++ b/app/assets/javascripts/content_editor/services/upload_helpers.js
@@ -0,0 +1,123 @@
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
+import { extractFilename, readFileAsDataURL } from './utils';
+
+export const acceptedMimes = {
+ image: ['image/jpeg', 'image/png', 'image/gif', 'image/jpg'],
+};
+
+const extractAttachmentLinkUrl = (html) => {
+ const parser = new DOMParser();
+ const { body } = parser.parseFromString(html, 'text/html');
+ const link = body.querySelector('a');
+ const src = link.getAttribute('href');
+ const { canonicalSrc } = link.dataset;
+
+ return { src, canonicalSrc };
+};
+
+/**
+ * Uploads a file with a post request to the URL indicated
+ * in the uploadsPath parameter. The expected response of the
+ * uploads service is a JSON object that contains, at least, a
+ * link property. The link property should contain markdown link
+ * definition (i.e. [GitLab](https://gitlab.com)).
+ *
+ * This Markdown will be rendered to extract its canonical and full
+ * URLs using GitLab Flavored Markdown renderer in the backend.
+ *
+ * @param {Object} params
+ * @param {String} params.uploadsPath An absolute URL that points to a service
+ * that allows sending a file for uploading via POST request.
+ * @param {String} params.renderMarkdown A function that accepts a markdown string
+ * and returns a rendered version in HTML format.
+ * @param {File} params.file The file to upload
+ *
+ * @returns Returns an object with two properties:
+ *
+ * canonicalSrc: The URL as defined in the Markdown
+ * src: The absolute URL that points to the resource in the server
+ */
+export const uploadFile = async ({ uploadsPath, renderMarkdown, file }) => {
+ const formData = new FormData();
+ formData.append('file', file, file.name);
+
+ const { data } = await axios.post(uploadsPath, formData);
+ const { markdown } = data.link;
+ const rendered = await renderMarkdown(markdown);
+
+ return extractAttachmentLinkUrl(rendered);
+};
+
+const uploadImage = 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: extractFilename(src),
+ canonicalSrc,
+ }),
+ );
+ } catch (e) {
+ editor.commands.deleteRange({ from: position, to: position + 1 });
+ editor.emit('error', {
+ error: __('An error occurred while uploading the image. Please try again.'),
+ });
+ }
+};
+
+const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown }) => {
+ await Promise.resolve();
+
+ const { view } = editor;
+
+ const text = extractFilename(file.name);
+
+ const { state } = view;
+ const { from } = state.selection;
+
+ editor.commands.insertContent({
+ type: 'loading',
+ attrs: { label: text },
+ });
+
+ try {
+ const { src, canonicalSrc } = await uploadFile({ file, uploadsPath, renderMarkdown });
+
+ editor.commands.insertContentAt(
+ { from, to: from + 1 },
+ { type: 'text', text, marks: [{ type: 'link', attrs: { href: src, canonicalSrc } }] },
+ );
+ } catch (e) {
+ editor.commands.deleteRange({ from, to: from + 1 });
+ editor.emit('error', {
+ error: __('An error occurred while uploading the file. Please try again.'),
+ });
+ }
+};
+
+export const handleFileEvent = ({ editor, file, uploadsPath, renderMarkdown }) => {
+ if (!file) return false;
+
+ if (acceptedMimes.image.includes(file?.type)) {
+ uploadImage({ editor, file, uploadsPath, renderMarkdown });
+
+ return true;
+ }
+
+ uploadAttachment({ editor, file, uploadsPath, renderMarkdown });
+
+ return true;
+};
diff --git a/app/assets/javascripts/content_editor/services/utils.js b/app/assets/javascripts/content_editor/services/utils.js
index 2a2c7f617da..b3856b0dd74 100644
--- a/app/assets/javascripts/content_editor/services/utils.js
+++ b/app/assets/javascripts/content_editor/services/utils.js
@@ -4,8 +4,18 @@ export const hasSelection = (tiptapEditor) => {
return from < to;
};
-export const getImageAlt = (src) => {
- return src.replace(/^.*\/|\..*$/g, '').replace(/\W+/g, ' ');
+/**
+ * Extracts filename from a URL
+ *
+ * @example
+ * > extractFilename('https://gitlab.com/images/logo-full.png')
+ * < 'logo-full'
+ *
+ * @param {string} src The URL to extract filename from
+ * @returns {string}
+ */
+export const extractFilename = (src) => {
+ return src.replace(/^.*\/|\..+?$/g, '');
};
export const readFileAsDataURL = (file) => {