diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-10-20 12:40:42 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-10-20 12:40:42 +0300 |
commit | ee664acb356f8123f4f6b00b73c1e1cf0866c7fb (patch) | |
tree | f8479f94a28f66654c6a4f6fb99bad6b4e86a40e /app/assets/javascripts/content_editor | |
parent | 62f7d5c5b69180e82ae8196b7b429eeffc8e7b4f (diff) |
Add latest changes from gitlab-org/gitlab@15-5-stable-eev15.5.0-rc42
Diffstat (limited to 'app/assets/javascripts/content_editor')
14 files changed, 655 insertions, 26 deletions
diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue index 659c447e861..22381377389 100644 --- a/app/assets/javascripts/content_editor/components/content_editor.vue +++ b/app/assets/javascripts/content_editor/components/content_editor.vue @@ -3,7 +3,7 @@ import { EditorContent as TiptapEditorContent } from '@tiptap/vue-2'; import { __ } from '~/locale'; import { VARIANT_DANGER } from '~/flash'; import { createContentEditor } from '../services/create_content_editor'; -import { ALERT_EVENT } from '../constants'; +import { ALERT_EVENT, TIPTAP_AUTOFOCUS_OPTIONS } from '../constants'; import ContentEditorAlert from './content_editor_alert.vue'; import ContentEditorProvider from './content_editor_provider.vue'; import EditorStateObserver from './editor_state_observer.vue'; @@ -51,6 +51,12 @@ export default { required: false, default: '', }, + autofocus: { + type: [String, Boolean], + required: false, + default: false, + validator: (autofocus) => TIPTAP_AUTOFOCUS_OPTIONS.includes(autofocus), + }, }, data() { return { @@ -67,7 +73,7 @@ export default { }, }, created() { - const { renderMarkdown, uploadsPath, extensions, serializerConfig } = this; + const { renderMarkdown, uploadsPath, extensions, serializerConfig, autofocus } = this; // This is a non-reactive attribute intentionally since this is a complex object. this.contentEditor = createContentEditor({ @@ -75,6 +81,9 @@ export default { uploadsPath, extensions, serializerConfig, + tiptapOptions: { + autofocus, + }, }); }, mounted() { @@ -141,7 +150,12 @@ export default { <template> <content-editor-provider :content-editor="contentEditor"> <div> - <editor-state-observer @docUpdate="notifyChange" @focus="focus" @blur="blur" /> + <editor-state-observer + @docUpdate="notifyChange" + @focus="focus" + @blur="blur" + @keydown="$emit('keydown', $event)" + /> <content-editor-alert /> <div data-testid="content-editor" diff --git a/app/assets/javascripts/content_editor/components/editor_state_observer.vue b/app/assets/javascripts/content_editor/components/editor_state_observer.vue index 41c3771bf41..ccb46e3b593 100644 --- a/app/assets/javascripts/content_editor/components/editor_state_observer.vue +++ b/app/assets/javascripts/content_editor/components/editor_state_observer.vue @@ -1,6 +1,6 @@ <script> import { debounce } from 'lodash'; -import { ALERT_EVENT } from '../constants'; +import { ALERT_EVENT, KEYDOWN_EVENT } from '../constants'; export const tiptapToComponentMap = { update: 'docUpdate', @@ -10,7 +10,7 @@ export const tiptapToComponentMap = { blur: 'blur', }; -export const eventHubEvents = [ALERT_EVENT]; +export const eventHubEvents = [ALERT_EVENT, KEYDOWN_EVENT]; const getComponentEventName = (tiptapEventName) => tiptapToComponentMap[tiptapEventName]; diff --git a/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue b/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue new file mode 100644 index 00000000000..987b7044272 --- /dev/null +++ b/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue @@ -0,0 +1,264 @@ +<script> +import { GlDropdownItem, GlAvatarLabeled } from '@gitlab/ui'; + +export default { + components: { + GlDropdownItem, + GlAvatarLabeled, + }, + + props: { + char: { + type: String, + required: true, + }, + + nodeType: { + type: String, + required: true, + }, + + nodeProps: { + type: Object, + required: true, + }, + + items: { + type: Array, + required: true, + }, + + command: { + type: Function, + required: true, + }, + }, + + data() { + return { + selectedIndex: 0, + }; + }, + + computed: { + isReference() { + return this.nodeType.startsWith('reference'); + }, + + isCommand() { + return this.isReference && this.nodeProps.referenceType === 'command'; + }, + + isUser() { + return this.isReference && this.nodeProps.referenceType === 'user'; + }, + + isIssue() { + return this.isReference && this.nodeProps.referenceType === 'issue'; + }, + + isLabel() { + return this.isReference && this.nodeProps.referenceType === 'label'; + }, + + isEpic() { + return this.isReference && this.nodeProps.referenceType === 'epic'; + }, + + isSnippet() { + return this.isReference && this.nodeProps.referenceType === 'snippet'; + }, + + isVulnerability() { + return this.isReference && this.nodeProps.referenceType === 'vulnerability'; + }, + + isMergeRequest() { + return this.isReference && this.nodeProps.referenceType === 'merge_request'; + }, + + isMilestone() { + return this.isReference && this.nodeProps.referenceType === 'milestone'; + }, + + isEmoji() { + return this.nodeType === 'emoji'; + }, + }, + + watch: { + items() { + this.selectedIndex = 0; + }, + }, + + methods: { + getText(item) { + if (this.isEmoji) return item.e; + + switch (this.isReference && this.nodeProps.referenceType) { + case 'user': + return `${this.char}${item.username}`; + case 'issue': + case 'merge_request': + return `${this.char}${item.iid}`; + case 'snippet': + return `${this.char}${item.id}`; + case 'milestone': + return `${this.char}${item.title}`; + case 'label': + return item.title; + case 'command': + return `${this.char}${item.name}`; + case 'epic': + return item.reference; + case 'vulnerability': + return `[vulnerability:${item.id}]`; + default: + return ''; + } + }, + + getProps(item) { + const props = {}; + + if (this.isEmoji) { + Object.assign(props, { + name: item.name, + unicodeVersion: item.u, + title: item.d, + moji: item.e, + }); + } + + if (this.isLabel || this.isMilestone) { + Object.assign(props, { + originalText: `${this.char}${ + /\W/.test(item.title) ? JSON.stringify(item.title) : item.title + }`, + }); + } + + if (this.isLabel) { + Object.assign(props, { + text: item.title, + color: item.color, + }); + } + + Object.assign(props, this.nodeProps); + + return props; + }, + + onKeyDown({ event }) { + if (event.key === 'ArrowUp') { + this.upHandler(); + return true; + } + + if (event.key === 'ArrowDown') { + this.downHandler(); + return true; + } + + if (event.key === 'Enter') { + this.enterHandler(); + return true; + } + + return false; + }, + + upHandler() { + this.selectedIndex = (this.selectedIndex + this.items.length - 1) % this.items.length; + }, + + downHandler() { + this.selectedIndex = (this.selectedIndex + 1) % this.items.length; + }, + + enterHandler() { + this.selectItem(this.selectedIndex); + }, + + selectItem(index) { + const item = this.items[index]; + + if (item) { + this.command({ + text: this.getText(item), + ...this.getProps(item), + }); + } + }, + + avatarSubLabel(item) { + return item.count ? `${item.name} (${item.count})` : item.name; + }, + }, +}; +</script> + +<template> + <ul + :class="{ show: items.length > 0 }" + class="gl-new-dropdown dropdown-menu gl-relative" + data-testid="content-editor-suggestions-dropdown" + > + <div class="gl-new-dropdown-inner gl-overflow-y-auto"> + <gl-dropdown-item + v-for="(item, index) in items" + :key="index" + :class="{ 'gl-bg-gray-50': index === selectedIndex }" + @click="selectItem(index)" + > + <gl-avatar-labeled + v-if="isUser" + :label="item.username" + :sub-label="avatarSubLabel(item)" + :src="item.avatar_url" + :entity-name="item.username" + :shape="item.type === 'Group' ? 'rect' : 'circle'" + :size="32" + /> + <span v-if="isIssue || isMergeRequest"> + <small>{{ item.iid }}</small> + {{ item.title }} + </span> + <span v-if="isVulnerability || isSnippet"> + <small>{{ item.id }}</small> + {{ item.title }} + </span> + <span v-if="isEpic"> + <small>{{ item.reference }}</small> + {{ item.title }} + </span> + <span v-if="isMilestone"> + {{ item.title }} + </span> + <span v-if="isLabel" class="gl-display-flex gl-align-items-center"> + <span + data-testid="label-color-box" + class="gl-rounded-base gl-display-block gl-w-5 gl-h-5 gl-mr-3" + :style="{ backgroundColor: item.color }" + ></span> + {{ item.title }} + </span> + <span v-if="isCommand"> + /{{ item.name }} <small> {{ item.params[0] }} </small><br /> + <em> + <small> {{ item.description }} </small> + </em> + </span> + <div v-if="isEmoji" class="gl-display-flex gl-align-items-center"> + <div class="gl-pr-4 gl-font-lg">{{ item.e }}</div> + <div class="gl-flex-grow-1"> + {{ item.name }}<br /> + <small>{{ item.d }}</small> + </div> + </div> + </gl-dropdown-item> + </div> + </ul> +</template> diff --git a/app/assets/javascripts/content_editor/components/wrappers/label.vue b/app/assets/javascripts/content_editor/components/wrappers/label.vue new file mode 100644 index 00000000000..4206c866032 --- /dev/null +++ b/app/assets/javascripts/content_editor/components/wrappers/label.vue @@ -0,0 +1,34 @@ +<script> +import { NodeViewWrapper } from '@tiptap/vue-2'; +import { GlLabel } from '@gitlab/ui'; +import { isScopedLabel } from '~/lib/utils/common_utils'; + +export default { + name: 'DetailsWrapper', + components: { + NodeViewWrapper, + GlLabel, + }, + props: { + node: { + type: Object, + required: true, + }, + }, + computed: { + isScopedLabel() { + return isScopedLabel({ title: this.node.attrs.originalText }); + }, + }, +}; +</script> +<template> + <node-view-wrapper class="gl-display-inline-block"> + <gl-label + size="sm" + :scoped="isScopedLabel" + :background-color="node.attrs.color" + :title="node.attrs.text" + /> + </node-view-wrapper> +</template> diff --git a/app/assets/javascripts/content_editor/constants/index.js b/app/assets/javascripts/content_editor/constants/index.js index 564cca23afa..14862727811 100644 --- a/app/assets/javascripts/content_editor/constants/index.js +++ b/app/assets/javascripts/content_editor/constants/index.js @@ -42,10 +42,8 @@ export const TEXT_STYLE_DROPDOWN_ITEMS = [ }, ]; -export const LOADING_CONTENT_EVENT = 'loading'; -export const LOADING_SUCCESS_EVENT = 'loadingSuccess'; -export const LOADING_ERROR_EVENT = 'loadingError'; export const ALERT_EVENT = 'alert'; +export const KEYDOWN_EVENT = 'keydown'; export const PARSE_HTML_PRIORITY_LOWEST = 1; export const PARSE_HTML_PRIORITY_DEFAULT = 50; @@ -66,3 +64,5 @@ export const SAFE_VIDEO_EXT = ['mp4', 'm4v', 'mov', 'webm', 'ogv']; export const SAFE_AUDIO_EXT = ['mp3', 'oga', 'ogg', 'spx', 'wav']; export const DIAGRAM_LANGUAGES = ['plantuml', 'mermaid']; + +export const TIPTAP_AUTOFOCUS_OPTIONS = [true, false, 'start', 'end', 'all']; diff --git a/app/assets/javascripts/content_editor/extensions/diagram.js b/app/assets/javascripts/content_editor/extensions/diagram.js index d9983b8c1c5..7c4a56468eb 100644 --- a/app/assets/javascripts/content_editor/extensions/diagram.js +++ b/app/assets/javascripts/content_editor/extensions/diagram.js @@ -1,5 +1,6 @@ import { lowlight } from 'lowlight/lib/core'; import { textblockTypeInputRule } from '@tiptap/core'; +import { base64DecodeUnicode } from '~/lib/utils/text_utility'; import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; import languageLoader from '../services/code_block_language_loader'; import CodeBlockHighlight from './code_block_highlight'; @@ -45,7 +46,9 @@ export default CodeBlockHighlight.extend({ priority: PARSE_HTML_PRIORITY_HIGHEST, tag: '[data-diagram]', getContent(element, schema) { - const source = atob(element.dataset.diagramSrc.replace('data:text/plain;base64,', '')); + const source = base64DecodeUnicode( + element.dataset.diagramSrc.replace('data:text/plain;base64,', ''), + ); const node = schema.node('paragraph', {}, [schema.text(source)]); return node.content; }, diff --git a/app/assets/javascripts/content_editor/extensions/external_keydown_handler.js b/app/assets/javascripts/content_editor/extensions/external_keydown_handler.js new file mode 100644 index 00000000000..e940614083e --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/external_keydown_handler.js @@ -0,0 +1,38 @@ +import { Extension } from '@tiptap/core'; +import { Plugin, PluginKey } from 'prosemirror-state'; +import { KEYDOWN_EVENT } from '../constants'; + +/** + * This extension bubbles up the keydown event, captured by ProseMirror in the + * contenteditale element, to the presentation layer implemented in vue. + * + * The purpose of this mechanism is allowing clients of the + * content editor to attach keyboard shortcuts for behavior outside + * of the Content Editor’s boundaries, i.e. submitting a form to save changes. + */ +export default Extension.create({ + name: 'keyboardShortcut', + addOptions() { + return { + eventHub: null, + }; + }, + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey('keyboardShortcut'), + props: { + handleKeyDown: (_, event) => { + const { + options: { eventHub }, + } = this; + + eventHub.$emit(KEYDOWN_EVENT, event); + + return false; + }, + }, + }), + ]; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/heading.js b/app/assets/javascripts/content_editor/extensions/heading.js index 48303cdeca4..41903162ba5 100644 --- a/app/assets/javascripts/content_editor/extensions/heading.js +++ b/app/assets/javascripts/content_editor/extensions/heading.js @@ -1 +1,15 @@ -export { Heading as default } from '@tiptap/extension-heading'; +import { Heading } from '@tiptap/extension-heading'; +import { textblockTypeInputRule } from '@tiptap/core'; + +export default Heading.extend({ + addInputRules() { + return this.options.levels.map((level) => { + return textblockTypeInputRule({ + // make sure heading regex doesn't conflict with issue references + find: new RegExp(`^(#{1,${level}})[ \t]$`), + type: this.type, + getAttributes: { level }, + }); + }); + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/reference.js b/app/assets/javascripts/content_editor/extensions/reference.js index 5e459e65de2..707beaf1231 100644 --- a/app/assets/javascripts/content_editor/extensions/reference.js +++ b/app/assets/javascripts/content_editor/extensions/reference.js @@ -46,22 +46,10 @@ export default Node.create({ tag: 'a.gfm:not([data-link=true])', priority: PARSE_HTML_PRIORITY_HIGHEST, }, - { - tag: 'span.gl-label', - }, ]; }, 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, - ]; + return ['a', { href: '#' }, node.attrs.text]; }, }); diff --git a/app/assets/javascripts/content_editor/extensions/reference_label.js b/app/assets/javascripts/content_editor/extensions/reference_label.js new file mode 100644 index 00000000000..716e191c3d5 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/reference_label.js @@ -0,0 +1,35 @@ +import { VueNodeViewRenderer } from '@tiptap/vue-2'; +import { SCOPED_LABEL_DELIMITER } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; +import LabelWrapper from '../components/wrappers/label.vue'; +import Reference from './reference'; + +export default Reference.extend({ + name: 'reference_label', + + addAttributes() { + return { + ...this.parent(), + text: { + default: null, + parseHTML: (element) => { + const text = element.querySelector('.gl-label-text').textContent; + const scopedText = element.querySelector('.gl-label-text-scoped')?.textContent; + if (!scopedText) return text; + return `${text}${SCOPED_LABEL_DELIMITER}${scopedText}`; + }, + }, + color: { + default: null, + parseHTML: (element) => element.querySelector('.gl-label-text').style.backgroundColor, + }, + }; + }, + + parseHTML() { + return [{ tag: 'span.gl-label' }]; + }, + + addNodeView() { + return new VueNodeViewRenderer(LabelWrapper); + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/suggestions.js b/app/assets/javascripts/content_editor/extensions/suggestions.js new file mode 100644 index 00000000000..8976b9cafee --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/suggestions.js @@ -0,0 +1,227 @@ +import { Node } from '@tiptap/core'; +import { VueRenderer } from '@tiptap/vue-2'; +import tippy from 'tippy.js'; +import Suggestion from '@tiptap/suggestion'; +import { PluginKey } from 'prosemirror-state'; +import { isFunction, uniqueId, memoize } from 'lodash'; +import axios from '~/lib/utils/axios_utils'; +import { initEmojiMap, getAllEmoji } from '~/emoji'; +import SuggestionsDropdown from '../components/suggestions_dropdown.vue'; + +function find(haystack, needle) { + return String(haystack).toLocaleLowerCase().includes(String(needle).toLocaleLowerCase()); +} + +function createSuggestionPlugin({ + editor, + char, + dataSource, + search, + limit = Infinity, + nodeType, + nodeProps = {}, +}) { + const fetchData = memoize( + isFunction(dataSource) ? dataSource : async () => (await axios.get(dataSource)).data, + ); + + return Suggestion({ + editor, + char, + pluginKey: new PluginKey(uniqueId('suggestions')), + + command: ({ editor: tiptapEditor, range, props }) => { + tiptapEditor + .chain() + .focus() + .insertContentAt(range, [ + { type: nodeType, attrs: props }, + { type: 'text', text: ' ' }, + ]) + .run(); + }, + + async items({ query }) { + if (!dataSource) return []; + + try { + const items = await fetchData(); + + return items.filter(search(query)).slice(0, limit); + } catch { + return []; + } + }, + + render: () => { + let component; + let popup; + + return { + onStart: (props) => { + component = new VueRenderer(SuggestionsDropdown, { + propsData: { + ...props, + char, + nodeType, + nodeProps, + }, + editor: props.editor, + }); + + if (!props.clientRect) { + return; + } + + popup = tippy('body', { + getReferenceClientRect: props.clientRect, + appendTo: () => document.body, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: 'manual', + placement: 'bottom-start', + }); + }, + + onUpdate(props) { + component?.updateProps(props); + + if (!props.clientRect) { + return; + } + + popup?.[0].setProps({ + getReferenceClientRect: props.clientRect, + }); + }, + + onKeyDown(props) { + if (props.event.key === 'Escape') { + popup?.[0].hide(); + + return true; + } + + return component?.ref?.onKeyDown(props); + }, + + onExit() { + popup?.[0].destroy(); + component?.destroy(); + }, + }; + }, + }); +} + +export default Node.create({ + name: 'suggestions', + + addProseMirrorPlugins() { + return [ + createSuggestionPlugin({ + editor: this.editor, + char: '@', + dataSource: gl.GfmAutoComplete?.dataSources.members, + nodeType: 'reference', + nodeProps: { + referenceType: 'user', + }, + search: (query) => ({ name, username }) => find(name, query) || find(username, query), + }), + createSuggestionPlugin({ + editor: this.editor, + char: '#', + dataSource: gl.GfmAutoComplete?.dataSources.issues, + nodeType: 'reference', + nodeProps: { + referenceType: 'issue', + }, + search: (query) => ({ iid, title }) => find(iid, query) || find(title, query), + }), + createSuggestionPlugin({ + editor: this.editor, + char: '$', + dataSource: gl.GfmAutoComplete?.dataSources.snippets, + nodeType: 'reference', + nodeProps: { + referenceType: 'snippet', + }, + search: (query) => ({ id, title }) => find(id, query) || find(title, query), + }), + createSuggestionPlugin({ + editor: this.editor, + char: '~', + dataSource: gl.GfmAutoComplete?.dataSources.labels, + nodeType: 'reference_label', + nodeProps: { + referenceType: 'label', + }, + search: (query) => ({ title }) => find(title, query), + }), + createSuggestionPlugin({ + editor: this.editor, + char: '&', + dataSource: gl.GfmAutoComplete?.dataSources.epics, + nodeType: 'reference', + nodeProps: { + referenceType: 'epic', + }, + search: (query) => ({ iid, title }) => find(iid, query) || find(title, query), + }), + createSuggestionPlugin({ + editor: this.editor, + char: '[vulnerability:', + dataSource: gl.GfmAutoComplete?.dataSources.vulnerabilities, + nodeType: 'reference', + nodeProps: { + referenceType: 'vulnerability', + }, + search: (query) => ({ id, title }) => find(id, query) || find(title, query), + }), + createSuggestionPlugin({ + editor: this.editor, + char: '!', + dataSource: gl.GfmAutoComplete?.dataSources.mergeRequests, + nodeType: 'reference', + nodeProps: { + referenceType: 'merge_request', + }, + search: (query) => ({ iid, title }) => find(iid, query) || find(title, query), + }), + createSuggestionPlugin({ + editor: this.editor, + char: '%', + dataSource: gl.GfmAutoComplete?.dataSources.milestones, + nodeType: 'reference', + nodeProps: { + referenceType: 'milestone', + }, + search: (query) => ({ iid, title }) => find(iid, query) || find(title, query), + }), + createSuggestionPlugin({ + editor: this.editor, + char: '/', + dataSource: gl.GfmAutoComplete?.dataSources.commands, + nodeType: 'reference', + nodeProps: { + referenceType: 'command', + }, + search: (query) => ({ name }) => find(name, query), + }), + createSuggestionPlugin({ + editor: this.editor, + char: ':', + dataSource: () => Object.values(getAllEmoji()), + nodeType: 'emoji', + search: (query) => ({ d, name }) => find(d, query) || find(name, query), + limit: 10, + }), + ]; + }, + + onCreate() { + initEmojiMap(); + }, +}); 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 5ed7f3dc23d..0d78390e769 100644 --- a/app/assets/javascripts/content_editor/services/create_content_editor.js +++ b/app/assets/javascripts/content_editor/services/create_content_editor.js @@ -18,6 +18,7 @@ import Diagram from '../extensions/diagram'; import Document from '../extensions/document'; import Dropcursor from '../extensions/dropcursor'; import Emoji from '../extensions/emoji'; +import ExternalKeydownHandler from '../extensions/external_keydown_handler'; import Figure from '../extensions/figure'; import FigureCaption from '../extensions/figure_caption'; import FootnoteDefinition from '../extensions/footnote_definition'; @@ -42,10 +43,12 @@ import OrderedList from '../extensions/ordered_list'; import Paragraph from '../extensions/paragraph'; import PasteMarkdown from '../extensions/paste_markdown'; import Reference from '../extensions/reference'; +import ReferenceLabel from '../extensions/reference_label'; import ReferenceDefinition from '../extensions/reference_definition'; import Sourcemap from '../extensions/sourcemap'; import Strike from '../extensions/strike'; import Subscript from '../extensions/subscript'; +import Suggestions from '../extensions/suggestions'; import Superscript from '../extensions/superscript'; import Table from '../extensions/table'; import TableCell from '../extensions/table_cell'; @@ -121,6 +124,7 @@ export const createContentEditor = ({ Image, InlineDiff, Italic, + ExternalKeydownHandler.configure({ eventHub }), Link, ListItem, Loading, @@ -129,10 +133,12 @@ export const createContentEditor = ({ Paragraph, PasteMarkdown.configure({ eventHub, renderMarkdown }), Reference, + ReferenceLabel, ReferenceDefinition, Sourcemap, Strike, Subscript, + Suggestions, Superscript, TableCell, TableHeader, diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js index ba0cad6c91c..c990f6cf0b3 100644 --- a/app/assets/javascripts/content_editor/services/markdown_serializer.js +++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js @@ -33,6 +33,7 @@ import MathInline from '../extensions/math_inline'; import OrderedList from '../extensions/ordered_list'; import Paragraph from '../extensions/paragraph'; import Reference from '../extensions/reference'; +import ReferenceLabel from '../extensions/reference_label'; import ReferenceDefinition from '../extensions/reference_definition'; import Strike from '../extensions/strike'; import Subscript from '../extensions/subscript'; @@ -61,6 +62,7 @@ import { renderHTMLNode, renderContent, renderBulletList, + renderReference, preserveUnchanged, bold, italic, @@ -184,9 +186,8 @@ const defaultSerializerConfig = { [ListItem.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.list_item), [OrderedList.name]: preserveUnchanged(renderOrderedList), [Paragraph.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.paragraph), - [Reference.name]: (state, node) => { - state.write(node.attrs.originalText || node.attrs.text); - }, + [Reference.name]: renderReference, + [ReferenceLabel.name]: renderReference, [ReferenceDefinition.name]: preserveUnchanged({ render: (state, node, parent, index, same, sourceMarkdown) => { const nextSibling = parent.maybeChild(index + 1); diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js index 41114571df7..5c0cb21075a 100644 --- a/app/assets/javascripts/content_editor/services/serialization_helpers.js +++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js @@ -280,6 +280,7 @@ export function renderTableRow(state, node) { } export function renderTable(state, node) { + state.flushClose(); setIsInBlockTable(node, shouldRenderHTMLTable(node)); if (isInBlockTable(node)) renderTagOpen(state, 'table'); @@ -422,6 +423,10 @@ export function renderOrderedList(state, node) { }); } +export function renderReference(state, node) { + state.write(node.attrs.originalText || node.attrs.text); +} + const generateBoldTags = (wrapTagName = openTag) => { return (_, mark) => { const type = /^(\*\*|__|<strong|<b).*/.exec(mark.attrs.sourceMarkdown)?.[1]; |