diff options
Diffstat (limited to 'app/assets/javascripts/content_editor/services')
3 files changed, 140 insertions, 251 deletions
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 5c48c0b1d43..b7082910161 100644 --- a/app/assets/javascripts/content_editor/services/create_content_editor.js +++ b/app/assets/javascripts/content_editor/services/create_content_editor.js @@ -1,69 +1,8 @@ import { Editor } from '@tiptap/vue-2'; -import { isFunction } from 'lodash'; +import { isFunction, flatMap } from 'lodash'; import eventHubFactory from '~/helpers/event_hub_factory'; 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 CodeSuggestion from '../extensions/code_suggestion'; -import ColorChip from '../extensions/color_chip'; -import CopyPaste from '../extensions/copy_paste'; -import DescriptionItem from '../extensions/description_item'; -import DescriptionList from '../extensions/description_list'; -import Details from '../extensions/details'; -import DetailsContent from '../extensions/details_content'; -import Diagram from '../extensions/diagram'; -import DrawioDiagram from '../extensions/drawio_diagram'; -import Document from '../extensions/document'; -import Dropcursor from '../extensions/dropcursor'; -import Emoji from '../extensions/emoji'; -import ExternalKeydownHandler from '../extensions/external_keydown_handler'; -import Figure from '../extensions/figure'; -import FigureCaption from '../extensions/figure_caption'; -import FootnoteDefinition from '../extensions/footnote_definition'; -import FootnoteReference from '../extensions/footnote_reference'; -import FootnotesSection from '../extensions/footnotes_section'; -import Frontmatter from '../extensions/frontmatter'; -import Gapcursor from '../extensions/gapcursor'; -import HardBreak from '../extensions/hard_break'; -import Heading from '../extensions/heading'; -import History from '../extensions/history'; -import Highlight from '../extensions/highlight'; -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'; -import Link from '../extensions/link'; -import ListItem from '../extensions/list_item'; -import Loading from '../extensions/loading'; -import MathInline from '../extensions/math_inline'; -import OrderedList from '../extensions/ordered_list'; -import Paragraph from '../extensions/paragraph'; -import Reference from '../extensions/reference'; -import ReferenceLabel from '../extensions/reference_label'; -import ReferenceDefinition from '../extensions/reference_definition'; -import Selection from '../extensions/selection'; -import Sourcemap from '../extensions/sourcemap'; -import Strike from '../extensions/strike'; -import Subscript from '../extensions/subscript'; -import Suggestions from '../extensions/suggestions'; -import Superscript from '../extensions/superscript'; -import Table from '../extensions/table'; -import TableCell from '../extensions/table_cell'; -import TableHeader from '../extensions/table_header'; -import TableOfContents from '../extensions/table_of_contents'; -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 WordBreak from '../extensions/word_break'; +import * as builtInExtensions from '../extensions'; import { ContentEditor } from './content_editor'; import MarkdownSerializer from './markdown_serializer'; import createGlApiMarkdownDeserializer from './gl_api_markdown_deserializer'; @@ -107,68 +46,18 @@ export const createContentEditor = ({ render: renderMarkdown, }); - const builtInContentEditorExtensions = [ - Attachment.configure({ uploadsPath, renderMarkdown, eventHub }), - Audio, - Blockquote, - Bold, - BulletList, - Code, - ColorChip, - CodeBlockHighlight, - CodeSuggestion.configure({ config: codeSuggestionsConfig }), - DescriptionItem, - DescriptionList, - Details, - DetailsContent, - Document, - Diagram, - Dropcursor, - Emoji, - Figure, - FigureCaption, - FootnoteDefinition, - FootnoteReference, - FootnotesSection, - Frontmatter, - Gapcursor, - HardBreak, - Heading, - History, - Highlight, - HorizontalRule, - ...HTMLMarks, - ...HTMLNodes, - Image, - InlineDiff, - Italic, - ExternalKeydownHandler.configure({ eventHub }), - Link, - ListItem, - Loading, - MathInline, - OrderedList, - Paragraph, - CopyPaste.configure({ eventHub, renderMarkdown, serializer }), - Reference.configure({ assetResolver }), - ReferenceLabel, - ReferenceDefinition, - Selection, - Sourcemap, - Strike, - Subscript, - Superscript, - TableCell, - TableHeader, - TableOfContents, - TableRow, - Table, - TaskItem, - TaskList, - Text, - Video, - WordBreak, - ]; + const { Suggestions, DrawioDiagram, ...otherExtensions } = builtInExtensions; + + const builtInContentEditorExtensions = flatMap(otherExtensions).map((ext) => + ext.configure({ + uploadsPath, + renderMarkdown, + eventHub, + codeSuggestionsConfig, + serializer, + assetResolver, + }), + ); const allExtensions = [...builtInContentEditorExtensions, ...extensions]; diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js index 3b759de57f2..5688f30dcc3 100644 --- a/app/assets/javascripts/content_editor/services/markdown_serializer.js +++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js @@ -2,56 +2,7 @@ import { MarkdownSerializer as ProseMirrorMarkdownSerializer, defaultMarkdownSerializer, } from '~/lib/prosemirror_markdown_serializer'; -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 CodeSuggestion from '../extensions/code_suggestion'; -import DescriptionItem from '../extensions/description_item'; -import DescriptionList from '../extensions/description_list'; -import Details from '../extensions/details'; -import DetailsContent from '../extensions/details_content'; -import DrawioDiagram from '../extensions/drawio_diagram'; -import Diagram from '../extensions/diagram'; -import Emoji from '../extensions/emoji'; -import Figure from '../extensions/figure'; -import FigureCaption from '../extensions/figure_caption'; -import FootnoteDefinition from '../extensions/footnote_definition'; -import FootnoteReference from '../extensions/footnote_reference'; -import Frontmatter from '../extensions/frontmatter'; -import HardBreak from '../extensions/hard_break'; -import Heading from '../extensions/heading'; -import HorizontalRule from '../extensions/horizontal_rule'; -import Highlight from '../extensions/highlight'; -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'; -import Link from '../extensions/link'; -import ListItem from '../extensions/list_item'; -import Loading from '../extensions/loading'; -import MathInline from '../extensions/math_inline'; -import OrderedList from '../extensions/ordered_list'; -import Paragraph from '../extensions/paragraph'; -import Reference from '../extensions/reference'; -import ReferenceLabel from '../extensions/reference_label'; -import ReferenceDefinition from '../extensions/reference_definition'; -import Strike from '../extensions/strike'; -import Subscript from '../extensions/subscript'; -import Superscript from '../extensions/superscript'; -import Table from '../extensions/table'; -import TableCell from '../extensions/table_cell'; -import TableHeader from '../extensions/table_header'; -import TableOfContents from '../extensions/table_of_contents'; -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 WordBreak from '../extensions/word_break'; +import * as extensions from '../extensions'; import { renderCodeBlock, renderHardBreak, @@ -62,6 +13,8 @@ import { closeTag, renderOrderedList, renderImage, + renderHeading, + renderBlockquote, renderPlayable, renderHTMLNode, renderContent, @@ -78,13 +31,13 @@ import { const defaultSerializerConfig = { marks: { - [Bold.name]: bold, - [Italic.name]: italic, - [Code.name]: code, - [Subscript.name]: { open: '<sub>', close: '</sub>', mixable: true }, - [Superscript.name]: { open: '<sup>', close: '</sup>', mixable: true }, - [Highlight.name]: { open: '<mark>', close: '</mark>', mixable: true }, - [InlineDiff.name]: { + [extensions.Bold.name]: bold, + [extensions.Italic.name]: italic, + [extensions.Code.name]: code, + [extensions.Subscript.name]: { open: '<sub>', close: '</sub>', mixable: true }, + [extensions.Superscript.name]: { open: '<sup>', close: '</sup>', mixable: true }, + [extensions.Highlight.name]: { open: '<mark>', close: '</mark>', mixable: true }, + [extensions.InlineDiff.name]: { mixable: true, open(_, mark) { return mark.attrs.type === 'addition' ? '{+' : '{-'; @@ -93,14 +46,14 @@ const defaultSerializerConfig = { return mark.attrs.type === 'addition' ? '+}' : '-}'; }, }, - [Link.name]: link, - [MathInline.name]: { + [extensions.Link.name]: link, + [extensions.MathInline.name]: { open: (...args) => `$${defaultMarkdownSerializer.marks.code.open(...args)}`, close: (...args) => `${defaultMarkdownSerializer.marks.code.close(...args)}$`, escape: false, }, - [Strike.name]: strike, - ...HTMLMarks.reduce( + [extensions.Strike.name]: strike, + ...extensions.HTMLMarks.reduce( (acc, { name }) => ({ ...acc, [name]: { @@ -116,38 +69,27 @@ const defaultSerializerConfig = { }, nodes: { - [Audio.name]: preserveUnchanged({ + [extensions.Audio.name]: preserveUnchanged({ render: renderPlayable, inline: true, }), - [Blockquote.name]: preserveUnchanged((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]: preserveUnchanged(renderBulletList), - [CodeBlockHighlight.name]: preserveUnchanged(renderCodeBlock), - [Diagram.name]: preserveUnchanged(renderCodeBlock), - [CodeSuggestion.name]: preserveUnchanged(renderCodeBlock), - [DrawioDiagram.name]: preserveUnchanged({ + [extensions.Blockquote.name]: preserveUnchanged(renderBlockquote), + [extensions.BulletList.name]: preserveUnchanged(renderBulletList), + [extensions.CodeBlockHighlight.name]: preserveUnchanged(renderCodeBlock), + [extensions.Diagram.name]: preserveUnchanged(renderCodeBlock), + [extensions.CodeSuggestion.name]: preserveUnchanged(renderCodeBlock), + [extensions.DrawioDiagram.name]: preserveUnchanged({ render: renderImage, inline: true, }), - [DescriptionList.name]: renderHTMLNode('dl', true), - [DescriptionItem.name]: (state, node, parent, index) => { + [extensions.DescriptionList.name]: renderHTMLNode('dl', true), + [extensions.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(); }, - [Details.name]: renderHTMLNode('details', true), - [DetailsContent.name]: (state, node, parent, index) => { + [extensions.Details.name]: renderHTMLNode('details', true), + [extensions.DetailsContent.name]: (state, node, parent, index) => { if (!index) renderHTMLNode('summary')(state, node); else { if (index === 1) state.ensureNewLine(); @@ -155,23 +97,23 @@ const defaultSerializerConfig = { if (index === parent.childCount - 1) state.ensureNewLine(); } }, - [Emoji.name]: (state, node) => { + [extensions.Emoji.name]: (state, node) => { const { name } = node.attrs; state.write(`:${name}:`); }, - [FootnoteDefinition.name]: preserveUnchanged((state, node) => { + [extensions.FootnoteDefinition.name]: preserveUnchanged((state, node) => { state.write(`[^${node.attrs.identifier}]: `); state.renderInline(node); state.ensureNewLine(); }), - [FootnoteReference.name]: preserveUnchanged({ + [extensions.FootnoteReference.name]: preserveUnchanged({ render: (state, node) => { state.write(`[^${node.attrs.identifier}]`); }, inline: true, }), - [Frontmatter.name]: preserveUnchanged((state, node) => { + [extensions.Frontmatter.name]: preserveUnchanged((state, node) => { const { language } = node.attrs; const syntax = { toml: '+++', @@ -185,22 +127,24 @@ const defaultSerializerConfig = { state.write(syntax); state.closeBlock(node); }), - [Figure.name]: renderHTMLNode('figure'), - [FigureCaption.name]: renderHTMLNode('figcaption'), - [HardBreak.name]: preserveUnchanged(renderHardBreak), - [Heading.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.heading), - [HorizontalRule.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.horizontal_rule), - [Image.name]: preserveUnchanged({ + [extensions.Figure.name]: renderHTMLNode('figure'), + [extensions.FigureCaption.name]: renderHTMLNode('figcaption'), + [extensions.HardBreak.name]: preserveUnchanged(renderHardBreak), + [extensions.Heading.name]: preserveUnchanged(renderHeading), + [extensions.HorizontalRule.name]: preserveUnchanged( + defaultMarkdownSerializer.nodes.horizontal_rule, + ), + [extensions.Image.name]: preserveUnchanged({ render: renderImage, inline: true, }), - [ListItem.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.list_item), - [Loading.name]: () => {}, - [OrderedList.name]: preserveUnchanged(renderOrderedList), - [Paragraph.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.paragraph), - [Reference.name]: renderReference, - [ReferenceLabel.name]: renderReferenceLabel, - [ReferenceDefinition.name]: preserveUnchanged({ + [extensions.ListItem.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.list_item), + [extensions.Loading.name]: () => {}, + [extensions.OrderedList.name]: preserveUnchanged(renderOrderedList), + [extensions.Paragraph.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.paragraph), + [extensions.Reference.name]: renderReference, + [extensions.ReferenceLabel.name]: renderReferenceLabel, + [extensions.ReferenceDefinition.name]: preserveUnchanged({ render: (state, node, parent, index, same, sourceMarkdown) => { const nextSibling = parent.maybeChild(index + 1); @@ -211,7 +155,7 @@ const defaultSerializerConfig = { * because it isn’t necessary and a more compact text format * is preferred. */ - if (!nextSibling || nextSibling.type.name !== ReferenceDefinition.name) { + if (!nextSibling || nextSibling.type.name !== extensions.ReferenceDefinition.name) { state.closeBlock(node); } else { state.ensureNewLine(); @@ -219,34 +163,35 @@ const defaultSerializerConfig = { }, overwriteSourcePreservationStrategy: true, }), - [TableOfContents.name]: preserveUnchanged((state, node) => { + [extensions.TableOfContents.name]: preserveUnchanged((state, node) => { state.write('[[_TOC_]]'); state.closeBlock(node); }), - [Table.name]: preserveUnchanged(renderTable), - [TableCell.name]: renderTableCell, - [TableHeader.name]: renderTableCell, - [TableRow.name]: renderTableRow, - [TaskItem.name]: preserveUnchanged((state, node) => { + [extensions.Table.name]: preserveUnchanged(renderTable), + [extensions.TableCell.name]: renderTableCell, + [extensions.TableHeader.name]: renderTableCell, + [extensions.TableRow.name]: renderTableRow, + [extensions.TaskItem.name]: preserveUnchanged((state, node) => { let symbol = ' '; if (node.attrs.inapplicable) symbol = '~'; else if (node.attrs.checked) symbol = 'x'; state.write(`[${symbol}] `); + if (!node.textContent) state.write(' '); state.renderContent(node); }), - [TaskList.name]: preserveUnchanged((state, node) => { + [extensions.TaskList.name]: preserveUnchanged((state, node) => { if (node.attrs.numeric) renderOrderedList(state, node); else renderBulletList(state, node); }), - [Text.name]: defaultMarkdownSerializer.nodes.text, - [Video.name]: preserveUnchanged({ + [extensions.Text.name]: defaultMarkdownSerializer.nodes.text, + [extensions.Video.name]: preserveUnchanged({ render: renderPlayable, inline: true, }), - [WordBreak.name]: (state) => state.write('<wbr>'), - ...HTMLNodes.reduce((serializers, htmlNode) => { + [extensions.WordBreak.name]: (state) => state.write('<wbr>'), + ...extensions.HTMLNodes.reduce((serializers, htmlNode) => { return { ...serializers, [htmlNode.name]: (state, node) => renderHTMLNode(htmlNode.options.tagName)(state, node), @@ -310,7 +255,7 @@ export default class MarkdownSerializer { * changed. * @returns A String that represents the serialized document as Markdown */ - serialize({ doc, pristineDoc }) { + serialize({ doc, pristineDoc }, { useCanonicalSrc = true, skipEmptyNodes = false } = {}) { const changeTracker = createChangeTracker(doc, pristineDoc); const serializer = new ProseMirrorMarkdownSerializer( { @@ -325,6 +270,8 @@ export default class MarkdownSerializer { return serializer.serialize(doc, { tightLists: true, + useCanonicalSrc, + skipEmptyNodes, changeTracker, escapeExtraCharacters: /<|>/g, }); diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js index 7a2fbf8fcab..4bf61e34120 100644 --- a/app/assets/javascripts/content_editor/services/serialization_helpers.js +++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js @@ -1,4 +1,5 @@ -import { uniq, isString, omit, isFunction } from 'lodash'; +import { uniq, omit, isFunction } from 'lodash'; +import { defaultMarkdownSerializer } from '~/lib/prosemirror_markdown_serializer'; import { removeLastSlashInUrlPath, removeUrlProtocol } from '../../lib/utils/url_utility'; const defaultAttrs = { @@ -110,8 +111,8 @@ function htmlEncode(str = '') { .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') - .replace(/'/g, ''') - .replace(/"/g, '"'); + .replace(/'/g, ''') + .replace(/"/g, '"'); } const shouldIgnoreAttr = (tagName, attrKey, attrValue) => @@ -337,15 +338,22 @@ export function renderHardBreak(state, node, parent, index) { } } +function getMediaSrc(node, useCanonicalSrc = true) { + const { canonicalSrc, src } = node.attrs; + + if (useCanonicalSrc) return canonicalSrc || src || ''; + return src || ''; +} + export function renderImage(state, node) { - const { alt, canonicalSrc, src, title, width, height, isReference } = node.attrs; - const realSrc = canonicalSrc || src || ''; + const { alt, title, width, height, isReference } = node.attrs; + const realSrc = getMediaSrc(node, state.options.useCanonicalSrc); // eslint-disable-next-line @gitlab/require-i18n-strings if (realSrc.startsWith('data:') || realSrc.startsWith('blob:')) return; - if (isString(src) || isString(canonicalSrc)) { + if (realSrc) { const quotedTitle = title ? ` ${state.quote(title)}` : ''; - const sourceExpression = isReference ? `[${canonicalSrc}]` : `(${realSrc}${quotedTitle})`; + const sourceExpression = isReference ? `[${realSrc}]` : `(${realSrc}${quotedTitle})`; const sizeAttributes = []; if (width) { @@ -365,12 +373,44 @@ export function renderPlayable(state, node) { renderImage(state, node); } +export function renderHeading(state, node) { + if (state.options.skipEmptyNodes && !node.childCount) return; + + defaultMarkdownSerializer.nodes.heading(state, node); +} + +export function renderBlockquote(state, node) { + if (state.options.skipEmptyNodes) { + if (!node.childCount) return; + if (node.childCount === 1) { + const child = node.child(0); + if (child.type.name === 'paragraph' && !child.childCount) return; + } + } + + 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)); + } +} + export function renderCodeBlock(state, node) { + if (state.options.skipEmptyNodes && !node.childCount) return; + + let { language } = node.attrs; + if (language === 'plaintext') language = ''; + const numBackticks = Math.max(2, node.textContent.match(/```+/g)?.[0]?.length || 0) + 1; const backticks = state.repeat('`', numBackticks); state.write( `${backticks}${ - (node.attrs.language || '') + (node.attrs.langParams ? `:${node.attrs.langParams}` : '') + (language || '') + (node.attrs.langParams ? `:${node.attrs.langParams}` : '') }\n`, ); state.text(node.textContent, false); @@ -641,13 +681,20 @@ const isAutoLink = (linkMark, parent) => { */ const isBracketAutoLink = (sourceMarkdown) => /^<.+?>$/.test(sourceMarkdown); +function getLinkHref(mark, useCanonicalSrc = true) { + const { canonicalSrc, href } = mark.attrs; + + if (useCanonicalSrc) return canonicalSrc || href || ''; + return href || ''; +} + export const link = { open(state, mark, parent) { if (isAutoLink(mark, parent)) { return isBracketAutoLink(mark.attrs.sourceMarkdown) ? '<' : ''; } - const { canonicalSrc, href, title, sourceMarkdown } = mark.attrs; + const { href, title, sourceMarkdown } = mark.attrs; // eslint-disable-next-line @gitlab/require-i18n-strings if (href.startsWith('data:') || href.startsWith('blob:')) return ''; @@ -656,7 +703,9 @@ export const link = { return '['; } - const attrs = { href: state.esc(href || canonicalSrc || '') }; + const attrs = { + href: state.esc(getLinkHref(mark, state.options.useCanonicalSrc)), + }; if (title) { attrs.title = title; @@ -669,25 +718,29 @@ export const link = { return isBracketAutoLink(mark.attrs.sourceMarkdown) ? '>' : ''; } - const { canonicalSrc, href, title, sourceMarkdown, isReference } = mark.attrs; + const { href, title, sourceMarkdown, isReference } = mark.attrs; // eslint-disable-next-line @gitlab/require-i18n-strings if (href.startsWith('data:') || href.startsWith('blob:')) return ''; if (isReference) { - return `][${state.esc(canonicalSrc || href || '')}]`; + return `][${state.esc(getLinkHref(mark, state.options.useCanonicalSrc))}]`; } if (linkType(sourceMarkdown) === LINK_HTML) { return closeTag('a'); } - return `](${state.esc(canonicalSrc || href || '')}${title ? ` ${state.quote(title)}` : ''})`; + return `](${state.esc(getLinkHref(mark, state.options.useCanonicalSrc))}${ + title ? ` ${state.quote(title)}` : '' + })`; }, }; const generateStrikeTag = (wrapTagName = openTag) => { return (_, mark) => { + if (mark.attrs.htmlTag) return wrapTagName(mark.attrs.htmlTag); + const type = /^(~~|<del|<strike|<s).*/.exec(mark.attrs.sourceMarkdown)?.[1]; switch (type) { |