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>2021-09-20 16:18:24 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2021-09-20 16:18:24 +0300
commit0653e08efd039a5905f3fa4f6e9cef9f5d2f799c (patch)
tree4dcc884cf6d81db44adae4aa99f8ec1233a41f55 /app/assets/javascripts/content_editor
parent744144d28e3e7fddc117924fef88de5d9674fe4c (diff)
Add latest changes from gitlab-org/gitlab@14-3-stable-eev14.3.0-rc42
Diffstat (limited to 'app/assets/javascripts/content_editor')
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor.vue6
-rw-r--r--app/assets/javascripts/content_editor/components/formatting_bubble_menu.vue6
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/image.vue1
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue142
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/table_cell_body.vue23
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/table_cell_header.vue23
-rw-r--r--app/assets/javascripts/content_editor/constants.js4
-rw-r--r--app/assets/javascripts/content_editor/content_editor.stories.js27
-rw-r--r--app/assets/javascripts/content_editor/extensions/audio.js9
-rw-r--r--app/assets/javascripts/content_editor/extensions/blockquote.js34
-rw-r--r--app/assets/javascripts/content_editor/extensions/bullet_list.js20
-rw-r--r--app/assets/javascripts/content_editor/extensions/code_block_highlight.js6
-rw-r--r--app/assets/javascripts/content_editor/extensions/description_item.js49
-rw-r--r--app/assets/javascripts/content_editor/extensions/description_list.js23
-rw-r--r--app/assets/javascripts/content_editor/extensions/division.js17
-rw-r--r--app/assets/javascripts/content_editor/extensions/emoji.js18
-rw-r--r--app/assets/javascripts/content_editor/extensions/figure.js16
-rw-r--r--app/assets/javascripts/content_editor/extensions/figure_caption.js16
-rw-r--r--app/assets/javascripts/content_editor/extensions/html_marks.js66
-rw-r--r--app/assets/javascripts/content_editor/extensions/image.js23
-rw-r--r--app/assets/javascripts/content_editor/extensions/inline_diff.js6
-rw-r--r--app/assets/javascripts/content_editor/extensions/link.js16
-rw-r--r--app/assets/javascripts/content_editor/extensions/ordered_list.js16
-rw-r--r--app/assets/javascripts/content_editor/extensions/playable.js66
-rw-r--r--app/assets/javascripts/content_editor/extensions/reference.js41
-rw-r--r--app/assets/javascripts/content_editor/extensions/subscript.js10
-rw-r--r--app/assets/javascripts/content_editor/extensions/superscript.js10
-rw-r--r--app/assets/javascripts/content_editor/extensions/table_cell.js9
-rw-r--r--app/assets/javascripts/content_editor/extensions/table_header.js8
-rw-r--r--app/assets/javascripts/content_editor/extensions/task_item.js6
-rw-r--r--app/assets/javascripts/content_editor/extensions/task_list.js28
-rw-r--r--app/assets/javascripts/content_editor/extensions/video.js10
-rw-r--r--app/assets/javascripts/content_editor/services/create_content_editor.js16
-rw-r--r--app/assets/javascripts/content_editor/services/feature_flags.js3
-rw-r--r--app/assets/javascripts/content_editor/services/mark_utils.js17
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_serializer.js163
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_sourcemap.js40
-rw-r--r--app/assets/javascripts/content_editor/services/serialization_helpers.js345
38 files changed, 1164 insertions, 175 deletions
diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue
index a372233e543..02ab34447ca 100644
--- a/app/assets/javascripts/content_editor/components/content_editor.vue
+++ b/app/assets/javascripts/content_editor/components/content_editor.vue
@@ -100,11 +100,13 @@ export default {
:class="{ 'is-focused': focused }"
>
<top-toolbar ref="toolbar" class="gl-mb-4" />
- <formatting-bubble-menu />
<div v-if="isLoadingContent" class="gl-w-full gl-display-flex gl-justify-content-center">
<gl-loading-icon size="sm" />
</div>
- <tiptap-editor-content v-else class="md" :editor="contentEditor.tiptapEditor" />
+ <template v-else>
+ <formatting-bubble-menu />
+ <tiptap-editor-content class="md" :editor="contentEditor.tiptapEditor" />
+ </template>
</div>
</div>
</content-editor-provider>
diff --git a/app/assets/javascripts/content_editor/components/formatting_bubble_menu.vue b/app/assets/javascripts/content_editor/components/formatting_bubble_menu.vue
index 6c00480b87e..14a553ff30b 100644
--- a/app/assets/javascripts/content_editor/components/formatting_bubble_menu.vue
+++ b/app/assets/javascripts/content_editor/components/formatting_bubble_menu.vue
@@ -20,7 +20,11 @@ export default {
};
</script>
<template>
- <bubble-menu class="gl-shadow gl-rounded-base" :editor="tiptapEditor">
+ <bubble-menu
+ data-testid="formatting-bubble-menu"
+ class="gl-shadow gl-rounded-base"
+ :editor="tiptapEditor"
+ >
<gl-button-group>
<toolbar-button
data-testid="bold"
diff --git a/app/assets/javascripts/content_editor/components/wrappers/image.vue b/app/assets/javascripts/content_editor/components/wrappers/image.vue
index 3762324a431..5b81e5fddcc 100644
--- a/app/assets/javascripts/content_editor/components/wrappers/image.vue
+++ b/app/assets/javascripts/content_editor/components/wrappers/image.vue
@@ -22,6 +22,7 @@ export default {
<img
data-testid="image"
class="gl-max-w-full gl-h-auto"
+ :title="node.attrs.title"
:class="{ 'gl-opacity-5': node.attrs.uploading }"
:src="node.attrs.src"
/>
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
new file mode 100644
index 00000000000..c44e8145982
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue
@@ -0,0 +1,142 @@
+<script>
+import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui';
+import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2';
+import { selectedRect as getSelectedRect } from 'prosemirror-tables';
+import { __ } from '~/locale';
+
+const TABLE_CELL_HEADER = 'th';
+const TABLE_CELL_BODY = 'td';
+
+export default {
+ name: 'TableCellBaseWrapper',
+ components: {
+ NodeViewWrapper,
+ NodeViewContent,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
+ },
+ props: {
+ cellType: {
+ type: String,
+ validator: (type) => [TABLE_CELL_HEADER, TABLE_CELL_BODY].includes(type),
+ required: true,
+ },
+ editor: {
+ type: Object,
+ required: true,
+ },
+ getPos: {
+ type: Function,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ displayActionsDropdown: false,
+ preventHide: true,
+ selectedRect: null,
+ };
+ },
+ computed: {
+ totalRows() {
+ return this.selectedRect?.map.height;
+ },
+ totalCols() {
+ return this.selectedRect?.map.width;
+ },
+ isTableBodyCell() {
+ return this.cellType === TABLE_CELL_BODY;
+ },
+ },
+ mounted() {
+ this.editor.on('selectionUpdate', this.handleSelectionUpdate);
+ this.handleSelectionUpdate();
+ },
+ beforeDestroy() {
+ this.editor.off('selectionUpdate', this.handleSelectionUpdate);
+ },
+ methods: {
+ handleSelectionUpdate() {
+ const { state } = this.editor;
+ const { $cursor } = state.selection;
+
+ this.displayActionsDropdown = $cursor?.pos - $cursor?.parentOffset - 1 === this.getPos();
+ if (this.displayActionsDropdown) {
+ this.selectedRect = getSelectedRect(state);
+ }
+ },
+ runCommand(command) {
+ this.editor.chain()[command]().run();
+ this.hideDropdown();
+ },
+ handleHide($event) {
+ if (this.preventHide) {
+ $event.preventDefault();
+ }
+ this.preventHide = true;
+ },
+ hideDropdown() {
+ this.preventHide = false;
+ this.$refs.dropdown?.hide();
+ },
+ },
+ i18n: {
+ insertColumnBefore: __('Insert column before'),
+ insertColumnAfter: __('Insert column after'),
+ insertRowBefore: __('Insert row before'),
+ insertRowAfter: __('Insert row after'),
+ deleteRow: __('Delete row'),
+ deleteColumn: __('Delete column'),
+ deleteTable: __('Delete table'),
+ editTableActions: __('Edit table'),
+ },
+};
+</script>
+<template>
+ <node-view-wrapper
+ class="gl-relative gl-padding-5 gl-min-w-10"
+ :as="cellType"
+ @click="hideDropdown"
+ >
+ <span v-if="displayActionsDropdown" class="gl-absolute gl-right-0 gl-top-0">
+ <gl-dropdown
+ ref="dropdown"
+ dropup
+ icon="chevron-down"
+ size="small"
+ category="tertiary"
+ boundary="viewport"
+ no-caret
+ text-sr-only
+ :text="$options.i18n.editTableActions"
+ :popper-opts="{ positionFixed: true }"
+ @hide="handleHide($event)"
+ >
+ <gl-dropdown-item @click="runCommand('addColumnBefore')">
+ {{ $options.i18n.insertColumnBefore }}
+ </gl-dropdown-item>
+ <gl-dropdown-item @click="runCommand('addColumnAfter')">
+ {{ $options.i18n.insertColumnAfter }}
+ </gl-dropdown-item>
+ <gl-dropdown-item v-if="isTableBodyCell" @click="runCommand('addRowBefore')">
+ {{ $options.i18n.insertRowBefore }}
+ </gl-dropdown-item>
+ <gl-dropdown-item @click="runCommand('addRowAfter')">
+ {{ $options.i18n.insertRowAfter }}
+ </gl-dropdown-item>
+ <gl-dropdown-divider />
+ <gl-dropdown-item v-if="totalRows > 2 && isTableBodyCell" @click="runCommand('deleteRow')">
+ {{ $options.i18n.deleteRow }}
+ </gl-dropdown-item>
+ <gl-dropdown-item v-if="totalCols > 1" @click="runCommand('deleteColumn')">
+ {{ $options.i18n.deleteColumn }}
+ </gl-dropdown-item>
+ <gl-dropdown-item @click="runCommand('deleteTable')">
+ {{ $options.i18n.deleteTable }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </span>
+ <node-view-content />
+ </node-view-wrapper>
+</template>
diff --git a/app/assets/javascripts/content_editor/components/wrappers/table_cell_body.vue b/app/assets/javascripts/content_editor/components/wrappers/table_cell_body.vue
new file mode 100644
index 00000000000..6b4343dd5b8
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/wrappers/table_cell_body.vue
@@ -0,0 +1,23 @@
+<script>
+import TableCellBase from './table_cell_base.vue';
+
+export default {
+ name: 'TableCellBody',
+ components: {
+ TableCellBase,
+ },
+ props: {
+ editor: {
+ type: Object,
+ required: true,
+ },
+ getPos: {
+ type: Function,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <table-cell-base cell-type="td" v-bind="$props" />
+</template>
diff --git a/app/assets/javascripts/content_editor/components/wrappers/table_cell_header.vue b/app/assets/javascripts/content_editor/components/wrappers/table_cell_header.vue
new file mode 100644
index 00000000000..5f9889374f6
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/wrappers/table_cell_header.vue
@@ -0,0 +1,23 @@
+<script>
+import TableCellBase from './table_cell_base.vue';
+
+export default {
+ name: 'TableCellHeader',
+ components: {
+ TableCellBase,
+ },
+ props: {
+ editor: {
+ type: Object,
+ required: true,
+ },
+ getPos: {
+ type: Function,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <table-cell-base cell-type="th" v-bind="$props" />
+</template>
diff --git a/app/assets/javascripts/content_editor/constants.js b/app/assets/javascripts/content_editor/constants.js
index f277508f628..4af9dc8e405 100644
--- a/app/assets/javascripts/content_editor/constants.js
+++ b/app/assets/javascripts/content_editor/constants.js
@@ -45,3 +45,7 @@ export const TEXT_STYLE_DROPDOWN_ITEMS = [
export const LOADING_CONTENT_EVENT = 'loadingContent';
export const LOADING_SUCCESS_EVENT = 'loadingSuccess';
export const LOADING_ERROR_EVENT = 'loadingError';
+
+export const PARSE_HTML_PRIORITY_LOWEST = 1;
+export const PARSE_HTML_PRIORITY_DEFAULT = 50;
+export const PARSE_HTML_PRIORITY_HIGHEST = 100;
diff --git a/app/assets/javascripts/content_editor/content_editor.stories.js b/app/assets/javascripts/content_editor/content_editor.stories.js
new file mode 100644
index 00000000000..8f2ce8feb5d
--- /dev/null
+++ b/app/assets/javascripts/content_editor/content_editor.stories.js
@@ -0,0 +1,27 @@
+import { ContentEditor } from './index';
+
+export default {
+ component: ContentEditor,
+ title: 'Components/Content Editor',
+};
+
+const Template = (_, { argTypes }) => ({
+ components: { ContentEditor },
+ props: Object.keys(argTypes),
+ template: '<content-editor v-bind="$props" @initialized="loadContent" />',
+ methods: {
+ loadContent(contentEditor) {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ contentEditor.setSerializedContent('Hello content editor');
+ },
+ },
+});
+
+export const Default = Template.bind({});
+
+Default.args = {
+ renderMarkdown: () => '<p>Hello content editor</p>',
+ uploadsPath: '/uploads/',
+ serializerConfig: {},
+ extensions: [],
+};
diff --git a/app/assets/javascripts/content_editor/extensions/audio.js b/app/assets/javascripts/content_editor/extensions/audio.js
new file mode 100644
index 00000000000..25d4068c93f
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/audio.js
@@ -0,0 +1,9 @@
+import Playable from './playable';
+
+export default Playable.extend({
+ name: 'audio',
+ defaultOptions: {
+ ...Playable.options,
+ mediaType: 'audio',
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/blockquote.js b/app/assets/javascripts/content_editor/extensions/blockquote.js
index 45f53fe230b..4512ead44bc 100644
--- a/app/assets/javascripts/content_editor/extensions/blockquote.js
+++ b/app/assets/javascripts/content_editor/extensions/blockquote.js
@@ -1 +1,33 @@
-export { Blockquote as default } from '@tiptap/extension-blockquote';
+import { Blockquote } from '@tiptap/extension-blockquote';
+import { wrappingInputRule } from 'prosemirror-inputrules';
+import { getParents } from '~/lib/utils/dom_utils';
+import { getMarkdownSource } from '../services/markdown_sourcemap';
+
+export const multilineInputRegex = /^\s*>>>\s$/gm;
+
+export default Blockquote.extend({
+ addAttributes() {
+ return {
+ ...this.parent?.(),
+
+ multiline: {
+ default: false,
+ parseHTML: (element) => {
+ const source = getMarkdownSource(element);
+ const parentsIncludeBlockquote = getParents(element).some(
+ (p) => p.nodeName.toLowerCase() === 'blockquote',
+ );
+
+ return source && !source.startsWith('>') && !parentsIncludeBlockquote;
+ },
+ },
+ };
+ },
+
+ addInputRules() {
+ return [
+ ...this.parent?.(),
+ wrappingInputRule(multilineInputRegex, this.type, () => ({ multiline: true })),
+ ];
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/bullet_list.js b/app/assets/javascripts/content_editor/extensions/bullet_list.js
index 01ead571fe1..8d0faf7a9fe 100644
--- a/app/assets/javascripts/content_editor/extensions/bullet_list.js
+++ b/app/assets/javascripts/content_editor/extensions/bullet_list.js
@@ -1 +1,19 @@
-export { BulletList as default } from '@tiptap/extension-bullet-list';
+import { BulletList } from '@tiptap/extension-bullet-list';
+import { getMarkdownSource } from '../services/markdown_sourcemap';
+
+export default BulletList.extend({
+ addAttributes() {
+ return {
+ ...this.parent?.(),
+
+ bullet: {
+ default: '*',
+ parseHTML(element) {
+ const bullet = getMarkdownSource(element)?.charAt(0);
+
+ return '*+-'.includes(bullet) ? bullet : '*';
+ },
+ },
+ };
+ },
+});
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 c6d32fb8547..25f5837d2a6 100644
--- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
+++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
@@ -8,11 +8,7 @@ export default CodeBlockLowlight.extend({
return {
language: {
default: null,
- parseHTML: (element) => {
- return {
- language: extractLanguage(element),
- };
- },
+ parseHTML: (element) => extractLanguage(element),
},
class: {
default: 'code highlight js-syntax-highlight',
diff --git a/app/assets/javascripts/content_editor/extensions/description_item.js b/app/assets/javascripts/content_editor/extensions/description_item.js
new file mode 100644
index 00000000000..957fdede27b
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/description_item.js
@@ -0,0 +1,49 @@
+import { Node, mergeAttributes } from '@tiptap/core';
+
+export default Node.create({
+ name: 'descriptionItem',
+ content: 'block+',
+ defining: true,
+
+ addAttributes() {
+ return {
+ isTerm: {
+ default: true,
+ parseHTML: (element) => element.tagName.toLowerCase() === 'dt',
+ },
+ };
+ },
+
+ parseHTML() {
+ return [{ tag: 'dt' }, { tag: 'dd' }];
+ },
+
+ renderHTML({ HTMLAttributes: { isTerm, ...HTMLAttributes } }) {
+ return [
+ 'li',
+ mergeAttributes(HTMLAttributes, { class: isTerm ? 'dl-term' : 'dl-description' }),
+ 0,
+ ];
+ },
+
+ addKeyboardShortcuts() {
+ return {
+ Enter: () => {
+ return this.editor.commands.splitListItem('descriptionItem');
+ },
+ Tab: () => {
+ const { isTerm } = this.editor.getAttributes('descriptionItem');
+ if (isTerm)
+ return this.editor.commands.updateAttributes('descriptionItem', { isTerm: !isTerm });
+
+ return false;
+ },
+ 'Shift-Tab': () => {
+ const { isTerm } = this.editor.getAttributes('descriptionItem');
+ if (isTerm) return this.editor.commands.liftListItem('descriptionItem');
+
+ return this.editor.commands.updateAttributes('descriptionItem', { isTerm: true });
+ },
+ };
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/description_list.js b/app/assets/javascripts/content_editor/extensions/description_list.js
new file mode 100644
index 00000000000..a516dfad2b8
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/description_list.js
@@ -0,0 +1,23 @@
+import { Node, mergeAttributes } from '@tiptap/core';
+import { wrappingInputRule } from 'prosemirror-inputrules';
+
+export const inputRegex = /^\s*(<dl>)$/;
+
+export default Node.create({
+ name: 'descriptionList',
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ group: 'block list',
+ content: 'descriptionItem+',
+
+ parseHTML() {
+ return [{ tag: 'dl' }];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['ul', mergeAttributes(HTMLAttributes, { class: 'dl-content' }), 0];
+ },
+
+ addInputRules() {
+ return [wrappingInputRule(inputRegex, this.type)];
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/division.js b/app/assets/javascripts/content_editor/extensions/division.js
new file mode 100644
index 00000000000..c70d1700941
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/division.js
@@ -0,0 +1,17 @@
+import { Node } from '@tiptap/core';
+import { PARSE_HTML_PRIORITY_LOWEST } from '../constants';
+
+export default Node.create({
+ name: 'division',
+ content: 'block*',
+ group: 'block',
+ defining: true,
+
+ parseHTML() {
+ return [{ tag: 'div', priority: PARSE_HTML_PRIORITY_LOWEST }];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['div', HTMLAttributes, 0];
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/emoji.js b/app/assets/javascripts/content_editor/extensions/emoji.js
index d88b9f92215..de608c3aaa2 100644
--- a/app/assets/javascripts/content_editor/extensions/emoji.js
+++ b/app/assets/javascripts/content_editor/extensions/emoji.js
@@ -17,30 +17,18 @@ export default Node.create({
return {
moji: {
default: null,
- parseHTML: (element) => {
- return {
- moji: element.textContent,
- };
- },
+ parseHTML: (element) => element.textContent,
},
name: {
default: null,
- parseHTML: (element) => {
- return {
- name: element.dataset.name,
- };
- },
+ parseHTML: (element) => element.dataset.name,
},
title: {
default: null,
},
unicodeVersion: {
default: '6.0',
- parseHTML: (element) => {
- return {
- unicodeVersion: element.dataset.unicodeVersion,
- };
- },
+ parseHTML: (element) => element.dataset.unicodeVersion,
},
};
},
diff --git a/app/assets/javascripts/content_editor/extensions/figure.js b/app/assets/javascripts/content_editor/extensions/figure.js
new file mode 100644
index 00000000000..b2076894412
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/figure.js
@@ -0,0 +1,16 @@
+import { Node } from '@tiptap/core';
+
+export default Node.create({
+ name: 'figure',
+ content: 'block+',
+ group: 'block',
+ defining: true,
+
+ parseHTML() {
+ return [{ tag: 'figure' }];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['figure', HTMLAttributes, 0];
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/figure_caption.js b/app/assets/javascripts/content_editor/extensions/figure_caption.js
new file mode 100644
index 00000000000..ffd1b474f03
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/figure_caption.js
@@ -0,0 +1,16 @@
+import { Node } from '@tiptap/core';
+
+export default Node.create({
+ name: 'figureCaption',
+ content: 'inline*',
+ group: 'block',
+ defining: true,
+
+ parseHTML() {
+ return [{ tag: 'figcaption' }];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['figcaption', HTMLAttributes, 0];
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/html_marks.js b/app/assets/javascripts/content_editor/extensions/html_marks.js
new file mode 100644
index 00000000000..54adb9efa0c
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/html_marks.js
@@ -0,0 +1,66 @@
+import { Mark, mergeAttributes, markInputRule } from '@tiptap/core';
+import { PARSE_HTML_PRIORITY_LOWEST } from '../constants';
+import { markInputRegex, extractMarkAttributesFromMatch } from '../services/mark_utils';
+
+const marks = [
+ 'ins',
+ 'abbr',
+ 'bdo',
+ 'cite',
+ 'dfn',
+ 'mark',
+ 'small',
+ 'span',
+ 'time',
+ 'kbd',
+ 'q',
+ 'samp',
+ 'var',
+ 'ruby',
+ 'rp',
+ 'rt',
+];
+
+const attrs = {
+ time: ['datetime'],
+ abbr: ['title'],
+ span: ['dir'],
+ bdo: ['dir'],
+};
+
+export default marks.map((name) =>
+ Mark.create({
+ name,
+
+ inclusive: false,
+
+ defaultOptions: {
+ HTMLAttributes: {},
+ },
+
+ addAttributes() {
+ return (attrs[name] || []).reduce(
+ (acc, attr) => ({
+ ...acc,
+ [attr]: {
+ default: null,
+ parseHTML: (element) => element.getAttribute(attr),
+ },
+ }),
+ {},
+ );
+ },
+
+ parseHTML() {
+ return [{ tag: name, priority: PARSE_HTML_PRIORITY_LOWEST }];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return [name, mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
+ },
+
+ addInputRules() {
+ return [markInputRule(markInputRegex(name), this.type, extractMarkAttributesFromMatch)];
+ },
+ }),
+);
diff --git a/app/assets/javascripts/content_editor/extensions/image.js b/app/assets/javascripts/content_editor/extensions/image.js
index c9e8dfa4ad9..837fab0585f 100644
--- a/app/assets/javascripts/content_editor/extensions/image.js
+++ b/app/assets/javascripts/content_editor/extensions/image.js
@@ -1,6 +1,7 @@
import { Image } from '@tiptap/extension-image';
import { VueNodeViewRenderer } from '@tiptap/vue-2';
import ImageWrapper from '../components/wrappers/image.vue';
+import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
const resolveImageEl = (element) =>
element.nodeName === 'IMG' ? element : element.querySelector('img');
@@ -27,27 +28,27 @@ export default Image.extend({
parseHTML: (element) => {
const img = resolveImageEl(element);
- return {
- src: img.dataset.src || img.getAttribute('src'),
- };
+ return img.dataset.src || img.getAttribute('src');
},
},
canonicalSrc: {
default: null,
+ parseHTML: (element) => element.dataset.canonicalSrc,
+ },
+ alt: {
+ default: null,
parseHTML: (element) => {
- return {
- canonicalSrc: element.dataset.canonicalSrc,
- };
+ const img = resolveImageEl(element);
+
+ return img.getAttribute('alt');
},
},
- alt: {
+ title: {
default: null,
parseHTML: (element) => {
const img = resolveImageEl(element);
- return {
- alt: img.getAttribute('alt'),
- };
+ return img.getAttribute('title');
},
},
};
@@ -55,7 +56,7 @@ export default Image.extend({
parseHTML() {
return [
{
- priority: 100,
+ priority: PARSE_HTML_PRIORITY_HIGHEST,
tag: 'a.no-attachment-icon',
},
{
diff --git a/app/assets/javascripts/content_editor/extensions/inline_diff.js b/app/assets/javascripts/content_editor/extensions/inline_diff.js
index 9471d324764..3bd328958df 100644
--- a/app/assets/javascripts/content_editor/extensions/inline_diff.js
+++ b/app/assets/javascripts/content_editor/extensions/inline_diff.js
@@ -14,11 +14,7 @@ export default Mark.create({
return {
type: {
default: 'addition',
- parseHTML: (element) => {
- return {
- type: element.classList.contains('deletion') ? 'deletion' : 'addition',
- };
- },
+ parseHTML: (element) => (element.classList.contains('deletion') ? 'deletion' : 'addition'),
},
};
},
diff --git a/app/assets/javascripts/content_editor/extensions/link.js b/app/assets/javascripts/content_editor/extensions/link.js
index 53104fe07a3..fc0f38e6935 100644
--- a/app/assets/javascripts/content_editor/extensions/link.js
+++ b/app/assets/javascripts/content_editor/extensions/link.js
@@ -36,19 +36,15 @@ export default Link.extend({
...this.parent?.(),
href: {
default: null,
- parseHTML: (element) => {
- return {
- href: element.getAttribute('href'),
- };
- },
+ parseHTML: (element) => element.getAttribute('href'),
+ },
+ title: {
+ title: null,
+ parseHTML: (element) => element.getAttribute('title'),
},
canonicalSrc: {
default: null,
- parseHTML: (element) => {
- return {
- canonicalSrc: element.dataset.canonicalSrc,
- };
- },
+ parseHTML: (element) => element.dataset.canonicalSrc,
},
};
},
diff --git a/app/assets/javascripts/content_editor/extensions/ordered_list.js b/app/assets/javascripts/content_editor/extensions/ordered_list.js
index 9a79187d9c1..57d5bd6ebf8 100644
--- a/app/assets/javascripts/content_editor/extensions/ordered_list.js
+++ b/app/assets/javascripts/content_editor/extensions/ordered_list.js
@@ -1 +1,15 @@
-export { OrderedList as default } from '@tiptap/extension-ordered-list';
+import { OrderedList } from '@tiptap/extension-ordered-list';
+import { getMarkdownSource } from '../services/markdown_sourcemap';
+
+export default OrderedList.extend({
+ addAttributes() {
+ return {
+ ...this.parent?.(),
+
+ parens: {
+ default: false,
+ parseHTML: (element) => /^[0-9]+\)/.test(getMarkdownSource(element)),
+ },
+ };
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/playable.js b/app/assets/javascripts/content_editor/extensions/playable.js
new file mode 100644
index 00000000000..0062bc563db
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/playable.js
@@ -0,0 +1,66 @@
+/* eslint-disable @gitlab/require-i18n-strings */
+
+import { Node } from '@tiptap/core';
+
+const queryPlayableElement = (element, mediaType) => element.querySelector(mediaType);
+
+export default Node.create({
+ group: 'inline',
+ inline: true,
+ draggable: true,
+
+ addAttributes() {
+ return {
+ src: {
+ default: null,
+ parseHTML: (element) => {
+ const playable = queryPlayableElement(element, this.options.mediaType);
+
+ return playable.src;
+ },
+ },
+ canonicalSrc: {
+ default: null,
+ parseHTML: (element) => {
+ const playable = queryPlayableElement(element, this.options.mediaType);
+
+ return playable.dataset.canonicalSrc;
+ },
+ },
+ alt: {
+ default: null,
+ parseHTML: (element) => {
+ const playable = queryPlayableElement(element, this.options.mediaType);
+
+ return playable.dataset.title;
+ },
+ },
+ };
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: `.${this.options.mediaType}-container`,
+ },
+ ];
+ },
+
+ renderHTML({ node }) {
+ return [
+ 'span',
+ { class: `media-container ${this.options.mediaType}-container` },
+ [
+ this.options.mediaType,
+ {
+ src: node.attrs.src,
+ controls: true,
+ 'data-setup': '{}',
+ 'data-title': node.attrs.alt,
+ ...this.extraElementAttrs,
+ },
+ ],
+ ['a', { href: node.attrs.src }, node.attrs.alt],
+ ];
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/reference.js b/app/assets/javascripts/content_editor/extensions/reference.js
index 5f4484af9c8..5e459e65de2 100644
--- a/app/assets/javascripts/content_editor/extensions/reference.js
+++ b/app/assets/javascripts/content_editor/extensions/reference.js
@@ -1,4 +1,10 @@
import { Node } from '@tiptap/core';
+import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
+
+const getAnchor = (element) => {
+ if (element.nodeName === 'A') return element;
+ return element.querySelector('a');
+};
export default Node.create({
name: 'reference',
@@ -13,43 +19,23 @@ export default Node.create({
return {
className: {
default: null,
- parseHTML: (element) => {
- return {
- className: element.className,
- };
- },
+ parseHTML: (element) => getAnchor(element).className,
},
referenceType: {
default: null,
- parseHTML: (element) => {
- return {
- referenceType: element.dataset.referenceType,
- };
- },
+ parseHTML: (element) => getAnchor(element).dataset.referenceType,
},
originalText: {
default: null,
- parseHTML: (element) => {
- return {
- originalText: element.dataset.original,
- };
- },
+ parseHTML: (element) => getAnchor(element).dataset.original,
},
href: {
default: null,
- parseHTML: (element) => {
- return {
- href: element.getAttribute('href'),
- };
- },
+ parseHTML: (element) => getAnchor(element).getAttribute('href'),
},
text: {
default: null,
- parseHTML: (element) => {
- return {
- text: element.textContent,
- };
- },
+ parseHTML: (element) => getAnchor(element).textContent,
},
};
},
@@ -58,7 +44,10 @@ export default Node.create({
return [
{
tag: 'a.gfm:not([data-link=true])',
- priority: 51,
+ priority: PARSE_HTML_PRIORITY_HIGHEST,
+ },
+ {
+ tag: 'span.gl-label',
},
];
},
diff --git a/app/assets/javascripts/content_editor/extensions/subscript.js b/app/assets/javascripts/content_editor/extensions/subscript.js
index 4bf89796efe..d0766f42308 100644
--- a/app/assets/javascripts/content_editor/extensions/subscript.js
+++ b/app/assets/javascripts/content_editor/extensions/subscript.js
@@ -1 +1,9 @@
-export { Subscript as default } from '@tiptap/extension-subscript';
+import { markInputRule } from '@tiptap/core';
+import { Subscript } from '@tiptap/extension-subscript';
+import { markInputRegex, extractMarkAttributesFromMatch } from '../services/mark_utils';
+
+export default Subscript.extend({
+ addInputRules() {
+ return [markInputRule(markInputRegex('sub'), this.type, extractMarkAttributesFromMatch)];
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/superscript.js b/app/assets/javascripts/content_editor/extensions/superscript.js
index 3eb7d86d90d..6cd814977ea 100644
--- a/app/assets/javascripts/content_editor/extensions/superscript.js
+++ b/app/assets/javascripts/content_editor/extensions/superscript.js
@@ -1 +1,9 @@
-export { Superscript as default } from '@tiptap/extension-superscript';
+import { markInputRule } from '@tiptap/core';
+import { Superscript } from '@tiptap/extension-superscript';
+import { markInputRegex, extractMarkAttributesFromMatch } from '../services/mark_utils';
+
+export default Superscript.extend({
+ addInputRules() {
+ return [markInputRule(markInputRegex('sup'), this.type, extractMarkAttributesFromMatch)];
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/table_cell.js b/app/assets/javascripts/content_editor/extensions/table_cell.js
index 5bdc39231a1..befc33e669f 100644
--- a/app/assets/javascripts/content_editor/extensions/table_cell.js
+++ b/app/assets/javascripts/content_editor/extensions/table_cell.js
@@ -1,5 +1,12 @@
import { TableCell } from '@tiptap/extension-table-cell';
+import { VueNodeViewRenderer } from '@tiptap/vue-2';
+import TableCellBodyWrapper from '../components/wrappers/table_cell_body.vue';
+import { isBlockTablesFeatureEnabled } from '../services/feature_flags';
export default TableCell.extend({
- content: 'inline*',
+ content: isBlockTablesFeatureEnabled() ? 'block+' : 'inline*',
+
+ addNodeView() {
+ return VueNodeViewRenderer(TableCellBodyWrapper);
+ },
});
diff --git a/app/assets/javascripts/content_editor/extensions/table_header.js b/app/assets/javascripts/content_editor/extensions/table_header.js
index 23509706e4b..829b06fc14b 100644
--- a/app/assets/javascripts/content_editor/extensions/table_header.js
+++ b/app/assets/javascripts/content_editor/extensions/table_header.js
@@ -1,5 +1,11 @@
import { TableHeader } from '@tiptap/extension-table-header';
+import { VueNodeViewRenderer } from '@tiptap/vue-2';
+import TableCellHeaderWrapper from '../components/wrappers/table_cell_header.vue';
+import { isBlockTablesFeatureEnabled } from '../services/feature_flags';
export default TableHeader.extend({
- content: 'inline*',
+ content: isBlockTablesFeatureEnabled() ? 'block+' : 'inline*',
+ addNodeView() {
+ return VueNodeViewRenderer(TableCellHeaderWrapper);
+ },
});
diff --git a/app/assets/javascripts/content_editor/extensions/task_item.js b/app/assets/javascripts/content_editor/extensions/task_item.js
index 6163c0e043b..9b050edcb28 100644
--- a/app/assets/javascripts/content_editor/extensions/task_item.js
+++ b/app/assets/javascripts/content_editor/extensions/task_item.js
@@ -1,4 +1,5 @@
import { TaskItem } from '@tiptap/extension-task-item';
+import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
export default TaskItem.extend({
defaultOptions: {
@@ -12,7 +13,8 @@ export default TaskItem.extend({
default: false,
parseHTML: (element) => {
const checkbox = element.querySelector('input[type=checkbox].task-list-item-checkbox');
- return { checked: checkbox?.checked };
+
+ return checkbox?.checked;
},
renderHTML: (attributes) => ({
'data-checked': attributes.checked,
@@ -26,7 +28,7 @@ export default TaskItem.extend({
return [
{
tag: 'li.task-list-item',
- priority: 100,
+ priority: PARSE_HTML_PRIORITY_HIGHEST,
},
];
},
diff --git a/app/assets/javascripts/content_editor/extensions/task_list.js b/app/assets/javascripts/content_editor/extensions/task_list.js
index b7f6c857bc7..72c6e020102 100644
--- a/app/assets/javascripts/content_editor/extensions/task_list.js
+++ b/app/assets/javascripts/content_editor/extensions/task_list.js
@@ -1,16 +1,24 @@
import { mergeAttributes } from '@tiptap/core';
import { TaskList } from '@tiptap/extension-task-list';
+import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
+import { getMarkdownSource } from '../services/markdown_sourcemap';
export default TaskList.extend({
addAttributes() {
return {
- type: {
- default: 'ul',
- parseHTML: (element) => {
- return {
- type: element.tagName.toLowerCase() === 'ol' ? 'ol' : 'ul',
- };
- },
+ numeric: {
+ default: false,
+ parseHTML: (element) => element.tagName.toLowerCase() === 'ol',
+ },
+ start: {
+ default: 1,
+ parseHTML: (element) =>
+ element.hasAttribute('start') ? parseInt(element.getAttribute('start') || '', 10) : 1,
+ },
+
+ parens: {
+ default: false,
+ parseHTML: (element) => /^[0-9]+\)/.test(getMarkdownSource(element)),
},
};
},
@@ -19,12 +27,12 @@ export default TaskList.extend({
return [
{
tag: '.task-list',
- priority: 100,
+ priority: PARSE_HTML_PRIORITY_HIGHEST,
},
];
},
- renderHTML({ HTMLAttributes: { type, ...HTMLAttributes } }) {
- return [type, mergeAttributes(HTMLAttributes, { 'data-type': 'taskList' }), 0];
+ renderHTML({ HTMLAttributes: { numeric, ...HTMLAttributes } }) {
+ return [numeric ? 'ol' : 'ul', mergeAttributes(HTMLAttributes, { 'data-type': 'taskList' }), 0];
},
});
diff --git a/app/assets/javascripts/content_editor/extensions/video.js b/app/assets/javascripts/content_editor/extensions/video.js
new file mode 100644
index 00000000000..9923b7c04cd
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/video.js
@@ -0,0 +1,10 @@
+import Playable from './playable';
+
+export default Playable.extend({
+ name: 'video',
+ defaultOptions: {
+ ...Playable.options,
+ mediaType: 'video',
+ extraElementAttrs: { width: '400' },
+ },
+});
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 8997960203a..9b2d4c9a062 100644
--- a/app/assets/javascripts/content_editor/services/create_content_editor.js
+++ b/app/assets/javascripts/content_editor/services/create_content_editor.js
@@ -2,19 +2,26 @@ import { Editor } from '@tiptap/vue-2';
import { isFunction } from 'lodash';
import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants';
import Attachment from '../extensions/attachment';
+import Audio from '../extensions/audio';
import Blockquote from '../extensions/blockquote';
import Bold from '../extensions/bold';
import BulletList from '../extensions/bullet_list';
import Code from '../extensions/code';
import CodeBlockHighlight from '../extensions/code_block_highlight';
+import DescriptionItem from '../extensions/description_item';
+import DescriptionList from '../extensions/description_list';
+import Division from '../extensions/division';
import Document from '../extensions/document';
import Dropcursor from '../extensions/dropcursor';
import Emoji from '../extensions/emoji';
+import Figure from '../extensions/figure';
+import FigureCaption from '../extensions/figure_caption';
import Gapcursor from '../extensions/gapcursor';
import HardBreak from '../extensions/hard_break';
import Heading from '../extensions/heading';
import History from '../extensions/history';
import HorizontalRule from '../extensions/horizontal_rule';
+import HTMLMarks from '../extensions/html_marks';
import Image from '../extensions/image';
import InlineDiff from '../extensions/inline_diff';
import Italic from '../extensions/italic';
@@ -34,6 +41,7 @@ import TableRow from '../extensions/table_row';
import TaskItem from '../extensions/task_item';
import TaskList from '../extensions/task_list';
import Text from '../extensions/text';
+import Video from '../extensions/video';
import { ContentEditor } from './content_editor';
import createMarkdownSerializer from './markdown_serializer';
import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts';
@@ -62,19 +70,26 @@ export const createContentEditor = ({
const builtInContentEditorExtensions = [
Attachment.configure({ uploadsPath, renderMarkdown }),
+ Audio,
Blockquote,
Bold,
BulletList,
Code,
CodeBlockHighlight,
+ DescriptionItem,
+ DescriptionList,
Document,
+ Division,
Dropcursor,
Emoji,
+ Figure,
+ FigureCaption,
Gapcursor,
HardBreak,
Heading,
History,
HorizontalRule,
+ ...HTMLMarks,
Image,
InlineDiff,
Italic,
@@ -94,6 +109,7 @@ export const createContentEditor = ({
TaskItem,
TaskList,
Text,
+ Video,
];
const allExtensions = [...builtInContentEditorExtensions, ...extensions];
diff --git a/app/assets/javascripts/content_editor/services/feature_flags.js b/app/assets/javascripts/content_editor/services/feature_flags.js
new file mode 100644
index 00000000000..5f7a4595938
--- /dev/null
+++ b/app/assets/javascripts/content_editor/services/feature_flags.js
@@ -0,0 +1,3 @@
+export function isBlockTablesFeatureEnabled() {
+ return gon.features?.contentEditorBlockTables;
+}
diff --git a/app/assets/javascripts/content_editor/services/mark_utils.js b/app/assets/javascripts/content_editor/services/mark_utils.js
new file mode 100644
index 00000000000..6ccfed7810a
--- /dev/null
+++ b/app/assets/javascripts/content_editor/services/mark_utils.js
@@ -0,0 +1,17 @@
+export const markInputRegex = (tag) =>
+ new RegExp(`(<(${tag})((?: \\w+=".+?")+)?>([^<]+)</${tag}>)$`, 'gm');
+
+export const extractMarkAttributesFromMatch = ([, , , attrsString]) => {
+ const attrRegex = /(\w+)="(.+?)"/g;
+ const attrs = {};
+
+ let key;
+ let value;
+
+ do {
+ [, key, value] = attrRegex.exec(attrsString) || [];
+ if (key) attrs[key] = value;
+ } while (key);
+
+ return attrs;
+};
diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js
index df4d31c3d7f..bc6d98511f9 100644
--- a/app/assets/javascripts/content_editor/services/markdown_serializer.js
+++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js
@@ -3,15 +3,22 @@ import {
defaultMarkdownSerializer,
} from 'prosemirror-markdown/src/to_markdown';
import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model';
+import Audio from '../extensions/audio';
import Blockquote from '../extensions/blockquote';
import Bold from '../extensions/bold';
import BulletList from '../extensions/bullet_list';
import Code from '../extensions/code';
import CodeBlockHighlight from '../extensions/code_block_highlight';
+import DescriptionItem from '../extensions/description_item';
+import DescriptionList from '../extensions/description_list';
+import Division from '../extensions/division';
import Emoji from '../extensions/emoji';
+import Figure from '../extensions/figure';
+import FigureCaption from '../extensions/figure_caption';
import HardBreak from '../extensions/hard_break';
import Heading from '../extensions/heading';
import HorizontalRule from '../extensions/horizontal_rule';
+import HTMLMarks from '../extensions/html_marks';
import Image from '../extensions/image';
import InlineDiff from '../extensions/inline_diff';
import Italic from '../extensions/italic';
@@ -30,6 +37,20 @@ import TableRow from '../extensions/table_row';
import TaskItem from '../extensions/task_item';
import TaskList from '../extensions/task_list';
import Text from '../extensions/text';
+import Video from '../extensions/video';
+import {
+ isPlainURL,
+ renderHardBreak,
+ renderTable,
+ renderTableCell,
+ renderTableRow,
+ openTag,
+ closeTag,
+ renderOrderedList,
+ renderImage,
+ renderPlayable,
+ renderHTMLNode,
+} from './serialization_helpers';
const defaultSerializerConfig = {
marks: {
@@ -48,14 +69,15 @@ const defaultSerializerConfig = {
},
},
[Link.name]: {
- open() {
- return '[';
+ open(state, mark, parent, index) {
+ return isPlainURL(mark, parent, index, 1) ? '<' : '[';
},
- close(state, mark) {
+ close(state, mark, parent, index) {
const href = mark.attrs.canonicalSrc || mark.attrs.href;
- return `](${state.esc(href)}${
- mark.attrs.title ? ` ${state.quote(mark.attrs.title)}` : ''
- })`;
+
+ return isPlainURL(mark, parent, index, -1)
+ ? '>'
+ : `](${state.esc(href)}${mark.attrs.title ? ` ${state.quote(mark.attrs.title)}` : ''})`;
},
},
[Strike.name]: {
@@ -64,9 +86,35 @@ const defaultSerializerConfig = {
mixable: true,
expelEnclosingWhitespace: true,
},
+ ...HTMLMarks.reduce(
+ (acc, { name }) => ({
+ ...acc,
+ [name]: {
+ mixable: true,
+ open(state, node) {
+ return openTag(name, node.attrs);
+ },
+ close: closeTag(name),
+ },
+ }),
+ {},
+ ),
},
+
nodes: {
- [Blockquote.name]: defaultMarkdownSerializer.nodes.blockquote,
+ [Audio.name]: renderPlayable,
+ [Blockquote.name]: (state, node) => {
+ if (node.attrs.multiline) {
+ state.write('>>>');
+ state.ensureNewLine();
+ state.renderContent(node);
+ state.ensureNewLine();
+ state.write('>>>');
+ state.closeBlock(node);
+ } else {
+ state.wrapBlock('> ', null, node, () => state.renderContent(node));
+ }
+ },
[BulletList.name]: defaultMarkdownSerializer.nodes.bullet_list,
[CodeBlockHighlight.name]: (state, node) => {
state.write(`\`\`\`${node.attrs.language || ''}\n`);
@@ -75,94 +123,47 @@ const defaultSerializerConfig = {
state.write('```');
state.closeBlock(node);
},
+ [Division.name]: renderHTMLNode('div'),
+ [DescriptionList.name]: renderHTMLNode('dl', true),
+ [DescriptionItem.name]: (state, node, parent, index) => {
+ if (index === 1) state.ensureNewLine();
+ renderHTMLNode(node.attrs.isTerm ? 'dt' : 'dd')(state, node);
+ if (index === parent.childCount - 1) state.ensureNewLine();
+ },
[Emoji.name]: (state, node) => {
const { name } = node.attrs;
state.write(`:${name}:`);
},
- [HardBreak.name]: defaultMarkdownSerializer.nodes.hard_break,
+ [Figure.name]: renderHTMLNode('figure'),
+ [FigureCaption.name]: renderHTMLNode('figcaption'),
+ [HardBreak.name]: renderHardBreak,
[Heading.name]: defaultMarkdownSerializer.nodes.heading,
[HorizontalRule.name]: defaultMarkdownSerializer.nodes.horizontal_rule,
- [Image.name]: (state, node) => {
- const { alt, canonicalSrc, src, title } = node.attrs;
- const quotedTitle = title ? ` ${state.quote(title)}` : '';
-
- state.write(`![${state.esc(alt || '')}](${state.esc(canonicalSrc || src)}${quotedTitle})`);
- },
+ [Image.name]: renderImage,
[ListItem.name]: defaultMarkdownSerializer.nodes.list_item,
- [OrderedList.name]: defaultMarkdownSerializer.nodes.ordered_list,
+ [OrderedList.name]: renderOrderedList,
[Paragraph.name]: defaultMarkdownSerializer.nodes.paragraph,
[Reference.name]: (state, node) => {
state.write(node.attrs.originalText || node.attrs.text);
},
- [Table.name]: (state, node) => {
- state.renderContent(node);
- },
- [TableCell.name]: (state, node) => {
- state.renderInline(node);
- },
- [TableHeader.name]: (state, node) => {
- state.renderInline(node);
- },
- [TableRow.name]: (state, node) => {
- const isHeaderRow = node.child(0).type.name === 'tableHeader';
-
- const renderRow = () => {
- const cellWidths = [];
-
- state.flushClose(1);
-
- state.write('| ');
- node.forEach((cell, _, i) => {
- if (i) state.write(' | ');
-
- const { length } = state.out;
- state.render(cell, node, i);
- cellWidths.push(state.out.length - length);
- });
- state.write(' |');
-
- state.closeBlock(node);
-
- return cellWidths;
- };
-
- const renderHeaderRow = (cellWidths) => {
- state.flushClose(1);
-
- state.write('|');
- node.forEach((cell, _, i) => {
- if (i) state.write('|');
-
- state.write(cell.attrs.align === 'center' ? ':' : '-');
- state.write(state.repeat('-', cellWidths[i]));
- state.write(cell.attrs.align === 'center' || cell.attrs.align === 'right' ? ':' : '-');
- });
- state.write('|');
-
- state.closeBlock(node);
- };
-
- if (isHeaderRow) {
- renderHeaderRow(renderRow());
- } else {
- renderRow();
- }
- },
+ [Table.name]: renderTable,
+ [TableCell.name]: renderTableCell,
+ [TableHeader.name]: renderTableCell,
+ [TableRow.name]: renderTableRow,
[TaskItem.name]: (state, node) => {
state.write(`[${node.attrs.checked ? 'x' : ' '}] `);
state.renderContent(node);
},
[TaskList.name]: (state, node) => {
- if (node.attrs.type === 'ul') defaultMarkdownSerializer.nodes.bullet_list(state, node);
- else defaultMarkdownSerializer.nodes.ordered_list(state, node);
+ if (node.attrs.numeric) renderOrderedList(state, node);
+ else defaultMarkdownSerializer.nodes.bullet_list(state, node);
},
[Text.name]: defaultMarkdownSerializer.nodes.text,
+ [Video.name]: renderPlayable,
},
};
-const wrapHtmlPayload = (payload) => `<div>${payload}</div>`;
-
/**
* A markdown serializer converts arbitrary Markdown content
* into a ProseMirror document and viceversa. To convert Markdown
@@ -175,7 +176,7 @@ const wrapHtmlPayload = (payload) => `<div>${payload}</div>`;
* that parses the Markdown and converts it into HTML.
* @returns a markdown serializer
*/
-export default ({ render = () => null, serializerConfig }) => ({
+export default ({ render = () => null, serializerConfig = {} } = {}) => ({
/**
* Converts a Markdown string into a ProseMirror JSONDocument based
* on a ProseMirror schema.
@@ -187,15 +188,15 @@ export default ({ render = () => null, serializerConfig }) => ({
deserialize: async ({ schema, content }) => {
const html = await render(content);
- if (!html) {
- return null;
- }
+ if (!html) return null;
const parser = new DOMParser();
- const {
- body: { firstElementChild },
- } = parser.parseFromString(wrapHtmlPayload(html), 'text/html');
- const state = ProseMirrorDOMParser.fromSchema(schema).parse(firstElementChild);
+ const { body } = parser.parseFromString(html, 'text/html');
+
+ // append original source as a comment that nodes can access
+ body.append(document.createComment(content));
+
+ const state = ProseMirrorDOMParser.fromSchema(schema).parse(body);
return state.toJSON();
},
diff --git a/app/assets/javascripts/content_editor/services/markdown_sourcemap.js b/app/assets/javascripts/content_editor/services/markdown_sourcemap.js
new file mode 100644
index 00000000000..a1199589c9b
--- /dev/null
+++ b/app/assets/javascripts/content_editor/services/markdown_sourcemap.js
@@ -0,0 +1,40 @@
+const getFullSource = (element) => {
+ const commentNode = element.ownerDocument.body.lastChild;
+
+ if (commentNode.nodeName === '#comment') {
+ return commentNode.textContent.split('\n');
+ }
+
+ return [];
+};
+
+const getRangeFromSourcePos = (sourcePos) => {
+ const [start, end] = sourcePos.split('-');
+ const [startRow, startCol] = start.split(':');
+ const [endRow, endCol] = end.split(':');
+
+ return {
+ start: { row: Number(startRow) - 1, col: Number(startCol) - 1 },
+ end: { row: Number(endRow) - 1, col: Number(endCol) - 1 },
+ };
+};
+
+export const getMarkdownSource = (element) => {
+ if (!element.dataset.sourcepos) return undefined;
+
+ const source = getFullSource(element);
+ const range = getRangeFromSourcePos(element.dataset.sourcepos);
+ let elSource = '';
+
+ for (let i = range.start.row; i <= range.end.row; i += 1) {
+ if (i === range.start.row) {
+ elSource += source[i]?.substring(range.start.col);
+ } else if (i === range.end.row) {
+ elSource += `\n${source[i]?.substring(0, range.start.col)}`;
+ } else {
+ elSource += `\n${source[i]}` || '';
+ }
+ }
+
+ return elSource.trim();
+};
diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js
new file mode 100644
index 00000000000..b2327555b45
--- /dev/null
+++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js
@@ -0,0 +1,345 @@
+import { uniq } from 'lodash';
+import { isBlockTablesFeatureEnabled } from './feature_flags';
+
+const defaultAttrs = {
+ td: { colspan: 1, rowspan: 1, colwidth: null },
+ th: { colspan: 1, rowspan: 1, colwidth: null },
+};
+
+const ignoreAttrs = {
+ dd: ['isTerm'],
+ dt: ['isTerm'],
+};
+
+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);
+ return child.isText && child.marks.length === 0;
+ }
+
+ return false;
+}
+
+function containsParagraphWithOnlyText(cell) {
+ if (cell.childCount === 1) {
+ const child = cell.child(0);
+ if (child.type.name === 'paragraph') {
+ return containsOnlyText(child);
+ }
+ }
+
+ return false;
+}
+
+function getRowsAndCells(table) {
+ const cells = [];
+ const rows = [];
+ table.descendants((n) => {
+ if (n.type.name === 'tableCell' || n.type.name === 'tableHeader') {
+ cells.push(n);
+ return false;
+ }
+
+ if (n.type.name === 'tableRow') {
+ rows.push(n);
+ }
+
+ return true;
+ });
+ return { rows, cells };
+}
+
+function getChildren(node) {
+ const children = [];
+ for (let i = 0; i < node.childCount; i += 1) {
+ children.push(node.child(i));
+ }
+ return children;
+}
+
+function shouldRenderHTMLTable(table) {
+ const { rows, cells } = getRowsAndCells(table);
+
+ const cellChildCount = Math.max(...cells.map((cell) => cell.childCount));
+ const maxColspan = Math.max(...cells.map((cell) => cell.attrs.colspan));
+ const maxRowspan = Math.max(...cells.map((cell) => cell.attrs.rowspan));
+
+ const rowChildren = rows.map((row) => uniq(getChildren(row).map((cell) => cell.type.name)));
+ const cellTypeInFirstRow = rowChildren[0];
+ const cellTypesInOtherRows = uniq(rowChildren.slice(1).map(([type]) => type));
+
+ // if the first row has headers, and there are no headers anywhere else, render markdown table
+ if (
+ !(
+ cellTypeInFirstRow.length === 1 &&
+ cellTypeInFirstRow[0] === 'tableHeader' &&
+ cellTypesInOtherRows.length === 1 &&
+ cellTypesInOtherRows[0] === 'tableCell'
+ )
+ ) {
+ return true;
+ }
+
+ if (cellChildCount === 1 && maxColspan === 1 && maxRowspan === 1) {
+ // if all rows contain only one paragraph each and no rowspan/colspan, render markdown table
+ const children = uniq(cells.map((cell) => cell.child(0).type.name));
+ if (children.length === 1 && children[0] === 'paragraph') {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+function htmlEncode(str = '') {
+ return str
+ .replace(/&/g, '&amp;')
+ .replace(/</g, '&lt;')
+ .replace(/>/g, '&gt;')
+ .replace(/'/g, '&#39;')
+ .replace(/"/g, '&#34;');
+}
+
+export function openTag(tagName, attrs) {
+ let str = `<${tagName}`;
+
+ str += Object.entries(attrs || {})
+ .map(([key, value]) => {
+ if ((ignoreAttrs[tagName] || []).includes(key) || defaultAttrs[tagName]?.[key] === value)
+ return '';
+
+ return ` ${key}="${htmlEncode(value?.toString())}"`;
+ })
+ .join('');
+
+ return `${str}>`;
+}
+
+export function closeTag(tagName) {
+ return `</${tagName}>`;
+}
+
+function isInBlockTable(node) {
+ return tableMap.get(node);
+}
+
+function isInTable(node) {
+ return tableMap.has(node);
+}
+
+function setIsInBlockTable(table, value) {
+ tableMap.set(table, value);
+
+ const { rows, cells } = getRowsAndCells(table);
+ rows.forEach((row) => tableMap.set(row, value));
+ cells.forEach((cell) => {
+ tableMap.set(cell, value);
+ if (cell.childCount && cell.child(0).type.name === 'paragraph')
+ tableMap.set(cell.child(0), value);
+ });
+}
+
+function unsetIsInBlockTable(table) {
+ tableMap.delete(table);
+
+ const { rows, cells } = getRowsAndCells(table);
+ rows.forEach((row) => tableMap.delete(row));
+ cells.forEach((cell) => {
+ tableMap.delete(cell);
+ if (cell.childCount) tableMap.delete(cell.child(0));
+ });
+}
+
+function renderTagOpen(state, tagName, attrs) {
+ state.ensureNewLine();
+ state.write(openTag(tagName, attrs));
+}
+
+function renderTagClose(state, tagName, insertNewline = true) {
+ state.write(closeTag(tagName));
+ if (insertNewline) state.ensureNewLine();
+}
+
+function renderTableHeaderRowAsMarkdown(state, node, cellWidths) {
+ state.flushClose(1);
+
+ state.write('|');
+ node.forEach((cell, _, i) => {
+ if (i) state.write('|');
+
+ state.write(cell.attrs.align === 'center' ? ':' : '-');
+ state.write(state.repeat('-', cellWidths[i]));
+ state.write(cell.attrs.align === 'center' || cell.attrs.align === 'right' ? ':' : '-');
+ });
+ state.write('|');
+
+ state.closeBlock(node);
+}
+
+function renderTableRowAsMarkdown(state, node, isHeaderRow = false) {
+ const cellWidths = [];
+
+ state.flushClose(1);
+
+ state.write('| ');
+ node.forEach((cell, _, i) => {
+ if (i) state.write(' | ');
+
+ const { length } = state.out;
+ state.render(cell, node, i);
+ cellWidths.push(state.out.length - length);
+ });
+ state.write(' |');
+
+ state.closeBlock(node);
+
+ if (isHeaderRow) renderTableHeaderRowAsMarkdown(state, node, cellWidths);
+}
+
+function renderTableRowAsHTML(state, node) {
+ renderTagOpen(state, 'tr');
+
+ node.forEach((cell, _, i) => {
+ const tag = cell.type.name === 'tableHeader' ? 'th' : 'td';
+
+ renderTagOpen(state, tag, cell.attrs);
+
+ if (!containsParagraphWithOnlyText(cell)) {
+ state.closeBlock(node);
+ state.flushClose();
+ }
+
+ state.render(cell, node, i);
+ state.flushClose(1);
+
+ renderTagClose(state, tag);
+ });
+
+ renderTagClose(state, 'tr');
+}
+
+export function renderContent(state, node, forceRenderInline) {
+ if (node.type.inlineContent) {
+ if (containsOnlyText(node)) {
+ state.renderInline(node);
+ } else {
+ state.closeBlock(node);
+ state.flushClose();
+ state.renderInline(node);
+ state.closeBlock(node);
+ state.flushClose();
+ }
+ } else {
+ const renderInline = forceRenderInline || containsParagraphWithOnlyText(node);
+ if (!renderInline) {
+ state.closeBlock(node);
+ state.flushClose();
+ state.renderContent(node);
+ state.ensureNewLine();
+ } else {
+ state.renderInline(forceRenderInline ? node : node.child(0));
+ }
+ }
+}
+
+export function renderHTMLNode(tagName, forceRenderInline = false) {
+ return (state, node) => {
+ renderTagOpen(state, tagName, node.attrs);
+ renderContent(state, node, forceRenderInline);
+ renderTagClose(state, tagName, 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 (!isBlockTablesFeatureEnabled()) {
+ state.renderInline(node);
+ return;
+ }
+
+ if (!isInBlockTable(node) || containsParagraphWithOnlyText(node)) {
+ state.renderInline(node.child(0));
+ } else {
+ state.renderContent(node);
+ }
+}
+
+export function renderTableRow(state, node) {
+ if (isInBlockTable(node)) {
+ renderTableRowAsHTML(state, node);
+ } else {
+ renderTableRowAsMarkdown(state, node, node.child(0).type.name === 'tableHeader');
+ }
+}
+
+export function renderTable(state, node) {
+ if (isBlockTablesFeatureEnabled()) {
+ setIsInBlockTable(node, shouldRenderHTMLTable(node));
+ }
+
+ if (isInBlockTable(node)) renderTagOpen(state, 'table');
+
+ state.renderContent(node);
+
+ if (isInBlockTable(node)) renderTagClose(state, 'table');
+
+ // ensure at least one blank line after any table
+ state.closeBlock(node);
+ state.flushClose();
+
+ if (isBlockTablesFeatureEnabled()) {
+ unsetIsInBlockTable(node);
+ }
+}
+
+export function renderHardBreak(state, node, parent, index) {
+ const br = isInTable(parent) ? '<br>' : '\\\n';
+
+ for (let i = index + 1; i < parent.childCount; i += 1) {
+ if (parent.child(i).type !== node.type) {
+ state.write(br);
+ return;
+ }
+ }
+}
+
+export function renderImage(state, node) {
+ const { alt, canonicalSrc, src, title } = node.attrs;
+ const quotedTitle = title ? ` ${state.quote(title)}` : '';
+
+ state.write(`![${state.esc(alt || '')}](${state.esc(canonicalSrc || src)}${quotedTitle})`);
+}
+
+export function renderPlayable(state, node) {
+ renderImage(state, node);
+}