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-06-20 14:10:13 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2022-06-20 14:10:13 +0300
commit0ea3fcec397b69815975647f5e2aa5fe944a8486 (patch)
tree7979381b89d26011bcf9bdc989a40fcc2f1ed4ff /app/assets/javascripts/content_editor
parent72123183a20411a36d607d70b12d57c484394c8e (diff)
Add latest changes from gitlab-org/gitlab@15-1-stable-eev15.1.0-rc42
Diffstat (limited to 'app/assets/javascripts/content_editor')
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/code_block.vue157
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue34
-rw-r--r--app/assets/javascripts/content_editor/components/top_toolbar.vue14
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/code_block.vue79
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/footnote_definition.vue28
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue7
-rw-r--r--app/assets/javascripts/content_editor/extensions/code_block_highlight.js3
-rw-r--r--app/assets/javascripts/content_editor/extensions/diagram.js22
-rw-r--r--app/assets/javascripts/content_editor/extensions/footnote_definition.js27
-rw-r--r--app/assets/javascripts/content_editor/extensions/footnote_reference.js15
-rw-r--r--app/assets/javascripts/content_editor/extensions/footnotes_section.js5
-rw-r--r--app/assets/javascripts/content_editor/extensions/sourcemap.js18
-rw-r--r--app/assets/javascripts/content_editor/services/asset_resolver.js15
-rw-r--r--app/assets/javascripts/content_editor/services/code_block_language_loader.js8
-rw-r--r--app/assets/javascripts/content_editor/services/content_editor.js4
-rw-r--r--app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js329
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_serializer.js39
-rw-r--r--app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js180
-rw-r--r--app/assets/javascripts/content_editor/services/serialization_helpers.js222
19 files changed, 936 insertions, 270 deletions
diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/code_block.vue b/app/assets/javascripts/content_editor/components/bubble_menus/code_block.vue
index 518ddd7a09c..6c0ac8e54d2 100644
--- a/app/assets/javascripts/content_editor/components/bubble_menus/code_block.vue
+++ b/app/assets/javascripts/content_editor/components/bubble_menus/code_block.vue
@@ -1,5 +1,8 @@
<script>
import {
+ GlDropdownForm,
+ GlFormInput,
+ GlDropdownDivider,
GlButton,
GlButtonGroup,
GlDropdown,
@@ -20,23 +23,32 @@ const CODE_BLOCK_NODE_TYPES = [CodeBlockHighlight.name, Diagram.name, Frontmatte
export default {
components: {
BubbleMenu,
+ GlDropdownForm,
+ GlFormInput,
GlButton,
GlButtonGroup,
GlDropdown,
GlDropdownItem,
+ GlDropdownDivider,
GlSearchBoxByType,
EditorStateObserver,
},
directives: {
GlTooltip,
},
- inject: ['tiptapEditor'],
+ inject: ['tiptapEditor', 'contentEditor'],
data() {
return {
codeBlockType: undefined,
- selectedLanguage: {},
filterTerm: '',
filteredLanguages: [],
+
+ showCustomLanguageInput: false,
+ customLanguageType: '',
+
+ selectedLanguage: {},
+ isDiagram: false,
+ showPreview: false,
};
},
watch: {
@@ -52,24 +64,39 @@ export default {
return CODE_BLOCK_NODE_TYPES.some((type) => editor.isActive(type));
},
- updateSelectedLanguage() {
+ async updateCodeBlockInfoToState() {
this.codeBlockType = CODE_BLOCK_NODE_TYPES.find((type) => this.tiptapEditor.isActive(type));
- if (this.codeBlockType) {
- const { language } = this.tiptapEditor.getAttributes(this.codeBlockType);
- this.selectedLanguage = codeBlockLanguageLoader.findLanguageBySyntax(language);
- }
+ if (!this.codeBlockType) return;
+
+ const { language, isDiagram, showPreview } = this.tiptapEditor.getAttributes(
+ this.codeBlockType,
+ );
+ this.selectedLanguage = codeBlockLanguageLoader.findOrCreateLanguageBySyntax(
+ language,
+ isDiagram,
+ );
+ this.isDiagram = isDiagram;
+ this.showPreview = showPreview;
},
- copyCodeBlockText() {
+ getCodeBlockText() {
const { view } = this.tiptapEditor;
const { from } = this.tiptapEditor.state.selection;
const node = getParentByTagName(view.domAtPos(from).node, 'pre');
+ return node?.textContent || '';
+ },
- navigator.clipboard.writeText(node?.textContent || '');
+ copyCodeBlockText() {
+ navigator.clipboard.writeText(this.getCodeBlockText());
},
- async applySelectedLanguage(language) {
+ togglePreview() {
+ this.showPreview = !this.showPreview;
+ this.tiptapEditor.commands.updateAttributes(Diagram.name, { showPreview: this.showPreview });
+ },
+
+ async applyLanguage(language) {
this.selectedLanguage = language;
await codeBlockLanguageLoader.loadLanguage(language.syntax);
@@ -77,6 +104,21 @@ export default {
this.tiptapEditor.commands.setCodeBlock({ language: this.selectedLanguage.syntax });
},
+ clearCustomLanguageForm() {
+ this.showCustomLanguageInput = false;
+ this.customLanguageType = '';
+ },
+
+ applyCustomLanguage() {
+ this.showCustomLanguageInput = false;
+
+ const language = codeBlockLanguageLoader.findOrCreateLanguageBySyntax(
+ this.customLanguageType,
+ );
+
+ this.applyLanguage(language);
+ },
+
getReferenceClientRect() {
const { view } = this.tiptapEditor;
const { from } = this.tiptapEditor.state.selection;
@@ -101,15 +143,36 @@ export default {
getReferenceClientRect,
} /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
>
- <editor-state-observer @transaction="updateSelectedLanguage">
+ <editor-state-observer @transaction="updateCodeBlockInfoToState">
<gl-button-group>
<gl-dropdown
category="tertiary"
contenteditable="false"
boundary="viewport"
:text="selectedLanguage.label"
+ @hide="clearCustomLanguageForm"
>
- <template #header>
+ <template v-if="showCustomLanguageInput" #header>
+ <div class="gl-relative">
+ <gl-button
+ v-gl-tooltip
+ class="gl-absolute gl-mt-n3 gl-ml-2"
+ variant="default"
+ category="tertiary"
+ size="medium"
+ :aria-label="__('Go back')"
+ :title="__('Go back')"
+ icon="arrow-left"
+ @click.prevent.stop="showCustomLanguageInput = false"
+ />
+ <p
+ class="gl-text-center gl-new-dropdown-header-top gl-mb-0! gl-border-none! gl-pb-1!"
+ >
+ {{ __('Create custom type') }}
+ </p>
+ </div>
+ </template>
+ <template v-else #header>
<gl-search-box-by-type
v-model="filterTerm"
:clear-button-title="__('Clear')"
@@ -117,20 +180,59 @@ export default {
/>
</template>
- <template #highlighted-items>
+ <template v-if="!showCustomLanguageInput" #highlighted-items>
<gl-dropdown-item :key="selectedLanguage.syntax" is-check-item :is-checked="true">
{{ selectedLanguage.label }}
</gl-dropdown-item>
</template>
- <gl-dropdown-item
- v-for="language in filteredLanguages"
- v-show="selectedLanguage.syntax !== language.syntax"
- :key="language.syntax"
- @click="applySelectedLanguage(language)"
- >
- {{ language.label }}
- </gl-dropdown-item>
+ <template v-if="!showCustomLanguageInput" #default>
+ <gl-dropdown-item
+ v-for="language in filteredLanguages"
+ v-show="selectedLanguage.syntax !== language.syntax"
+ :key="language.syntax"
+ @click="applyLanguage(language)"
+ >
+ {{ language.label }}
+ </gl-dropdown-item>
+ </template>
+ <template v-else #default>
+ <gl-dropdown-form @submit.prevent="applyCustomLanguage">
+ <div class="gl-mx-4 gl-mt-2 gl-mb-3">
+ <gl-form-input v-model="customLanguageType" :placeholder="__('Language type')" />
+ </div>
+ <gl-dropdown-divider />
+ <div class="gl-mx-4 gl-mt-3 gl-display-flex gl-justify-content-end">
+ <gl-button
+ variant="default"
+ size="medium"
+ category="primary"
+ class="gl-mr-2 gl-w-auto!"
+ @click.prevent.stop="showCustomLanguageInput = false"
+ >
+ {{ __('Cancel') }}
+ </gl-button>
+ <gl-button
+ variant="confirm"
+ size="medium"
+ category="primary"
+ type="submit"
+ class="gl-w-auto!"
+ >
+ {{ __('Apply') }}
+ </gl-button>
+ </div>
+ </gl-dropdown-form>
+ </template>
+
+ <template v-if="!showCustomLanguageInput" #footer>
+ <gl-dropdown-item
+ data-testid="create-custom-type"
+ @click.capture.native.stop="showCustomLanguageInput = true"
+ >
+ {{ __('Create custom type') }}
+ </gl-dropdown-item>
+ </template>
</gl-dropdown>
<gl-button
v-gl-tooltip
@@ -144,6 +246,19 @@ export default {
@click="copyCodeBlockText"
/>
<gl-button
+ v-if="isDiagram"
+ v-gl-tooltip
+ variant="default"
+ category="tertiary"
+ size="medium"
+ :class="{ active: showPreview }"
+ data-testid="preview-diagram"
+ :aria-label="__('Preview diagram')"
+ :title="__('Preview diagram')"
+ icon="eye"
+ @click="togglePreview"
+ />
+ <gl-button
v-gl-tooltip
variant="default"
category="tertiary"
diff --git a/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue
new file mode 100644
index 00000000000..ecde593147c
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue
@@ -0,0 +1,34 @@
+<script>
+import { GlDropdown, GlDropdownItem, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ },
+ directives: {
+ GlTooltip,
+ },
+ inject: ['tiptapEditor'],
+ methods: {
+ execute(contentType, attrs) {
+ this.tiptapEditor.chain().focus().setNode(contentType, attrs).run();
+
+ this.$emit('execute', { contentType });
+ },
+ },
+};
+</script>
+<template>
+ <gl-dropdown size="small" category="tertiary" icon="plus">
+ <gl-dropdown-item @click="execute('diagram', { language: 'mermaid' })">
+ {{ __('Mermaid diagram') }}
+ </gl-dropdown-item>
+ <gl-dropdown-item @click="execute('diagram', { language: 'plantuml' })">
+ {{ __('PlantUML diagram') }}
+ </gl-dropdown-item>
+ <gl-dropdown-item @click="execute('horizontalRule')">
+ {{ __('Horizontal rule') }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/content_editor/components/top_toolbar.vue b/app/assets/javascripts/content_editor/components/top_toolbar.vue
index 19e150a4da9..b652e634b0c 100644
--- a/app/assets/javascripts/content_editor/components/top_toolbar.vue
+++ b/app/assets/javascripts/content_editor/components/top_toolbar.vue
@@ -5,6 +5,7 @@ import ToolbarImageButton from './toolbar_image_button.vue';
import ToolbarLinkButton from './toolbar_link_button.vue';
import ToolbarTableButton from './toolbar_table_button.vue';
import ToolbarTextStyleDropdown from './toolbar_text_style_dropdown.vue';
+import ToolbarMoreDropdown from './toolbar_more_dropdown.vue';
export default {
components: {
@@ -13,6 +14,7 @@ export default {
ToolbarLinkButton,
ToolbarTableButton,
ToolbarImageButton,
+ ToolbarMoreDropdown,
},
methods: {
trackToolbarControlExecution({ contentType, value }) {
@@ -117,16 +119,8 @@ export default {
:label="__('Add a collapsible section')"
@execute="trackToolbarControlExecution"
/>
- <toolbar-button
- data-testid="horizontal-rule"
- content-type="horizontalRule"
- icon-name="dash"
- class="gl-mx-2"
- editor-command="setHorizontalRule"
- :label="__('Add a horizontal rule')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-table-button @execute="trackToolbarControlExecution" />
+ <toolbar-table-button data-testid="table" @execute="trackToolbarControlExecution" />
+ <toolbar-more-dropdown data-testid="more" @execute="trackToolbarControlExecution" />
</div>
</template>
<style>
diff --git a/app/assets/javascripts/content_editor/components/wrappers/code_block.vue b/app/assets/javascripts/content_editor/components/wrappers/code_block.vue
index 1390b9b2daf..81f9b1f0af5 100644
--- a/app/assets/javascripts/content_editor/components/wrappers/code_block.vue
+++ b/app/assets/javascripts/content_editor/components/wrappers/code_block.vue
@@ -1,15 +1,26 @@
<script>
+import { debounce } from 'lodash';
import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2';
import { __ } from '~/locale';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+import SandboxedMermaid from '~/behaviors/components/sandboxed_mermaid.vue';
import codeBlockLanguageLoader from '../../services/code_block_language_loader';
+import EditorStateObserver from '../editor_state_observer.vue';
export default {
name: 'CodeBlock',
components: {
NodeViewWrapper,
NodeViewContent,
+ EditorStateObserver,
+ SandboxedMermaid,
},
+ inject: ['contentEditor'],
props: {
+ editor: {
+ type: Object,
+ required: true,
+ },
node: {
type: Object,
required: true,
@@ -18,27 +29,75 @@ export default {
type: Function,
required: true,
},
+ selected: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ diagramUrl: '',
+ diagramSource: '',
+ };
},
async mounted() {
- const lang = codeBlockLanguageLoader.findLanguageBySyntax(this.node.attrs.language);
+ this.updateDiagramPreview = debounce(
+ this.updateDiagramPreview,
+ DEFAULT_DEBOUNCE_AND_THROTTLE_MS,
+ );
+
+ const lang = codeBlockLanguageLoader.findOrCreateLanguageBySyntax(this.node.attrs.language);
await codeBlockLanguageLoader.loadLanguage(lang.syntax);
this.updateAttributes({ language: this.node.attrs.language });
},
+ methods: {
+ async updateDiagramPreview() {
+ if (!this.node.attrs.showPreview) {
+ this.diagramSource = '';
+ return;
+ }
+
+ if (!this.editor.isActive('diagram')) return;
+
+ this.diagramSource = this.$refs.nodeViewContent.$el.textContent;
+
+ if (this.node.attrs.language !== 'mermaid') {
+ this.diagramUrl = await this.contentEditor.renderDiagram(
+ this.diagramSource,
+ this.node.attrs.language,
+ );
+ }
+ },
+ },
i18n: {
frontmatter: __('frontmatter'),
},
+ userColorScheme: gon.user_color_scheme,
};
</script>
<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"
- >{{ $options.i18n.frontmatter }}:{{ node.attrs.language }}</span
+ <editor-state-observer @transaction="updateDiagramPreview">
+ <node-view-wrapper
+ :class="`content-editor-code-block gl-relative code highlight ${$options.userColorScheme}`"
+ as="pre"
>
- <node-view-content as="code" />
- </node-view-wrapper>
+ <div
+ v-if="node.attrs.showPreview"
+ class="gl-mt-n3! gl-ml-n4! gl-mr-n4! gl-mb-3 gl-bg-white! gl-p-4 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"
+ >
+ <sandboxed-mermaid v-if="node.attrs.language === 'mermaid'" :source="diagramSource" />
+ <img v-else ref="diagramContainer" :src="diagramUrl" />
+ </div>
+ <span
+ v-if="node.attrs.isFrontmatter"
+ data-testid="frontmatter-label"
+ class="gl-absolute gl-top-0 gl-right-3"
+ contenteditable="false"
+ >{{ $options.i18n.frontmatter }}:{{ node.attrs.language }}</span
+ >
+ <node-view-content ref="nodeViewContent" as="code" />
+ </node-view-wrapper>
+ </editor-state-observer>
</template>
diff --git a/app/assets/javascripts/content_editor/components/wrappers/footnote_definition.vue b/app/assets/javascripts/content_editor/components/wrappers/footnote_definition.vue
new file mode 100644
index 00000000000..8b7b02605f7
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/wrappers/footnote_definition.vue
@@ -0,0 +1,28 @@
+<script>
+import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2';
+
+export default {
+ name: 'FootnoteDefinitionWrapper',
+ components: {
+ NodeViewWrapper,
+ NodeViewContent,
+ },
+ props: {
+ node: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <node-view-wrapper class="gl-display-flex gl-font-sm" as="div">
+ <span
+ data-testid="footnote-label"
+ contenteditable="false"
+ class="gl-display-inline-flex gl-mr-2"
+ >{{ node.attrs.label }}:</span
+ >
+ <node-view-content />
+ </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 209e4629830..c0d6e32a739 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
@@ -101,6 +101,9 @@ export default {
deleteTable: __('Delete table'),
editTableActions: __('Edit table'),
},
+ dropdownPopperOpts: {
+ positionFixed: true,
+ },
};
</script>
<template>
@@ -124,9 +127,7 @@ export default {
no-caret
text-sr-only
:text="$options.i18n.editTableActions"
- :popper-opts="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ {
- positionFixed: true,
- } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
+ :popper-opts="$options.dropdownPopperOpts"
@hide="handleHide($event)"
>
<gl-dropdown-item @click="runCommand('addColumnBefore')">
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 cc4ba84a29d..61f6a233694 100644
--- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
+++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
@@ -4,7 +4,8 @@ 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');
+const extractLanguage = (element) => element.dataset.canonicalLang ?? element.getAttribute('lang');
+
export const backtickInputRegex = /^```([a-z]+)?[\s\n]$/;
export const tildeInputRegex = /^~~~([a-z]+)?[\s\n]$/;
diff --git a/app/assets/javascripts/content_editor/extensions/diagram.js b/app/assets/javascripts/content_editor/extensions/diagram.js
index f9dfeb92e9a..c59ca8a28b8 100644
--- a/app/assets/javascripts/content_editor/extensions/diagram.js
+++ b/app/assets/javascripts/content_editor/extensions/diagram.js
@@ -1,6 +1,10 @@
+import { textblockTypeInputRule } from '@tiptap/core';
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
+import languageLoader from '../services/code_block_language_loader';
import CodeBlockHighlight from './code_block_highlight';
+const backtickInputRegex = /^```(mermaid|plantuml)[\s\n]$/;
+
export default CodeBlockHighlight.extend({
name: 'diagram',
@@ -17,6 +21,9 @@ export default CodeBlockHighlight.extend({
isDiagram: {
default: true,
},
+ showPreview: {
+ default: true,
+ },
};
},
@@ -24,6 +31,11 @@ export default CodeBlockHighlight.extend({
return [
{
priority: PARSE_HTML_PRIORITY_HIGHEST,
+ tag: 'pre[lang="mermaid"]',
+ getAttrs: () => ({ language: 'mermaid' }),
+ },
+ {
+ priority: PARSE_HTML_PRIORITY_HIGHEST,
tag: '[data-diagram]',
getContent(element, schema) {
const source = atob(element.dataset.diagramSrc.replace('data:text/plain;base64,', ''));
@@ -54,6 +66,14 @@ export default CodeBlockHighlight.extend({
},
addInputRules() {
- return [];
+ const getAttributes = (match) => languageLoader?.loadLanguageFromInputRule(match) || {};
+
+ return [
+ textblockTypeInputRule({
+ find: backtickInputRegex,
+ type: this.type,
+ getAttributes,
+ }),
+ ];
},
});
diff --git a/app/assets/javascripts/content_editor/extensions/footnote_definition.js b/app/assets/javascripts/content_editor/extensions/footnote_definition.js
index dbab0de3421..bf752918934 100644
--- a/app/assets/javascripts/content_editor/extensions/footnote_definition.js
+++ b/app/assets/javascripts/content_editor/extensions/footnote_definition.js
@@ -1,12 +1,27 @@
import { mergeAttributes, Node } from '@tiptap/core';
+import { VueNodeViewRenderer } from '@tiptap/vue-2';
+import FootnoteDefinitionWrapper from '../components/wrappers/footnote_definition.vue';
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
+const extractFootnoteIdentifier = (idAttribute) => /^fn-(\w+)-\d+$/.exec(idAttribute)?.[1];
+
export default Node.create({
name: 'footnoteDefinition',
-
content: 'paragraph',
-
group: 'block',
+ isolating: true,
+ addAttributes() {
+ return {
+ identifier: {
+ default: null,
+ parseHTML: (element) => extractFootnoteIdentifier(element.getAttribute('id')),
+ },
+ label: {
+ default: null,
+ parseHTML: (element) => extractFootnoteIdentifier(element.getAttribute('id')),
+ },
+ };
+ },
parseHTML() {
return [
@@ -15,7 +30,11 @@ export default Node.create({
];
},
- renderHTML({ HTMLAttributes }) {
- return ['li', mergeAttributes(HTMLAttributes), 0];
+ renderHTML({ label, ...HTMLAttributes }) {
+ return ['div', mergeAttributes(HTMLAttributes), 0];
+ },
+
+ addNodeView() {
+ return new VueNodeViewRenderer(FootnoteDefinitionWrapper);
},
});
diff --git a/app/assets/javascripts/content_editor/extensions/footnote_reference.js b/app/assets/javascripts/content_editor/extensions/footnote_reference.js
index 1ac8016f774..ae5b8edc7af 100644
--- a/app/assets/javascripts/content_editor/extensions/footnote_reference.js
+++ b/app/assets/javascripts/content_editor/extensions/footnote_reference.js
@@ -1,6 +1,9 @@
import { Node, mergeAttributes } from '@tiptap/core';
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
+const extractFootnoteIdentifier = (element) =>
+ /^fnref-(\w+)-\d+$/.exec(element.querySelector('a')?.getAttribute('id'))?.[1];
+
export default Node.create({
name: 'footnoteReference',
@@ -16,13 +19,13 @@ export default Node.create({
addAttributes() {
return {
- footnoteId: {
+ identifier: {
default: null,
- parseHTML: (element) => element.querySelector('a').getAttribute('id'),
+ parseHTML: extractFootnoteIdentifier,
},
- footnoteNumber: {
+ label: {
default: null,
- parseHTML: (element) => element.textContent,
+ parseHTML: extractFootnoteIdentifier,
},
};
},
@@ -31,7 +34,7 @@ export default Node.create({
return [{ tag: 'sup.footnote-ref', priority: PARSE_HTML_PRIORITY_HIGHEST }];
},
- renderHTML({ HTMLAttributes: { footnoteNumber, footnoteId, ...HTMLAttributes } }) {
- return ['sup', mergeAttributes(HTMLAttributes), footnoteNumber];
+ renderHTML({ HTMLAttributes: { label, ...HTMLAttributes } }) {
+ return ['sup', mergeAttributes(HTMLAttributes), label];
},
});
diff --git a/app/assets/javascripts/content_editor/extensions/footnotes_section.js b/app/assets/javascripts/content_editor/extensions/footnotes_section.js
index 914a8934734..2b2c4177e1d 100644
--- a/app/assets/javascripts/content_editor/extensions/footnotes_section.js
+++ b/app/assets/javascripts/content_editor/extensions/footnotes_section.js
@@ -10,7 +10,10 @@ export default Node.create({
isolating: true,
parseHTML() {
- return [{ tag: 'section.footnotes > ol' }];
+ return [
+ { tag: 'section.footnotes', skip: true },
+ { tag: 'section.footnotes > ol', skip: true },
+ ];
},
renderHTML({ HTMLAttributes }) {
diff --git a/app/assets/javascripts/content_editor/extensions/sourcemap.js b/app/assets/javascripts/content_editor/extensions/sourcemap.js
index 94236e2e70e..87118074462 100644
--- a/app/assets/javascripts/content_editor/extensions/sourcemap.js
+++ b/app/assets/javascripts/content_editor/extensions/sourcemap.js
@@ -4,6 +4,8 @@ import Bold from './bold';
import BulletList from './bullet_list';
import Code from './code';
import CodeBlockHighlight from './code_block_highlight';
+import FootnoteReference from './footnote_reference';
+import FootnoteDefinition from './footnote_definition';
import Heading from './heading';
import HardBreak from './hard_break';
import HorizontalRule from './horizontal_rule';
@@ -13,6 +15,13 @@ import Link from './link';
import ListItem from './list_item';
import OrderedList from './ordered_list';
import Paragraph from './paragraph';
+import Strike from './strike';
+import TaskList from './task_list';
+import TaskItem from './task_item';
+import Table from './table';
+import TableCell from './table_cell';
+import TableHeader from './table_header';
+import TableRow from './table_row';
export default Extension.create({
addGlobalAttributes() {
@@ -24,6 +33,8 @@ export default Extension.create({
BulletList.name,
Code.name,
CodeBlockHighlight.name,
+ FootnoteReference.name,
+ FootnoteDefinition.name,
HardBreak.name,
Heading.name,
HorizontalRule.name,
@@ -33,6 +44,13 @@ export default Extension.create({
ListItem.name,
OrderedList.name,
Paragraph.name,
+ Strike.name,
+ TaskList.name,
+ TaskItem.name,
+ Table.name,
+ TableCell.name,
+ TableHeader.name,
+ TableRow.name,
],
attributes: {
sourceMarkdown: {
diff --git a/app/assets/javascripts/content_editor/services/asset_resolver.js b/app/assets/javascripts/content_editor/services/asset_resolver.js
index 942457b9664..c0bcddbe58d 100644
--- a/app/assets/javascripts/content_editor/services/asset_resolver.js
+++ b/app/assets/javascripts/content_editor/services/asset_resolver.js
@@ -1,13 +1,24 @@
import { memoize } from 'lodash';
+const parser = new DOMParser();
+
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');
}),
+
+ renderDiagram: memoize(async (code, language) => {
+ const backticks = '`'.repeat(4);
+ const html = await renderMarkdown(`${backticks}${language}\n${code}\n${backticks}`);
+
+ const { body } = parser.parseFromString(html, 'text/html');
+ const img = body.querySelector('img');
+ if (!img) return '';
+
+ return img.dataset.src || img.getAttribute('src');
+ }),
});
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 1afaf4bfef6..b7cf1bb087c 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
@@ -8,7 +8,7 @@ const codeBlockLanguageLoader = {
allLanguages: CODE_BLOCK_LANGUAGES,
- findLanguageBySyntax(value) {
+ findOrCreateLanguageBySyntax(value, isDiagram) {
const lowercaseValue = value?.toLowerCase() || 'plaintext';
return (
this.allLanguages.find(
@@ -16,7 +16,9 @@ const codeBlockLanguageLoader = {
syntax === lowercaseValue || variants?.toLowerCase().split(', ').includes(lowercaseValue),
) || {
syntax: lowercaseValue,
- label: sprintf(__(`Custom (%{language})`), { language: lowercaseValue }),
+ label: sprintf(isDiagram ? __(`Diagram (%{language})`) : __(`Custom (%{language})`), {
+ language: lowercaseValue,
+ }),
}
);
},
@@ -38,7 +40,7 @@ const codeBlockLanguageLoader = {
},
loadLanguageFromInputRule(match) {
- const { syntax } = this.findLanguageBySyntax(match[1]);
+ const { syntax } = this.findOrCreateLanguageBySyntax(match[1]);
this.loadLanguage(syntax);
diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js
index 52dacb84153..06757e7a280 100644
--- a/app/assets/javascripts/content_editor/services/content_editor.js
+++ b/app/assets/javascripts/content_editor/services/content_editor.js
@@ -52,6 +52,10 @@ export class ContentEditor {
return this._assetResolver.resolveUrl(canonicalSrc);
}
+ renderDiagram(code, language) {
+ return this._assetResolver.renderDiagram(code, language);
+ }
+
async setSerializedContent(serializedContent) {
const { _tiptapEditor: editor, _eventHub: eventHub } = this;
const { doc, tr } = editor.state;
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
index b6a3e0bc26a..2c462cdde91 100644
--- a/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js
+++ b/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js
@@ -20,9 +20,9 @@
*/
import { Mark } from 'prosemirror-model';
-import { visitParents } from 'unist-util-visit-parents';
+import { visitParents, SKIP } from 'unist-util-visit-parents';
import { toString } from 'hast-util-to-string';
-import { isFunction } from 'lodash';
+import { isFunction, isString, noop } from 'lodash';
/**
* Merges two ProseMirror text nodes if both text nodes
@@ -63,10 +63,12 @@ function maybeMerge(a, b) {
function createSourceMapAttributes(hastNode, source) {
const { position } = hastNode;
- return {
- sourceMapKey: `${position.start.offset}:${position.end.offset}`,
- sourceMarkdown: source.substring(position.start.offset, position.end.offset),
- };
+ return position && position.end
+ ? {
+ sourceMapKey: `${position.start.offset}:${position.end.offset}`,
+ sourceMarkdown: source.substring(position.start.offset, position.end.offset),
+ }
+ : {};
}
/**
@@ -141,6 +143,20 @@ class HastToProseMirrorConverterState {
return this.stack.length === 0;
}
+ findInStack(fn) {
+ const last = this.stack.length - 1;
+
+ for (let i = last; i >= 0; i -= 1) {
+ const item = this.stack[i];
+
+ if (fn(item) === true) {
+ return item;
+ }
+ }
+
+ return null;
+ }
+
/**
* Creates a text node and adds it to
* the top node in the stack.
@@ -249,33 +265,38 @@ class HastToProseMirrorConverterState {
* @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;
- }
+ const factories = {
+ root: {
+ selector: 'root',
+ wrapInParagraph: true,
+ handle: (state, hastNode) => state.openNode(schema.topNodeType, hastNode, {}, {}),
+ },
+ text: {
+ selector: 'text',
+ handle: (state, hastNode) => {
+ const found = state.findInStack((node) => isFunction(node.factorySpec.processText));
+ const { value: text } = hastNode;
+
+ if (/^\s+$/.test(text)) {
+ return;
+ }
- if (factorySpec.wrapTextInParagraph === true) {
- state.openNode(schema.nodeType('paragraph'));
- state.addText(schema, hastNode.value);
- state.closeNode();
- } else {
- state.addText(schema, hastNode.value);
- }
+ state.addText(schema, found ? found.factorySpec.processText(text) : text);
+ },
},
};
-
- 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,
- );
+ for (const [proseMirrorName, factorySpec] of Object.entries(proseMirrorFactorySpecs)) {
+ const factory = {
+ selector: factorySpec.selector,
+ skipChildren: factorySpec.skipChildren,
+ processText: factorySpec.processText,
+ parent: factorySpec.parent,
+ wrapInParagraph: factorySpec.wrapInParagraph,
+ };
+
+ if (factorySpec.type === 'block') {
+ factory.handle = (state, hastNode, parent) => {
+ const nodeType = schema.nodeType(proseMirrorName);
state.closeUntil(parent);
state.openNode(
@@ -297,9 +318,9 @@ const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source)
state.closeNode();
}
};
- } else if (factorySpec.inline) {
- const nodeType = schema.nodeType(factorySpec.inline);
- handlers[hastNodeTagName] = (state, hastNode, parent) => {
+ } else if (factorySpec.type === 'inline') {
+ const nodeType = schema.nodeType(proseMirrorName);
+ factory.handle = (state, hastNode, parent) => {
state.closeUntil(parent);
state.openNode(
nodeType,
@@ -310,23 +331,115 @@ const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source)
// 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) => {
+ } else if (factorySpec.type === 'mark') {
+ const markType = schema.marks[proseMirrorName];
+ factory.handle = (state, hastNode, parent) => {
state.openMark(markType, getAttrs(factorySpec, hastNode, parent, source));
if (factorySpec.inlineContent) {
state.addText(schema, hastNode.value);
}
};
+ } else if (factorySpec.type === 'ignore') {
+ factory.handle = noop;
} else {
- throw new RangeError(`Unrecognized node factory spec ${JSON.stringify(factorySpec)}`);
+ throw new RangeError(
+ `Unrecognized ProseMirror object type ${JSON.stringify(factorySpec.type)}`,
+ );
}
+
+ factories[proseMirrorName] = factory;
}
- return handlers;
+ return factories;
};
+const findFactory = (hastNode, ancestors, factories) =>
+ Object.entries(factories).find(([, factorySpec]) => {
+ const { selector } = factorySpec;
+
+ return isFunction(selector)
+ ? selector(hastNode, ancestors)
+ : [hastNode.tagName, hastNode.type].includes(selector);
+ })?.[1];
+
+const findParent = (ancestors, parent) => {
+ if (isString(parent)) {
+ return ancestors.reverse().find((ancestor) => ancestor.tagName === parent);
+ }
+
+ return ancestors[ancestors.length - 1];
+};
+
+const calcTextNodePosition = (textNode) => {
+ const { position, value, type } = textNode;
+
+ if (type !== 'text' || (!position.start && !position.end) || (position.start && position.end)) {
+ return textNode.position;
+ }
+
+ const span = value.length - 1;
+
+ if (position.start && !position.end) {
+ const { start } = position;
+
+ return {
+ start,
+ end: {
+ row: start.row,
+ column: start.column + span,
+ offset: start.offset + span,
+ },
+ };
+ }
+
+ const { end } = position;
+
+ return {
+ start: {
+ row: end.row,
+ column: end.column - span,
+ offset: end.offset - span,
+ },
+ end,
+ };
+};
+
+const removeEmptyTextNodes = (nodes) =>
+ nodes.filter(
+ (node) => node.type !== 'text' || (node.type === 'text' && !/^\s+$/.test(node.value)),
+ );
+
+const wrapInlineElements = (nodes, wrappableTags) =>
+ nodes.reduce((children, child) => {
+ const previous = children[children.length - 1];
+
+ if (child.type !== 'text' && !wrappableTags.includes(child.tagName)) {
+ return [...children, child];
+ }
+
+ const wrapperExists = previous?.properties.wrapper;
+
+ if (wrapperExists) {
+ const wrapper = previous;
+
+ wrapper.position.end = child.position.end;
+ wrapper.children.push(child);
+
+ return children;
+ }
+
+ const wrapper = {
+ type: 'element',
+ tagName: 'p',
+ position: calcTextNodePosition(child),
+ children: [child],
+ properties: { wrapper: true },
+ };
+
+ return [...children, wrapper];
+ }, []);
+
/**
* 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
@@ -339,8 +452,9 @@ const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source)
* The object should have the following shape:
*
* {
- * [hastNode.tagName]: {
- * [block|node|mark]: [ProseMirror.Node.name],
+ * [ProseMirrorNodeOrMarkName]: {
+ * type: 'block' | 'inline' | 'mark',
+ * selector: String | hastNode -> Boolean,
* ...configurationOptions
* }
* }
@@ -348,57 +462,21 @@ const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source)
* 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',
+ * horizontalRule: {
+ * type: 'block',
+ * selector: 'hr',
* },
- * h2: {
- * block: 'heading',
+ * heading: {
+ * type: 'block',
+ * selector: (hastNode) => ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(hastNode),
* },
- * img: {
- * node: 'image',
+ * bold: {
+ * type: 'mark'
+ * selector: (hastNode) => ['b', 'strong'].includes(hastNode),
* },
- * strong: {
- * mark: 'bold',
- * }
- * }
+ * // etc
+ * }
*
- * 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
* ----------------------
@@ -406,6 +484,28 @@ const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source)
* You can customize the conversion process for every node or mark
* setting the following properties in the specification object:
*
+ * **type**
+ *
+ * The `type` property should have one of following three values:
+ *
+ * 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 an image or a mention object.
+ * 3. "mark": A ProseMirror mark.
+ * 4. "ignore": A hast node that should be ignored and won’t be mapped to a
+ * ProseMirror node.
+ *
+ * **selector**
+ *
+ * The `selector` property matches a HastNode to a ProseMirror node or
+ * Mark. If you assign a string value to this property, the converter
+ * will match the first hast node with a `tagName` or `type` property
+ * that equals the string value.
+ *
+ * If you assign a function, the converter will invoke the function with
+ * the hast node and its ancestors. The function should return `true`
+ * if the hastNode matches the custom criteria implemented in the function
+ *
* **getAttrs**
*
* Computes a ProseMirror node or mark attributes. The converter will invoke
@@ -415,12 +515,19 @@ const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source)
* 2. hasParents: All the hast node’s ancestors up to the root node
* 3. source: Markdown source file’s content
*
- * **wrapTextInParagraph**
+ * **wrapInParagraph**
*
- * 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
+ * This property only applies to block nodes. If a block node contains inline
+ * elements like text, images, links, etc, the converter will wrap those inline
+ * elements in a paragraph. This is useful for ProseMirror block
* nodes that don’t allow text directly such as list items and tables.
*
+ * **processText**
+ *
+ * This property only applies to block nodes. If a block node contains text,
+ * it allows applying a processing function to that text. This is useful when
+ * you can transform the text node, i.e trim(), substring(), etc.
+ *
* **skipChildren**
*
* Skips a hast node’s children while traversing the tree.
@@ -434,6 +541,13 @@ const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source)
* Use this property along skipChildren to provide custom processing of child nodes
* for a block node.
*
+ * **parent**
+ *
+ * Specifies what is the node’s parent. This is useful when the node’s parent is not
+ * its direct ancestor in Abstract Syntax Tree. For example, imagine that you want
+ * to make <tr> elements a direct children of tables and skip `<thead>` and `<tbody>`
+ * altogether.
+ *
* @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
@@ -442,17 +556,20 @@ const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source)
*
* @returns A ProseMirror document
*/
-export const createProseMirrorDocFromMdastTree = ({ schema, factorySpecs, tree, source }) => {
+export const createProseMirrorDocFromMdastTree = ({
+ schema,
+ factorySpecs,
+ wrappableTags,
+ 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];
+ const factory = findFactory(hastNode, ancestors, proseMirrorNodeFactories);
- if (!handler) {
+ if (!factory) {
throw new Error(
`Hast node of type "${
hastNode.tagName || hastNode.type
@@ -460,9 +577,25 @@ export const createProseMirrorDocFromMdastTree = ({ schema, factorySpecs, tree,
);
}
- handler(state, hastNode, parent, ancestors);
+ const parent = findParent(ancestors, factory.parent);
+
+ if (factory.wrapInParagraph) {
+ /**
+ * Modifying parameters is a bad practice. For performance reasons,
+ * the author of the unist-util-visit-parents function recommends
+ * modifying nodes in place to avoid traversing the Abstract Syntax
+ * Tree more than once
+ */
+ // eslint-disable-next-line no-param-reassign
+ hastNode.children = wrapInlineElements(
+ removeEmptyTextNodes(hastNode.children),
+ wrappableTags,
+ );
+ }
+
+ factory.handle(state, hastNode, parent);
- return skipChildren === true ? 'skip' : true;
+ return factory.skipChildren === true ? SKIP : true;
});
let doc;
diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js
index d665f24bba1..2d33a16f1a5 100644
--- a/app/assets/javascripts/content_editor/services/markdown_serializer.js
+++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js
@@ -17,7 +17,6 @@ import Diagram from '../extensions/diagram';
import Emoji from '../extensions/emoji';
import Figure from '../extensions/figure';
import FigureCaption from '../extensions/figure_caption';
-import FootnotesSection from '../extensions/footnotes_section';
import FootnoteDefinition from '../extensions/footnote_definition';
import FootnoteReference from '../extensions/footnote_reference';
import Frontmatter from '../extensions/frontmatter';
@@ -60,11 +59,13 @@ import {
renderPlayable,
renderHTMLNode,
renderContent,
+ renderBulletList,
preserveUnchanged,
bold,
italic,
link,
code,
+ strike,
} from './serialization_helpers';
const defaultSerializerConfig = {
@@ -89,12 +90,7 @@ const defaultSerializerConfig = {
close: (...args) => `${defaultMarkdownSerializer.marks.code.close(...args)}$`,
escape: false,
},
- [Strike.name]: {
- open: '~~',
- close: '~~',
- mixable: true,
- expelEnclosingWhitespace: true,
- },
+ [Strike.name]: strike,
...HTMLMarks.reduce(
(acc, { name }) => ({
...acc,
@@ -124,7 +120,7 @@ const defaultSerializerConfig = {
state.wrapBlock('> ', null, node, () => state.renderContent(node));
}
}),
- [BulletList.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.bullet_list),
+ [BulletList.name]: preserveUnchanged(renderBulletList),
[CodeBlockHighlight.name]: preserveUnchanged(renderCodeBlock),
[Diagram.name]: renderCodeBlock,
[Division.name]: (state, node) => {
@@ -157,15 +153,14 @@ const defaultSerializerConfig = {
state.write(`:${name}:`);
},
- [FootnoteDefinition.name]: (state, node) => {
+ [FootnoteDefinition.name]: preserveUnchanged((state, node) => {
+ state.write(`[^${node.attrs.identifier}]: `);
state.renderInline(node);
- },
- [FootnoteReference.name]: (state, node) => {
- state.write(`[^${node.attrs.footnoteNumber}]`);
- },
- [FootnotesSection.name]: (state, node) => {
- state.renderList(node, '', (index) => `[^${index + 1}]: `);
- },
+ state.ensureNewLine();
+ }),
+ [FootnoteReference.name]: preserveUnchanged((state, node) => {
+ state.write(`[^${node.attrs.identifier}]`);
+ }),
[Frontmatter.name]: (state, node) => {
const { language } = node.attrs;
const syntax = {
@@ -196,18 +191,18 @@ const defaultSerializerConfig = {
state.write('[[_TOC_]]');
state.closeBlock(node);
},
- [Table.name]: renderTable,
+ [Table.name]: preserveUnchanged(renderTable),
[TableCell.name]: renderTableCell,
[TableHeader.name]: renderTableCell,
[TableRow.name]: renderTableRow,
- [TaskItem.name]: (state, node) => {
+ [TaskItem.name]: preserveUnchanged((state, node) => {
state.write(`[${node.attrs.checked ? 'x' : ' '}] `);
state.renderContent(node);
- },
- [TaskList.name]: (state, node) => {
+ }),
+ [TaskList.name]: preserveUnchanged((state, node) => {
if (node.attrs.numeric) renderOrderedList(state, node);
- else defaultMarkdownSerializer.nodes.bullet_list(state, node);
- },
+ else renderBulletList(state, node);
+ }),
[Text.name]: defaultMarkdownSerializer.nodes.text,
[Video.name]: renderPlayable,
[WordBreak.name]: (state) => state.write('<wbr>'),
diff --git a/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js b/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js
index 770de1df0d0..da10c684b0b 100644
--- a/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js
+++ b/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js
@@ -2,39 +2,51 @@ import { isString } from 'lodash';
import { render } from '~/lib/gfm';
import { createProseMirrorDocFromMdastTree } from './hast_to_prosemirror_converter';
+const wrappableTags = ['img', 'br', 'code', 'i', 'em', 'b', 'strong', 'a', 'strike', 's', 'del'];
+
+const isTaskItem = (hastNode) => {
+ const { className } = hastNode.properties;
+
+ return (
+ hastNode.tagName === 'li' && Array.isArray(className) && className.includes('task-list-item')
+ );
+};
+
+const getTableCellAttrs = (hastNode) => ({
+ colspan: parseInt(hastNode.properties.colSpan, 10) || 1,
+ rowspan: parseInt(hastNode.properties.rowSpan, 10) || 1,
+});
+
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',
+ blockquote: { type: 'block', selector: 'blockquote' },
+ paragraph: { type: 'block', selector: 'p' },
+ listItem: {
+ type: 'block',
+ wrapInParagraph: true,
+ selector: (hastNode) => hastNode.tagName === 'li' && !hastNode.properties.className,
+ processText: (text) => text.trimRight(),
+ },
+ orderedList: {
+ type: 'block',
+ selector: (hastNode) => hastNode.tagName === 'ol' && !hastNode.properties.className,
+ },
+ bulletList: {
+ type: 'block',
+ selector: (hastNode) => hastNode.tagName === 'ul' && !hastNode.properties.className,
+ },
+ heading: {
+ type: 'block',
+ selector: (hastNode) => ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(hastNode.tagName),
+ getAttrs: (hastNode) => {
+ const level = parseInt(/(\d)$/.exec(hastNode.tagName)?.[1], 10) || 1;
+
+ return { level };
+ },
+ },
+ codeBlock: {
+ type: 'block',
skipChildren: true,
+ selector: 'pre',
getContent: ({ hastNodeText }) => hastNodeText.replace(/\n$/, ''),
getAttrs: (hastNode) => {
const languageClass = hastNode.children[0]?.properties.className?.[0];
@@ -43,28 +55,111 @@ const factorySpecs = {
return { language };
},
},
- hr: { inline: 'horizontalRule' },
- img: {
- inline: 'image',
+ horizontalRule: {
+ type: 'block',
+ selector: 'hr',
+ },
+ taskList: {
+ type: 'block',
+ selector: (hastNode) => {
+ const { className } = hastNode.properties;
+
+ return (
+ ['ul', 'ol'].includes(hastNode.tagName) &&
+ Array.isArray(className) &&
+ className.includes('contains-task-list')
+ );
+ },
+ getAttrs: (hastNode) => ({
+ numeric: hastNode.tagName === 'ol',
+ }),
+ },
+ taskItem: {
+ type: 'block',
+ wrapInParagraph: true,
+ selector: isTaskItem,
+ getAttrs: (hastNode) => ({
+ checked: hastNode.children[0].properties.checked,
+ }),
+ processText: (text) => text.trimLeft(),
+ },
+ taskItemCheckbox: {
+ type: 'ignore',
+ selector: (hastNode, ancestors) =>
+ hastNode.tagName === 'input' && isTaskItem(ancestors[ancestors.length - 1]),
+ },
+ table: {
+ type: 'block',
+ selector: 'table',
+ },
+ tableRow: {
+ type: 'block',
+ selector: 'tr',
+ parent: 'table',
+ },
+ tableHeader: {
+ type: 'block',
+ selector: 'th',
+ getAttrs: getTableCellAttrs,
+ wrapInParagraph: true,
+ },
+ tableCell: {
+ type: 'block',
+ selector: 'td',
+ getAttrs: getTableCellAttrs,
+ wrapInParagraph: true,
+ },
+ ignoredTableNodes: {
+ type: 'ignore',
+ selector: (hastNode) => ['thead', 'tbody', 'tfoot'].includes(hastNode.tagName),
+ },
+ footnoteDefinition: {
+ type: 'block',
+ selector: 'footnotedefinition',
+ getAttrs: (hastNode) => hastNode.properties,
+ },
+ image: {
+ type: 'inline',
+ selector: 'img',
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',
+ hardBreak: {
+ type: 'inline',
+ selector: 'br',
+ },
+ footnoteReference: {
+ type: 'inline',
+ selector: 'footnotereference',
+ getAttrs: (hastNode) => hastNode.properties,
+ },
+ code: {
+ type: 'mark',
+ selector: 'code',
+ },
+ italic: {
+ type: 'mark',
+ selector: (hastNode) => ['em', 'i'].includes(hastNode.tagName),
+ },
+ bold: {
+ type: 'mark',
+ selector: (hastNode) => ['strong', 'b'].includes(hastNode.tagName),
+ },
+ link: {
+ type: 'mark',
+ selector: 'a',
getAttrs: (hastNode) => ({
href: hastNode.properties.href,
title: hastNode.properties.title,
}),
},
+ strike: {
+ type: 'mark',
+ selector: (hastNode) => ['strike', 's', 'del'].includes(hastNode.tagName),
+ },
};
export default () => {
@@ -77,6 +172,7 @@ export default () => {
schema,
factorySpecs,
tree,
+ wrappableTags,
source: markdown,
}),
});
diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js
index 089d30edec7..88f5192af77 100644
--- a/app/assets/javascripts/content_editor/services/serialization_helpers.js
+++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js
@@ -1,4 +1,4 @@
-import { uniq, isString } from 'lodash';
+import { uniq, isString, omit } from 'lodash';
const defaultAttrs = {
td: { colspan: 1, rowspan: 1, colwidth: null },
@@ -12,22 +12,6 @@ const ignoreAttrs = {
const tableMap = new WeakMap();
-// Source taken from
-// prosemirror-markdown/src/to_markdown.js
-export function isPlainURL(link, parent, index, side) {
- if (link.attrs.title || !/^\w+:/.test(link.attrs.href)) return false;
- const content = parent.child(index + (side < 0 ? -1 : 0));
- if (
- !content.isText ||
- content.text !== link.attrs.href ||
- content.marks[content.marks.length - 1] !== link
- )
- return false;
- if (index === (side < 0 ? 1 : parent.childCount - 1)) return true;
- const next = parent.child(index + (side < 0 ? -2 : 1));
- return !link.isInSet(next.marks);
-}
-
function containsOnlyText(node) {
if (node.childCount === 1) {
const child = node.child(0);
@@ -219,7 +203,7 @@ function renderTableRowAsHTML(state, node) {
node.forEach((cell, _, i) => {
const tag = cell.type.name === 'tableHeader' ? 'th' : 'td';
- renderTagOpen(state, tag, cell.attrs);
+ renderTagOpen(state, tag, omit(cell.attrs, 'sourceMapKey', 'sourceMarkdown'));
if (!containsParagraphWithOnlyText(cell)) {
state.closeBlock(node);
@@ -272,19 +256,6 @@ export function renderHTMLNode(tagName, forceRenderContentInline = false) {
};
}
-export function renderOrderedList(state, node) {
- const { parens } = node.attrs;
- const start = node.attrs.start || 1;
- const maxW = String(start + node.childCount - 1).length;
- const space = state.repeat(' ', maxW + 2);
- const delimiter = parens ? ')' : '.';
-
- state.renderList(node, space, (i) => {
- const nStr = String(start + i);
- return `${state.repeat(' ', maxW - nStr.length) + nStr}${delimiter} `;
- });
-}
-
export function renderTableCell(state, node) {
if (!isInBlockTable(node) || containsParagraphWithOnlyText(node)) {
state.renderInline(node.child(0));
@@ -364,7 +335,72 @@ export function preserveUnchanged(render) {
};
}
-const generateBoldTags = (open = true) => {
+/**
+ * We extracted this function from
+ * https://github.com/ProseMirror/prosemirror-markdown/blob/master/src/to_markdown.ts#L350.
+ *
+ * We need to overwrite this function because we don’t want to wrap the list item nodes
+ * with the bullet delimiter when the list item node hasn’t changed
+ */
+const renderList = (state, node, delim, firstDelim) => {
+ if (state.closed && state.closed.type === node.type) state.flushClose(3);
+ else if (state.inTightList) state.flushClose(1);
+
+ const isTight =
+ typeof node.attrs.tight !== 'undefined' ? node.attrs.tight : state.options.tightLists;
+ const prevTight = state.inTightList;
+
+ state.inTightList = isTight;
+
+ node.forEach((child, _, i) => {
+ const same = state.options.changeTracker.get(child);
+
+ if (i && isTight) {
+ state.flushClose(1);
+ }
+
+ if (same) {
+ // Avoid wrapping list item when node hasn’t changed
+ state.render(child, node, i);
+ } else {
+ state.wrapBlock(delim, firstDelim(i), node, () => state.render(child, node, i));
+ }
+ });
+
+ state.inTightList = prevTight;
+};
+
+export const renderBulletList = (state, node) => {
+ const { sourceMarkdown, bullet: bulletAttr } = node.attrs;
+ const bullet = /^(\*|\+|-)\s/.exec(sourceMarkdown)?.[1] || bulletAttr || '*';
+
+ renderList(state, node, ' ', () => `${bullet} `);
+};
+
+export function renderOrderedList(state, node) {
+ const { sourceMarkdown } = node.attrs;
+ let start;
+ let delimiter;
+
+ if (sourceMarkdown) {
+ const match = /^(\d+)(\)|\.)/.exec(sourceMarkdown);
+ start = parseInt(match[1], 10) || 1;
+ [, , delimiter] = match;
+ } else {
+ start = node.attrs.start || 1;
+ delimiter = node.attrs.parens ? ')' : '.';
+ }
+
+ const maxW = String(start + node.childCount - 1).length;
+ const space = state.repeat(' ', maxW + 2);
+
+ renderList(state, node, space, (i) => {
+ const nStr = String(start + i);
+ return `${state.repeat(' ', maxW - nStr.length) + nStr}${delimiter} `;
+ });
+}
+
+const generateBoldTags = (wrapTagName = openTag) => {
return (_, mark) => {
const type = /^(\*\*|__|<strong|<b).*/.exec(mark.attrs.sourceMarkdown)?.[1];
@@ -375,7 +411,7 @@ const generateBoldTags = (open = true) => {
// eslint-disable-next-line @gitlab/require-i18n-strings
case '<strong':
case '<b':
- return (open ? openTag : closeTag)(type.substring(1));
+ return wrapTagName(type.substring(1));
default:
return '**';
}
@@ -384,12 +420,12 @@ const generateBoldTags = (open = true) => {
export const bold = {
open: generateBoldTags(),
- close: generateBoldTags(false),
+ close: generateBoldTags(closeTag),
mixable: true,
expelEnclosingWhitespace: true,
};
-const generateItalicTag = (open = true) => {
+const generateItalicTag = (wrapTagName = openTag) => {
return (_, mark) => {
const type = /^(\*|_|<em|<i).*/.exec(mark.attrs.sourceMarkdown)?.[1];
@@ -400,7 +436,7 @@ const generateItalicTag = (open = true) => {
// eslint-disable-next-line @gitlab/require-i18n-strings
case '<em':
case '<i':
- return (open ? openTag : closeTag)(type.substring(1));
+ return wrapTagName(type.substring(1));
default:
return '_';
}
@@ -409,17 +445,17 @@ const generateItalicTag = (open = true) => {
export const italic = {
open: generateItalicTag(),
- close: generateItalicTag(false),
+ close: generateItalicTag(closeTag),
mixable: true,
expelEnclosingWhitespace: true,
};
-const generateCodeTag = (open = true) => {
+const generateCodeTag = (wrapTagName = openTag) => {
return (_, mark) => {
const type = /^(`|<code).*/.exec(mark.attrs.sourceMarkdown)?.[1];
if (type === '<code') {
- return (open ? openTag : closeTag)(type.substring(1));
+ return wrapTagName(type.substring(1));
}
return '`';
@@ -428,7 +464,7 @@ const generateCodeTag = (open = true) => {
export const code = {
open: generateCodeTag(),
- close: generateCodeTag(false),
+ close: generateCodeTag(closeTag),
mixable: true,
expelEnclosingWhitespace: true,
};
@@ -446,10 +482,79 @@ const linkType = (sourceMarkdown) => {
return LINK_HTML;
};
+const removeUrlProtocol = (url) => url.replace(/^\w+:\/?\/?/, '');
+
+const normalizeUrl = (url) => decodeURIComponent(removeUrlProtocol(url));
+
+/**
+ * Validates that the provided URL is well-formed
+ *
+ * @param {String} url
+ * @returns Returns true when the browser’s URL constructor
+ * can successfully parse the URL string
+ */
+const isValidUrl = (url) => {
+ try {
+ return new URL(url) && true;
+ } catch {
+ return false;
+ }
+};
+
+const findChildWithMark = (mark, parent) => {
+ let child;
+ let offset;
+ let index;
+
+ parent.forEach((_child, _offset, _index) => {
+ if (mark.isInSet(_child.marks)) {
+ child = _child;
+ offset = _offset;
+ index = _index;
+ }
+ });
+
+ return child ? { child, offset, index } : null;
+};
+
+/**
+ * This function detects whether a link should be serialized
+ * as an autolink.
+ *
+ * See https://github.github.com/gfm/#autolinks-extension-
+ * to understand the parsing rules of autolinks.
+ * */
+const isAutoLink = (linkMark, parent) => {
+ const { title, href } = linkMark.attrs;
+
+ if (title || !/^\w+:/.test(href)) {
+ return false;
+ }
+
+ const { child } = findChildWithMark(linkMark, parent);
+
+ if (
+ !child ||
+ !child.isText ||
+ !isValidUrl(href) ||
+ normalizeUrl(child.text) !== normalizeUrl(href)
+ ) {
+ return false;
+ }
+
+ return true;
+};
+
+/**
+ * Returns true if the user used brackets to the define
+ * the autolink in the original markdown source
+ */
+const isBracketAutoLink = (sourceMarkdown) => /^<.+?>$/.test(sourceMarkdown);
+
export const link = {
- open(state, mark, parent, index) {
- if (isPlainURL(mark, parent, index, 1)) {
- return '<';
+ open(state, mark, parent) {
+ if (isAutoLink(mark, parent)) {
+ return isBracketAutoLink(mark.attrs.sourceMarkdown) ? '<' : '';
}
const { canonicalSrc, href, title, sourceMarkdown } = mark.attrs;
@@ -466,9 +571,9 @@ export const link = {
return openTag('a', attrs);
},
- close(state, mark, parent, index) {
- if (isPlainURL(mark, parent, index, -1)) {
- return '>';
+ close(state, mark, parent) {
+ if (isAutoLink(mark, parent)) {
+ return isBracketAutoLink(mark.attrs.sourceMarkdown) ? '>' : '';
}
const { canonicalSrc, href, title, sourceMarkdown } = mark.attrs;
@@ -480,3 +585,28 @@ export const link = {
return `](${state.esc(canonicalSrc || href)}${title ? ` ${state.quote(title)}` : ''})`;
},
};
+
+const generateStrikeTag = (wrapTagName = openTag) => {
+ return (_, mark) => {
+ const type = /^(~~|<del|<strike|<s).*/.exec(mark.attrs.sourceMarkdown)?.[1];
+
+ switch (type) {
+ case '~~':
+ return type;
+ /* eslint-disable @gitlab/require-i18n-strings */
+ case '<del':
+ case '<strike':
+ case '<s':
+ return wrapTagName(type.substring(1));
+ default:
+ return '~~';
+ }
+ };
+};
+
+export const strike = {
+ open: generateStrikeTag(),
+ close: generateStrikeTag(closeTag),
+ mixable: true,
+ expelEnclosingWhitespace: true,
+};