diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-05-19 15:44:42 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-05-19 15:44:42 +0000 |
commit | 4555e1b21c365ed8303ffb7a3325d773c9b8bf31 (patch) | |
tree | 5423a1c7516cffe36384133ade12572cf709398d /app/assets/javascripts/content_editor | |
parent | e570267f2f6b326480d284e0164a6464ba4081bc (diff) | |
download | gitlab-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')
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; |