summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/content_editor
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-05-19 15:44:42 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2021-05-19 15:44:42 +0000
commit4555e1b21c365ed8303ffb7a3325d773c9b8bf31 (patch)
tree5423a1c7516cffe36384133ade12572cf709398d /app/assets/javascripts/content_editor
parente570267f2f6b326480d284e0164a6464ba4081bc (diff)
downloadgitlab-ce-4555e1b21c365ed8303ffb7a3325d773c9b8bf31.tar.gz
Add latest changes from gitlab-org/gitlab@13-12-stable-eev13.12.0-rc42
Diffstat (limited to 'app/assets/javascripts/content_editor')
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor.vue22
-rw-r--r--app/assets/javascripts/content_editor/components/divider.vue3
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_button.vue65
-rw-r--r--app/assets/javascripts/content_editor/components/top_toolbar.vue94
-rw-r--r--app/assets/javascripts/content_editor/constants.js5
-rw-r--r--app/assets/javascripts/content_editor/extensions/blockquote.js5
-rw-r--r--app/assets/javascripts/content_editor/extensions/bold.js5
-rw-r--r--app/assets/javascripts/content_editor/extensions/bullet_list.js5
-rw-r--r--app/assets/javascripts/content_editor/extensions/code.js5
-rw-r--r--app/assets/javascripts/content_editor/extensions/code_block_highlight.js53
-rw-r--r--app/assets/javascripts/content_editor/extensions/document.js3
-rw-r--r--app/assets/javascripts/content_editor/extensions/dropcursor.js3
-rw-r--r--app/assets/javascripts/content_editor/extensions/gapcursor.js3
-rw-r--r--app/assets/javascripts/content_editor/extensions/hard_break.js5
-rw-r--r--app/assets/javascripts/content_editor/extensions/heading.js5
-rw-r--r--app/assets/javascripts/content_editor/extensions/history.js3
-rw-r--r--app/assets/javascripts/content_editor/extensions/horizontal_rule.js5
-rw-r--r--app/assets/javascripts/content_editor/extensions/image.js9
-rw-r--r--app/assets/javascripts/content_editor/extensions/italic.js4
-rw-r--r--app/assets/javascripts/content_editor/extensions/link.js5
-rw-r--r--app/assets/javascripts/content_editor/extensions/list_item.js5
-rw-r--r--app/assets/javascripts/content_editor/extensions/ordered_list.js5
-rw-r--r--app/assets/javascripts/content_editor/extensions/paragraph.js5
-rw-r--r--app/assets/javascripts/content_editor/extensions/text.js5
-rw-r--r--app/assets/javascripts/content_editor/index.js2
-rw-r--r--app/assets/javascripts/content_editor/services/build_serializer_config.js22
-rw-r--r--app/assets/javascripts/content_editor/services/content_editor.js25
-rw-r--r--app/assets/javascripts/content_editor/services/create_content_editor.js76
-rw-r--r--app/assets/javascripts/content_editor/services/create_editor.js60
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_serializer.js101
-rw-r--r--app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js61
31 files changed, 516 insertions, 158 deletions
diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue
index 839d4de912d..7896268acf0 100644
--- a/app/assets/javascripts/content_editor/components/content_editor.vue
+++ b/app/assets/javascripts/content_editor/components/content_editor.vue
@@ -1,18 +1,24 @@
<script>
-import { EditorContent } from 'tiptap';
-import createEditor from '../services/create_editor';
+import { EditorContent as TiptapEditorContent } from '@tiptap/vue-2';
+import { ContentEditor } from '../services/content_editor';
+import TopToolbar from './top_toolbar.vue';
export default {
components: {
- EditorContent,
+ TiptapEditorContent,
+ TopToolbar,
},
- data() {
- return {
- editor: createEditor(),
- };
+ props: {
+ contentEditor: {
+ type: ContentEditor,
+ required: true,
+ },
},
};
</script>
<template>
- <editor-content :editor="editor" />
+ <div class="md md-area" :class="{ 'is-focused': contentEditor.tiptapEditor.isFocused }">
+ <top-toolbar class="gl-mb-4" :content-editor="contentEditor" />
+ <tiptap-editor-content :editor="contentEditor.tiptapEditor" />
+ </div>
</template>
diff --git a/app/assets/javascripts/content_editor/components/divider.vue b/app/assets/javascripts/content_editor/components/divider.vue
new file mode 100644
index 00000000000..b77bd7b7cf3
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/divider.vue
@@ -0,0 +1,3 @@
+<template>
+ <span class="gl-mx-3 gl-border-r-solid gl-border-r-1 gl-border-gray-200"></span>
+</template>
diff --git a/app/assets/javascripts/content_editor/components/toolbar_button.vue b/app/assets/javascripts/content_editor/components/toolbar_button.vue
new file mode 100644
index 00000000000..0af12812f3b
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/toolbar_button.vue
@@ -0,0 +1,65 @@
+<script>
+import { GlButton, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
+import { Editor as TiptapEditor } from '@tiptap/vue-2';
+
+export default {
+ components: {
+ GlButton,
+ },
+ directives: {
+ GlTooltip,
+ },
+ props: {
+ iconName: {
+ type: String,
+ required: true,
+ },
+ tiptapEditor: {
+ type: TiptapEditor,
+ required: true,
+ },
+ contentType: {
+ type: String,
+ required: true,
+ },
+ label: {
+ type: String,
+ required: true,
+ },
+ editorCommand: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ isActive() {
+ return this.tiptapEditor.isActive(this.contentType) && this.tiptapEditor.isFocused;
+ },
+ },
+ methods: {
+ execute() {
+ const { contentType } = this;
+
+ if (this.editorCommand) {
+ this.tiptapEditor.chain()[this.editorCommand]().focus().run();
+ }
+
+ this.$emit('execute', { contentType });
+ },
+ },
+};
+</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"
+ />
+</template>
diff --git a/app/assets/javascripts/content_editor/components/top_toolbar.vue b/app/assets/javascripts/content_editor/components/top_toolbar.vue
new file mode 100644
index 00000000000..b18649d4e57
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/top_toolbar.vue
@@ -0,0 +1,94 @@
+<script>
+import Tracking from '~/tracking';
+import { CONTENT_EDITOR_TRACKING_LABEL, TOOLBAR_CONTROL_TRACKING_ACTION } from '../constants';
+import { ContentEditor } from '../services/content_editor';
+import Divider from './divider.vue';
+import ToolbarButton from './toolbar_button.vue';
+
+const trackingMixin = Tracking.mixin({
+ label: CONTENT_EDITOR_TRACKING_LABEL,
+});
+
+export default {
+ components: {
+ ToolbarButton,
+ Divider,
+ },
+ mixins: [trackingMixin],
+ props: {
+ contentEditor: {
+ type: ContentEditor,
+ required: true,
+ },
+ },
+ methods: {
+ trackToolbarControlExecution({ contentType: property, value }) {
+ this.track(TOOLBAR_CONTROL_TRACKING_ACTION, {
+ property,
+ value,
+ });
+ },
+ },
+};
+</script>
+<template>
+ <div
+ class="gl-display-flex gl-justify-content-end gl-pb-3 gl-pt-0 gl-border-b-solid gl-border-b-1 gl-border-b-gray-200"
+ >
+ <toolbar-button
+ data-testid="bold"
+ content-type="bold"
+ icon-name="bold"
+ editor-command="toggleBold"
+ :label="__('Bold text')"
+ :tiptap-editor="contentEditor.tiptapEditor"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="italic"
+ content-type="italic"
+ icon-name="italic"
+ editor-command="toggleItalic"
+ :label="__('Italic text')"
+ :tiptap-editor="contentEditor.tiptapEditor"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="code"
+ content-type="code"
+ icon-name="code"
+ editor-command="toggleCode"
+ :label="__('Code')"
+ :tiptap-editor="contentEditor.tiptapEditor"
+ @execute="trackToolbarControlExecution"
+ />
+ <divider />
+ <toolbar-button
+ data-testid="blockquote"
+ content-type="blockquote"
+ icon-name="quote"
+ editor-command="toggleBlockquote"
+ :label="__('Insert a quote')"
+ :tiptap-editor="contentEditor.tiptapEditor"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="bullet-list"
+ content-type="bulletList"
+ icon-name="list-bulleted"
+ 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"
+ editor-command="toggleOrderedList"
+ :label="__('Add a numbered list')"
+ :tiptap-editor="contentEditor.tiptapEditor"
+ @execute="trackToolbarControlExecution"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/content_editor/constants.js b/app/assets/javascripts/content_editor/constants.js
index eb6deff434d..45ebd87dac9 100644
--- a/app/assets/javascripts/content_editor/constants.js
+++ b/app/assets/javascripts/content_editor/constants.js
@@ -3,3 +3,8 @@ import { s__ } from '~/locale';
export const PROVIDE_SERIALIZER_OR_RENDERER_ERROR = s__(
'ContentEditor|You have to provide a renderMarkdown function or a custom serializer',
);
+
+export const CONTENT_EDITOR_TRACKING_LABEL = 'content_editor';
+export const TOOLBAR_CONTROL_TRACKING_ACTION = 'execute_toolbar_control';
+export const KEYBOARD_SHORTCUT_TRACKING_ACTION = 'execute_keyboard_shortcut';
+export const INPUT_RULE_TRACKING_ACTION = 'execute_input_rule';
diff --git a/app/assets/javascripts/content_editor/extensions/blockquote.js b/app/assets/javascripts/content_editor/extensions/blockquote.js
new file mode 100644
index 00000000000..a4297b4550c
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/blockquote.js
@@ -0,0 +1,5 @@
+import { Blockquote } from '@tiptap/extension-blockquote';
+import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
+
+export const tiptapExtension = Blockquote;
+export const serializer = defaultMarkdownSerializer.nodes.blockquote;
diff --git a/app/assets/javascripts/content_editor/extensions/bold.js b/app/assets/javascripts/content_editor/extensions/bold.js
new file mode 100644
index 00000000000..e90e7b59da0
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/bold.js
@@ -0,0 +1,5 @@
+import { Bold } from '@tiptap/extension-bold';
+import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
+
+export const tiptapExtension = Bold;
+export const serializer = defaultMarkdownSerializer.marks.strong;
diff --git a/app/assets/javascripts/content_editor/extensions/bullet_list.js b/app/assets/javascripts/content_editor/extensions/bullet_list.js
new file mode 100644
index 00000000000..178b798e2d4
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/bullet_list.js
@@ -0,0 +1,5 @@
+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;
diff --git a/app/assets/javascripts/content_editor/extensions/code.js b/app/assets/javascripts/content_editor/extensions/code.js
new file mode 100644
index 00000000000..8be50dc39c5
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/code.js
@@ -0,0 +1,5 @@
+import { Code } from '@tiptap/extension-code';
+import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
+
+export const tiptapExtension = Code;
+export const serializer = defaultMarkdownSerializer.marks.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 1d050ed208b..ce8bd57c7e3 100644
--- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
+++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
@@ -1,38 +1,27 @@
-import { CodeBlockHighlight as BaseCodeBlockHighlight } from 'tiptap-extensions';
+import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight';
+import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
-export default class GlCodeBlockHighlight extends BaseCodeBlockHighlight {
- get schema() {
- const baseSchema = super.schema;
+const extractLanguage = (element) => element.firstElementChild?.getAttribute('lang');
+const ExtendedCodeBlockLowlight = CodeBlockLowlight.extend({
+ addAttributes() {
return {
- ...baseSchema,
- attrs: {
- params: {
- default: null,
+ ...this.parent(),
+ /* `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),
+ };
},
},
- parseDOM: [
- {
- tag: 'pre',
- preserveWhitespace: 'full',
- getAttrs: (node) => {
- const code = node.querySelector('code');
-
- if (!code) {
- return null;
- }
-
- return {
- /* `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: code.getAttribute('lang'),
- };
- },
- },
- ],
};
- }
-}
+ },
+});
+
+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
new file mode 100644
index 00000000000..99aa8d6235a
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/document.js
@@ -0,0 +1,3 @@
+import Document from '@tiptap/extension-document';
+
+export const tiptapExtension = Document;
diff --git a/app/assets/javascripts/content_editor/extensions/dropcursor.js b/app/assets/javascripts/content_editor/extensions/dropcursor.js
new file mode 100644
index 00000000000..44c378ac7db
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/dropcursor.js
@@ -0,0 +1,3 @@
+import Dropcursor from '@tiptap/extension-dropcursor';
+
+export const tiptapExtension = Dropcursor;
diff --git a/app/assets/javascripts/content_editor/extensions/gapcursor.js b/app/assets/javascripts/content_editor/extensions/gapcursor.js
new file mode 100644
index 00000000000..2db862e4580
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/gapcursor.js
@@ -0,0 +1,3 @@
+import Gapcursor from '@tiptap/extension-gapcursor';
+
+export const tiptapExtension = Gapcursor;
diff --git a/app/assets/javascripts/content_editor/extensions/hard_break.js b/app/assets/javascripts/content_editor/extensions/hard_break.js
new file mode 100644
index 00000000000..dc1ba431151
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/hard_break.js
@@ -0,0 +1,5 @@
+import { HardBreak } from '@tiptap/extension-hard-break';
+import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
+
+export const tiptapExtension = HardBreak;
+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
new file mode 100644
index 00000000000..f69869d1e09
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/heading.js
@@ -0,0 +1,5 @@
+import { Heading } from '@tiptap/extension-heading';
+import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
+
+export const tiptapExtension = Heading;
+export const serializer = defaultMarkdownSerializer.nodes.heading;
diff --git a/app/assets/javascripts/content_editor/extensions/history.js b/app/assets/javascripts/content_editor/extensions/history.js
new file mode 100644
index 00000000000..554d797d30a
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/history.js
@@ -0,0 +1,3 @@
+import History from '@tiptap/extension-history';
+
+export const tiptapExtension = History;
diff --git a/app/assets/javascripts/content_editor/extensions/horizontal_rule.js b/app/assets/javascripts/content_editor/extensions/horizontal_rule.js
new file mode 100644
index 00000000000..dcc59476518
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/horizontal_rule.js
@@ -0,0 +1,5 @@
+import { HorizontalRule } from '@tiptap/extension-horizontal-rule';
+import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
+
+export const tiptapExtension = HorizontalRule;
+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
new file mode 100644
index 00000000000..4f0109fd751
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/image.js
@@ -0,0 +1,9 @@
+import { Image } from '@tiptap/extension-image';
+import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
+
+const ExtendedImage = Image.extend({
+ defaultOptions: { inline: true },
+});
+
+export const tiptapExtension = ExtendedImage;
+export const serializer = defaultMarkdownSerializer.nodes.image;
diff --git a/app/assets/javascripts/content_editor/extensions/italic.js b/app/assets/javascripts/content_editor/extensions/italic.js
new file mode 100644
index 00000000000..b8a7c4aba3e
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/italic.js
@@ -0,0 +1,4 @@
+import { Italic } from '@tiptap/extension-italic';
+
+export const tiptapExtension = Italic;
+export const serializer = { open: '_', close: '_', mixable: true, expelEnclosingWhitespace: true };
diff --git a/app/assets/javascripts/content_editor/extensions/link.js b/app/assets/javascripts/content_editor/extensions/link.js
new file mode 100644
index 00000000000..9a2fa7a5c98
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/link.js
@@ -0,0 +1,5 @@
+import { Link } from '@tiptap/extension-link';
+import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
+
+export const tiptapExtension = Link;
+export const serializer = defaultMarkdownSerializer.marks.link;
diff --git a/app/assets/javascripts/content_editor/extensions/list_item.js b/app/assets/javascripts/content_editor/extensions/list_item.js
new file mode 100644
index 00000000000..86da98f6df7
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/list_item.js
@@ -0,0 +1,5 @@
+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;
diff --git a/app/assets/javascripts/content_editor/extensions/ordered_list.js b/app/assets/javascripts/content_editor/extensions/ordered_list.js
new file mode 100644
index 00000000000..d980ab8bf10
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/ordered_list.js
@@ -0,0 +1,5 @@
+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;
diff --git a/app/assets/javascripts/content_editor/extensions/paragraph.js b/app/assets/javascripts/content_editor/extensions/paragraph.js
new file mode 100644
index 00000000000..6c9f204b8ac
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/paragraph.js
@@ -0,0 +1,5 @@
+import { Paragraph } from '@tiptap/extension-paragraph';
+import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
+
+export const tiptapExtension = Paragraph;
+export const serializer = defaultMarkdownSerializer.nodes.paragraph;
diff --git a/app/assets/javascripts/content_editor/extensions/text.js b/app/assets/javascripts/content_editor/extensions/text.js
new file mode 100644
index 00000000000..0d76aa1f1a7
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/text.js
@@ -0,0 +1,5 @@
+import { Text } from '@tiptap/extension-text';
+import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
+
+export const tiptapExtension = Text;
+export const serializer = defaultMarkdownSerializer.nodes.text;
diff --git a/app/assets/javascripts/content_editor/index.js b/app/assets/javascripts/content_editor/index.js
index e6ef3965da1..2a7dc9b713d 100644
--- a/app/assets/javascripts/content_editor/index.js
+++ b/app/assets/javascripts/content_editor/index.js
@@ -1,2 +1,2 @@
-export { default as createEditor } from './services/create_editor';
+export * from './services/create_content_editor';
export { default as ContentEditor } from './components/content_editor.vue';
diff --git a/app/assets/javascripts/content_editor/services/build_serializer_config.js b/app/assets/javascripts/content_editor/services/build_serializer_config.js
new file mode 100644
index 00000000000..75e2b0f9eba
--- /dev/null
+++ b/app/assets/javascripts/content_editor/services/build_serializer_config.js
@@ -0,0 +1,22 @@
+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
new file mode 100644
index 00000000000..e2188f5aa69
--- /dev/null
+++ b/app/assets/javascripts/content_editor/services/content_editor.js
@@ -0,0 +1,25 @@
+/* eslint-disable no-underscore-dangle */
+export class ContentEditor {
+ constructor({ tiptapEditor, serializer }) {
+ this._tiptapEditor = tiptapEditor;
+ this._serializer = serializer;
+ }
+
+ get tiptapEditor() {
+ return this._tiptapEditor;
+ }
+
+ async setSerializedContent(serializedContent) {
+ const { _tiptapEditor: editor, _serializer: serializer } = this;
+
+ editor.commands.setContent(
+ await serializer.deserialize({ schema: editor.schema, content: serializedContent }),
+ );
+ }
+
+ getSerializedContent() {
+ const { _tiptapEditor: editor, _serializer: serializer } = this;
+
+ return serializer.serialize({ schema: editor.schema, content: editor.getJSON() });
+ }
+}
diff --git a/app/assets/javascripts/content_editor/services/create_content_editor.js b/app/assets/javascripts/content_editor/services/create_content_editor.js
new file mode 100644
index 00000000000..df45287e6cb
--- /dev/null
+++ b/app/assets/javascripts/content_editor/services/create_content_editor.js
@@ -0,0 +1,76 @@
+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 Text from '../extensions/text';
+import buildSerializerConfig from './build_serializer_config';
+import { ContentEditor } from './content_editor';
+import createMarkdownSerializer from './markdown_serializer';
+import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts';
+
+const builtInContentEditorExtensions = [
+ Blockquote,
+ Bold,
+ BulletList,
+ Code,
+ CodeBlockHighlight,
+ Document,
+ Dropcursor,
+ Gapcursor,
+ HardBreak,
+ Heading,
+ History,
+ HorizontalRule,
+ Image,
+ Italic,
+ Link,
+ ListItem,
+ OrderedList,
+ Paragraph,
+ Text,
+];
+
+const collectTiptapExtensions = (extensions = []) =>
+ extensions.map(({ tiptapExtension }) => tiptapExtension);
+
+const createTiptapEditor = ({ extensions = [], ...options } = {}) =>
+ new Editor({
+ extensions: [...extensions],
+ editorProps: {
+ attributes: {
+ class: 'gl-outline-0!',
+ },
+ },
+ ...options,
+ });
+
+export const createContentEditor = ({ renderMarkdown, extensions = [], tiptapOptions } = {}) => {
+ if (!isFunction(renderMarkdown)) {
+ throw new Error(PROVIDE_SERIALIZER_OR_RENDERER_ERROR);
+ }
+
+ const allExtensions = [...builtInContentEditorExtensions, ...extensions];
+ const tiptapExtensions = collectTiptapExtensions(allExtensions).map(trackInputRulesAndShortcuts);
+ const tiptapEditor = createTiptapEditor({ extensions: tiptapExtensions, ...tiptapOptions });
+ const serializerConfig = buildSerializerConfig(allExtensions);
+ const serializer = createMarkdownSerializer({ render: renderMarkdown, serializerConfig });
+
+ return new ContentEditor({ tiptapEditor, serializer });
+};
diff --git a/app/assets/javascripts/content_editor/services/create_editor.js b/app/assets/javascripts/content_editor/services/create_editor.js
deleted file mode 100644
index 128d332b0a2..00000000000
--- a/app/assets/javascripts/content_editor/services/create_editor.js
+++ /dev/null
@@ -1,60 +0,0 @@
-import { isFunction, isString } from 'lodash';
-import { Editor } from 'tiptap';
-import {
- Bold,
- Italic,
- Code,
- Link,
- Image,
- Heading,
- Blockquote,
- HorizontalRule,
- BulletList,
- OrderedList,
- ListItem,
-} from 'tiptap-extensions';
-import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants';
-import CodeBlockHighlight from '../extensions/code_block_highlight';
-import createMarkdownSerializer from './markdown_serializer';
-
-const createEditor = async ({ content, renderMarkdown, serializer: customSerializer } = {}) => {
- if (!customSerializer && !isFunction(renderMarkdown)) {
- throw new Error(PROVIDE_SERIALIZER_OR_RENDERER_ERROR);
- }
-
- const editor = new Editor({
- extensions: [
- new Bold(),
- new Italic(),
- new Code(),
- new Link(),
- new Image(),
- new Heading({ levels: [1, 2, 3, 4, 5, 6] }),
- new Blockquote(),
- new HorizontalRule(),
- new BulletList(),
- new ListItem(),
- new OrderedList(),
- new CodeBlockHighlight(),
- ],
- });
- const serializer = customSerializer || createMarkdownSerializer({ render: renderMarkdown });
-
- editor.setSerializedContent = async (serializedContent) => {
- editor.setContent(
- await serializer.deserialize({ schema: editor.schema, content: serializedContent }),
- );
- };
-
- editor.getSerializedContent = () => {
- return serializer.serialize({ schema: editor.schema, content: editor.getJSON() });
- };
-
- if (isString(content)) {
- await editor.setSerializedContent(content);
- }
-
- return editor;
-};
-
-export default createEditor;
diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js
index e3b5775e320..f121cc9affd 100644
--- a/app/assets/javascripts/content_editor/services/markdown_serializer.js
+++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js
@@ -1,7 +1,4 @@
-import {
- MarkdownSerializer as ProseMirrorMarkdownSerializer,
- defaultMarkdownSerializer,
-} from 'prosemirror-markdown';
+import { MarkdownSerializer as ProseMirrorMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model';
const wrapHtmlPayload = (payload) => `<div>${payload}</div>`;
@@ -18,56 +15,46 @@ const wrapHtmlPayload = (payload) => `<div>${payload}</div>`;
* that parses the Markdown and converts it into HTML.
* @returns a markdown serializer
*/
-const create = ({ render = () => null }) => {
- return {
- /**
- * Converts a Markdown string into a ProseMirror JSONDocument based
- * on a ProseMirror schema.
- * @param {ProseMirror.Schema} params.schema A ProseMirror schema that defines
- * the types of content supported in the document
- * @param {String} params.content An arbitrary markdown string
- * @returns A ProseMirror JSONDocument
- */
- deserialize: async ({ schema, content }) => {
- const html = await render(content);
-
- if (!html) {
- return null;
- }
-
- const parser = new DOMParser();
- const {
- body: { firstElementChild },
- } = parser.parseFromString(wrapHtmlPayload(html), 'text/html');
- const state = ProseMirrorDOMParser.fromSchema(schema).parse(firstElementChild);
-
- return state.toJSON();
- },
-
- /**
- * Converts a ProseMirror JSONDocument based
- * on a ProseMirror schema into Markdown
- * @param {ProseMirror.Schema} params.schema A ProseMirror schema that defines
- * the types of content supported in the document
- * @param {String} params.content A ProseMirror JSONDocument
- * @returns A Markdown string
- */
- serialize: ({ schema, content }) => {
- const document = schema.nodeFromJSON(content);
- const serializer = new ProseMirrorMarkdownSerializer(defaultMarkdownSerializer.nodes, {
- ...defaultMarkdownSerializer.marks,
- bold: {
- // creates a bold alias for the strong mark converter
- ...defaultMarkdownSerializer.marks.strong,
- },
- italic: { open: '_', close: '_', mixable: true, expelEnclosingWhitespace: true },
- });
-
- return serializer.serialize(document, {
- tightLists: true,
- });
- },
- };
-};
-
-export default create;
+export default ({ render = () => null, serializerConfig }) => ({
+ /**
+ * Converts a Markdown string into a ProseMirror JSONDocument based
+ * on a ProseMirror schema.
+ * @param {ProseMirror.Schema} params.schema A ProseMirror schema that defines
+ * the types of content supported in the document
+ * @param {String} params.content An arbitrary markdown string
+ * @returns A ProseMirror JSONDocument
+ */
+ deserialize: async ({ schema, content }) => {
+ const html = await render(content);
+
+ if (!html) {
+ return null;
+ }
+
+ const parser = new DOMParser();
+ const {
+ body: { firstElementChild },
+ } = parser.parseFromString(wrapHtmlPayload(html), 'text/html');
+ const state = ProseMirrorDOMParser.fromSchema(schema).parse(firstElementChild);
+
+ return state.toJSON();
+ },
+
+ /**
+ * Converts a ProseMirror JSONDocument based
+ * on a ProseMirror schema into Markdown
+ * @param {ProseMirror.Schema} params.schema A ProseMirror schema that defines
+ * the types of content supported in the document
+ * @param {String} params.content A ProseMirror JSONDocument
+ * @returns A Markdown string
+ */
+ serialize: ({ schema, content }) => {
+ const proseMirrorDocument = schema.nodeFromJSON(content);
+ const { nodes, marks } = serializerConfig;
+ const serializer = new ProseMirrorMarkdownSerializer(nodes, marks);
+
+ return serializer.serialize(proseMirrorDocument, {
+ tightLists: true,
+ });
+ },
+});
diff --git a/app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js b/app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js
new file mode 100644
index 00000000000..860e5372bc2
--- /dev/null
+++ b/app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js
@@ -0,0 +1,61 @@
+import { mapValues, omit } from 'lodash';
+import { InputRule } from 'prosemirror-inputrules';
+import { ENTER_KEY, BACKSPACE_KEY } from '~/lib/utils/keys';
+import Tracking from '~/tracking';
+import {
+ CONTENT_EDITOR_TRACKING_LABEL,
+ KEYBOARD_SHORTCUT_TRACKING_ACTION,
+ INPUT_RULE_TRACKING_ACTION,
+} from '../constants';
+
+const trackKeyboardShortcut = (contentType, commandFn, shortcut) => () => {
+ Tracking.event(undefined, KEYBOARD_SHORTCUT_TRACKING_ACTION, {
+ label: CONTENT_EDITOR_TRACKING_LABEL,
+ property: `${contentType}.${shortcut}`,
+ });
+ return commandFn();
+};
+
+const trackInputRule = (contentType, inputRule) => {
+ return new InputRule(inputRule.match, (...args) => {
+ const result = inputRule.handler(...args);
+
+ if (result) {
+ Tracking.event(undefined, INPUT_RULE_TRACKING_ACTION, {
+ label: CONTENT_EDITOR_TRACKING_LABEL,
+ property: contentType,
+ });
+ }
+
+ return result;
+ });
+};
+
+const trackInputRulesAndShortcuts = (tiptapExtension) => {
+ return tiptapExtension.extend({
+ addKeyboardShortcuts() {
+ const shortcuts = this.parent?.() || {};
+ const { name } = this;
+
+ /**
+ * We don’t want to track keyboard shortcuts
+ * that are not deliberately executed to create
+ * new types of content
+ */
+ const withoutEnterShortcut = omit(shortcuts, [ENTER_KEY, BACKSPACE_KEY]);
+ const decorated = mapValues(withoutEnterShortcut, (commandFn, shortcut) =>
+ trackKeyboardShortcut(name, commandFn, shortcut),
+ );
+
+ return decorated;
+ },
+ addInputRules() {
+ const inputRules = this.parent?.() || [];
+ const { name } = this;
+
+ return inputRules.map((inputRule) => trackInputRule(name, inputRule));
+ },
+ });
+};
+
+export default trackInputRulesAndShortcuts;