diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-08-19 09:08:42 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-08-19 09:08:42 +0000 |
commit | b76ae638462ab0f673e5915986070518dd3f9ad3 (patch) | |
tree | bdab0533383b52873be0ec0eb4d3c66598ff8b91 /app/assets/javascripts/content_editor | |
parent | 434373eabe7b4be9593d18a585fb763f1e5f1a6f (diff) | |
download | gitlab-ce-b76ae638462ab0f673e5915986070518dd3f9ad3.tar.gz |
Add latest changes from gitlab-org/gitlab@14-2-stable-eev14.2.0-rc42
Diffstat (limited to 'app/assets/javascripts/content_editor')
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) => { |