summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/content_editor
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-07-20 15:40:28 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2022-07-20 15:40:28 +0000
commitb595cb0c1dec83de5bdee18284abe86614bed33b (patch)
tree8c3d4540f193c5ff98019352f554e921b3a41a72 /app/assets/javascripts/content_editor
parent2f9104a328fc8a4bddeaa4627b595166d24671d0 (diff)
downloadgitlab-ce-b595cb0c1dec83de5bdee18284abe86614bed33b.tar.gz
Add latest changes from gitlab-org/gitlab@15-2-stable-eev15.2.0-rc42
Diffstat (limited to 'app/assets/javascripts/content_editor')
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue20
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor_provider.vue2
-rw-r--r--app/assets/javascripts/content_editor/components/editor_state_observer.vue2
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue53
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_table_button.vue2
-rw-r--r--app/assets/javascripts/content_editor/components/top_toolbar.vue54
-rw-r--r--app/assets/javascripts/content_editor/extensions/code_block_highlight.js5
-rw-r--r--app/assets/javascripts/content_editor/extensions/division.js31
-rw-r--r--app/assets/javascripts/content_editor/extensions/html_nodes.js25
-rw-r--r--app/assets/javascripts/content_editor/extensions/sourcemap.js10
-rw-r--r--app/assets/javascripts/content_editor/services/content_editor.js4
-rw-r--r--app/assets/javascripts/content_editor/services/create_content_editor.js4
-rw-r--r--app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js6
-rw-r--r--app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js222
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_serializer.js18
-rw-r--r--app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js45
-rw-r--r--app/assets/javascripts/content_editor/services/serialization_helpers.js10
17 files changed, 291 insertions, 222 deletions
diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue b/app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue
index e35fbf14de5..f0726ff3e63 100644
--- a/app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue
+++ b/app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue
@@ -91,6 +91,26 @@ export default {
@execute="trackToolbarControlExecution"
/>
<toolbar-button
+ data-testid="superscript"
+ content-type="superscript"
+ icon-name="superscript"
+ editor-command="toggleSuperscript"
+ category="tertiary"
+ size="medium"
+ :label="__('Superscript')"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="subscript"
+ content-type="subscript"
+ icon-name="subscript"
+ editor-command="toggleSubscript"
+ category="tertiary"
+ size="medium"
+ :label="__('Subscript')"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
data-testid="link"
content-type="link"
icon-name="link"
diff --git a/app/assets/javascripts/content_editor/components/content_editor_provider.vue b/app/assets/javascripts/content_editor/components/content_editor_provider.vue
index cba3b627390..5dcff1f6295 100644
--- a/app/assets/javascripts/content_editor/components/content_editor_provider.vue
+++ b/app/assets/javascripts/content_editor/components/content_editor_provider.vue
@@ -19,7 +19,7 @@ export default {
},
},
render() {
- return this.$slots.default;
+ return this.$scopedSlots.default?.();
},
};
</script>
diff --git a/app/assets/javascripts/content_editor/components/editor_state_observer.vue b/app/assets/javascripts/content_editor/components/editor_state_observer.vue
index 02de6470cf2..252f69f7a5d 100644
--- a/app/assets/javascripts/content_editor/components/editor_state_observer.vue
+++ b/app/assets/javascripts/content_editor/components/editor_state_observer.vue
@@ -58,7 +58,7 @@ export default {
},
},
render() {
- return this.$slots.default;
+ return this.$scopedSlots.default?.();
},
};
</script>
diff --git a/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue
index ecde593147c..6e4cde5dad6 100644
--- a/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue
+++ b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue
@@ -10,9 +10,31 @@ export default {
GlTooltip,
},
inject: ['tiptapEditor'],
+ data() {
+ return {
+ isActive: {},
+ };
+ },
methods: {
- execute(contentType, attrs) {
- this.tiptapEditor.chain().focus().setNode(contentType, attrs).run();
+ insert(contentType, ...args) {
+ this.tiptapEditor
+ .chain()
+ .focus()
+ .setNode(contentType, ...args)
+ .run();
+
+ this.$emit('execute', { contentType });
+ },
+
+ insertList(listType, listItemType) {
+ if (!this.tiptapEditor.isActive(listType))
+ this.tiptapEditor.chain().focus().toggleList(listType, listItemType).run();
+
+ this.$emit('execute', { contentType: listType });
+ },
+
+ execute(command, contentType) {
+ this.tiptapEditor.chain().focus()[command]().run();
this.$emit('execute', { contentType });
},
@@ -20,15 +42,30 @@ export default {
};
</script>
<template>
- <gl-dropdown size="small" category="tertiary" icon="plus">
- <gl-dropdown-item @click="execute('diagram', { language: 'mermaid' })">
- {{ __('Mermaid diagram') }}
+ <gl-dropdown size="small" category="tertiary" icon="plus" class="content-editor-dropdown" right>
+ <gl-dropdown-item @click="insert('codeBlock')">
+ {{ __('Code block') }}
</gl-dropdown-item>
- <gl-dropdown-item @click="execute('diagram', { language: 'plantuml' })">
- {{ __('PlantUML diagram') }}
+ <gl-dropdown-item @click="insertList('details', 'detailsContent')">
+ {{ __('Details block') }}
+ </gl-dropdown-item>
+ <gl-dropdown-item class="gl-sm-display-none!" @click="insertList('bulletList', 'listItem')">
+ {{ __('Bullet list') }}
+ </gl-dropdown-item>
+ <gl-dropdown-item class="gl-sm-display-none!" @click="insertList('orderedList', 'listItem')">
+ {{ __('Ordered list') }}
+ </gl-dropdown-item>
+ <gl-dropdown-item class="gl-sm-display-none!" @click="insertList('taskList', 'taskItem')">
+ {{ __('Task list') }}
</gl-dropdown-item>
- <gl-dropdown-item @click="execute('horizontalRule')">
+ <gl-dropdown-item @click="execute('setHorizontalRule', 'horizontalRule')">
{{ __('Horizontal rule') }}
</gl-dropdown-item>
+ <gl-dropdown-item @click="insert('diagram', { language: 'mermaid' })">
+ {{ __('Mermaid diagram') }}
+ </gl-dropdown-item>
+ <gl-dropdown-item @click="insert('diagram', { language: 'plantuml' })">
+ {{ __('PlantUML diagram') }}
+ </gl-dropdown-item>
</gl-dropdown>
</template>
diff --git a/app/assets/javascripts/content_editor/components/toolbar_table_button.vue b/app/assets/javascripts/content_editor/components/toolbar_table_button.vue
index 46db806da94..18928acef3c 100644
--- a/app/assets/javascripts/content_editor/components/toolbar_table_button.vue
+++ b/app/assets/javascripts/content_editor/components/toolbar_table_button.vue
@@ -62,7 +62,7 @@ export default {
};
</script>
<template>
- <gl-dropdown size="small" category="tertiary" icon="table" class="table-dropdown">
+ <gl-dropdown size="small" category="tertiary" icon="table" class="content-editor-dropdown" right>
<gl-dropdown-form class="gl-px-3!">
<div v-for="r of list(maxRows)" :key="r" class="gl-display-flex">
<gl-button
diff --git a/app/assets/javascripts/content_editor/components/top_toolbar.vue b/app/assets/javascripts/content_editor/components/top_toolbar.vue
index b652e634b0c..65d71814268 100644
--- a/app/assets/javascripts/content_editor/components/top_toolbar.vue
+++ b/app/assets/javascripts/content_editor/components/top_toolbar.vue
@@ -51,12 +51,12 @@ export default {
@execute="trackToolbarControlExecution"
/>
<toolbar-button
- data-testid="strike"
- content-type="strike"
- icon-name="strikethrough"
+ data-testid="blockquote"
+ content-type="blockquote"
+ icon-name="quote"
class="gl-mx-2"
- editor-command="toggleStrike"
- :label="__('Strikethrough')"
+ editor-command="toggleBlockquote"
+ :label="__('Insert a quote')"
@execute="trackToolbarControlExecution"
/>
<toolbar-button
@@ -69,34 +69,11 @@ export default {
@execute="trackToolbarControlExecution"
/>
<toolbar-link-button data-testid="link" @execute="trackToolbarControlExecution" />
- <toolbar-image-button
- ref="imageButton"
- data-testid="image"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="blockquote"
- content-type="blockquote"
- icon-name="quote"
- class="gl-mx-2"
- editor-command="toggleBlockquote"
- :label="__('Insert a quote')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-button
- data-testid="code-block"
- content-type="codeBlock"
- icon-name="doc-code"
- class="gl-mx-2"
- editor-command="toggleCodeBlock"
- :label="__('Insert a code block')"
- @execute="trackToolbarControlExecution"
- />
<toolbar-button
data-testid="bullet-list"
content-type="bulletList"
icon-name="list-bulleted"
- class="gl-mx-2"
+ class="gl-mx-2 gl-display-none gl-sm-display-inline"
editor-command="toggleBulletList"
:label="__('Add a bullet list')"
@execute="trackToolbarControlExecution"
@@ -105,18 +82,23 @@ export default {
data-testid="ordered-list"
content-type="orderedList"
icon-name="list-numbered"
- class="gl-mx-2"
+ class="gl-mx-2 gl-display-none gl-sm-display-inline"
editor-command="toggleOrderedList"
:label="__('Add a numbered list')"
@execute="trackToolbarControlExecution"
/>
<toolbar-button
- data-testid="details"
- content-type="details"
- icon-name="details-block"
- class="gl-mx-2"
- editor-command="toggleDetails"
- :label="__('Add a collapsible section')"
+ data-testid="task-list"
+ content-type="taskList"
+ icon-name="list-task"
+ class="gl-mx-2 gl-display-none gl-sm-display-inline"
+ editor-command="toggleTaskList"
+ :label="__('Add a task list')"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-image-button
+ ref="imageButton"
+ data-testid="image"
@execute="trackToolbarControlExecution"
/>
<toolbar-table-button data-testid="table" @execute="trackToolbarControlExecution" />
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 61f6a233694..edf8b3d3a0b 100644
--- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
+++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
@@ -42,11 +42,14 @@ export default CodeBlockLowlight.extend({
},
parseHTML() {
return [
- ...(this.parent?.() || []),
{
tag: 'div.markdown-code-block',
skip: true,
},
+ {
+ tag: 'pre.js-syntax-highlight',
+ preserveWhitespace: 'full',
+ },
];
},
renderHTML({ HTMLAttributes }) {
diff --git a/app/assets/javascripts/content_editor/extensions/division.js b/app/assets/javascripts/content_editor/extensions/division.js
deleted file mode 100644
index 566ed85acf3..00000000000
--- a/app/assets/javascripts/content_editor/extensions/division.js
+++ /dev/null
@@ -1,31 +0,0 @@
-import { Node } from '@tiptap/core';
-import { PARSE_HTML_PRIORITY_LOWEST } from '../constants';
-
-const getDiv = (element) => {
- if (element.nodeName === 'DIV') return element;
- return element.querySelector('div');
-};
-
-export default Node.create({
- name: 'division',
- content: 'block*',
- group: 'block',
- defining: true,
-
- addAttributes() {
- return {
- className: {
- default: null,
- parseHTML: (element) => getDiv(element).className || null,
- },
- };
- },
-
- parseHTML() {
- return [{ tag: 'div', priority: PARSE_HTML_PRIORITY_LOWEST }];
- },
-
- renderHTML({ HTMLAttributes }) {
- return ['div', HTMLAttributes, 0];
- },
-});
diff --git a/app/assets/javascripts/content_editor/extensions/html_nodes.js b/app/assets/javascripts/content_editor/extensions/html_nodes.js
new file mode 100644
index 00000000000..23409354814
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/html_nodes.js
@@ -0,0 +1,25 @@
+import { Node } from '@tiptap/core';
+import { PARSE_HTML_PRIORITY_LOWEST } from '../constants';
+
+const tags = ['div', 'pre'];
+
+const createHtmlNodeExtension = (tagName) =>
+ Node.create({
+ name: tagName,
+ content: 'block*',
+ group: 'block',
+ defining: true,
+ addOptions() {
+ return {
+ tagName,
+ };
+ },
+ parseHTML() {
+ return [{ tag: tagName, priority: PARSE_HTML_PRIORITY_LOWEST }];
+ },
+ renderHTML({ HTMLAttributes }) {
+ return [tagName, HTMLAttributes, 0];
+ },
+ });
+
+export default tags.map(createHtmlNodeExtension);
diff --git a/app/assets/javascripts/content_editor/extensions/sourcemap.js b/app/assets/javascripts/content_editor/extensions/sourcemap.js
index 87118074462..618f17b1c5e 100644
--- a/app/assets/javascripts/content_editor/extensions/sourcemap.js
+++ b/app/assets/javascripts/content_editor/extensions/sourcemap.js
@@ -9,6 +9,7 @@ import FootnoteDefinition from './footnote_definition';
import Heading from './heading';
import HardBreak from './hard_break';
import HorizontalRule from './horizontal_rule';
+import HTMLNodes from './html_nodes';
import Image from './image';
import Italic from './italic';
import Link from './link';
@@ -51,13 +52,22 @@ export default Extension.create({
TableCell.name,
TableHeader.name,
TableRow.name,
+ ...HTMLNodes.map((htmlNode) => htmlNode.name),
],
attributes: {
+ /**
+ * The reason to add a function that returns an empty
+ * string in these attributes is indicate that these
+ * attributes shouldn’t be rendered in the ProseMirror
+ * view.
+ */
sourceMarkdown: {
default: null,
+ renderHTML: () => '',
},
sourceMapKey: {
default: null,
+ renderHTML: () => '',
},
},
},
diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js
index 06757e7a280..867bf0b4d55 100644
--- a/app/assets/javascripts/content_editor/services/content_editor.js
+++ b/app/assets/javascripts/content_editor/services/content_editor.js
@@ -39,12 +39,12 @@ export class ContentEditor {
this._eventHub.dispose();
}
- deserialize(serializedContent) {
+ deserialize(markdown) {
const { _tiptapEditor: editor, _deserializer: deserializer } = this;
return deserializer.deserialize({
schema: editor.schema,
- content: serializedContent,
+ markdown,
});
}
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 15aac3d86e5..c5cfa9a4285 100644
--- a/app/assets/javascripts/content_editor/services/create_content_editor.js
+++ b/app/assets/javascripts/content_editor/services/create_content_editor.js
@@ -16,7 +16,6 @@ import DescriptionList from '../extensions/description_list';
import Details from '../extensions/details';
import DetailsContent from '../extensions/details_content';
import Diagram from '../extensions/diagram';
-import Division from '../extensions/division';
import Document from '../extensions/document';
import Dropcursor from '../extensions/dropcursor';
import Emoji from '../extensions/emoji';
@@ -32,6 +31,7 @@ import Heading from '../extensions/heading';
import History from '../extensions/history';
import HorizontalRule from '../extensions/horizontal_rule';
import HTMLMarks from '../extensions/html_marks';
+import HTMLNodes from '../extensions/html_nodes';
import Image from '../extensions/image';
import InlineDiff from '../extensions/inline_diff';
import Italic from '../extensions/italic';
@@ -103,7 +103,6 @@ export const createContentEditor = ({
DetailsContent,
Document,
Diagram,
- Division,
Dropcursor,
Emoji,
Figure,
@@ -118,6 +117,7 @@ export const createContentEditor = ({
History,
HorizontalRule,
...HTMLMarks,
+ ...HTMLNodes,
Image,
InlineDiff,
Italic,
diff --git a/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js b/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js
index dcd56e55268..fa46bd9ff81 100644
--- a/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js
+++ b/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js
@@ -16,8 +16,8 @@ export default ({ render }) => {
* document. The dom property contains the HTML generated from the Markdown Source.
*/
return {
- deserialize: async ({ schema, content }) => {
- const html = await render(content);
+ deserialize: async ({ schema, markdown }) => {
+ const html = await render(markdown);
if (!html) return {};
@@ -25,7 +25,7 @@ export default ({ render }) => {
const { body } = parser.parseFromString(html, 'text/html');
// append original source as a comment that nodes can access
- body.append(document.createComment(content));
+ body.append(document.createComment(markdown));
return { document: ProseMirrorDOMParser.fromSchema(schema).parse(body) };
},
diff --git a/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js b/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js
index 2c462cdde91..312ab88de4a 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
@@ -21,9 +21,10 @@
import { Mark } from 'prosemirror-model';
import { visitParents, SKIP } from 'unist-util-visit-parents';
-import { toString } from 'hast-util-to-string';
import { isFunction, isString, noop } from 'lodash';
+const NO_ATTRIBUTES = {};
+
/**
* Merges two ProseMirror text nodes if both text nodes
* have the same set of marks.
@@ -51,7 +52,7 @@ function maybeMerge(a, b) {
* Hast node documentation: https://github.com/syntax-tree/hast
*
* @param {HastNode} hastNode A Hast node
- * @param {String} source Markdown source file
+ * @param {String} markdown Markdown source file
*
* @returns It returns an object with the following attributes:
*
@@ -60,13 +61,13 @@ function maybeMerge(a, b) {
* - sourceMarkdown: A node’s original Markdown source extrated
* from the Markdown source file.
*/
-function createSourceMapAttributes(hastNode, source) {
+function createSourceMapAttributes(hastNode, markdown) {
const { position } = hastNode;
return position && position.end
? {
sourceMapKey: `${position.start.offset}:${position.end.offset}`,
- sourceMarkdown: source.substring(position.start.offset, position.end.offset),
+ sourceMarkdown: markdown.substring(position.start.offset, position.end.offset),
}
: {};
}
@@ -82,16 +83,16 @@ function createSourceMapAttributes(hastNode, source) {
* @param {*} proseMirrorNodeSpec ProseMirror node spec object
* @param {HastNode} hastNode A hast node
* @param {Array<HastNode>} hastParents All the ancestors of the hastNode
- * @param {String} source Markdown source file’s content
+ * @param {String} markdown Markdown source file’s content
*
* @returns An object that contains a ProseMirror node’s attributes
*/
-function getAttrs(proseMirrorNodeSpec, hastNode, hastParents, source) {
+function getAttrs(proseMirrorNodeSpec, hastNode, hastParents, markdown) {
const { getAttrs: specGetAttrs } = proseMirrorNodeSpec;
return {
- ...createSourceMapAttributes(hastNode, source),
- ...(isFunction(specGetAttrs) ? specGetAttrs(hastNode, hastParents, source) : {}),
+ ...createSourceMapAttributes(hastNode, markdown),
+ ...(isFunction(specGetAttrs) ? specGetAttrs(hastNode, hastParents, markdown) : {}),
};
}
@@ -136,6 +137,10 @@ class HastToProseMirrorConverterState {
return this.stack[this.stack.length - 1];
}
+ get topNode() {
+ return this.findInStack((item) => item.type === 'node');
+ }
+
/**
* Detects if the node stack is empty
*/
@@ -177,7 +182,7 @@ class HastToProseMirrorConverterState {
*/
addText(schema, text) {
if (!text) return;
- const nodes = this.top.content;
+ const nodes = this.topNode?.content;
const last = nodes[nodes.length - 1];
const node = schema.text(text, this.marks);
const merged = maybeMerge(last, node);
@@ -187,57 +192,92 @@ class HastToProseMirrorConverterState {
} else {
nodes.push(node);
}
-
- this.closeMarks();
}
/**
* Adds a mark to the set of marks stored temporarily
- * until addText is called.
- * @param {*} markType
- * @param {*} attrs
+ * until an inline node is created.
+ * @param {https://prosemirror.net/docs/ref/#model.MarkType} schemaType Mark schema type
+ * @param {https://github.com/syntax-tree/hast#nodes} hastNode AST node that the mark is based on
+ * @param {Object} attrs Mark attributes
+ * @param {Object} factorySpec Specifications on how th mark should be created
*/
- openMark(markType, attrs) {
- this.marks = markType.create(attrs).addToSet(this.marks);
+ openMark(schemaType, hastNode, attrs, factorySpec) {
+ const mark = schemaType.create(attrs);
+ this.stack.push({
+ type: 'mark',
+ mark,
+ attrs,
+ hastNode,
+ factorySpec,
+ });
+
+ this.marks = mark.addToSet(this.marks);
}
/**
- * Empties the temporary Mark set.
+ * Removes a mark from the list of active marks that
+ * are applied to inline nodes.
*/
- closeMarks() {
- this.marks = Mark.none;
+ closeMark() {
+ const { mark } = this.stack.pop();
+
+ this.marks = mark.removeFromSet(this.marks);
}
/**
* Adds a node to the stack data structure.
*
- * @param {Schema.NodeType} type ProseMirror Schema for the node
- * @param {HastNode} hastNode Hast node from which the ProseMirror node will be created
+ * @param {https://prosemirror.net/docs/ref/#model.NodeType} schemaType ProseMirror Schema for the node
+ * @param {https://github.com/syntax-tree/hast#nodes} hastNode Hast node from which the ProseMirror node will be created
* @param {*} attrs Node’s attributes
* @param {*} factorySpec The factory spec used to create the node factory
*/
- openNode(type, hastNode, attrs, factorySpec) {
- this.stack.push({ type, attrs, content: [], hastNode, factorySpec });
+ openNode(schemaType, hastNode, attrs, factorySpec) {
+ this.stack.push({
+ type: 'node',
+ schemaType,
+ attrs,
+ content: [],
+ hastNode,
+ factorySpec,
+ });
}
/**
* Removes the top ProseMirror node from the
* conversion stack and adds the node to the
* previous element.
- * @returns
*/
closeNode() {
- const { type, attrs, content } = this.stack.pop();
- const node = type.createAndFill(attrs, content);
-
- if (!node) return null;
-
- if (this.marks.length) {
- this.marks = Mark.none;
+ const { schemaType, attrs, content, factorySpec } = this.stack.pop();
+ const node =
+ factorySpec.type === 'inline' && this.marks.length
+ ? schemaType.createAndFill(attrs, content, this.marks)
+ : schemaType.createAndFill(attrs, content);
+
+ if (!node) {
+ /*
+ When the node returned by `createAndFill` is null is because the `content` passed as a parameter
+ doesn’t conform with the document schema. We are handling the most likely scenario here that happens
+ when a paragraph is inside another paragraph.
+
+ This scenario happens when the converter encounters a mark wrapping one or more paragraphs.
+ In this case, the converter will wrap the mark in a paragraph as well because ProseMirror does
+ not allow marks wrapping block nodes or being direct children of certain nodes like the root nodes
+ or list items.
+ */
+ if (
+ schemaType.name === 'paragraph' &&
+ content.some((child) => child.type.name === 'paragraph')
+ ) {
+ this.topNode.content.push(...content);
+ }
+ return null;
}
if (!this.empty) {
- this.top.content.push(node);
+ this.topNode.content.push(node);
}
return node;
@@ -245,9 +285,27 @@ class HastToProseMirrorConverterState {
closeUntil(hastNode) {
while (hastNode !== this.top?.hastNode) {
- this.closeNode();
+ if (this.top.type === 'node') {
+ this.closeNode();
+ } else {
+ this.closeMark();
+ }
}
}
+
+ buildDoc() {
+ let doc;
+
+ do {
+ if (this.top.type === 'node') {
+ doc = this.closeNode();
+ } else {
+ this.closeMark();
+ }
+ } while (!this.empty);
+
+ return doc;
+ }
}
/**
@@ -260,20 +318,21 @@ class HastToProseMirrorConverterState {
* @param {model.ProseMirrorSchema} schema A ProseMirror schema used to create the
* ProseMirror nodes and marks.
* @param {Object} proseMirrorFactorySpecs ProseMirror nodes factory specifications.
- * @param {String} source Markdown source file’s content
+ * @param {String} markdown Markdown source file’s content
*
* @returns An object that contains ProseMirror node factories
*/
-const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source) => {
+const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, markdown) => {
const factories = {
root: {
selector: 'root',
wrapInParagraph: true,
- handle: (state, hastNode) => state.openNode(schema.topNodeType, hastNode, {}, {}),
+ handle: (state, hastNode) =>
+ state.openNode(schema.topNodeType, hastNode, NO_ATTRIBUTES, factories.root),
},
text: {
selector: 'text',
- handle: (state, hastNode) => {
+ handle: (state, hastNode, parent) => {
const found = state.findInStack((node) => isFunction(node.factorySpec.processText));
const { value: text } = hastNode;
@@ -281,17 +340,14 @@ const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source)
return;
}
+ state.closeUntil(parent);
state.addText(schema, found ? found.factorySpec.processText(text) : text);
},
},
};
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,
+ ...factorySpec,
};
if (factorySpec.type === 'block') {
@@ -299,48 +355,22 @@ const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source)
const nodeType = schema.nodeType(proseMirrorName);
state.closeUntil(parent);
- state.openNode(
- nodeType,
- hastNode,
- getAttrs(factorySpec, hastNode, parent, source),
- factorySpec,
- );
-
- /**
- * If a getContent function is provided, we immediately close
- * the node to delegate content processing to this function.
- * */
- if (isFunction(factorySpec.getContent)) {
- state.addText(
- schema,
- factorySpec.getContent({ hastNode, hastNodeText: toString(hastNode) }),
- );
- state.closeNode();
- }
+ state.openNode(nodeType, hastNode, getAttrs(factory, hastNode, parent, markdown), factory);
};
- } else if (factorySpec.type === 'inline') {
+ } else if (factory.type === 'inline') {
const nodeType = schema.nodeType(proseMirrorName);
factory.handle = (state, hastNode, parent) => {
state.closeUntil(parent);
- state.openNode(
- nodeType,
- hastNode,
- getAttrs(factorySpec, hastNode, parent, source),
- factorySpec,
- );
+ state.openNode(nodeType, hastNode, getAttrs(factory, hastNode, parent, markdown), factory);
// Inline nodes do not have children therefore they are immediately closed
state.closeNode();
};
- } else if (factorySpec.type === 'mark') {
+ } else if (factory.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);
- }
+ state.openMark(markType, hastNode, getAttrs(factory, hastNode, parent, markdown), factory);
};
- } else if (factorySpec.type === 'ignore') {
+ } else if (factory.type === 'ignore') {
factory.handle = noop;
} else {
throw new RangeError(
@@ -371,7 +401,7 @@ const findParent = (ancestors, parent) => {
return ancestors[ancestors.length - 1];
};
-const calcTextNodePosition = (textNode) => {
+const resolveNodePosition = (textNode) => {
const { position, value, type } = textNode;
if (type !== 'text' || (!position.start && !position.end) || (position.start && position.end)) {
@@ -414,11 +444,14 @@ const wrapInlineElements = (nodes, wrappableTags) =>
nodes.reduce((children, child) => {
const previous = children[children.length - 1];
- if (child.type !== 'text' && !wrappableTags.includes(child.tagName)) {
+ if (
+ child.type === 'comment' ||
+ (child.type !== 'text' && !wrappableTags.includes(child.tagName))
+ ) {
return [...children, child];
}
- const wrapperExists = previous?.properties.wrapper;
+ const wrapperExists = previous?.properties?.wrapper;
if (wrapperExists) {
const wrapper = previous;
@@ -432,7 +465,7 @@ const wrapInlineElements = (nodes, wrappableTags) =>
const wrapper = {
type: 'element',
tagName: 'p',
- position: calcTextNodePosition(child),
+ position: resolveNodePosition(child),
children: [child],
properties: { wrapper: true },
};
@@ -528,19 +561,6 @@ const wrapInlineElements = (nodes, wrappableTags) =>
* 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.
- *
- * **getContent**
- *
- * Allows to pass a custom function that returns the content of a block node. The
- * Content is limited to a single text node therefore the function should return
- * a String value.
- *
- * Use this property along skipChildren to provide custom processing of child nodes
- * for a block node.
- *
* **parent**
*
* Specifies what is the node’s parent. This is useful when the node’s parent is not
@@ -561,20 +581,16 @@ export const createProseMirrorDocFromMdastTree = ({
factorySpecs,
wrappableTags,
tree,
- source,
+ markdown,
}) => {
- const proseMirrorNodeFactories = createProseMirrorNodeFactories(schema, factorySpecs, source);
+ const proseMirrorNodeFactories = createProseMirrorNodeFactories(schema, factorySpecs, markdown);
const state = new HastToProseMirrorConverterState();
visitParents(tree, (hastNode, ancestors) => {
const factory = findFactory(hastNode, ancestors, proseMirrorNodeFactories);
if (!factory) {
- throw new Error(
- `Hast node of type "${
- hastNode.tagName || hastNode.type
- }" not supported by this converter. Please, provide an specification.`,
- );
+ return SKIP;
}
const parent = findParent(ancestors, factory.parent);
@@ -595,14 +611,8 @@ export const createProseMirrorDocFromMdastTree = ({
factory.handle(state, hastNode, parent);
- return factory.skipChildren === true ? SKIP : true;
+ return true;
});
- let doc;
-
- do {
- doc = state.closeNode();
- } while (!state.empty);
-
- return doc;
+ return state.buildDoc();
};
diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js
index 2d33a16f1a5..c1c7af6b1af 100644
--- a/app/assets/javascripts/content_editor/services/markdown_serializer.js
+++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js
@@ -12,7 +12,6 @@ import DescriptionItem from '../extensions/description_item';
import DescriptionList from '../extensions/description_list';
import Details from '../extensions/details';
import DetailsContent from '../extensions/details_content';
-import Division from '../extensions/division';
import Diagram from '../extensions/diagram';
import Emoji from '../extensions/emoji';
import Figure from '../extensions/figure';
@@ -24,6 +23,7 @@ import HardBreak from '../extensions/hard_break';
import Heading from '../extensions/heading';
import HorizontalRule from '../extensions/horizontal_rule';
import HTMLMarks from '../extensions/html_marks';
+import HTMLNodes from '../extensions/html_nodes';
import Image from '../extensions/image';
import InlineDiff from '../extensions/inline_diff';
import Italic from '../extensions/italic';
@@ -123,16 +123,6 @@ const defaultSerializerConfig = {
[BulletList.name]: preserveUnchanged(renderBulletList),
[CodeBlockHighlight.name]: preserveUnchanged(renderCodeBlock),
[Diagram.name]: renderCodeBlock,
- [Division.name]: (state, node) => {
- if (node.attrs.className?.includes('js-markdown-code')) {
- state.renderInline(node);
- } else {
- const newNode = node;
- delete newNode.attrs.className;
-
- renderHTMLNode('div')(state, newNode);
- }
- },
[DescriptionList.name]: renderHTMLNode('dl', true),
[DescriptionItem.name]: (state, node, parent, index) => {
if (index === 1) state.ensureNewLine();
@@ -206,6 +196,12 @@ const defaultSerializerConfig = {
[Text.name]: defaultMarkdownSerializer.nodes.text,
[Video.name]: renderPlayable,
[WordBreak.name]: (state) => state.write('<wbr>'),
+ ...HTMLNodes.reduce((serializers, htmlNode) => {
+ return {
+ ...serializers,
+ [htmlNode.name]: (state, node) => renderHTMLNode(htmlNode.options.tagName)(state, node),
+ };
+ }, {}),
},
};
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 da10c684b0b..8e2c066e011 100644
--- a/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js
+++ b/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js
@@ -1,11 +1,10 @@
-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;
+ const className = hastNode.properties?.className;
return (
hastNode.tagName === 'li' && Array.isArray(className) && className.includes('task-list-item')
@@ -23,16 +22,16 @@ const factorySpecs = {
listItem: {
type: 'block',
wrapInParagraph: true,
- selector: (hastNode) => hastNode.tagName === 'li' && !hastNode.properties.className,
+ selector: (hastNode) => hastNode.tagName === 'li' && !hastNode.properties?.className,
processText: (text) => text.trimRight(),
},
orderedList: {
type: 'block',
- selector: (hastNode) => hastNode.tagName === 'ol' && !hastNode.properties.className,
+ selector: (hastNode) => hastNode.tagName === 'ol' && !hastNode.properties?.className,
},
bulletList: {
type: 'block',
- selector: (hastNode) => hastNode.tagName === 'ul' && !hastNode.properties.className,
+ selector: (hastNode) => hastNode.tagName === 'ul' && !hastNode.properties?.className,
},
heading: {
type: 'block',
@@ -45,15 +44,8 @@ const factorySpecs = {
},
codeBlock: {
type: 'block',
- skipChildren: true,
- selector: 'pre',
- getContent: ({ hastNodeText }) => hastNodeText.replace(/\n$/, ''),
- getAttrs: (hastNode) => {
- const languageClass = hastNode.children[0]?.properties.className?.[0];
- const language = isString(languageClass) ? languageClass.replace('language-', '') : null;
-
- return { language };
- },
+ selector: 'codeblock',
+ getAttrs: (hastNode) => ({ ...hastNode.properties }),
},
horizontalRule: {
type: 'block',
@@ -62,7 +54,7 @@ const factorySpecs = {
taskList: {
type: 'block',
selector: (hastNode) => {
- const { className } = hastNode.properties;
+ const className = hastNode.properties?.className;
return (
['ul', 'ol'].includes(hastNode.tagName) &&
@@ -88,6 +80,11 @@ const factorySpecs = {
selector: (hastNode, ancestors) =>
hastNode.tagName === 'input' && isTaskItem(ancestors[ancestors.length - 1]),
},
+ div: {
+ type: 'block',
+ selector: 'div',
+ wrapInParagraph: true,
+ },
table: {
type: 'block',
selector: 'table',
@@ -118,6 +115,11 @@ const factorySpecs = {
selector: 'footnotedefinition',
getAttrs: (hastNode) => hastNode.properties,
},
+ pre: {
+ type: 'block',
+ selector: 'pre',
+ wrapInParagraph: true,
+ },
image: {
type: 'inline',
selector: 'img',
@@ -160,11 +162,19 @@ const factorySpecs = {
type: 'mark',
selector: (hastNode) => ['strike', 's', 'del'].includes(hastNode.tagName),
},
+ /* TODO
+ * Implement proper editing support for HTML comments in the Content Editor
+ * https://gitlab.com/gitlab-org/gitlab/-/issues/342173
+ */
+ comment: {
+ type: 'ignore',
+ selector: (hastNode) => hastNode.type === 'comment',
+ },
};
export default () => {
return {
- deserialize: async ({ schema, content: markdown }) => {
+ deserialize: async ({ schema, markdown }) => {
const document = await render({
markdown,
renderer: (tree) =>
@@ -173,8 +183,9 @@ export default () => {
factorySpecs,
tree,
wrappableTags,
- source: markdown,
+ markdown,
}),
+ skipRendering: ['footnoteReference', 'footnoteDefinition', 'code'],
});
return { document };
diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js
index 88f5192af77..7d5e718b41c 100644
--- a/app/assets/javascripts/content_editor/services/serialization_helpers.js
+++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js
@@ -5,6 +5,8 @@ const defaultAttrs = {
th: { colspan: 1, rowspan: 1, colwidth: null },
};
+const defaultIgnoreAttrs = ['sourceMarkdown', 'sourceMapKey'];
+
const ignoreAttrs = {
dd: ['isTerm'],
dt: ['isTerm'],
@@ -101,13 +103,17 @@ function htmlEncode(str = '') {
.replace(/"/g, '&#34;');
}
+const shouldIgnoreAttr = (tagName, attrKey, attrValue) =>
+ ignoreAttrs[tagName]?.includes(attrKey) ||
+ defaultIgnoreAttrs.includes(attrKey) ||
+ defaultAttrs[tagName]?.[attrKey] === attrValue;
+
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 '';
+ if (shouldIgnoreAttr(tagName, key, value)) return '';
return ` ${key}="${htmlEncode(value?.toString())}"`;
})