diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-06-02 03:09:20 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-06-02 03:09:20 +0300 |
commit | e35ac5e805fcb47d43591f605a9d5abfb75f44b8 (patch) | |
tree | fca5b55f47b18ade0891c322b14eb50baf0a44ef /app/assets/javascripts/content_editor | |
parent | e2ef50dafcf51e811123dd71179334de2ea3edf9 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets/javascripts/content_editor')
5 files changed, 151 insertions, 34 deletions
diff --git a/app/assets/javascripts/content_editor/extensions/sourcemap.js b/app/assets/javascripts/content_editor/extensions/sourcemap.js index 61d1d983846..1cc60e33e13 100644 --- a/app/assets/javascripts/content_editor/extensions/sourcemap.js +++ b/app/assets/javascripts/content_editor/extensions/sourcemap.js @@ -14,6 +14,8 @@ import ListItem from './list_item'; import OrderedList from './ordered_list'; import Paragraph from './paragraph'; import Strike from './strike'; +import TaskList from './task_list'; +import TaskItem from './task_item'; export default Extension.create({ addGlobalAttributes() { @@ -35,6 +37,8 @@ export default Extension.create({ OrderedList.name, Paragraph.name, Strike.name, + TaskList.name, + TaskItem.name, ], attributes: { sourceMarkdown: { 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 9f8505416ba..92faaabac0d 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 @@ -22,7 +22,7 @@ import { Mark } from 'prosemirror-model'; import { visitParents } from 'unist-util-visit-parents'; import { toString } from 'hast-util-to-string'; -import { isFunction } from 'lodash'; +import { isFunction, noop } from 'lodash'; /** * Merges two ProseMirror text nodes if both text nodes @@ -268,17 +268,19 @@ const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source) selector: 'text', handle: (state, hastNode, parent) => { const { factorySpec } = state.top; + const { processText, wrapTextInParagraph } = factorySpec; + const { value: text } = hastNode; - if (/^\s+$/.test(hastNode.value)) { + if (/^\s+$/.test(text)) { return; } - if (factorySpec.wrapTextInParagraph === true) { + if (wrapTextInParagraph === true) { state.openNode(schema.nodeType('paragraph'), hastNode, getAttrs({}, parent, [], source)); - state.addText(schema, hastNode.value); + state.addText(schema, isFunction(processText) ? processText(text) : text); state.closeNode(); } else { - state.addText(schema, hastNode.value); + state.addText(schema, text); } }, }, @@ -287,6 +289,7 @@ const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source) const factory = { selector: factorySpec.selector, skipChildren: factorySpec.skipChildren, + processText: factorySpec.processText, }; if (factorySpec.type === 'block') { @@ -335,6 +338,8 @@ const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source) state.addText(schema, hastNode.value); } }; + } else if (factorySpec.type === 'ignore') { + factory.handle = noop; } else { throw new RangeError( `Unrecognized ProseMirror object type ${JSON.stringify(factorySpec.type)}`, @@ -347,12 +352,12 @@ const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source) return factories; }; -const findFactory = (hastNode, factories) => +const findFactory = (hastNode, ancestors, factories) => Object.entries(factories).find(([, factorySpec]) => { const { selector } = factorySpec; return isFunction(selector) - ? selector(hastNode) + ? selector(hastNode, ancestors) : [hastNode.tagName, hastNode.type].includes(selector); })?.[1]; @@ -408,6 +413,8 @@ const findFactory = (hastNode, factories) => * 2. "inline": A ProseMirror node that doesn’t contain any children although * it can have inline content like an image or a mention object. * 3. "mark": A ProseMirror mark. + * 4. "ignore": A hast node that should be ignored and won’t be mapped to a + * ProseMirror node. * * **selector** * @@ -417,8 +424,8 @@ const findFactory = (hastNode, factories) => * that equals the string value. * * If you assign a function, the converter will invoke the function with - * the hast node. The function should return `true` if the hastNode matches - * the custom criteria implemented in the function + * the hast node and its ancestors. The function should return `true` + * if the hastNode matches the custom criteria implemented in the function * * **getAttrs** * @@ -435,6 +442,12 @@ const findFactory = (hastNode, factories) => * it will wrap that text in a paragraph. This is useful for ProseMirror block * nodes that don’t allow text directly such as list items and tables. * + * **processText** + * + * This property only applies to block nodes. If a block node contains text, + * it allows applying a processing function to that text. This is useful when + * you can transform the text node, i.e trim(), substring(), etc. + * * **skipChildren** * * Skips a hast node’s children while traversing the tree. @@ -461,7 +474,8 @@ export const createProseMirrorDocFromMdastTree = ({ schema, factorySpecs, tree, const state = new HastToProseMirrorConverterState(); visitParents(tree, (hastNode, ancestors) => { - const factory = findFactory(hastNode, proseMirrorNodeFactories); + const factory = findFactory(hastNode, ancestors, proseMirrorNodeFactories); + const parent = ancestors[ancestors.length - 1]; if (!factory) { throw new Error( @@ -471,8 +485,6 @@ export const createProseMirrorDocFromMdastTree = ({ schema, factorySpecs, tree, ); } - const parent = ancestors[ancestors.length - 1]; - factory.handle(state, hastNode, parent); return factory.skipChildren === true ? 'skip' : true; diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js index cd3ac6fcb1f..dccc545f6cb 100644 --- a/app/assets/javascripts/content_editor/services/markdown_serializer.js +++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js @@ -60,6 +60,7 @@ import { renderPlayable, renderHTMLNode, renderContent, + renderBulletList, preserveUnchanged, bold, italic, @@ -120,7 +121,7 @@ const defaultSerializerConfig = { state.wrapBlock('> ', null, node, () => state.renderContent(node)); } }), - [BulletList.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.bullet_list), + [BulletList.name]: preserveUnchanged(renderBulletList), [CodeBlockHighlight.name]: preserveUnchanged(renderCodeBlock), [Diagram.name]: renderCodeBlock, [Division.name]: (state, node) => { @@ -196,14 +197,14 @@ const defaultSerializerConfig = { [TableCell.name]: renderTableCell, [TableHeader.name]: renderTableCell, [TableRow.name]: renderTableRow, - [TaskItem.name]: (state, node) => { + [TaskItem.name]: preserveUnchanged((state, node) => { state.write(`[${node.attrs.checked ? 'x' : ' '}] `); state.renderContent(node); - }, - [TaskList.name]: (state, node) => { + }), + [TaskList.name]: preserveUnchanged((state, node) => { if (node.attrs.numeric) renderOrderedList(state, node); - else defaultMarkdownSerializer.nodes.bullet_list(state, node); - }, + else renderBulletList(state, node); + }), [Text.name]: defaultMarkdownSerializer.nodes.text, [Video.name]: renderPlayable, [WordBreak.name]: (state) => state.write('<wbr>'), diff --git a/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js b/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js index 4cbbfc36151..bec18bcdd81 100644 --- a/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js +++ b/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js @@ -2,12 +2,31 @@ import { isString } from 'lodash'; import { render } from '~/lib/gfm'; import { createProseMirrorDocFromMdastTree } from './hast_to_prosemirror_converter'; +const isTaskItem = (hastNode) => { + const { className } = hastNode.properties; + + return ( + hastNode.tagName === 'li' && Array.isArray(className) && className.includes('task-list-item') + ); +}; + const factorySpecs = { blockquote: { type: 'block', selector: 'blockquote' }, paragraph: { type: 'block', selector: 'p' }, - listItem: { type: 'block', selector: 'li', wrapTextInParagraph: true }, - orderedList: { type: 'block', selector: 'ol' }, - bulletList: { type: 'block', selector: 'ul' }, + listItem: { + type: 'block', + wrapTextInParagraph: true, + processText: (text) => text.trim(), + selector: (hastNode) => hastNode.tagName === 'li' && !hastNode.properties.className, + }, + orderedList: { + type: 'block', + selector: (hastNode) => hastNode.tagName === 'ol' && !hastNode.properties.className, + }, + bulletList: { + type: 'block', + selector: (hastNode) => hastNode.tagName === 'ul' && !hastNode.properties.className, + }, heading: { type: 'block', selector: (hastNode) => ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(hastNode.tagName), @@ -33,6 +52,35 @@ const factorySpecs = { type: 'block', selector: 'hr', }, + taskList: { + type: 'block', + selector: (hastNode) => { + const { className } = hastNode.properties; + + return ( + ['ul', 'ol'].includes(hastNode.tagName) && + Array.isArray(className) && + className.includes('contains-task-list') + ); + }, + getAttrs: (hastNode) => ({ + numeric: hastNode.tagName === 'ol', + }), + }, + taskItem: { + type: 'block', + wrapTextInParagraph: true, + processText: (text) => text.trim(), + selector: isTaskItem, + getAttrs: (hastNode) => ({ + checked: hastNode.children[0].properties.checked, + }), + }, + taskItemCheckbox: { + type: 'ignore', + selector: (hastNode, ancestors) => + hastNode.tagName === 'input' && isTaskItem(ancestors[ancestors.length - 1]), + }, image: { type: 'inline', selector: 'img', diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js index c11ce08de63..4019b772269 100644 --- a/app/assets/javascripts/content_editor/services/serialization_helpers.js +++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js @@ -272,19 +272,6 @@ export function renderHTMLNode(tagName, forceRenderContentInline = false) { }; } -export function renderOrderedList(state, node) { - const { parens } = node.attrs; - const start = node.attrs.start || 1; - const maxW = String(start + node.childCount - 1).length; - const space = state.repeat(' ', maxW + 2); - const delimiter = parens ? ')' : '.'; - - state.renderList(node, space, (i) => { - const nStr = String(start + i); - return `${state.repeat(' ', maxW - nStr.length) + nStr}${delimiter} `; - }); -} - export function renderTableCell(state, node) { if (!isInBlockTable(node) || containsParagraphWithOnlyText(node)) { state.renderInline(node.child(0)); @@ -364,6 +351,71 @@ export function preserveUnchanged(render) { }; } +/** + * We extracted this function from + * https://github.com/ProseMirror/prosemirror-markdown/blob/master/src/to_markdown.ts#L350. + * + * We need to overwrite this function because we don’t want to wrap the list item nodes + * with the bullet delimiter when the list item node hasn’t changed + */ +const renderList = (state, node, delim, firstDelim) => { + if (state.closed && state.closed.type === node.type) state.flushClose(3); + else if (state.inTightList) state.flushClose(1); + + const isTight = + typeof node.attrs.tight !== 'undefined' ? node.attrs.tight : state.options.tightLists; + const prevTight = state.inTightList; + + state.inTightList = isTight; + + node.forEach((child, _, i) => { + const same = state.options.changeTracker.get(child); + + if (i && isTight) { + state.flushClose(1); + } + + if (same) { + // Avoid wrapping list item when node hasn’t changed + state.render(child, node, i); + } else { + state.wrapBlock(delim, firstDelim(i), node, () => state.render(child, node, i)); + } + }); + + state.inTightList = prevTight; +}; + +export const renderBulletList = (state, node) => { + const { sourceMarkdown, bullet: bulletAttr } = node.attrs; + const bullet = /^(\*|\+|-)\s/.exec(sourceMarkdown)?.[1] || bulletAttr || '*'; + + renderList(state, node, ' ', () => `${bullet} `); +}; + +export function renderOrderedList(state, node) { + const { sourceMarkdown } = node.attrs; + let start; + let delimiter; + + if (sourceMarkdown) { + const match = /^(\d+)(\)|\.)/.exec(sourceMarkdown); + start = parseInt(match[1], 10) || 1; + [, , delimiter] = match; + } else { + start = node.attrs.start || 1; + delimiter = node.attrs.parens ? ')' : '.'; + } + + const maxW = String(start + node.childCount - 1).length; + const space = state.repeat(' ', maxW + 2); + + renderList(state, node, space, (i) => { + const nStr = String(start + i); + return `${state.repeat(' ', maxW - nStr.length) + nStr}${delimiter} `; + }); +} + const generateBoldTags = (wrapTagName = openTag) => { return (_, mark) => { const type = /^(\*\*|__|<strong|<b).*/.exec(mark.attrs.sourceMarkdown)?.[1]; |