diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-05-19 07:33:21 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-05-19 07:33:21 +0000 |
commit | 36a59d088eca61b834191dacea009677a96c052f (patch) | |
tree | e4f33972dab5d8ef79e3944a9f403035fceea43f /app/assets/javascripts/content_editor | |
parent | a1761f15ec2cae7c7f7bbda39a75494add0dfd6f (diff) | |
download | gitlab-ce-36a59d088eca61b834191dacea009677a96c052f.tar.gz |
Add latest changes from gitlab-org/gitlab@15-0-stable-eev15.0.0-rc42
Diffstat (limited to 'app/assets/javascripts/content_editor')
35 files changed, 1980 insertions, 453 deletions
diff --git a/app/assets/javascripts/content_editor/components/code_block_bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/code_block.vue index 87f22a27856..518ddd7a09c 100644 --- a/app/assets/javascripts/content_editor/components/code_block_bubble_menu.vue +++ b/app/assets/javascripts/content_editor/components/bubble_menus/code_block.vue @@ -8,11 +8,12 @@ import { GlTooltipDirective as GlTooltip, } from '@gitlab/ui'; import { BubbleMenu } from '@tiptap/vue-2'; -import codeBlockLanguageLoader from '../services/code_block_language_loader'; -import CodeBlockHighlight from '../extensions/code_block_highlight'; -import Diagram from '../extensions/diagram'; -import Frontmatter from '../extensions/frontmatter'; -import EditorStateObserver from './editor_state_observer.vue'; +import { getParentByTagName } from '~/lib/utils/dom_utils'; +import codeBlockLanguageLoader from '../../services/code_block_language_loader'; +import CodeBlockHighlight from '../../extensions/code_block_highlight'; +import Diagram from '../../extensions/diagram'; +import Frontmatter from '../../extensions/frontmatter'; +import EditorStateObserver from '../editor_state_observer.vue'; const CODE_BLOCK_NODE_TYPES = [CodeBlockHighlight.name, Diagram.name, Frontmatter.name]; @@ -32,6 +33,7 @@ export default { inject: ['tiptapEditor'], data() { return { + codeBlockType: undefined, selectedLanguage: {}, filterTerm: '', filteredLanguages: [], @@ -50,47 +52,40 @@ export default { return CODE_BLOCK_NODE_TYPES.some((type) => editor.isActive(type)); }, - getSelectedLanguage() { - const { language } = this.tiptapEditor.getAttributes(this.getCodeBlockType()); + updateSelectedLanguage() { + this.codeBlockType = CODE_BLOCK_NODE_TYPES.find((type) => this.tiptapEditor.isActive(type)); - this.selectedLanguage = codeBlockLanguageLoader.findLanguageBySyntax(language); + if (this.codeBlockType) { + const { language } = this.tiptapEditor.getAttributes(this.codeBlockType); + this.selectedLanguage = codeBlockLanguageLoader.findLanguageBySyntax(language); + } }, - async setSelectedLanguage(language) { - this.selectedLanguage = language; - - await codeBlockLanguageLoader.loadLanguages([language.syntax]); + copyCodeBlockText() { + const { view } = this.tiptapEditor; + const { from } = this.tiptapEditor.state.selection; + const node = getParentByTagName(view.domAtPos(from).node, 'pre'); - this.tiptapEditor.commands.setCodeBlock({ language: this.selectedLanguage.syntax }); + navigator.clipboard.writeText(node?.textContent || ''); }, - tippyOnBeforeUpdate(tippy, props) { - if (props.getReferenceClientRect) { - // eslint-disable-next-line no-param-reassign - props.getReferenceClientRect = () => { - const { view } = this.tiptapEditor; - const { from } = this.tiptapEditor.state.selection; + async applySelectedLanguage(language) { + this.selectedLanguage = language; - for (let { node } = view.domAtPos(from); node; node = node.parentElement) { - if (node.nodeName?.toLowerCase() === 'pre') { - return node.getBoundingClientRect(); - } - } + await codeBlockLanguageLoader.loadLanguage(language.syntax); - return new DOMRect(-1000, -1000, 0, 0); - }; - } + this.tiptapEditor.commands.setCodeBlock({ language: this.selectedLanguage.syntax }); }, - deleteCodeBlock() { - this.tiptapEditor.chain().focus().deleteNode(this.getCodeBlockType()).run(); + getReferenceClientRect() { + const { view } = this.tiptapEditor; + const { from } = this.tiptapEditor.state.selection; + const node = getParentByTagName(view.domAtPos(from).node, 'pre'); + return node?.getBoundingClientRect() || new DOMRect(-1000, -1000, 0, 0); }, - getCodeBlockType() { - return ( - CODE_BLOCK_NODE_TYPES.find((type) => this.tiptapEditor.isActive(type)) || - CodeBlockHighlight.name - ); + deleteCodeBlock() { + this.tiptapEditor.chain().focus().deleteNode(this.codeBlockType).run(); }, }, }; @@ -98,15 +93,22 @@ export default { <template> <bubble-menu data-testid="code-block-bubble-menu" - class="gl-shadow gl-rounded-base" + class="gl-shadow gl-rounded-base gl-bg-white" :editor="tiptapEditor" plugin-key="bubbleMenuCodeBlock" :should-show="shouldShow" - :tippy-options="{ onBeforeUpdate: tippyOnBeforeUpdate }" + :tippy-options="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { + getReferenceClientRect, + } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" > - <editor-state-observer @transaction="getSelectedLanguage"> + <editor-state-observer @transaction="updateSelectedLanguage"> <gl-button-group> - <gl-dropdown contenteditable="false" boundary="viewport" :text="selectedLanguage.label"> + <gl-dropdown + category="tertiary" + contenteditable="false" + boundary="viewport" + :text="selectedLanguage.label" + > <template #header> <gl-search-box-by-type v-model="filterTerm" @@ -125,7 +127,7 @@ export default { v-for="language in filteredLanguages" v-show="selectedLanguage.syntax !== language.syntax" :key="language.syntax" - @click="setSelectedLanguage(language)" + @click="applySelectedLanguage(language)" > {{ language.label }} </gl-dropdown-item> @@ -133,8 +135,20 @@ export default { <gl-button v-gl-tooltip variant="default" - category="primary" + category="tertiary" + size="medium" + data-testid="copy-code-block" + :aria-label="__('Copy code')" + :title="__('Copy code')" + icon="copy-to-clipboard" + @click="copyCodeBlockText" + /> + <gl-button + v-gl-tooltip + variant="default" + category="tertiary" size="medium" + data-testid="delete-code-block" :aria-label="__('Delete code block')" :title="__('Delete code block')" icon="remove" diff --git a/app/assets/javascripts/content_editor/components/formatting_bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue index 103079534bc..e35fbf14de5 100644 --- a/app/assets/javascripts/content_editor/components/formatting_bubble_menu.vue +++ b/app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue @@ -1,13 +1,16 @@ <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 Code from '../extensions/code'; -import CodeBlockHighlight from '../extensions/code_block_highlight'; -import Diagram from '../extensions/diagram'; -import Frontmatter from '../extensions/frontmatter'; -import ToolbarButton from './toolbar_button.vue'; +import { BUBBLE_MENU_TRACKING_ACTION } from '../../constants'; +import trackUIControl from '../../services/track_ui_control'; +import Image from '../../extensions/image'; +import Audio from '../../extensions/audio'; +import Video from '../../extensions/video'; +import Code from '../../extensions/code'; +import CodeBlockHighlight from '../../extensions/code_block_highlight'; +import Diagram from '../../extensions/diagram'; +import Frontmatter from '../../extensions/frontmatter'; +import ToolbarButton from '../toolbar_button.vue'; export default { components: { @@ -24,7 +27,15 @@ export default { shouldShow: ({ editor, from, to }) => { if (from === to) return false; - const exclude = [Code.name, CodeBlockHighlight.name, Diagram.name, Frontmatter.name]; + const exclude = [ + Code.name, + CodeBlockHighlight.name, + Diagram.name, + Frontmatter.name, + Image.name, + Audio.name, + Video.name, + ]; return !exclude.some((type) => editor.isActive(type)); }, @@ -34,7 +45,7 @@ export default { <template> <bubble-menu data-testid="formatting-bubble-menu" - class="gl-shadow gl-rounded-base" + class="gl-shadow gl-rounded-base gl-bg-white" :editor="tiptapEditor" :should-show="shouldShow" > @@ -44,7 +55,7 @@ export default { content-type="bold" icon-name="bold" editor-command="toggleBold" - category="primary" + category="tertiary" size="medium" :label="__('Bold text')" @execute="trackToolbarControlExecution" @@ -54,7 +65,7 @@ export default { content-type="italic" icon-name="italic" editor-command="toggleItalic" - category="primary" + category="tertiary" size="medium" :label="__('Italic text')" @execute="trackToolbarControlExecution" @@ -64,7 +75,7 @@ export default { content-type="strike" icon-name="strikethrough" editor-command="toggleStrike" - category="primary" + category="tertiary" size="medium" :label="__('Strikethrough')" @execute="trackToolbarControlExecution" @@ -74,11 +85,24 @@ export default { content-type="code" icon-name="code" editor-command="toggleCode" - category="primary" + category="tertiary" size="medium" :label="__('Code')" @execute="trackToolbarControlExecution" /> + <toolbar-button + data-testid="link" + content-type="link" + icon-name="link" + editor-command="toggleLink" + :editor-command-params="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { + href: '', + } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" + category="tertiary" + size="medium" + :label="__('Insert link')" + @execute="trackToolbarControlExecution" + /> </gl-button-group> </bubble-menu> </template> diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/link.vue b/app/assets/javascripts/content_editor/components/bubble_menus/link.vue new file mode 100644 index 00000000000..dae0bc63b5a --- /dev/null +++ b/app/assets/javascripts/content_editor/components/bubble_menus/link.vue @@ -0,0 +1,189 @@ +<script> +import { + GlLink, + GlForm, + GlFormGroup, + GlFormInput, + GlButton, + GlButtonGroup, + GlTooltipDirective as GlTooltip, +} from '@gitlab/ui'; +import { BubbleMenu } from '@tiptap/vue-2'; +import Link from '../../extensions/link'; +import EditorStateObserver from '../editor_state_observer.vue'; + +export default { + components: { + BubbleMenu, + GlForm, + GlFormGroup, + GlFormInput, + GlLink, + GlButton, + GlButtonGroup, + EditorStateObserver, + }, + directives: { + GlTooltip, + }, + inject: ['tiptapEditor', 'contentEditor'], + data() { + return { + linkHref: undefined, + linkCanonicalSrc: undefined, + linkTitle: undefined, + + isEditing: false, + }; + }, + watch: { + linkCanonicalSrc(value) { + if (!value) this.isEditing = true; + }, + }, + methods: { + shouldShow() { + const shouldShow = this.tiptapEditor.isActive(Link.name); + + if (!shouldShow) this.isEditing = false; + + return shouldShow; + }, + + startEditingLink() { + // select the entire link + this.tiptapEditor.chain().focus().extendMarkRange(Link.name).run(); + + this.isEditing = true; + }, + + async endEditingLink() { + this.isEditing = false; + + this.linkHref = await this.contentEditor.resolveUrl(this.linkCanonicalSrc); + + if (!this.linkCanonicalSrc && !this.linkHref) { + this.removeLink(); + } + }, + + cancelEditingLink() { + this.endEditingLink(); + this.updateLinkToState(); + }, + + async saveEditedLink() { + if (!this.linkCanonicalSrc) { + this.removeLink(); + } else { + this.tiptapEditor + .chain() + .focus() + .extendMarkRange(Link.name) + .updateAttributes(Link.name, { + href: this.linkCanonicalSrc, + canonicalSrc: this.linkCanonicalSrc, + title: this.linkTitle, + }) + .run(); + } + + this.endEditingLink(); + }, + + updateLinkToState() { + if (!this.tiptapEditor.isActive(Link.name)) return; + + const { href, title, canonicalSrc } = this.tiptapEditor.getAttributes(Link.name); + + this.linkTitle = title; + this.linkHref = href; + this.linkCanonicalSrc = canonicalSrc || href; + }, + + copyLinkHref() { + navigator.clipboard.writeText(this.linkCanonicalSrc); + }, + + removeLink() { + this.tiptapEditor.chain().focus().extendMarkRange(Link.name).unsetLink().run(); + }, + }, +}; +</script> +<template> + <bubble-menu + data-testid="link-bubble-menu" + class="gl-shadow gl-rounded-base gl-bg-white" + :editor="tiptapEditor" + plugin-key="bubbleMenuLink" + :should-show="() => shouldShow()" + :tippy-options="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { + placement: 'bottom', + } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" + > + <editor-state-observer @transaction="updateLinkToState"> + <gl-button-group v-if="!isEditing" class="gl-display-flex gl-align-items-center"> + <gl-link + v-gl-tooltip + :href="linkHref" + :aria-label="linkCanonicalSrc" + :title="linkCanonicalSrc" + target="_blank" + class="gl-px-3 gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis" + > + {{ linkCanonicalSrc }} + </gl-link> + <gl-button + v-gl-tooltip + variant="default" + category="tertiary" + size="medium" + data-testid="copy-link-url" + :aria-label="__('Copy link URL')" + :title="__('Copy link URL')" + icon="copy-to-clipboard" + @click="copyLinkHref" + /> + <gl-button + v-gl-tooltip + variant="default" + category="tertiary" + size="medium" + data-testid="edit-link" + :aria-label="__('Edit link')" + :title="__('Edit link')" + icon="pencil" + @click="startEditingLink" + /> + <gl-button + v-gl-tooltip + variant="default" + category="tertiary" + size="medium" + data-testid="remove-link" + :aria-label="__('Remove link')" + :title="__('Remove link')" + icon="unlink" + @click="removeLink" + /> + </gl-button-group> + <gl-form v-else class="bubble-menu-form gl-p-4 gl-w-100" @submit.prevent="saveEditedLink"> + <gl-form-group :label="__('URL')" label-for="link-href"> + <gl-form-input id="link-href" v-model="linkCanonicalSrc" data-testid="link-href" /> + </gl-form-group> + <gl-form-group :label="__('Title')" label-for="link-title"> + <gl-form-input id="link-title" v-model="linkTitle" data-testid="link-title" /> + </gl-form-group> + <div class="gl-display-flex gl-justify-content-end"> + <gl-button class="gl-mr-3" data-testid="cancel-link" @click="cancelEditingLink"> + {{ __('Cancel') }} + </gl-button> + <gl-button variant="confirm" type="submit"> + {{ __('Apply') }} + </gl-button> + </div> + </gl-form> + </editor-state-observer> + </bubble-menu> +</template> diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/media.vue b/app/assets/javascripts/content_editor/components/bubble_menus/media.vue new file mode 100644 index 00000000000..a36a860c440 --- /dev/null +++ b/app/assets/javascripts/content_editor/components/bubble_menus/media.vue @@ -0,0 +1,288 @@ +<script> +import { + GlLink, + GlForm, + GlFormGroup, + GlFormInput, + GlLoadingIcon, + GlButton, + GlButtonGroup, + GlTooltipDirective as GlTooltip, +} from '@gitlab/ui'; +import { BubbleMenu } from '@tiptap/vue-2'; +import { __ } from '~/locale'; +import Audio from '../../extensions/audio'; +import Image from '../../extensions/image'; +import Video from '../../extensions/video'; +import EditorStateObserver from '../editor_state_observer.vue'; +import { acceptedMimes } from '../../services/upload_helpers'; + +const MEDIA_TYPES = [Audio.name, Image.name, Video.name]; + +export default { + i18n: { + copySourceLabels: { + [Audio.name]: __('Copy audio URL'), + [Image.name]: __('Copy image URL'), + [Video.name]: __('Copy video URL'), + }, + editLabels: { + [Audio.name]: __('Edit audio description'), + [Image.name]: __('Edit image description'), + [Video.name]: __('Edit video description'), + }, + replaceLabels: { + [Audio.name]: __('Replace audio'), + [Image.name]: __('Replace image'), + [Video.name]: __('Replace video'), + }, + deleteLabels: { + [Audio.name]: __('Delete audio'), + [Image.name]: __('Delete image'), + [Video.name]: __('Delete video'), + }, + }, + components: { + BubbleMenu, + GlForm, + GlFormGroup, + GlFormInput, + GlLink, + GlLoadingIcon, + GlButton, + GlButtonGroup, + EditorStateObserver, + }, + directives: { + GlTooltip, + }, + inject: ['tiptapEditor', 'contentEditor'], + data() { + return { + mediaType: undefined, + mediaSrc: undefined, + mediaCanonicalSrc: undefined, + mediaAlt: undefined, + mediaTitle: undefined, + + isEditing: false, + isUpdating: false, + isUploading: false, + }; + }, + computed: { + copySourceLabel() { + return this.$options.i18n.copySourceLabels[this.mediaType]; + }, + editLabel() { + return this.$options.i18n.editLabels[this.mediaType]; + }, + replaceLabel() { + return this.$options.i18n.replaceLabels[this.mediaType]; + }, + deleteLabel() { + return this.$options.i18n.deleteLabels[this.mediaType]; + }, + showProgressIndicator() { + return this.isUploading || this.isUpdating; + }, + }, + methods: { + shouldShow() { + const shouldShow = MEDIA_TYPES.some((type) => this.tiptapEditor.isActive(type)); + + if (!shouldShow) this.isEditing = false; + + return shouldShow; + }, + + startEditingMedia() { + this.isEditing = true; + }, + + endEditingMedia() { + this.isEditing = false; + + this.updateMediaInfoToState(); + }, + + cancelEditingMedia() { + this.endEditingMedia(); + this.updateMediaInfoToState(); + }, + + async saveEditedMedia() { + this.isUpdating = true; + + this.mediaSrc = await this.contentEditor.resolveUrl(this.mediaCanonicalSrc); + + const position = this.tiptapEditor.state.selection.from; + + this.tiptapEditor + .chain() + .focus() + .updateAttributes(this.mediaType, { + src: this.mediaSrc, + alt: this.mediaAlt, + canonicalSrc: this.mediaCanonicalSrc, + title: this.mediaTitle, + }) + .run(); + + this.tiptapEditor.commands.setNodeSelection(position); + + this.endEditingMedia(); + + this.isUpdating = false; + }, + + async updateMediaInfoToState() { + this.mediaType = MEDIA_TYPES.find((type) => this.tiptapEditor.isActive(type)); + + if (!this.mediaType) return; + + this.isUpdating = true; + + const { src, title, alt, canonicalSrc, uploading } = this.tiptapEditor.getAttributes( + this.mediaType, + ); + + this.mediaTitle = title; + this.mediaAlt = alt; + this.mediaCanonicalSrc = canonicalSrc || src; + this.isUploading = uploading; + this.mediaSrc = await this.contentEditor.resolveUrl(this.mediaCanonicalSrc); + + this.isUpdating = false; + }, + + replaceMedia() { + this.$refs.fileSelector.click(); + }, + + onFileSelect(e) { + this.tiptapEditor + .chain() + .focus() + .deleteSelection() + .uploadAttachment({ + file: e.target.files[0], + }) + .run(); + + this.$refs.fileSelector.value = ''; + }, + + copyMediaSrc() { + navigator.clipboard.writeText(this.mediaCanonicalSrc); + }, + + deleteMedia() { + this.tiptapEditor.chain().focus().deleteSelection().run(); + }, + }, + + acceptedMimes, +}; +</script> +<template> + <bubble-menu + data-testid="media-bubble-menu" + class="gl-shadow gl-rounded-base gl-bg-white" + :editor="tiptapEditor" + plugin-key="bubbleMenuMedia" + :should-show="() => shouldShow()" + > + <editor-state-observer @transaction="updateMediaInfoToState"> + <gl-button-group v-if="!isEditing" class="gl-display-flex gl-align-items-center"> + <gl-loading-icon v-if="showProgressIndicator" class="gl-pl-4 gl-pr-3" /> + <input + ref="fileSelector" + type="file" + name="content_editor_image" + :accept="$options.acceptedMimes[mediaType]" + class="gl-display-none" + data-qa-selector="file_upload_field" + @change="onFileSelect" + /> + + <gl-link + v-if="!showProgressIndicator" + v-gl-tooltip + :href="mediaSrc" + :aria-label="mediaCanonicalSrc" + :title="mediaCanonicalSrc" + target="_blank" + class="gl-px-3 gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis" + > + {{ mediaCanonicalSrc }} + </gl-link> + <gl-button + v-gl-tooltip + variant="default" + category="tertiary" + size="medium" + data-testid="copy-media-src" + :aria-label="copySourceLabel" + :title="copySourceLabel" + icon="copy-to-clipboard" + @click="copyMediaSrc" + /> + <gl-button + v-if="!showProgressIndicator" + v-gl-tooltip + variant="default" + category="tertiary" + size="medium" + data-testid="edit-media" + :aria-label="editLabel" + :title="editLabel" + icon="pencil" + @click="startEditingMedia" + /> + <gl-button + v-gl-tooltip + variant="default" + category="tertiary" + size="medium" + data-testid="replace-media" + :aria-label="replaceLabel" + :title="replaceLabel" + icon="upload" + @click="replaceMedia" + /> + <gl-button + v-gl-tooltip + variant="default" + category="tertiary" + size="medium" + data-testid="delete-media" + :aria-label="deleteLabel" + :title="deleteLabel" + icon="remove" + @click="deleteMedia" + /> + </gl-button-group> + <gl-form v-else class="bubble-menu-form gl-p-4 gl-w-100" @submit.prevent="saveEditedMedia"> + <gl-form-group :label="__('URL')" label-for="media-src"> + <gl-form-input id="media-src" v-model="mediaCanonicalSrc" data-testid="media-src" /> + </gl-form-group> + <gl-form-group :label="__('Description (alt text)')" label-for="media-alt"> + <gl-form-input id="media-alt" v-model="mediaAlt" data-testid="media-alt" /> + </gl-form-group> + <gl-form-group :label="__('Title')" label-for="media-title"> + <gl-form-input id="media-title" v-model="mediaTitle" data-testid="media-title" /> + </gl-form-group> + <div class="gl-display-flex gl-justify-content-end"> + <gl-button + class="gl-mr-3" + data-testid="cancel-editing-media" + @click="cancelEditingMedia" + >{{ __('Cancel') }}</gl-button + > + <gl-button variant="confirm" type="submit">{{ __('Apply') }}</gl-button> + </div> + </gl-form> + </editor-state-observer> + </bubble-menu> +</template> diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue index 5b3f4f4ddf2..74ae37b6d06 100644 --- a/app/assets/javascripts/content_editor/components/content_editor.vue +++ b/app/assets/javascripts/content_editor/components/content_editor.vue @@ -4,8 +4,10 @@ import { createContentEditor } from '../services/create_content_editor'; import ContentEditorAlert from './content_editor_alert.vue'; import ContentEditorProvider from './content_editor_provider.vue'; import EditorStateObserver from './editor_state_observer.vue'; -import FormattingBubbleMenu from './formatting_bubble_menu.vue'; -import CodeBlockBubbleMenu from './code_block_bubble_menu.vue'; +import FormattingBubbleMenu from './bubble_menus/formatting.vue'; +import CodeBlockBubbleMenu from './bubble_menus/code_block.vue'; +import LinkBubbleMenu from './bubble_menus/link.vue'; +import MediaBubbleMenu from './bubble_menus/media.vue'; import TopToolbar from './top_toolbar.vue'; import LoadingIndicator from './loading_indicator.vue'; @@ -18,6 +20,8 @@ export default { TopToolbar, FormattingBubbleMenu, CodeBlockBubbleMenu, + LinkBubbleMenu, + MediaBubbleMenu, EditorStateObserver, }, props: { @@ -92,6 +96,8 @@ export default { <div class="gl-relative"> <formatting-bubble-menu /> <code-block-bubble-menu /> + <link-bubble-menu /> + <media-bubble-menu /> <tiptap-editor-content class="md" :editor="contentEditor.tiptapEditor" /> <loading-indicator /> </div> diff --git a/app/assets/javascripts/content_editor/components/divider.vue b/app/assets/javascripts/content_editor/components/divider.vue deleted file mode 100644 index b77bd7b7cf3..00000000000 --- a/app/assets/javascripts/content_editor/components/divider.vue +++ /dev/null @@ -1,3 +0,0 @@ -<template> - <span class="gl-mx-3 gl-border-r-solid gl-border-r-1 gl-border-gray-200"></span> -</template> diff --git a/app/assets/javascripts/content_editor/components/loading_indicator.vue b/app/assets/javascripts/content_editor/components/loading_indicator.vue index 620324adb06..7bc953e0dc3 100644 --- a/app/assets/javascripts/content_editor/components/loading_indicator.vue +++ b/app/assets/javascripts/content_editor/components/loading_indicator.vue @@ -34,7 +34,7 @@ export default { class="gl-w-full gl-display-flex gl-justify-content-center gl-align-items-center gl-absolute gl-top-0 gl-bottom-0" > <div class="gl-bg-white gl-absolute gl-w-full gl-h-full gl-opacity-3"></div> - <gl-loading-icon size="md" /> + <gl-loading-icon size="lg" /> </div> </editor-state-observer> </template> diff --git a/app/assets/javascripts/content_editor/components/toolbar_button.vue b/app/assets/javascripts/content_editor/components/toolbar_button.vue index cdb877152d4..c16dc34e36f 100644 --- a/app/assets/javascripts/content_editor/components/toolbar_button.vue +++ b/app/assets/javascripts/content_editor/components/toolbar_button.vue @@ -29,6 +29,11 @@ export default { required: false, default: '', }, + editorCommandParams: { + type: Object, + required: false, + default: null, + }, variant: { type: String, required: false, @@ -42,7 +47,7 @@ export default { size: { type: String, required: false, - default: 'small', + default: 'medium', }, }, data() { @@ -58,7 +63,7 @@ export default { const { contentType } = this; if (this.editorCommand) { - this.tiptapEditor.chain()[this.editorCommand]().focus().run(); + this.tiptapEditor.chain()[this.editorCommand](this.editorCommandParams).focus().run(); } this.$emit('execute', { contentType }); diff --git a/app/assets/javascripts/content_editor/components/top_toolbar.vue b/app/assets/javascripts/content_editor/components/top_toolbar.vue index 89182b3a09f..19e150a4da9 100644 --- a/app/assets/javascripts/content_editor/components/top_toolbar.vue +++ b/app/assets/javascripts/content_editor/components/top_toolbar.vue @@ -1,6 +1,5 @@ <script> 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'; import ToolbarLinkButton from './toolbar_link_button.vue'; @@ -14,7 +13,6 @@ export default { ToolbarLinkButton, ToolbarTableButton, ToolbarImageButton, - Divider, }, methods: { trackToolbarControlExecution({ contentType, value }) { @@ -25,13 +23,13 @@ export default { </script> <template> <div - class="gl-display-flex gl-justify-content-end gl-pb-3 gl-pt-0 gl-border-b-solid gl-border-b-1 gl-border-b-gray-200" + class="gl-display-flex gl-flex-wrap gl-pb-3 gl-pt-0 gl-border-b-solid gl-border-b-1 gl-border-b-gray-200" > <toolbar-text-style-dropdown data-testid="text-styles" + class="gl-mr-3" @execute="trackToolbarControlExecution" /> - <divider /> <toolbar-button data-testid="bold" content-type="bold" @@ -69,7 +67,6 @@ export default { @execute="trackToolbarControlExecution" /> <toolbar-link-button data-testid="link" @execute="trackToolbarControlExecution" /> - <divider /> <toolbar-image-button ref="imageButton" data-testid="image" diff --git a/app/assets/javascripts/content_editor/components/wrappers/frontmatter.vue b/app/assets/javascripts/content_editor/components/wrappers/code_block.vue index e8829d00986..1390b9b2daf 100644 --- a/app/assets/javascripts/content_editor/components/wrappers/frontmatter.vue +++ b/app/assets/javascripts/content_editor/components/wrappers/code_block.vue @@ -1,9 +1,10 @@ <script> import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2'; import { __ } from '~/locale'; +import codeBlockLanguageLoader from '../../services/code_block_language_loader'; export default { - name: 'FrontMatter', + name: 'CodeBlock', components: { NodeViewWrapper, NodeViewContent, @@ -13,6 +14,16 @@ export default { type: Object, required: true, }, + updateAttributes: { + type: Function, + required: true, + }, + }, + async mounted() { + const lang = codeBlockLanguageLoader.findLanguageBySyntax(this.node.attrs.language); + await codeBlockLanguageLoader.loadLanguage(lang.syntax); + + this.updateAttributes({ language: this.node.attrs.language }); }, i18n: { frontmatter: __('frontmatter'), @@ -22,6 +33,7 @@ export default { <template> <node-view-wrapper class="content-editor-code-block gl-relative code highlight" as="pre"> <span + v-if="node.attrs.isFrontmatter" data-testid="frontmatter-label" class="gl-absolute gl-top-0 gl-right-3" contenteditable="false" diff --git a/app/assets/javascripts/content_editor/components/wrappers/media.vue b/app/assets/javascripts/content_editor/components/wrappers/media.vue deleted file mode 100644 index 37119bdd066..00000000000 --- a/app/assets/javascripts/content_editor/components/wrappers/media.vue +++ /dev/null @@ -1,51 +0,0 @@ -<script> -import { GlLoadingIcon } from '@gitlab/ui'; -import { NodeViewWrapper } from '@tiptap/vue-2'; - -const tagNameMap = { - image: 'img', - video: 'video', - audio: 'audio', -}; - -export default { - name: 'MediaWrapper', - components: { - NodeViewWrapper, - GlLoadingIcon, - }, - props: { - node: { - type: Object, - required: true, - }, - }, - computed: { - tagName() { - return tagNameMap[this.node.type.name] || 'img'; - }, - }, -}; -</script> -<template> - <node-view-wrapper class="gl-display-inline-block"> - <span class="gl-relative" :class="{ [`media-container ${tagName}-container`]: true }"> - <gl-loading-icon v-if="node.attrs.uploading" class="gl-absolute gl-left-50p gl-top-half" /> - <component - :is="tagName" - data-testid="media" - :class="{ - 'gl-max-w-full gl-h-auto': tagName !== 'audio', - 'gl-opacity-5': node.attrs.uploading, - }" - :title="node.attrs.title || node.attrs.alt" - :alt="node.attrs.alt" - :src="node.attrs.src" - controls="true" - /> - <a v-if="tagName !== 'img'" :href="node.attrs.canonicalSrc || node.attrs.src" @click.prevent> - {{ node.attrs.title || node.attrs.alt }} - </a> - </span> - </node-view-wrapper> -</template> diff --git a/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue b/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue index 41c083111c5..209e4629830 100644 --- a/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue +++ b/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue @@ -124,7 +124,9 @@ export default { no-caret text-sr-only :text="$options.i18n.editTableActions" - :popper-opts="{ positionFixed: true }" + :popper-opts="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { + positionFixed: true, + } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" @hide="handleHide($event)" > <gl-dropdown-item @click="runCommand('addColumnBefore')"> diff --git a/app/assets/javascripts/content_editor/constants/code_block_languages.js b/app/assets/javascripts/content_editor/constants/code_block_languages.js new file mode 100644 index 00000000000..1a4dbe4fa22 --- /dev/null +++ b/app/assets/javascripts/content_editor/constants/code_block_languages.js @@ -0,0 +1,210 @@ +/* eslint-disable @gitlab/require-i18n-strings */ +// List of languages referenced from https://github.com/wooorm/lowlight#data +const CODE_BLOCK_LANGUAGES = [ + { syntax: '1c', label: '1C:Enterprise' }, + { syntax: 'abnf', label: 'Augmented Backus-Naur Form' }, + { syntax: 'accesslog', label: 'Apache Access Log' }, + { syntax: 'actionscript', variants: 'as', label: 'ActionScript' }, + { syntax: 'ada', label: 'Ada' }, + { syntax: 'angelscript', variants: 'asc', label: 'AngelScript' }, + { syntax: 'apache', variants: 'apacheconf', label: 'Apache config' }, + { syntax: 'applescript', variants: 'osascript', label: 'AppleScript' }, + { syntax: 'arcade', label: 'ArcGIS Arcade' }, + { syntax: 'arduino', variants: 'ino', label: 'Arduino' }, + { syntax: 'armasm', variants: 'arm', label: 'ARM Assembly' }, + { syntax: 'asciidoc', variants: 'adoc', label: 'AsciiDoc' }, + { syntax: 'aspectj', label: 'AspectJ' }, + { syntax: 'autohotkey', variants: 'ahk', label: 'AutoHotkey' }, + { syntax: 'autoit', label: 'AutoIt' }, + { syntax: 'avrasm', label: 'AVR Assembly' }, + { syntax: 'awk', label: 'Awk' }, + { syntax: 'axapta', variants: 'x++', label: 'X++' }, + { syntax: 'bash', variants: 'sh', label: 'Bash' }, + { syntax: 'basic', label: 'BASIC' }, + { syntax: 'bnf', label: 'Backus-Naur Form' }, + { syntax: 'brainfuck', variants: 'bf', label: 'Brainfuck' }, + { syntax: 'c', variants: 'h', label: 'C' }, + { syntax: 'cal', label: 'C/AL' }, + { syntax: 'capnproto', variants: 'capnp', label: "Cap'n Proto" }, + { syntax: 'ceylon', label: 'Ceylon' }, + { syntax: 'clean', variants: 'icl, dcl', label: 'Clean' }, + { syntax: 'clojure', variants: 'clj, edn', label: 'Clojure' }, + { syntax: 'clojure-repl', label: 'Clojure REPL' }, + { syntax: 'cmake', variants: 'cmake.in', label: 'CMake' }, + { syntax: 'coffeescript', variants: 'coffee, cson, iced', label: 'CoffeeScript' }, + { syntax: 'coq', label: 'Coq' }, + { syntax: 'cos', variants: 'cls', label: 'Caché Object Script' }, + { syntax: 'cpp', variants: 'cc, c++, h++, hpp, hh, hxx, cxx', label: 'C++' }, + { syntax: 'crmsh', variants: 'crm, pcmk', label: 'crmsh' }, + { syntax: 'crystal', variants: 'cr', label: 'Crystal' }, + { syntax: 'csharp', variants: 'cs, c#', label: 'C#' }, + { syntax: 'csp', label: 'CSP' }, + { syntax: 'css', label: 'CSS' }, + { syntax: 'd', label: 'D' }, + { syntax: 'dart', label: 'Dart' }, + { syntax: 'delphi', variants: 'dpr, dfm, pas, pascal', label: 'Delphi' }, + { syntax: 'diff', variants: 'patch', label: 'Diff' }, + { syntax: 'django', variants: 'jinja', label: 'Django' }, + { syntax: 'dns', variants: 'bind, zone', label: 'DNS Zone' }, + { syntax: 'dockerfile', variants: 'docker', label: 'Dockerfile' }, + { syntax: 'dos', variants: 'bat, cmd', label: 'Batch file (DOS)' }, + { syntax: 'dsconfig', label: 'DSConfig' }, + { syntax: 'dts', label: 'Device Tree' }, + { syntax: 'dust', variants: 'dst', label: 'Dust' }, + { syntax: 'ebnf', label: 'Extended Backus-Naur Form' }, + { syntax: 'elixir', variants: 'ex, exs', label: 'Elixir' }, + { syntax: 'elm', label: 'Elm' }, + { syntax: 'erb', label: 'ERB' }, + { syntax: 'erlang', variants: 'erl', label: 'Erlang' }, + { syntax: 'erlang-repl', label: 'Erlang REPL' }, + { syntax: 'excel', variants: 'xlsx, xls', label: 'Excel formulae' }, + { syntax: 'fix', label: 'FIX' }, + { syntax: 'flix', label: 'Flix' }, + { syntax: 'fortran', variants: 'f90, f95', label: 'Fortran' }, + { syntax: 'fsharp', variants: 'fs, f#', label: 'F#' }, + { syntax: 'gams', variants: 'gms', label: 'GAMS' }, + { syntax: 'gauss', variants: 'gss', label: 'GAUSS' }, + { syntax: 'gcode', variants: 'nc', label: 'G-code (ISO 6983)' }, + { syntax: 'gherkin', variants: 'feature', label: 'Gherkin' }, + { syntax: 'glsl', label: 'GLSL' }, + { syntax: 'gml', label: 'GML' }, + { syntax: 'go', variants: 'golang', label: 'Go' }, + { syntax: 'golo', label: 'Golo' }, + { syntax: 'gradle', label: 'Gradle' }, + { syntax: 'graphql', variants: 'gql', label: 'GraphQL' }, + { syntax: 'groovy', label: 'Groovy' }, + { syntax: 'haml', label: 'HAML' }, + { + syntax: 'handlebars', + variants: 'hbs, html.hbs, html.handlebars, htmlbars', + label: 'Handlebars', + }, + { syntax: 'haskell', variants: 'hs', label: 'Haskell' }, + { syntax: 'haxe', variants: 'hx', label: 'Haxe' }, + { syntax: 'hsp', label: 'HSP' }, + { syntax: 'http', variants: 'https', label: 'HTTP' }, + { syntax: 'hy', variants: 'hylang', label: 'Hy' }, + { syntax: 'inform7', variants: 'i7', label: 'Inform 7' }, + { syntax: 'ini', variants: 'toml', label: 'TOML, also INI' }, + { syntax: 'irpf90', label: 'IRPF90' }, + { syntax: 'isbl', label: 'ISBL' }, + { syntax: 'java', variants: 'jsp', label: 'Java' }, + { syntax: 'javascript', variants: 'js, jsx, mjs, cjs', label: 'Javascript' }, + { syntax: 'jboss-cli', variants: 'wildfly-cli', label: 'JBoss CLI' }, + { syntax: 'json', label: 'JSON' }, + { syntax: 'julia', label: 'Julia' }, + { syntax: 'julia-repl', variants: 'jldoctest', label: 'Julia REPL' }, + { syntax: 'kotlin', variants: 'kt, kts', label: 'Kotlin' }, + { syntax: 'lasso', variants: 'ls, lassoscript', label: 'Lasso' }, + { syntax: 'latex', variants: 'tex', label: 'LaTeX' }, + { syntax: 'ldif', label: 'LDIF' }, + { syntax: 'leaf', label: 'Leaf' }, + { syntax: 'less', label: 'Less' }, + { syntax: 'lisp', label: 'Lisp' }, + { syntax: 'livecodeserver', label: 'LiveCode' }, + { syntax: 'livescript', variants: 'ls', label: 'LiveScript' }, + { syntax: 'llvm', label: 'LLVM IR' }, + { syntax: 'lsl', label: 'LSL (Linden Scripting Language)' }, + { syntax: 'lua', label: 'Lua' }, + { syntax: 'makefile', variants: 'mk, mak, make', label: 'Makefile' }, + { syntax: 'markdown', variants: 'md, mkdown, mkd', label: 'Markdown' }, + { syntax: 'mathematica', variants: 'mma, wl', label: 'Mathematica' }, + { syntax: 'matlab', label: 'Matlab' }, + { syntax: 'maxima', label: 'Maxima' }, + { syntax: 'mel', label: 'MEL' }, + { syntax: 'mercury', variants: 'm, moo', label: 'Mercury' }, + { syntax: 'mipsasm', variants: 'mips', label: 'MIPS Assembly' }, + { syntax: 'mizar', label: 'Mizar' }, + { syntax: 'mojolicious', label: 'Mojolicious' }, + { syntax: 'monkey', label: 'Monkey' }, + { syntax: 'moonscript', variants: 'moon', label: 'MoonScript' }, + { syntax: 'n1ql', label: 'N1QL' }, + { syntax: 'nestedtext', variants: 'nt', label: 'Nested Text' }, + { syntax: 'nginx', variants: 'nginxconf', label: 'Nginx config' }, + { syntax: 'nim', label: 'Nim' }, + { syntax: 'nix', variants: 'nixos', label: 'Nix' }, + { syntax: 'node-repl', label: 'Node REPL' }, + { syntax: 'nsis', label: 'NSIS' }, + { + syntax: 'objectivec', + variants: 'mm, objc, obj-c, obj-c++, objective-c++', + label: 'Objective-C', + }, + { syntax: 'ocaml', variants: 'ml', label: 'OCaml' }, + { syntax: 'openscad', variants: 'scad', label: 'OpenSCAD' }, + { syntax: 'oxygene', label: 'Oxygene' }, + { syntax: 'parser3', label: 'Parser3' }, + { syntax: 'perl', variants: 'pl, pm', label: 'Perl' }, + { syntax: 'pf', variants: 'pf.conf', label: 'Packet Filter config' }, + { syntax: 'pgsql', variants: 'postgres, postgresql', label: 'PostgreSQL' }, + { syntax: 'php', label: 'PHP' }, + { syntax: 'php-template', label: 'PHP template' }, + { syntax: 'plaintext', variants: 'text, txt', label: 'Plain text' }, + { syntax: 'pony', label: 'Pony' }, + { syntax: 'powershell', variants: 'pwsh, ps, ps1', label: 'PowerShell' }, + { syntax: 'processing', variants: 'pde', label: 'Processing' }, + { syntax: 'profile', label: 'Python profiler' }, + { syntax: 'prolog', label: 'Prolog' }, + { syntax: 'properties', label: '.properties' }, + { syntax: 'protobuf', label: 'Protocol Buffers' }, + { syntax: 'puppet', variants: 'pp', label: 'Puppet' }, + { syntax: 'purebasic', variants: 'pb, pbi', label: 'PureBASIC' }, + { syntax: 'python', variants: 'py, gyp, ipython', label: 'Python' }, + { syntax: 'python-repl', variants: 'pycon', label: 'Python REPL' }, + { syntax: 'q', variants: 'k, kdb', label: 'Q' }, + { syntax: 'qml', variants: 'qt', label: 'QML' }, + { syntax: 'r', label: 'R' }, + { syntax: 'reasonml', variants: 're', label: 'ReasonML' }, + { syntax: 'rib', label: 'RenderMan RIB' }, + { syntax: 'roboconf', variants: 'graph, instances', label: 'Roboconf' }, + { syntax: 'routeros', variants: 'mikrotik', label: 'Microtik RouterOS script' }, + { syntax: 'rsl', label: 'RenderMan RSL' }, + { syntax: 'ruby', variants: 'rb, gemspec, podspec, thor, irb', label: 'Ruby' }, + { syntax: 'ruleslanguage', label: 'Oracle Rules Language' }, + { syntax: 'rust', variants: 'rs', label: 'Rust' }, + { syntax: 'sas', label: 'SAS' }, + { syntax: 'scala', label: 'Scala' }, + { syntax: 'scheme', label: 'Scheme' }, + { syntax: 'scilab', variants: 'sci', label: 'Scilab' }, + { syntax: 'scss', label: 'SCSS' }, + { syntax: 'shell', variants: 'console, shellsession', label: 'Shell Session' }, + { syntax: 'smali', label: 'Smali' }, + { syntax: 'smalltalk', variants: 'st', label: 'Smalltalk' }, + { syntax: 'sml', variants: 'ml', label: 'SML (Standard ML)' }, + { syntax: 'sqf', label: 'SQF' }, + { syntax: 'sql', label: 'SQL' }, + { syntax: 'stan', variants: 'stanfuncs', label: 'Stan' }, + { syntax: 'stata', variants: 'do, ado', label: 'Stata' }, + { syntax: 'step21', variants: 'p21, step, stp', label: 'STEP Part 21' }, + { syntax: 'stylus', variants: 'styl', label: 'Stylus' }, + { syntax: 'subunit', label: 'SubUnit' }, + { syntax: 'swift', label: 'Swift' }, + { syntax: 'taggerscript', label: 'Tagger Script' }, + { syntax: 'tap', label: 'Test Anything Protocol' }, + { syntax: 'tcl', variants: 'tk', label: 'Tcl' }, + { syntax: 'thrift', label: 'Thrift' }, + { syntax: 'tp', label: 'TP' }, + { syntax: 'twig', variants: 'craftcms', label: 'Twig' }, + { syntax: 'typescript', variants: 'ts, tsx', label: 'TypeScript' }, + { syntax: 'vala', label: 'Vala' }, + { syntax: 'vbnet', variants: 'vb', label: 'Visual Basic .NET' }, + { syntax: 'vbscript', variants: 'vbs', label: 'VBScript' }, + { syntax: 'vbscript-html', label: 'VBScript in HTML' }, + { syntax: 'verilog', variants: 'v, sv, svh', label: 'Verilog' }, + { syntax: 'vhdl', label: 'VHDL' }, + { syntax: 'vim', label: 'Vim Script' }, + { syntax: 'wasm', label: 'WebAssembly' }, + { syntax: 'wren', label: 'Wren' }, + { syntax: 'x86asm', label: 'Intel x86 Assembly' }, + { syntax: 'xl', variants: 'tao', label: 'XL' }, + { + syntax: 'xml', + variants: 'html, xhtml, rss, atom, xjb, xsd, xsl, plist, wsf, svg', + label: 'HTML, XML', + }, + { syntax: 'xquery', variants: 'xpath, xq', label: 'XQuery' }, + { syntax: 'yaml', variants: 'yml', label: 'YAML' }, + { syntax: 'zephir', variants: 'zep', label: 'Zephir' }, +]; + +export default CODE_BLOCK_LANGUAGES; diff --git a/app/assets/javascripts/content_editor/constants.js b/app/assets/javascripts/content_editor/constants/index.js index a39a243ec6b..a39a243ec6b 100644 --- a/app/assets/javascripts/content_editor/constants.js +++ b/app/assets/javascripts/content_editor/constants/index.js diff --git a/app/assets/javascripts/content_editor/extensions/blockquote.js b/app/assets/javascripts/content_editor/extensions/blockquote.js index 5632bc28592..9b424ac8367 100644 --- a/app/assets/javascripts/content_editor/extensions/blockquote.js +++ b/app/assets/javascripts/content_editor/extensions/blockquote.js @@ -26,7 +26,7 @@ export default Blockquote.extend({ const multilineInputRegex = /^\s*>>>\s$/gm; return [ - ...this.parent?.(), + ...this.parent(), wrappingInputRule({ find: multilineInputRegex, type: this.type, 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 61f379fc0a2..cc4ba84a29d 100644 --- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js +++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js @@ -1,6 +1,8 @@ import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight'; import { textblockTypeInputRule } from '@tiptap/core'; -import codeBlockLanguageLoader from '../services/code_block_language_loader'; +import { VueNodeViewRenderer } from '@tiptap/vue-2'; +import languageLoader from '../services/code_block_language_loader'; +import CodeBlockWrapper from '../components/wrappers/code_block.vue'; const extractLanguage = (element) => element.getAttribute('lang'); export const backtickInputRegex = /^```([a-z]+)?[\s\n]$/; @@ -9,14 +11,6 @@ export const tildeInputRegex = /^~~~([a-z]+)?[\s\n]$/; export default CodeBlockLowlight.extend({ isolating: true, exitOnArrowDown: false, - - addOptions() { - return { - ...this.parent?.(), - languageLoader: codeBlockLanguageLoader, - }; - }, - addAttributes() { return { language: { @@ -30,7 +24,6 @@ export default CodeBlockLowlight.extend({ }; }, addInputRules() { - const { languageLoader } = this.options; const getAttributes = (match) => languageLoader?.loadLanguageFromInputRule(match) || {}; return [ @@ -65,4 +58,8 @@ export default CodeBlockLowlight.extend({ ['code', {}, 0], ]; }, + + addNodeView() { + return new VueNodeViewRenderer(CodeBlockWrapper); + }, }); diff --git a/app/assets/javascripts/content_editor/extensions/diagram.js b/app/assets/javascripts/content_editor/extensions/diagram.js index d192b815092..f9dfeb92e9a 100644 --- a/app/assets/javascripts/content_editor/extensions/diagram.js +++ b/app/assets/javascripts/content_editor/extensions/diagram.js @@ -14,6 +14,9 @@ export default CodeBlockHighlight.extend({ return element.dataset.diagram; }, }, + isDiagram: { + default: true, + }, }; }, diff --git a/app/assets/javascripts/content_editor/extensions/frontmatter.js b/app/assets/javascripts/content_editor/extensions/frontmatter.js index 9842027e192..2ec22158106 100644 --- a/app/assets/javascripts/content_editor/extensions/frontmatter.js +++ b/app/assets/javascripts/content_editor/extensions/frontmatter.js @@ -1,10 +1,18 @@ -import { VueNodeViewRenderer } from '@tiptap/vue-2'; import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; -import FrontmatterWrapper from '../components/wrappers/frontmatter.vue'; import CodeBlockHighlight from './code_block_highlight'; export default CodeBlockHighlight.extend({ name: 'frontmatter', + + addAttributes() { + return { + ...this.parent?.(), + isFrontmatter: { + default: true, + }, + }; + }, + parseHTML() { return [ { @@ -24,9 +32,6 @@ export default CodeBlockHighlight.extend({ }, }; }, - addNodeView() { - return new VueNodeViewRenderer(FrontmatterWrapper); - }, addInputRules() { return []; diff --git a/app/assets/javascripts/content_editor/extensions/image.js b/app/assets/javascripts/content_editor/extensions/image.js index 311db8151cb..25f976f524f 100644 --- a/app/assets/javascripts/content_editor/extensions/image.js +++ b/app/assets/javascripts/content_editor/extensions/image.js @@ -1,6 +1,4 @@ import { Image } from '@tiptap/extension-image'; -import { VueNodeViewRenderer } from '@tiptap/vue-2'; -import MediaWrapper from '../components/wrappers/media.vue'; import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; const resolveImageEl = (element) => @@ -77,7 +75,4 @@ export default Image.extend({ }, ]; }, - addNodeView() { - return VueNodeViewRenderer(MediaWrapper); - }, }); diff --git a/app/assets/javascripts/content_editor/extensions/paste_markdown.js b/app/assets/javascripts/content_editor/extensions/paste_markdown.js index c349aa42a62..f87e4d8d1dd 100644 --- a/app/assets/javascripts/content_editor/extensions/paste_markdown.js +++ b/app/assets/javascripts/content_editor/extensions/paste_markdown.js @@ -2,7 +2,7 @@ import { Extension } from '@tiptap/core'; import { Plugin, PluginKey } from 'prosemirror-state'; import { __ } from '~/locale'; import { VARIANT_DANGER } from '~/flash'; -import createMarkdownDeserializer from '../services/markdown_deserializer'; +import createMarkdownDeserializer from '../services/gl_api_markdown_deserializer'; import { ALERT_EVENT, LOADING_CONTENT_EVENT, @@ -10,10 +10,14 @@ import { LOADING_ERROR_EVENT, EXTENSION_PRIORITY_HIGHEST, } from '../constants'; +import CodeBlockHighlight from './code_block_highlight'; +import Diagram from './diagram'; +import Frontmatter from './frontmatter'; const TEXT_FORMAT = 'text/plain'; const HTML_FORMAT = 'text/html'; const VS_CODE_FORMAT = 'vscode-editor-data'; +const CODE_BLOCK_NODE_TYPES = [CodeBlockHighlight.name, Diagram.name, Frontmatter.name]; export default Extension.create({ name: 'pasteMarkdown', @@ -75,6 +79,11 @@ export default Extension.create({ return false; } + // if a code block is active, paste as plain text + if (CODE_BLOCK_NODE_TYPES.some((type) => this.editor.isActive(type))) { + return false; + } + this.editor.commands.pasteMarkdown(content); return true; diff --git a/app/assets/javascripts/content_editor/extensions/playable.js b/app/assets/javascripts/content_editor/extensions/playable.js index 2c5269377c5..ed343d8acf8 100644 --- a/app/assets/javascripts/content_editor/extensions/playable.js +++ b/app/assets/javascripts/content_editor/extensions/playable.js @@ -1,8 +1,6 @@ /* eslint-disable @gitlab/require-i18n-strings */ import { Node } from '@tiptap/core'; -import { VueNodeViewRenderer } from '@tiptap/vue-2'; -import MediaWrapper from '../components/wrappers/media.vue'; const queryPlayableElement = (element, mediaType) => element.querySelector(mediaType); @@ -68,8 +66,4 @@ export default Node.create({ ['a', { href: node.attrs.src }, node.attrs.title || node.attrs.alt || ''], ]; }, - - addNodeView() { - return VueNodeViewRenderer(MediaWrapper); - }, }); diff --git a/app/assets/javascripts/content_editor/extensions/sourcemap.js b/app/assets/javascripts/content_editor/extensions/sourcemap.js new file mode 100644 index 00000000000..94236e2e70e --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/sourcemap.js @@ -0,0 +1,48 @@ +import { Extension } from '@tiptap/core'; +import Blockquote from './blockquote'; +import Bold from './bold'; +import BulletList from './bullet_list'; +import Code from './code'; +import CodeBlockHighlight from './code_block_highlight'; +import Heading from './heading'; +import HardBreak from './hard_break'; +import HorizontalRule from './horizontal_rule'; +import Image from './image'; +import Italic from './italic'; +import Link from './link'; +import ListItem from './list_item'; +import OrderedList from './ordered_list'; +import Paragraph from './paragraph'; + +export default Extension.create({ + addGlobalAttributes() { + return [ + { + types: [ + Bold.name, + Blockquote.name, + BulletList.name, + Code.name, + CodeBlockHighlight.name, + HardBreak.name, + Heading.name, + HorizontalRule.name, + Image.name, + Italic.name, + Link.name, + ListItem.name, + OrderedList.name, + Paragraph.name, + ], + attributes: { + sourceMarkdown: { + default: null, + }, + sourceMapKey: { + default: null, + }, + }, + }, + ]; + }, +}); diff --git a/app/assets/javascripts/content_editor/services/asset_resolver.js b/app/assets/javascripts/content_editor/services/asset_resolver.js new file mode 100644 index 00000000000..942457b9664 --- /dev/null +++ b/app/assets/javascripts/content_editor/services/asset_resolver.js @@ -0,0 +1,13 @@ +import { memoize } from 'lodash'; + +export default ({ renderMarkdown }) => ({ + resolveUrl: memoize(async (canonicalSrc) => { + const html = await renderMarkdown(`[link](${canonicalSrc})`); + if (!html) return canonicalSrc; + + const parser = new DOMParser(); + const { body } = parser.parseFromString(html, 'text/html'); + + return body.querySelector('a').getAttribute('href'); + }), +}); diff --git a/app/assets/javascripts/content_editor/services/code_block_language_loader.js b/app/assets/javascripts/content_editor/services/code_block_language_loader.js index 081400cfd9a..1afaf4bfef6 100644 --- a/app/assets/javascripts/content_editor/services/code_block_language_loader.js +++ b/app/assets/javascripts/content_editor/services/code_block_language_loader.js @@ -1,215 +1,7 @@ import { lowlight } from 'lowlight/lib/core'; import { __, sprintf } from '~/locale'; - -/* eslint-disable @gitlab/require-i18n-strings */ -// List of languages referenced from https://github.com/wooorm/lowlight#data -const CODE_BLOCK_LANGUAGES = [ - { syntax: '1c', label: '1C:Enterprise' }, - { syntax: 'abnf', label: 'Augmented Backus-Naur Form' }, - { syntax: 'accesslog', label: 'Apache Access Log' }, - { syntax: 'actionscript', variants: 'as', label: 'ActionScript' }, - { syntax: 'ada', label: 'Ada' }, - { syntax: 'angelscript', variants: 'asc', label: 'AngelScript' }, - { syntax: 'apache', variants: 'apacheconf', label: 'Apache config' }, - { syntax: 'applescript', variants: 'osascript', label: 'AppleScript' }, - { syntax: 'arcade', label: 'ArcGIS Arcade' }, - { syntax: 'arduino', variants: 'ino', label: 'Arduino' }, - { syntax: 'armasm', variants: 'arm', label: 'ARM Assembly' }, - { syntax: 'asciidoc', variants: 'adoc', label: 'AsciiDoc' }, - { syntax: 'aspectj', label: 'AspectJ' }, - { syntax: 'autohotkey', variants: 'ahk', label: 'AutoHotkey' }, - { syntax: 'autoit', label: 'AutoIt' }, - { syntax: 'avrasm', label: 'AVR Assembly' }, - { syntax: 'awk', label: 'Awk' }, - { syntax: 'axapta', variants: 'x++', label: 'X++' }, - { syntax: 'bash', variants: 'sh', label: 'Bash' }, - { syntax: 'basic', label: 'BASIC' }, - { syntax: 'bnf', label: 'Backus-Naur Form' }, - { syntax: 'brainfuck', variants: 'bf', label: 'Brainfuck' }, - { syntax: 'c', variants: 'h', label: 'C' }, - { syntax: 'cal', label: 'C/AL' }, - { syntax: 'capnproto', variants: 'capnp', label: "Cap'n Proto" }, - { syntax: 'ceylon', label: 'Ceylon' }, - { syntax: 'clean', variants: 'icl, dcl', label: 'Clean' }, - { syntax: 'clojure', variants: 'clj, edn', label: 'Clojure' }, - { syntax: 'clojure-repl', label: 'Clojure REPL' }, - { syntax: 'cmake', variants: 'cmake.in', label: 'CMake' }, - { syntax: 'coffeescript', variants: 'coffee, cson, iced', label: 'CoffeeScript' }, - { syntax: 'coq', label: 'Coq' }, - { syntax: 'cos', variants: 'cls', label: 'Caché Object Script' }, - { syntax: 'cpp', variants: 'cc, c++, h++, hpp, hh, hxx, cxx', label: 'C++' }, - { syntax: 'crmsh', variants: 'crm, pcmk', label: 'crmsh' }, - { syntax: 'crystal', variants: 'cr', label: 'Crystal' }, - { syntax: 'csharp', variants: 'cs, c#', label: 'C#' }, - { syntax: 'csp', label: 'CSP' }, - { syntax: 'css', label: 'CSS' }, - { syntax: 'd', label: 'D' }, - { syntax: 'dart', label: 'Dart' }, - { syntax: 'delphi', variants: 'dpr, dfm, pas, pascal', label: 'Delphi' }, - { syntax: 'diff', variants: 'patch', label: 'Diff' }, - { syntax: 'django', variants: 'jinja', label: 'Django' }, - { syntax: 'dns', variants: 'bind, zone', label: 'DNS Zone' }, - { syntax: 'dockerfile', variants: 'docker', label: 'Dockerfile' }, - { syntax: 'dos', variants: 'bat, cmd', label: 'Batch file (DOS)' }, - { syntax: 'dsconfig', label: 'DSConfig' }, - { syntax: 'dts', label: 'Device Tree' }, - { syntax: 'dust', variants: 'dst', label: 'Dust' }, - { syntax: 'ebnf', label: 'Extended Backus-Naur Form' }, - { syntax: 'elixir', variants: 'ex, exs', label: 'Elixir' }, - { syntax: 'elm', label: 'Elm' }, - { syntax: 'erb', label: 'ERB' }, - { syntax: 'erlang', variants: 'erl', label: 'Erlang' }, - { syntax: 'erlang-repl', label: 'Erlang REPL' }, - { syntax: 'excel', variants: 'xlsx, xls', label: 'Excel formulae' }, - { syntax: 'fix', label: 'FIX' }, - { syntax: 'flix', label: 'Flix' }, - { syntax: 'fortran', variants: 'f90, f95', label: 'Fortran' }, - { syntax: 'fsharp', variants: 'fs, f#', label: 'F#' }, - { syntax: 'gams', variants: 'gms', label: 'GAMS' }, - { syntax: 'gauss', variants: 'gss', label: 'GAUSS' }, - { syntax: 'gcode', variants: 'nc', label: 'G-code (ISO 6983)' }, - { syntax: 'gherkin', variants: 'feature', label: 'Gherkin' }, - { syntax: 'glsl', label: 'GLSL' }, - { syntax: 'gml', label: 'GML' }, - { syntax: 'go', variants: 'golang', label: 'Go' }, - { syntax: 'golo', label: 'Golo' }, - { syntax: 'gradle', label: 'Gradle' }, - { syntax: 'graphql', variants: 'gql', label: 'GraphQL' }, - { syntax: 'groovy', label: 'Groovy' }, - { syntax: 'haml', label: 'HAML' }, - { - syntax: 'handlebars', - variants: 'hbs, html.hbs, html.handlebars, htmlbars', - label: 'Handlebars', - }, - { syntax: 'haskell', variants: 'hs', label: 'Haskell' }, - { syntax: 'haxe', variants: 'hx', label: 'Haxe' }, - { syntax: 'hsp', label: 'HSP' }, - { syntax: 'http', variants: 'https', label: 'HTTP' }, - { syntax: 'hy', variants: 'hylang', label: 'Hy' }, - { syntax: 'inform7', variants: 'i7', label: 'Inform 7' }, - { syntax: 'ini', variants: 'toml', label: 'TOML, also INI' }, - { syntax: 'irpf90', label: 'IRPF90' }, - { syntax: 'isbl', label: 'ISBL' }, - { syntax: 'java', variants: 'jsp', label: 'Java' }, - { syntax: 'javascript', variants: 'js, jsx, mjs, cjs', label: 'Javascript' }, - { syntax: 'jboss-cli', variants: 'wildfly-cli', label: 'JBoss CLI' }, - { syntax: 'json', label: 'JSON' }, - { syntax: 'julia', label: 'Julia' }, - { syntax: 'julia-repl', variants: 'jldoctest', label: 'Julia REPL' }, - { syntax: 'kotlin', variants: 'kt, kts', label: 'Kotlin' }, - { syntax: 'lasso', variants: 'ls, lassoscript', label: 'Lasso' }, - { syntax: 'latex', variants: 'tex', label: 'LaTeX' }, - { syntax: 'ldif', label: 'LDIF' }, - { syntax: 'leaf', label: 'Leaf' }, - { syntax: 'less', label: 'Less' }, - { syntax: 'lisp', label: 'Lisp' }, - { syntax: 'livecodeserver', label: 'LiveCode' }, - { syntax: 'livescript', variants: 'ls', label: 'LiveScript' }, - { syntax: 'llvm', label: 'LLVM IR' }, - { syntax: 'lsl', label: 'LSL (Linden Scripting Language)' }, - { syntax: 'lua', label: 'Lua' }, - { syntax: 'makefile', variants: 'mk, mak, make', label: 'Makefile' }, - { syntax: 'markdown', variants: 'md, mkdown, mkd', label: 'Markdown' }, - { syntax: 'mathematica', variants: 'mma, wl', label: 'Mathematica' }, - { syntax: 'matlab', label: 'Matlab' }, - { syntax: 'maxima', label: 'Maxima' }, - { syntax: 'mel', label: 'MEL' }, - { syntax: 'mercury', variants: 'm, moo', label: 'Mercury' }, - { syntax: 'mipsasm', variants: 'mips', label: 'MIPS Assembly' }, - { syntax: 'mizar', label: 'Mizar' }, - { syntax: 'mojolicious', label: 'Mojolicious' }, - { syntax: 'monkey', label: 'Monkey' }, - { syntax: 'moonscript', variants: 'moon', label: 'MoonScript' }, - { syntax: 'n1ql', label: 'N1QL' }, - { syntax: 'nestedtext', variants: 'nt', label: 'Nested Text' }, - { syntax: 'nginx', variants: 'nginxconf', label: 'Nginx config' }, - { syntax: 'nim', label: 'Nim' }, - { syntax: 'nix', variants: 'nixos', label: 'Nix' }, - { syntax: 'node-repl', label: 'Node REPL' }, - { syntax: 'nsis', label: 'NSIS' }, - { - syntax: 'objectivec', - variants: 'mm, objc, obj-c, obj-c++, objective-c++', - label: 'Objective-C', - }, - { syntax: 'ocaml', variants: 'ml', label: 'OCaml' }, - { syntax: 'openscad', variants: 'scad', label: 'OpenSCAD' }, - { syntax: 'oxygene', label: 'Oxygene' }, - { syntax: 'parser3', label: 'Parser3' }, - { syntax: 'perl', variants: 'pl, pm', label: 'Perl' }, - { syntax: 'pf', variants: 'pf.conf', label: 'Packet Filter config' }, - { syntax: 'pgsql', variants: 'postgres, postgresql', label: 'PostgreSQL' }, - { syntax: 'php', label: 'PHP' }, - { syntax: 'php-template', label: 'PHP template' }, - { syntax: 'plaintext', variants: 'text, txt', label: 'Plain text' }, - { syntax: 'pony', label: 'Pony' }, - { syntax: 'powershell', variants: 'pwsh, ps, ps1', label: 'PowerShell' }, - { syntax: 'processing', variants: 'pde', label: 'Processing' }, - { syntax: 'profile', label: 'Python profiler' }, - { syntax: 'prolog', label: 'Prolog' }, - { syntax: 'properties', label: '.properties' }, - { syntax: 'protobuf', label: 'Protocol Buffers' }, - { syntax: 'puppet', variants: 'pp', label: 'Puppet' }, - { syntax: 'purebasic', variants: 'pb, pbi', label: 'PureBASIC' }, - { syntax: 'python', variants: 'py, gyp, ipython', label: 'Python' }, - { syntax: 'python-repl', variants: 'pycon', label: 'Python REPL' }, - { syntax: 'q', variants: 'k, kdb', label: 'Q' }, - { syntax: 'qml', variants: 'qt', label: 'QML' }, - { syntax: 'r', label: 'R' }, - { syntax: 'reasonml', variants: 're', label: 'ReasonML' }, - { syntax: 'rib', label: 'RenderMan RIB' }, - { syntax: 'roboconf', variants: 'graph, instances', label: 'Roboconf' }, - { syntax: 'routeros', variants: 'mikrotik', label: 'Microtik RouterOS script' }, - { syntax: 'rsl', label: 'RenderMan RSL' }, - { syntax: 'ruby', variants: 'rb, gemspec, podspec, thor, irb', label: 'Ruby' }, - { syntax: 'ruleslanguage', label: 'Oracle Rules Language' }, - { syntax: 'rust', variants: 'rs', label: 'Rust' }, - { syntax: 'sas', label: 'SAS' }, - { syntax: 'scala', label: 'Scala' }, - { syntax: 'scheme', label: 'Scheme' }, - { syntax: 'scilab', variants: 'sci', label: 'Scilab' }, - { syntax: 'scss', label: 'SCSS' }, - { syntax: 'shell', variants: 'console, shellsession', label: 'Shell Session' }, - { syntax: 'smali', label: 'Smali' }, - { syntax: 'smalltalk', variants: 'st', label: 'Smalltalk' }, - { syntax: 'sml', variants: 'ml', label: 'SML (Standard ML)' }, - { syntax: 'sqf', label: 'SQF' }, - { syntax: 'sql', label: 'SQL' }, - { syntax: 'stan', variants: 'stanfuncs', label: 'Stan' }, - { syntax: 'stata', variants: 'do, ado', label: 'Stata' }, - { syntax: 'step21', variants: 'p21, step, stp', label: 'STEP Part 21' }, - { syntax: 'stylus', variants: 'styl', label: 'Stylus' }, - { syntax: 'subunit', label: 'SubUnit' }, - { syntax: 'swift', label: 'Swift' }, - { syntax: 'taggerscript', label: 'Tagger Script' }, - { syntax: 'tap', label: 'Test Anything Protocol' }, - { syntax: 'tcl', variants: 'tk', label: 'Tcl' }, - { syntax: 'thrift', label: 'Thrift' }, - { syntax: 'tp', label: 'TP' }, - { syntax: 'twig', variants: 'craftcms', label: 'Twig' }, - { syntax: 'typescript', variants: 'ts, tsx', label: 'TypeScript' }, - { syntax: 'vala', label: 'Vala' }, - { syntax: 'vbnet', variants: 'vb', label: 'Visual Basic .NET' }, - { syntax: 'vbscript', variants: 'vbs', label: 'VBScript' }, - { syntax: 'vbscript-html', label: 'VBScript in HTML' }, - { syntax: 'verilog', variants: 'v, sv, svh', label: 'Verilog' }, - { syntax: 'vhdl', label: 'VHDL' }, - { syntax: 'vim', label: 'Vim Script' }, - { syntax: 'wasm', label: 'WebAssembly' }, - { syntax: 'wren', label: 'Wren' }, - { syntax: 'x86asm', label: 'Intel x86 Assembly' }, - { syntax: 'xl', variants: 'tao', label: 'XL' }, - { - syntax: 'xml', - variants: 'html, xhtml, rss, atom, xjb, xsd, xsl, plist, wsf, svg', - label: 'HTML, XML', - }, - { syntax: 'xquery', variants: 'xpath, xq', label: 'XQuery' }, - { syntax: 'yaml', variants: 'yml', label: 'YAML' }, - { syntax: 'zephir', variants: 'zep', label: 'Zephir' }, -]; -/* eslint-enable @gitlab/require-i18n-strings */ +import CODE_BLOCK_LANGUAGES from '../constants/code_block_languages'; +import languageLoader from './highlight_js_language_loader'; const codeBlockLanguageLoader = { lowlight, @@ -245,38 +37,24 @@ const codeBlockLanguageLoader = { return this.lowlight.registered(language); }, - loadLanguagesFromDOM(domTree) { - const languages = []; - - domTree.querySelectorAll('pre').forEach((preElement) => { - languages.push(preElement.getAttribute('lang')); - }); - - return this.loadLanguages(languages); - }, - loadLanguageFromInputRule(match) { const { syntax } = this.findLanguageBySyntax(match[1]); - this.loadLanguages([syntax]); + this.loadLanguage(syntax); return { language: syntax }; }, - loadLanguages(languageList = []) { - const loaders = languageList - .filter((languageName) => !this.isLanguageLoaded(languageName)) - .map((languageName) => { - return import( - /* webpackChunkName: 'highlight.language.js' */ `highlight.js/lib/languages/${languageName}` - ) - .then(({ default: language }) => { - this.lowlight.registerLanguage(languageName, language); - }) - .catch(() => false); - }); + async loadLanguage(languageName) { + if (this.isLanguageLoaded(languageName)) return false; - return Promise.all(loaders); + try { + const { default: language } = await languageLoader[languageName](); + this.lowlight.registerLanguage(languageName, language); + return true; + } catch { + return false; + } }, }; diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js index 56badf965ee..52dacb84153 100644 --- a/app/assets/javascripts/content_editor/services/content_editor.js +++ b/app/assets/javascripts/content_editor/services/content_editor.js @@ -3,12 +3,13 @@ import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } fro /* eslint-disable no-underscore-dangle */ export class ContentEditor { - constructor({ tiptapEditor, serializer, deserializer, eventHub, languageLoader }) { + constructor({ tiptapEditor, serializer, deserializer, assetResolver, eventHub }) { this._tiptapEditor = tiptapEditor; this._serializer = serializer; this._deserializer = deserializer; this._eventHub = eventHub; - this._languageLoader = languageLoader; + this._assetResolver = assetResolver; + this._pristineDoc = null; } get tiptapEditor() { @@ -19,6 +20,10 @@ export class ContentEditor { return this._eventHub; } + get changed() { + return this._pristineDoc?.eq(this.tiptapEditor.state.doc); + } + get empty() { const doc = this.tiptapEditor?.state.doc; @@ -34,28 +39,30 @@ export class ContentEditor { this._eventHub.dispose(); } + deserialize(serializedContent) { + const { _tiptapEditor: editor, _deserializer: deserializer } = this; + + return deserializer.deserialize({ + schema: editor.schema, + content: serializedContent, + }); + } + + resolveUrl(canonicalSrc) { + return this._assetResolver.resolveUrl(canonicalSrc); + } + async setSerializedContent(serializedContent) { - const { - _tiptapEditor: editor, - _deserializer: deserializer, - _eventHub: eventHub, - _languageLoader: languageLoader, - } = this; + const { _tiptapEditor: editor, _eventHub: eventHub } = this; const { doc, tr } = editor.state; const selection = TextSelection.create(doc, 0, doc.content.size); try { eventHub.$emit(LOADING_CONTENT_EVENT); - const result = await deserializer.deserialize({ - schema: editor.schema, - content: serializedContent, - }); - - if (Object.keys(result).length !== 0) { - const { document, dom } = result; - - await languageLoader.loadLanguagesFromDOM(dom); + const { document } = await this.deserialize(serializedContent); + if (document) { + this._pristineDoc = document; tr.setSelection(selection) .replaceSelectionWith(document, false) .setMeta('preventUpdate', true); @@ -70,8 +77,9 @@ export class ContentEditor { } getSerializedContent() { - const { _tiptapEditor: editor, _serializer: serializer } = this; + const { _tiptapEditor: editor, _serializer: serializer, _pristineDoc: pristineDoc } = this; + const { doc } = editor.state; - return serializer.serialize({ schema: editor.schema, content: editor.getJSON() }); + return serializer.serialize({ doc, pristineDoc }); } } 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 af19a0ab0e4..15aac3d86e5 100644 --- a/app/assets/javascripts/content_editor/services/create_content_editor.js +++ b/app/assets/javascripts/content_editor/services/create_content_editor.js @@ -43,6 +43,7 @@ import OrderedList from '../extensions/ordered_list'; import Paragraph from '../extensions/paragraph'; import PasteMarkdown from '../extensions/paste_markdown'; import Reference from '../extensions/reference'; +import Sourcemap from '../extensions/sourcemap'; import Strike from '../extensions/strike'; import Subscript from '../extensions/subscript'; import Superscript from '../extensions/superscript'; @@ -58,9 +59,10 @@ import Video from '../extensions/video'; import WordBreak from '../extensions/word_break'; import { ContentEditor } from './content_editor'; import createMarkdownSerializer from './markdown_serializer'; -import createMarkdownDeserializer from './markdown_deserializer'; +import createGlApiMarkdownDeserializer from './gl_api_markdown_deserializer'; +import createRemarkMarkdownDeserializer from './remark_markdown_deserializer'; +import createAssetResolver from './asset_resolver'; import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts'; -import languageLoader from './code_block_language_loader'; const createTiptapEditor = ({ extensions = [], ...options } = {}) => new Editor({ @@ -94,7 +96,7 @@ export const createContentEditor = ({ BulletList, Code, ColorChip, - CodeBlockHighlight.configure({ lowlight, languageLoader }), + CodeBlockHighlight.configure({ lowlight }), DescriptionItem, DescriptionList, Details, @@ -127,6 +129,7 @@ export const createContentEditor = ({ Paragraph, PasteMarkdown.configure({ renderMarkdown, eventHub }), Reference, + Sourcemap, Strike, Subscript, Superscript, @@ -146,7 +149,18 @@ export const createContentEditor = ({ const trackedExtensions = allExtensions.map(trackInputRulesAndShortcuts); const tiptapEditor = createTiptapEditor({ extensions: trackedExtensions, ...tiptapOptions }); const serializer = createMarkdownSerializer({ serializerConfig }); - const deserializer = createMarkdownDeserializer({ render: renderMarkdown }); + const deserializer = window.gon?.features?.preserveUnchangedMarkdown + ? createRemarkMarkdownDeserializer() + : createGlApiMarkdownDeserializer({ + render: renderMarkdown, + }); + const assetResolver = createAssetResolver({ renderMarkdown }); - return new ContentEditor({ tiptapEditor, serializer, eventHub, deserializer, languageLoader }); + return new ContentEditor({ + tiptapEditor, + serializer, + eventHub, + deserializer, + assetResolver, + }); }; diff --git a/app/assets/javascripts/content_editor/services/markdown_deserializer.js b/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js index cd4863d8eac..dcd56e55268 100644 --- a/app/assets/javascripts/content_editor/services/markdown_deserializer.js +++ b/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js @@ -27,7 +27,7 @@ export default ({ render }) => { // append original source as a comment that nodes can access body.append(document.createComment(content)); - return { document: ProseMirrorDOMParser.fromSchema(schema).parse(body), dom: body }; + return { document: ProseMirrorDOMParser.fromSchema(schema).parse(body) }; }, }; }; diff --git a/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js b/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js new file mode 100644 index 00000000000..b6a3e0bc26a --- /dev/null +++ b/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js @@ -0,0 +1,475 @@ +/** + * This module implements a function that converts a Hast Abstract + * Syntax Tree (AST) to a ProseMirror document. + * + * It is based on the prosemirror-markdown’s from_markdown module + * https://github.com/ProseMirror/prosemirror-markdown/blob/master/src/from_markdown.js. + * + * It deviates significantly from the original because + * prosemirror-markdown supports converting an markdown-it AST instead of a + * HAST one. It also adds sourcemap attributes automatically to every + * ProseMirror node and mark created during the conversion process. + * + * We recommend becoming familiar with HAST and ProseMirror documents to + * facilitate the understanding of the behavior implemented in this module. + * + * Unist syntax tree documentation: https://github.com/syntax-tree/unist + * Hast tree documentation: https://github.com/syntax-tree/hast + * ProseMirror document documentation: https://prosemirror.net/docs/ref/#model.Document_Structure + * visit-parents documentation: https://github.com/syntax-tree/unist-util-visit-parents + */ + +import { Mark } from 'prosemirror-model'; +import { visitParents } from 'unist-util-visit-parents'; +import { toString } from 'hast-util-to-string'; +import { isFunction } from 'lodash'; + +/** + * Merges two ProseMirror text nodes if both text nodes + * have the same set of marks. + * + * @param {ProseMirror.Node} a first ProseMirror node + * @param {ProseMirror.Node} b second ProseMirror node + * @returns {model.Node} A new text node that results from combining + * the text of the two text node parameters or null. + */ +function maybeMerge(a, b) { + if (a && a.isText && b && b.isText && Mark.sameSet(a.marks, b.marks)) { + return a.withText(a.text + b.text); + } + + return null; +} + +/** + * Creates an object that contains sourcemap position information + * included in a Hast Abstract Syntax Tree. The Content + * Editor uses the sourcemap information to restore the + * original source of a node when the user doesn’t change it. + * + * Unist syntax tree documentation: https://github.com/syntax-tree/unist + * Hast node documentation: https://github.com/syntax-tree/hast + * + * @param {HastNode} hastNode A Hast node + * @param {String} source Markdown source file + * + * @returns It returns an object with the following attributes: + * + * - sourceMapKey: A string that uniquely identifies what is + * the position of the hast node in the Markdown source file. + * - sourceMarkdown: A node’s original Markdown source extrated + * from the Markdown source file. + */ +function createSourceMapAttributes(hastNode, source) { + const { position } = hastNode; + + return { + sourceMapKey: `${position.start.offset}:${position.end.offset}`, + sourceMarkdown: source.substring(position.start.offset, position.end.offset), + }; +} + +/** + * Compute ProseMirror node’s attributes from a Hast node. + * By default, this function includes sourcemap position + * information in the object returned. + * + * Other attributes are retrieved by invoking a getAttrs + * function provided by the ProseMirror node factory spec. + * + * @param {*} proseMirrorNodeSpec ProseMirror node spec object + * @param {HastNode} hastNode A hast node + * @param {Array<HastNode>} hastParents All the ancestors of the hastNode + * @param {String} source Markdown source file’s content + * + * @returns An object that contains a ProseMirror node’s attributes + */ +function getAttrs(proseMirrorNodeSpec, hastNode, hastParents, source) { + const { getAttrs: specGetAttrs } = proseMirrorNodeSpec; + + return { + ...createSourceMapAttributes(hastNode, source), + ...(isFunction(specGetAttrs) ? specGetAttrs(hastNode, hastParents, source) : {}), + }; +} + +/** + * Keeps track of the Hast -> ProseMirror conversion process. + * + * When the `openNode` method is invoked, it adds the node to a stack + * data structure. When the `closeNode` method is invoked, it removes the + * last element from the Stack, creates a ProseMirror node, and adds that + * ProseMirror node to the previous node in the Stack. + * + * For example, given a Hast tree with three levels of nodes: + * + * - blockquote + * - paragraph + * - text + * + * 3. text + * 2. paragraph + * 1. blockquote + * + * Calling `closeNode` will fold the text node into paragraph. A 2nd + * call to this method will fold "paragraph" into "blockquote". + * + * Mark state + * + * When the `openMark` method is invoked, this class adds the Mark to a `MarkSet` + * object. When a text node is added, it assigns all the opened marks to that text + * node and cleans the marks. It takes care of merging text nodes with the same + * set of marks as well. + */ +class HastToProseMirrorConverterState { + constructor() { + this.stack = []; + this.marks = Mark.none; + } + + /** + * Gets the first element of the node stack + */ + get top() { + return this.stack[this.stack.length - 1]; + } + + /** + * Detects if the node stack is empty + */ + get empty() { + return this.stack.length === 0; + } + + /** + * Creates a text node and adds it to + * the top node in the stack. + * + * It applies the marks stored temporarily + * by calling the `addMark` method. After + * the text node is added, it clears the mark + * set afterward. + * + * If the top block node has a text + * node with the same set of marks as the + * text node created, this method merges + * both text nodes + * + * @param {ProseMirror.Schema} schema ProseMirror schema + * @param {String} text Text + * @returns + */ + addText(schema, text) { + if (!text) return; + const nodes = this.top.content; + const last = nodes[nodes.length - 1]; + const node = schema.text(text, this.marks); + const merged = maybeMerge(last, node); + + if (last && merged) { + nodes[nodes.length - 1] = merged; + } else { + nodes.push(node); + } + + this.closeMarks(); + } + + /** + * Adds a mark to the set of marks stored temporarily + * until addText is called. + * @param {*} markType + * @param {*} attrs + */ + openMark(markType, attrs) { + this.marks = markType.create(attrs).addToSet(this.marks); + } + + /** + * Empties the temporary Mark set. + */ + closeMarks() { + this.marks = Mark.none; + } + + /** + * Adds a node to the stack data structure. + * + * @param {Schema.NodeType} type ProseMirror Schema for the node + * @param {HastNode} hastNode Hast node from which the ProseMirror node will be created + * @param {*} attrs Node’s attributes + * @param {*} factorySpec The factory spec used to create the node factory + */ + openNode(type, hastNode, attrs, factorySpec) { + this.stack.push({ type, attrs, content: [], hastNode, factorySpec }); + } + + /** + * Removes the top ProseMirror node from the + * conversion stack and adds the node to the + * previous element. + * @returns + */ + closeNode() { + const { type, attrs, content } = this.stack.pop(); + const node = type.createAndFill(attrs, content); + + if (!node) return null; + + if (this.marks.length) { + this.marks = Mark.none; + } + + if (!this.empty) { + this.top.content.push(node); + } + + return node; + } + + closeUntil(hastNode) { + while (hastNode !== this.top?.hastNode) { + this.closeNode(); + } + } +} + +/** + * Create ProseMirror node/mark factories based on one or more + * factory specifications. + * + * Note: Read `createProseMirrorDocFromMdastTree` documentation + * for instructions about how to define these specifications. + * + * @param {model.ProseMirrorSchema} schema A ProseMirror schema used to create the + * ProseMirror nodes and marks. + * @param {Object} proseMirrorFactorySpecs ProseMirror nodes factory specifications. + * @param {String} source Markdown source file’s content + * + * @returns An object that contains ProseMirror node factories + */ +const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source) => { + const handlers = { + root: (state, hastNode) => state.openNode(schema.topNodeType, hastNode, {}), + text: (state, hastNode) => { + const { factorySpec } = state.top; + + if (/^\s+$/.test(hastNode.value)) { + return; + } + + if (factorySpec.wrapTextInParagraph === true) { + state.openNode(schema.nodeType('paragraph')); + state.addText(schema, hastNode.value); + state.closeNode(); + } else { + state.addText(schema, hastNode.value); + } + }, + }; + + for (const [hastNodeTagName, factorySpec] of Object.entries(proseMirrorFactorySpecs)) { + if (factorySpec.block) { + handlers[hastNodeTagName] = (state, hastNode, parent, ancestors) => { + const nodeType = schema.nodeType( + isFunction(factorySpec.block) + ? factorySpec.block(hastNode, parent, ancestors) + : factorySpec.block, + ); + + state.closeUntil(parent); + state.openNode( + nodeType, + hastNode, + getAttrs(factorySpec, hastNode, parent, source), + factorySpec, + ); + + /** + * If a getContent function is provided, we immediately close + * the node to delegate content processing to this function. + * */ + if (isFunction(factorySpec.getContent)) { + state.addText( + schema, + factorySpec.getContent({ hastNode, hastNodeText: toString(hastNode) }), + ); + state.closeNode(); + } + }; + } else if (factorySpec.inline) { + const nodeType = schema.nodeType(factorySpec.inline); + handlers[hastNodeTagName] = (state, hastNode, parent) => { + state.closeUntil(parent); + state.openNode( + nodeType, + hastNode, + getAttrs(factorySpec, hastNode, parent, source), + factorySpec, + ); + // Inline nodes do not have children therefore they are immediately closed + state.closeNode(); + }; + } else if (factorySpec.mark) { + const markType = schema.marks[factorySpec.mark]; + handlers[hastNodeTagName] = (state, hastNode, parent) => { + state.openMark(markType, getAttrs(factorySpec, hastNode, parent, source)); + + if (factorySpec.inlineContent) { + state.addText(schema, hastNode.value); + } + }; + } else { + throw new RangeError(`Unrecognized node factory spec ${JSON.stringify(factorySpec)}`); + } + } + + return handlers; +}; + +/** + * Converts a Hast AST to a ProseMirror document based on a series + * of specifications that describe how to map all the nodes of the former + * to ProseMirror nodes or marks. + * + * The specification object describes how to map a Hast node to a ProseMirror node or mark. + * The converter will trigger an error if it doesn’t find a specification + * for a Hast node while traversing the AST. + * + * The object should have the following shape: + * + * { + * [hastNode.tagName]: { + * [block|node|mark]: [ProseMirror.Node.name], + * ...configurationOptions + * } + * } + * + * Where each property in the object represents a HAST node with a given tag name, for example: + * + * { + * h1: {}, + * h2: {}, + * table: {}, + * strong: {}, + * // etc + * } + * + * You can specify the type of ProseMirror object adding one the following + * properties: + * + * 1. "block": A ProseMirror node that contains one or more children. + * 2. "inline": A ProseMirror node that doesn’t contain any children although + * it can have inline content like a code block or a reference. + * 3. "mark": A ProseMirror mark. + * + * The value of that property should be the name of the ProseMirror node or mark, i.e: + * + * { + * h1: { + * block: 'heading', + * }, + * h2: { + * block: 'heading', + * }, + * img: { + * node: 'image', + * }, + * strong: { + * mark: 'bold', + * } + * } + * + * You can compute a ProseMirror’s node or mark name based on the HAST node + * by passing a function instead of a String. The converter invokes the function + * and provides a HAST node object: + * + * { + * list: { + * block: (hastNode) => { + * let type = 'bulletList'; + + * if (hastNode.children.some(isTaskItem)) { + * type = 'taskList'; + * } else if (hastNode.ordered) { + * type = 'orderedList'; + * } + + * return type; + * } + * } + * } + * + * Configuration options + * ---------------------- + * + * You can customize the conversion process for every node or mark + * setting the following properties in the specification object: + * + * **getAttrs** + * + * Computes a ProseMirror node or mark attributes. The converter will invoke + * `getAttrs` with the following parameters: + * + * 1. hastNode: The hast node + * 2. hasParents: All the hast node’s ancestors up to the root node + * 3. source: Markdown source file’s content + * + * **wrapTextInParagraph** + * + * This property only applies to block nodes. If a block node contains text, + * it will wrap that text in a paragraph. This is useful for ProseMirror block + * nodes that don’t allow text directly such as list items and tables. + * + * **skipChildren** + * + * Skips a hast node’s children while traversing the tree. + * + * **getContent** + * + * Allows to pass a custom function that returns the content of a block node. The + * Content is limited to a single text node therefore the function should return + * a String value. + * + * Use this property along skipChildren to provide custom processing of child nodes + * for a block node. + * + * @param {model.Document_Schema} params.schema A ProseMirror schema that specifies the shape + * of the ProseMirror document. + * @param {Object} params.factorySpec A factory specification as described above + * @param {Hast} params.tree https://github.com/syntax-tree/hast + * @param {String} params.source Markdown source from which the MDast tree was generated + * + * @returns A ProseMirror document + */ +export const createProseMirrorDocFromMdastTree = ({ schema, factorySpecs, tree, source }) => { + const proseMirrorNodeFactories = createProseMirrorNodeFactories(schema, factorySpecs, source); + const state = new HastToProseMirrorConverterState(); + + visitParents(tree, (hastNode, ancestors) => { + const parent = ancestors[ancestors.length - 1]; + const skipChildren = factorySpecs[hastNode.tagName]?.skipChildren; + + const handler = proseMirrorNodeFactories[hastNode.tagName || hastNode.type]; + + if (!handler) { + throw new Error( + `Hast node of type "${ + hastNode.tagName || hastNode.type + }" not supported by this converter. Please, provide an specification.`, + ); + } + + handler(state, hastNode, parent, ancestors); + + return skipChildren === true ? 'skip' : true; + }); + + let doc; + + do { + doc = state.closeNode(); + } while (!state.empty); + + return doc; +}; diff --git a/app/assets/javascripts/content_editor/services/highlight_js_language_loader.js b/app/assets/javascripts/content_editor/services/highlight_js_language_loader.js new file mode 100644 index 00000000000..a0ebbebed4e --- /dev/null +++ b/app/assets/javascripts/content_editor/services/highlight_js_language_loader.js @@ -0,0 +1,248 @@ +/** + * This file is generated based on the contents of highlight.js/lib/languages to avoid + * utilizing dynamic expressions within `import()` which were the source of some + * confusion when attempting to produce deterministic webpack compilations across + * multiple build environments. + * + * This list of highlight-able languages will need to be updated as new options are + * introduced within the highlight.js dependency. + */ + +export default { + '1c': () => import(/* webpackChunkName: 'hl-1c' */ 'highlight.js/lib/languages/1c'), + abnf: () => import(/* webpackChunkName: 'hl-abnf' */ 'highlight.js/lib/languages/abnf'), + accesslog: () => + import(/* webpackChunkName: 'hl-accesslog' */ 'highlight.js/lib/languages/accesslog'), + actionscript: () => + import(/* webpackChunkName: 'hl-actionscript' */ 'highlight.js/lib/languages/actionscript'), + ada: () => import(/* webpackChunkName: 'hl-ada' */ 'highlight.js/lib/languages/ada'), + angelscript: () => + import(/* webpackChunkName: 'hl-angelscript' */ 'highlight.js/lib/languages/angelscript'), + apache: () => import(/* webpackChunkName: 'hl-apache' */ 'highlight.js/lib/languages/apache'), + applescript: () => + import(/* webpackChunkName: 'hl-applescript' */ 'highlight.js/lib/languages/applescript'), + arcade: () => import(/* webpackChunkName: 'hl-arcade' */ 'highlight.js/lib/languages/arcade'), + arduino: () => import(/* webpackChunkName: 'hl-arduino' */ 'highlight.js/lib/languages/arduino'), + armasm: () => import(/* webpackChunkName: 'hl-armasm' */ 'highlight.js/lib/languages/armasm'), + asciidoc: () => + import(/* webpackChunkName: 'hl-asciidoc' */ 'highlight.js/lib/languages/asciidoc'), + aspectj: () => import(/* webpackChunkName: 'hl-aspectj' */ 'highlight.js/lib/languages/aspectj'), + autohotkey: () => + import(/* webpackChunkName: 'hl-autohotkey' */ 'highlight.js/lib/languages/autohotkey'), + autoit: () => import(/* webpackChunkName: 'hl-autoit' */ 'highlight.js/lib/languages/autoit'), + avrasm: () => import(/* webpackChunkName: 'hl-avrasm' */ 'highlight.js/lib/languages/avrasm'), + awk: () => import(/* webpackChunkName: 'hl-awk' */ 'highlight.js/lib/languages/awk'), + axapta: () => import(/* webpackChunkName: 'hl-axapta' */ 'highlight.js/lib/languages/axapta'), + bash: () => import(/* webpackChunkName: 'hl-bash' */ 'highlight.js/lib/languages/bash'), + basic: () => import(/* webpackChunkName: 'hl-basic' */ 'highlight.js/lib/languages/basic'), + bnf: () => import(/* webpackChunkName: 'hl-bnf' */ 'highlight.js/lib/languages/bnf'), + brainfuck: () => + import(/* webpackChunkName: 'hl-brainfuck' */ 'highlight.js/lib/languages/brainfuck'), + c: () => import(/* webpackChunkName: 'hl-c' */ 'highlight.js/lib/languages/c'), + cal: () => import(/* webpackChunkName: 'hl-cal' */ 'highlight.js/lib/languages/cal'), + capnproto: () => + import(/* webpackChunkName: 'hl-capnproto' */ 'highlight.js/lib/languages/capnproto'), + ceylon: () => import(/* webpackChunkName: 'hl-ceylon' */ 'highlight.js/lib/languages/ceylon'), + clean: () => import(/* webpackChunkName: 'hl-clean' */ 'highlight.js/lib/languages/clean'), + 'clojure-repl': () => + import(/* webpackChunkName: 'hl-clojure-repl' */ 'highlight.js/lib/languages/clojure-repl'), + clojure: () => import(/* webpackChunkName: 'hl-clojure' */ 'highlight.js/lib/languages/clojure'), + cmake: () => import(/* webpackChunkName: 'hl-cmake' */ 'highlight.js/lib/languages/cmake'), + coffeescript: () => + import(/* webpackChunkName: 'hl-coffeescript' */ 'highlight.js/lib/languages/coffeescript'), + coq: () => import(/* webpackChunkName: 'hl-coq' */ 'highlight.js/lib/languages/coq'), + cos: () => import(/* webpackChunkName: 'hl-cos' */ 'highlight.js/lib/languages/cos'), + cpp: () => import(/* webpackChunkName: 'hl-cpp' */ 'highlight.js/lib/languages/cpp'), + crmsh: () => import(/* webpackChunkName: 'hl-crmsh' */ 'highlight.js/lib/languages/crmsh'), + crystal: () => import(/* webpackChunkName: 'hl-crystal' */ 'highlight.js/lib/languages/crystal'), + csharp: () => import(/* webpackChunkName: 'hl-csharp' */ 'highlight.js/lib/languages/csharp'), + csp: () => import(/* webpackChunkName: 'hl-csp' */ 'highlight.js/lib/languages/csp'), + css: () => import(/* webpackChunkName: 'hl-css' */ 'highlight.js/lib/languages/css'), + d: () => import(/* webpackChunkName: 'hl-d' */ 'highlight.js/lib/languages/d'), + dart: () => import(/* webpackChunkName: 'hl-dart' */ 'highlight.js/lib/languages/dart'), + delphi: () => import(/* webpackChunkName: 'hl-delphi' */ 'highlight.js/lib/languages/delphi'), + diff: () => import(/* webpackChunkName: 'hl-diff' */ 'highlight.js/lib/languages/diff'), + django: () => import(/* webpackChunkName: 'hl-django' */ 'highlight.js/lib/languages/django'), + dns: () => import(/* webpackChunkName: 'hl-dns' */ 'highlight.js/lib/languages/dns'), + dockerfile: () => + import(/* webpackChunkName: 'hl-dockerfile' */ 'highlight.js/lib/languages/dockerfile'), + dos: () => import(/* webpackChunkName: 'hl-dos' */ 'highlight.js/lib/languages/dos'), + dsconfig: () => + import(/* webpackChunkName: 'hl-dsconfig' */ 'highlight.js/lib/languages/dsconfig'), + dts: () => import(/* webpackChunkName: 'hl-dts' */ 'highlight.js/lib/languages/dts'), + dust: () => import(/* webpackChunkName: 'hl-dust' */ 'highlight.js/lib/languages/dust'), + ebnf: () => import(/* webpackChunkName: 'hl-ebnf' */ 'highlight.js/lib/languages/ebnf'), + elixir: () => import(/* webpackChunkName: 'hl-elixir' */ 'highlight.js/lib/languages/elixir'), + elm: () => import(/* webpackChunkName: 'hl-elm' */ 'highlight.js/lib/languages/elm'), + erb: () => import(/* webpackChunkName: 'hl-erb' */ 'highlight.js/lib/languages/erb'), + 'erlang-repl': () => + import(/* webpackChunkName: 'hl-erlang-repl' */ 'highlight.js/lib/languages/erlang-repl'), + erlang: () => import(/* webpackChunkName: 'hl-erlang' */ 'highlight.js/lib/languages/erlang'), + excel: () => import(/* webpackChunkName: 'hl-excel' */ 'highlight.js/lib/languages/excel'), + fix: () => import(/* webpackChunkName: 'hl-fix' */ 'highlight.js/lib/languages/fix'), + flix: () => import(/* webpackChunkName: 'hl-flix' */ 'highlight.js/lib/languages/flix'), + fortran: () => import(/* webpackChunkName: 'hl-fortran' */ 'highlight.js/lib/languages/fortran'), + fsharp: () => import(/* webpackChunkName: 'hl-fsharp' */ 'highlight.js/lib/languages/fsharp'), + gams: () => import(/* webpackChunkName: 'hl-gams' */ 'highlight.js/lib/languages/gams'), + gauss: () => import(/* webpackChunkName: 'hl-gauss' */ 'highlight.js/lib/languages/gauss'), + gcode: () => import(/* webpackChunkName: 'hl-gcode' */ 'highlight.js/lib/languages/gcode'), + gherkin: () => import(/* webpackChunkName: 'hl-gherkin' */ 'highlight.js/lib/languages/gherkin'), + glsl: () => import(/* webpackChunkName: 'hl-glsl' */ 'highlight.js/lib/languages/glsl'), + gml: () => import(/* webpackChunkName: 'hl-gml' */ 'highlight.js/lib/languages/gml'), + go: () => import(/* webpackChunkName: 'hl-go' */ 'highlight.js/lib/languages/go'), + golo: () => import(/* webpackChunkName: 'hl-golo' */ 'highlight.js/lib/languages/golo'), + gradle: () => import(/* webpackChunkName: 'hl-gradle' */ 'highlight.js/lib/languages/gradle'), + groovy: () => import(/* webpackChunkName: 'hl-groovy' */ 'highlight.js/lib/languages/groovy'), + haml: () => import(/* webpackChunkName: 'hl-haml' */ 'highlight.js/lib/languages/haml'), + handlebars: () => + import(/* webpackChunkName: 'hl-handlebars' */ 'highlight.js/lib/languages/handlebars'), + haskell: () => import(/* webpackChunkName: 'hl-haskell' */ 'highlight.js/lib/languages/haskell'), + haxe: () => import(/* webpackChunkName: 'hl-haxe' */ 'highlight.js/lib/languages/haxe'), + hsp: () => import(/* webpackChunkName: 'hl-hsp' */ 'highlight.js/lib/languages/hsp'), + http: () => import(/* webpackChunkName: 'hl-http' */ 'highlight.js/lib/languages/http'), + hy: () => import(/* webpackChunkName: 'hl-hy' */ 'highlight.js/lib/languages/hy'), + inform7: () => import(/* webpackChunkName: 'hl-inform7' */ 'highlight.js/lib/languages/inform7'), + ini: () => import(/* webpackChunkName: 'hl-ini' */ 'highlight.js/lib/languages/ini'), + irpf90: () => import(/* webpackChunkName: 'hl-irpf90' */ 'highlight.js/lib/languages/irpf90'), + isbl: () => import(/* webpackChunkName: 'hl-isbl' */ 'highlight.js/lib/languages/isbl'), + java: () => import(/* webpackChunkName: 'hl-java' */ 'highlight.js/lib/languages/java'), + javascript: () => + import(/* webpackChunkName: 'hl-javascript' */ 'highlight.js/lib/languages/javascript'), + 'jboss-cli': () => + import(/* webpackChunkName: 'hl-jboss-cli' */ 'highlight.js/lib/languages/jboss-cli'), + json: () => import(/* webpackChunkName: 'hl-json' */ 'highlight.js/lib/languages/json'), + 'julia-repl': () => + import(/* webpackChunkName: 'hl-julia-repl' */ 'highlight.js/lib/languages/julia-repl'), + julia: () => import(/* webpackChunkName: 'hl-julia' */ 'highlight.js/lib/languages/julia'), + kotlin: () => import(/* webpackChunkName: 'hl-kotlin' */ 'highlight.js/lib/languages/kotlin'), + lasso: () => import(/* webpackChunkName: 'hl-lasso' */ 'highlight.js/lib/languages/lasso'), + latex: () => import(/* webpackChunkName: 'hl-latex' */ 'highlight.js/lib/languages/latex'), + ldif: () => import(/* webpackChunkName: 'hl-ldif' */ 'highlight.js/lib/languages/ldif'), + leaf: () => import(/* webpackChunkName: 'hl-leaf' */ 'highlight.js/lib/languages/leaf'), + less: () => import(/* webpackChunkName: 'hl-less' */ 'highlight.js/lib/languages/less'), + lisp: () => import(/* webpackChunkName: 'hl-lisp' */ 'highlight.js/lib/languages/lisp'), + livecodeserver: () => + import(/* webpackChunkName: 'hl-livecodeserver' */ 'highlight.js/lib/languages/livecodeserver'), + livescript: () => + import(/* webpackChunkName: 'hl-livescript' */ 'highlight.js/lib/languages/livescript'), + llvm: () => import(/* webpackChunkName: 'hl-llvm' */ 'highlight.js/lib/languages/llvm'), + lsl: () => import(/* webpackChunkName: 'hl-lsl' */ 'highlight.js/lib/languages/lsl'), + lua: () => import(/* webpackChunkName: 'hl-lua' */ 'highlight.js/lib/languages/lua'), + makefile: () => + import(/* webpackChunkName: 'hl-makefile' */ 'highlight.js/lib/languages/makefile'), + markdown: () => + import(/* webpackChunkName: 'hl-markdown' */ 'highlight.js/lib/languages/markdown'), + mathematica: () => + import(/* webpackChunkName: 'hl-mathematica' */ 'highlight.js/lib/languages/mathematica'), + matlab: () => import(/* webpackChunkName: 'hl-matlab' */ 'highlight.js/lib/languages/matlab'), + maxima: () => import(/* webpackChunkName: 'hl-maxima' */ 'highlight.js/lib/languages/maxima'), + mel: () => import(/* webpackChunkName: 'hl-mel' */ 'highlight.js/lib/languages/mel'), + mercury: () => import(/* webpackChunkName: 'hl-mercury' */ 'highlight.js/lib/languages/mercury'), + mipsasm: () => import(/* webpackChunkName: 'hl-mipsasm' */ 'highlight.js/lib/languages/mipsasm'), + mizar: () => import(/* webpackChunkName: 'hl-mizar' */ 'highlight.js/lib/languages/mizar'), + mojolicious: () => + import(/* webpackChunkName: 'hl-mojolicious' */ 'highlight.js/lib/languages/mojolicious'), + monkey: () => import(/* webpackChunkName: 'hl-monkey' */ 'highlight.js/lib/languages/monkey'), + moonscript: () => + import(/* webpackChunkName: 'hl-moonscript' */ 'highlight.js/lib/languages/moonscript'), + n1ql: () => import(/* webpackChunkName: 'hl-n1ql' */ 'highlight.js/lib/languages/n1ql'), + nestedtext: () => + import(/* webpackChunkName: 'hl-nestedtext' */ 'highlight.js/lib/languages/nestedtext'), + nginx: () => import(/* webpackChunkName: 'hl-nginx' */ 'highlight.js/lib/languages/nginx'), + nim: () => import(/* webpackChunkName: 'hl-nim' */ 'highlight.js/lib/languages/nim'), + nix: () => import(/* webpackChunkName: 'hl-nix' */ 'highlight.js/lib/languages/nix'), + 'node-repl': () => + import(/* webpackChunkName: 'hl-node-repl' */ 'highlight.js/lib/languages/node-repl'), + nsis: () => import(/* webpackChunkName: 'hl-nsis' */ 'highlight.js/lib/languages/nsis'), + objectivec: () => + import(/* webpackChunkName: 'hl-objectivec' */ 'highlight.js/lib/languages/objectivec'), + ocaml: () => import(/* webpackChunkName: 'hl-ocaml' */ 'highlight.js/lib/languages/ocaml'), + openscad: () => + import(/* webpackChunkName: 'hl-openscad' */ 'highlight.js/lib/languages/openscad'), + oxygene: () => import(/* webpackChunkName: 'hl-oxygene' */ 'highlight.js/lib/languages/oxygene'), + parser3: () => import(/* webpackChunkName: 'hl-parser3' */ 'highlight.js/lib/languages/parser3'), + perl: () => import(/* webpackChunkName: 'hl-perl' */ 'highlight.js/lib/languages/perl'), + pf: () => import(/* webpackChunkName: 'hl-pf' */ 'highlight.js/lib/languages/pf'), + pgsql: () => import(/* webpackChunkName: 'hl-pgsql' */ 'highlight.js/lib/languages/pgsql'), + 'php-template': () => + import(/* webpackChunkName: 'hl-php-template' */ 'highlight.js/lib/languages/php-template'), + php: () => import(/* webpackChunkName: 'hl-php' */ 'highlight.js/lib/languages/php'), + plaintext: () => + import(/* webpackChunkName: 'hl-plaintext' */ 'highlight.js/lib/languages/plaintext'), + pony: () => import(/* webpackChunkName: 'hl-pony' */ 'highlight.js/lib/languages/pony'), + powershell: () => + import(/* webpackChunkName: 'hl-powershell' */ 'highlight.js/lib/languages/powershell'), + processing: () => + import(/* webpackChunkName: 'hl-processing' */ 'highlight.js/lib/languages/processing'), + profile: () => import(/* webpackChunkName: 'hl-profile' */ 'highlight.js/lib/languages/profile'), + prolog: () => import(/* webpackChunkName: 'hl-prolog' */ 'highlight.js/lib/languages/prolog'), + properties: () => + import(/* webpackChunkName: 'hl-properties' */ 'highlight.js/lib/languages/properties'), + protobuf: () => + import(/* webpackChunkName: 'hl-protobuf' */ 'highlight.js/lib/languages/protobuf'), + puppet: () => import(/* webpackChunkName: 'hl-puppet' */ 'highlight.js/lib/languages/puppet'), + purebasic: () => + import(/* webpackChunkName: 'hl-purebasic' */ 'highlight.js/lib/languages/purebasic'), + 'python-repl': () => + import(/* webpackChunkName: 'hl-python-repl' */ 'highlight.js/lib/languages/python-repl'), + python: () => import(/* webpackChunkName: 'hl-python' */ 'highlight.js/lib/languages/python'), + q: () => import(/* webpackChunkName: 'hl-q' */ 'highlight.js/lib/languages/q'), + qml: () => import(/* webpackChunkName: 'hl-qml' */ 'highlight.js/lib/languages/qml'), + r: () => import(/* webpackChunkName: 'hl-r' */ 'highlight.js/lib/languages/r'), + reasonml: () => + import(/* webpackChunkName: 'hl-reasonml' */ 'highlight.js/lib/languages/reasonml'), + rib: () => import(/* webpackChunkName: 'hl-rib' */ 'highlight.js/lib/languages/rib'), + roboconf: () => + import(/* webpackChunkName: 'hl-roboconf' */ 'highlight.js/lib/languages/roboconf'), + routeros: () => + import(/* webpackChunkName: 'hl-routeros' */ 'highlight.js/lib/languages/routeros'), + rsl: () => import(/* webpackChunkName: 'hl-rsl' */ 'highlight.js/lib/languages/rsl'), + ruby: () => import(/* webpackChunkName: 'hl-ruby' */ 'highlight.js/lib/languages/ruby'), + ruleslanguage: () => + import(/* webpackChunkName: 'hl-ruleslanguage' */ 'highlight.js/lib/languages/ruleslanguage'), + rust: () => import(/* webpackChunkName: 'hl-rust' */ 'highlight.js/lib/languages/rust'), + sas: () => import(/* webpackChunkName: 'hl-sas' */ 'highlight.js/lib/languages/sas'), + scala: () => import(/* webpackChunkName: 'hl-scala' */ 'highlight.js/lib/languages/scala'), + scheme: () => import(/* webpackChunkName: 'hl-scheme' */ 'highlight.js/lib/languages/scheme'), + scilab: () => import(/* webpackChunkName: 'hl-scilab' */ 'highlight.js/lib/languages/scilab'), + scss: () => import(/* webpackChunkName: 'hl-scss' */ 'highlight.js/lib/languages/scss'), + shell: () => import(/* webpackChunkName: 'hl-shell' */ 'highlight.js/lib/languages/shell'), + smali: () => import(/* webpackChunkName: 'hl-smali' */ 'highlight.js/lib/languages/smali'), + smalltalk: () => + import(/* webpackChunkName: 'hl-smalltalk' */ 'highlight.js/lib/languages/smalltalk'), + sml: () => import(/* webpackChunkName: 'hl-sml' */ 'highlight.js/lib/languages/sml'), + sqf: () => import(/* webpackChunkName: 'hl-sqf' */ 'highlight.js/lib/languages/sqf'), + sql: () => import(/* webpackChunkName: 'hl-sql' */ 'highlight.js/lib/languages/sql'), + stan: () => import(/* webpackChunkName: 'hl-stan' */ 'highlight.js/lib/languages/stan'), + stata: () => import(/* webpackChunkName: 'hl-stata' */ 'highlight.js/lib/languages/stata'), + step21: () => import(/* webpackChunkName: 'hl-step21' */ 'highlight.js/lib/languages/step21'), + stylus: () => import(/* webpackChunkName: 'hl-stylus' */ 'highlight.js/lib/languages/stylus'), + subunit: () => import(/* webpackChunkName: 'hl-subunit' */ 'highlight.js/lib/languages/subunit'), + swift: () => import(/* webpackChunkName: 'hl-swift' */ 'highlight.js/lib/languages/swift'), + taggerscript: () => + import(/* webpackChunkName: 'hl-taggerscript' */ 'highlight.js/lib/languages/taggerscript'), + tap: () => import(/* webpackChunkName: 'hl-tap' */ 'highlight.js/lib/languages/tap'), + tcl: () => import(/* webpackChunkName: 'hl-tcl' */ 'highlight.js/lib/languages/tcl'), + thrift: () => import(/* webpackChunkName: 'hl-thrift' */ 'highlight.js/lib/languages/thrift'), + tp: () => import(/* webpackChunkName: 'hl-tp' */ 'highlight.js/lib/languages/tp'), + twig: () => import(/* webpackChunkName: 'hl-twig' */ 'highlight.js/lib/languages/twig'), + typescript: () => + import(/* webpackChunkName: 'hl-typescript' */ 'highlight.js/lib/languages/typescript'), + vala: () => import(/* webpackChunkName: 'hl-vala' */ 'highlight.js/lib/languages/vala'), + vbnet: () => import(/* webpackChunkName: 'hl-vbnet' */ 'highlight.js/lib/languages/vbnet'), + 'vbscript-html': () => + import(/* webpackChunkName: 'hl-vbscript-html' */ 'highlight.js/lib/languages/vbscript-html'), + vbscript: () => + import(/* webpackChunkName: 'hl-vbscript' */ 'highlight.js/lib/languages/vbscript'), + verilog: () => import(/* webpackChunkName: 'hl-verilog' */ 'highlight.js/lib/languages/verilog'), + vhdl: () => import(/* webpackChunkName: 'hl-vhdl' */ 'highlight.js/lib/languages/vhdl'), + vim: () => import(/* webpackChunkName: 'hl-vim' */ 'highlight.js/lib/languages/vim'), + wasm: () => import(/* webpackChunkName: 'hl-wasm' */ 'highlight.js/lib/languages/wasm'), + wren: () => import(/* webpackChunkName: 'hl-wren' */ 'highlight.js/lib/languages/wren'), + x86asm: () => import(/* webpackChunkName: 'hl-x86asm' */ 'highlight.js/lib/languages/x86asm'), + xl: () => import(/* webpackChunkName: 'hl-xl' */ 'highlight.js/lib/languages/xl'), + xml: () => import(/* webpackChunkName: 'hl-xml' */ 'highlight.js/lib/languages/xml'), + xquery: () => import(/* webpackChunkName: 'hl-xquery' */ 'highlight.js/lib/languages/xquery'), + yaml: () => import(/* webpackChunkName: 'hl-yaml' */ 'highlight.js/lib/languages/yaml'), + zephir: () => import(/* webpackChunkName: 'hl-zephir' */ 'highlight.js/lib/languages/zephir'), +}; diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js index c2be7bc9195..d665f24bba1 100644 --- a/app/assets/javascripts/content_editor/services/markdown_serializer.js +++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js @@ -48,7 +48,6 @@ import Text from '../extensions/text'; import Video from '../extensions/video'; import WordBreak from '../extensions/word_break'; import { - isPlainURL, renderCodeBlock, renderHardBreak, renderTable, @@ -61,36 +60,30 @@ import { renderPlayable, renderHTMLNode, renderContent, + preserveUnchanged, + bold, + italic, + link, + code, } from './serialization_helpers'; const defaultSerializerConfig = { marks: { - [Bold.name]: defaultMarkdownSerializer.marks.strong, - [Italic.name]: { open: '_', close: '_', mixable: true, expelEnclosingWhitespace: true }, - [Code.name]: defaultMarkdownSerializer.marks.code, + [Bold.name]: bold, + [Italic.name]: italic, + [Code.name]: code, [Subscript.name]: { open: '<sub>', close: '</sub>', mixable: true }, [Superscript.name]: { open: '<sup>', close: '</sup>', mixable: true }, [InlineDiff.name]: { mixable: true, - open(state, mark) { + open(_, mark) { return mark.attrs.type === 'addition' ? '{+' : '{-'; }, - close(state, mark) { + close(_, mark) { return mark.attrs.type === 'addition' ? '+}' : '-}'; }, }, - [Link.name]: { - open(state, mark, parent, index) { - return isPlainURL(mark, parent, index, 1) ? '<' : '['; - }, - close(state, mark, parent, index) { - const href = mark.attrs.canonicalSrc || mark.attrs.href; - - return isPlainURL(mark, parent, index, -1) - ? '>' - : `](${state.esc(href)}${mark.attrs.title ? ` ${state.quote(mark.attrs.title)}` : ''})`; - }, - }, + [Link.name]: link, [MathInline.name]: { open: (...args) => `$${defaultMarkdownSerializer.marks.code.open(...args)}`, close: (...args) => `${defaultMarkdownSerializer.marks.code.close(...args)}$`, @@ -119,7 +112,7 @@ const defaultSerializerConfig = { nodes: { [Audio.name]: renderPlayable, - [Blockquote.name]: (state, node) => { + [Blockquote.name]: preserveUnchanged((state, node) => { if (node.attrs.multiline) { state.write('>>>'); state.ensureNewLine(); @@ -130,9 +123,9 @@ const defaultSerializerConfig = { } else { state.wrapBlock('> ', null, node, () => state.renderContent(node)); } - }, - [BulletList.name]: defaultMarkdownSerializer.nodes.bullet_list, - [CodeBlockHighlight.name]: renderCodeBlock, + }), + [BulletList.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.bullet_list), + [CodeBlockHighlight.name]: preserveUnchanged(renderCodeBlock), [Diagram.name]: renderCodeBlock, [Division.name]: (state, node) => { if (node.attrs.className?.includes('js-markdown-code')) { @@ -189,13 +182,13 @@ const defaultSerializerConfig = { }, [Figure.name]: renderHTMLNode('figure'), [FigureCaption.name]: renderHTMLNode('figcaption'), - [HardBreak.name]: renderHardBreak, - [Heading.name]: defaultMarkdownSerializer.nodes.heading, - [HorizontalRule.name]: defaultMarkdownSerializer.nodes.horizontal_rule, - [Image.name]: renderImage, - [ListItem.name]: defaultMarkdownSerializer.nodes.list_item, - [OrderedList.name]: renderOrderedList, - [Paragraph.name]: defaultMarkdownSerializer.nodes.paragraph, + [HardBreak.name]: preserveUnchanged(renderHardBreak), + [Heading.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.heading), + [HorizontalRule.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.horizontal_rule), + [Image.name]: preserveUnchanged(renderImage), + [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); }, @@ -221,29 +214,60 @@ const defaultSerializerConfig = { }, }; +const createChangeTracker = (doc, pristineDoc) => { + const changeTracker = new WeakMap(); + const pristineSourceMarkdownMap = new Map(); + + if (doc && pristineDoc) { + pristineDoc.descendants((node) => { + if (node.attrs.sourceMapKey) { + pristineSourceMarkdownMap.set(`${node.attrs.sourceMapKey}${node.type.name}`, node); + } + }); + doc.descendants((node) => { + const pristineNode = pristineSourceMarkdownMap.get( + `${node.attrs.sourceMapKey}${node.type.name}`, + ); + + if (pristineNode) { + changeTracker.set(node, node.eq(pristineNode)); + } + }); + } + + return changeTracker; +}; + /** - * A markdown serializer converts arbitrary Markdown content - * into a ProseMirror document and viceversa. To convert Markdown - * into a ProseMirror document, the Markdown should be rendered. + * Converts a ProseMirror document to Markdown. See the + * following documentation to learn how to implement + * custom node and mark serializer functions. + * + * https://github.com/prosemirror/prosemirror-markdown * - * The client should provide a render function to allow flexibility - * on the desired rendering approach. + * @param {Object} params.nodes ProseMirror node serializer functions + * @param {Object} params.marks ProseMirror marks serializer config * - * @param {Function} params.render Render function - * that parses the Markdown and converts it into HTML. * @returns a markdown serializer */ export default ({ serializerConfig = {} } = {}) => ({ /** - * Converts a ProseMirror JSONDocument based - * on a ProseMirror schema into Markdown - * @param {ProseMirror.Schema} params.schema A ProseMirror schema that defines - * the types of content supported in the document - * @param {String} params.content A ProseMirror JSONDocument - * @returns A Markdown string + * Serializes a ProseMirror document as Markdown. If a node contains + * sourcemap metadata, the serializer is capable of restoring the + * Markdown from which the node was generated using a Markdown + * deserializer. + * + * See the Sourcemap metadata extension and the remark_markdown_deserializer + * service for more information. + * + * @param {ProseMirror.Node} params.doc ProseMirror document to convert into Markdown + * @param {ProseMirror.Node} params.pristineDoc Pristine version of the document that + * should be converted into Markdown. This is used to detect which nodes in the document + * changed. + * @returns A String that represents the serialized document as Markdown */ - serialize: ({ schema, content }) => { - const proseMirrorDocument = schema.nodeFromJSON(content); + serialize: ({ doc, pristineDoc }) => { + const changeTracker = createChangeTracker(doc, pristineDoc); const serializer = new ProseMirrorMarkdownSerializer( { ...defaultSerializerConfig.nodes, @@ -255,8 +279,9 @@ export default ({ serializerConfig = {} } = {}) => ({ }, ); - return serializer.serialize(proseMirrorDocument, { + return serializer.serialize(doc, { tightLists: true, + changeTracker, }); }, }); diff --git a/app/assets/javascripts/content_editor/services/markdown_sourcemap.js b/app/assets/javascripts/content_editor/services/markdown_sourcemap.js index 4285e04bbab..fe1b32c5b0a 100644 --- a/app/assets/javascripts/content_editor/services/markdown_sourcemap.js +++ b/app/assets/javascripts/content_editor/services/markdown_sourcemap.js @@ -30,7 +30,7 @@ export const getMarkdownSource = (element) => { for (let i = range.start.row; i <= range.end.row; i += 1) { if (i === range.start.row) { - elSource += source[i]?.substring(range.start.col); + elSource += source[i].substring(range.start.col); } else if (i === range.end.row) { elSource += `\n${source[i]?.substring(0, range.start.col)}`; } else { diff --git a/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js b/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js new file mode 100644 index 00000000000..770de1df0d0 --- /dev/null +++ b/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js @@ -0,0 +1,87 @@ +import { isString } from 'lodash'; +import { render } from '~/lib/gfm'; +import { createProseMirrorDocFromMdastTree } from './hast_to_prosemirror_converter'; + +const factorySpecs = { + blockquote: { block: 'blockquote' }, + p: { block: 'paragraph' }, + li: { block: 'listItem', wrapTextInParagraph: true }, + ul: { block: 'bulletList' }, + ol: { block: 'orderedList' }, + h1: { + block: 'heading', + getAttrs: () => ({ level: 1 }), + }, + h2: { + block: 'heading', + getAttrs: () => ({ level: 2 }), + }, + h3: { + block: 'heading', + getAttrs: () => ({ level: 3 }), + }, + h4: { + block: 'heading', + getAttrs: () => ({ level: 4 }), + }, + h5: { + block: 'heading', + getAttrs: () => ({ level: 5 }), + }, + h6: { + block: 'heading', + getAttrs: () => ({ level: 6 }), + }, + pre: { + block: 'codeBlock', + skipChildren: true, + getContent: ({ hastNodeText }) => hastNodeText.replace(/\n$/, ''), + getAttrs: (hastNode) => { + const languageClass = hastNode.children[0]?.properties.className?.[0]; + const language = isString(languageClass) ? languageClass.replace('language-', '') : null; + + return { language }; + }, + }, + hr: { inline: 'horizontalRule' }, + img: { + inline: 'image', + getAttrs: (hastNode) => ({ + src: hastNode.properties.src, + title: hastNode.properties.title, + alt: hastNode.properties.alt, + }), + }, + br: { inline: 'hardBreak' }, + code: { mark: 'code' }, + em: { mark: 'italic' }, + i: { mark: 'italic' }, + strong: { mark: 'bold' }, + b: { mark: 'bold' }, + a: { + mark: 'link', + getAttrs: (hastNode) => ({ + href: hastNode.properties.href, + title: hastNode.properties.title, + }), + }, +}; + +export default () => { + return { + deserialize: async ({ schema, content: markdown }) => { + const document = await render({ + markdown, + renderer: (tree) => + createProseMirrorDocFromMdastTree({ + schema, + factorySpecs, + tree, + source: markdown, + }), + }); + + return { document }; + }, + }; +}; diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js index 3e48434c6f9..089d30edec7 100644 --- a/app/assets/javascripts/content_editor/services/serialization_helpers.js +++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js @@ -349,3 +349,134 @@ export function renderCodeBlock(state, node) { state.write('```'); state.closeBlock(node); } + +export function preserveUnchanged(render) { + return (state, node, parent, index) => { + const { sourceMarkdown } = node.attrs; + const same = state.options.changeTracker.get(node); + + if (same) { + state.write(sourceMarkdown); + state.closeBlock(node); + } else { + render(state, node, parent, index); + } + }; +} + +const generateBoldTags = (open = true) => { + return (_, mark) => { + const type = /^(\*\*|__|<strong|<b).*/.exec(mark.attrs.sourceMarkdown)?.[1]; + + switch (type) { + case '**': + case '__': + return type; + // eslint-disable-next-line @gitlab/require-i18n-strings + case '<strong': + case '<b': + return (open ? openTag : closeTag)(type.substring(1)); + default: + return '**'; + } + }; +}; + +export const bold = { + open: generateBoldTags(), + close: generateBoldTags(false), + mixable: true, + expelEnclosingWhitespace: true, +}; + +const generateItalicTag = (open = true) => { + return (_, mark) => { + const type = /^(\*|_|<em|<i).*/.exec(mark.attrs.sourceMarkdown)?.[1]; + + switch (type) { + case '*': + case '_': + return type; + // eslint-disable-next-line @gitlab/require-i18n-strings + case '<em': + case '<i': + return (open ? openTag : closeTag)(type.substring(1)); + default: + return '_'; + } + }; +}; + +export const italic = { + open: generateItalicTag(), + close: generateItalicTag(false), + mixable: true, + expelEnclosingWhitespace: true, +}; + +const generateCodeTag = (open = true) => { + return (_, mark) => { + const type = /^(`|<code).*/.exec(mark.attrs.sourceMarkdown)?.[1]; + + if (type === '<code') { + return (open ? openTag : closeTag)(type.substring(1)); + } + + return '`'; + }; +}; + +export const code = { + open: generateCodeTag(), + close: generateCodeTag(false), + mixable: true, + expelEnclosingWhitespace: true, +}; + +const LINK_HTML = 'linkHtml'; +const LINK_MARKDOWN = 'linkMarkdown'; + +const linkType = (sourceMarkdown) => { + const expression = /^(\[|<a).*/.exec(sourceMarkdown)?.[1]; + + if (!expression || expression === '[') { + return LINK_MARKDOWN; + } + + return LINK_HTML; +}; + +export const link = { + open(state, mark, parent, index) { + if (isPlainURL(mark, parent, index, 1)) { + return '<'; + } + + const { canonicalSrc, href, title, sourceMarkdown } = mark.attrs; + + if (linkType(sourceMarkdown) === LINK_MARKDOWN) { + return '['; + } + + const attrs = { href: state.esc(href || canonicalSrc) }; + + if (title) { + attrs.title = title; + } + + return openTag('a', attrs); + }, + close(state, mark, parent, index) { + if (isPlainURL(mark, parent, index, -1)) { + return '>'; + } + + const { canonicalSrc, href, title, sourceMarkdown } = mark.attrs; + + if (linkType(sourceMarkdown) === LINK_HTML) { + return closeTag('a'); + } + + return `](${state.esc(canonicalSrc || href)}${title ? ` ${state.quote(title)}` : ''})`; + }, +}; diff --git a/app/assets/javascripts/content_editor/services/upload_helpers.js b/app/assets/javascripts/content_editor/services/upload_helpers.js index ed2c4b39131..09f0738b51b 100644 --- a/app/assets/javascripts/content_editor/services/upload_helpers.js +++ b/app/assets/javascripts/content_editor/services/upload_helpers.js @@ -70,6 +70,8 @@ const uploadContent = async ({ type, editor, file, uploadsPath, renderMarkdown, const position = state.selection.from - 1; const { tr } = state; + editor.commands.setNodeSelection(position); + try { const { src, canonicalSrc } = await uploadFile({ file, uploadsPath, renderMarkdown }); @@ -81,6 +83,8 @@ const uploadContent = async ({ type, editor, file, uploadsPath, renderMarkdown, canonicalSrc, }), ); + + editor.commands.setNodeSelection(position); } catch (e) { editor.commands.deleteRange({ from: position, to: position + 1 }); eventHub.$emit('alert', { diff --git a/app/assets/javascripts/content_editor/services/utils.js b/app/assets/javascripts/content_editor/services/utils.js index b3856b0dd74..e352fa8a9db 100644 --- a/app/assets/javascripts/content_editor/services/utils.js +++ b/app/assets/javascripts/content_editor/services/utils.js @@ -15,7 +15,7 @@ export const hasSelection = (tiptapEditor) => { * @returns {string} */ export const extractFilename = (src) => { - return src.replace(/^.*\/|\..+?$/g, ''); + return src.replace(/^.*\/|\.[^.]+?$/g, ''); }; export const readFileAsDataURL = (file) => { |