Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-05-19 10:33:21 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2022-05-19 10:33:21 +0300
commit36a59d088eca61b834191dacea009677a96c052f (patch)
treee4f33972dab5d8ef79e3944a9f403035fceea43f /app/assets/javascripts/content_editor
parenta1761f15ec2cae7c7f7bbda39a75494add0dfd6f (diff)
Add latest changes from gitlab-org/gitlab@15-0-stable-eev15.0.0-rc42
Diffstat (limited to 'app/assets/javascripts/content_editor')
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/code_block.vue (renamed from app/assets/javascripts/content_editor/components/code_block_bubble_menu.vue)94
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue (renamed from app/assets/javascripts/content_editor/components/formatting_bubble_menu.vue)50
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/link.vue189
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/media.vue288
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor.vue10
-rw-r--r--app/assets/javascripts/content_editor/components/divider.vue3
-rw-r--r--app/assets/javascripts/content_editor/components/loading_indicator.vue2
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_button.vue9
-rw-r--r--app/assets/javascripts/content_editor/components/top_toolbar.vue7
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/code_block.vue (renamed from app/assets/javascripts/content_editor/components/wrappers/frontmatter.vue)14
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/media.vue51
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue4
-rw-r--r--app/assets/javascripts/content_editor/constants/code_block_languages.js210
-rw-r--r--app/assets/javascripts/content_editor/constants/index.js (renamed from app/assets/javascripts/content_editor/constants.js)0
-rw-r--r--app/assets/javascripts/content_editor/extensions/blockquote.js2
-rw-r--r--app/assets/javascripts/content_editor/extensions/code_block_highlight.js17
-rw-r--r--app/assets/javascripts/content_editor/extensions/diagram.js3
-rw-r--r--app/assets/javascripts/content_editor/extensions/frontmatter.js15
-rw-r--r--app/assets/javascripts/content_editor/extensions/image.js5
-rw-r--r--app/assets/javascripts/content_editor/extensions/paste_markdown.js11
-rw-r--r--app/assets/javascripts/content_editor/extensions/playable.js6
-rw-r--r--app/assets/javascripts/content_editor/extensions/sourcemap.js48
-rw-r--r--app/assets/javascripts/content_editor/services/asset_resolver.js13
-rw-r--r--app/assets/javascripts/content_editor/services/code_block_language_loader.js246
-rw-r--r--app/assets/javascripts/content_editor/services/content_editor.js46
-rw-r--r--app/assets/javascripts/content_editor/services/create_content_editor.js24
-rw-r--r--app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js (renamed from app/assets/javascripts/content_editor/services/markdown_deserializer.js)2
-rw-r--r--app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js475
-rw-r--r--app/assets/javascripts/content_editor/services/highlight_js_language_loader.js248
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_serializer.js115
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_sourcemap.js2
-rw-r--r--app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js87
-rw-r--r--app/assets/javascripts/content_editor/services/serialization_helpers.js131
-rw-r--r--app/assets/javascripts/content_editor/services/upload_helpers.js4
-rw-r--r--app/assets/javascripts/content_editor/services/utils.js2
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) => {