diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-07-20 15:40:28 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-07-20 15:40:28 +0000 |
commit | b595cb0c1dec83de5bdee18284abe86614bed33b (patch) | |
tree | 8c3d4540f193c5ff98019352f554e921b3a41a72 /app/assets/javascripts/content_editor | |
parent | 2f9104a328fc8a4bddeaa4627b595166d24671d0 (diff) | |
download | gitlab-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')
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, '"'); } +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())}"`; }) |