diff options
Diffstat (limited to 'app/assets/javascripts/content_editor/services')
6 files changed, 503 insertions, 81 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 8997960203a..9b2d4c9a062 100644 --- a/app/assets/javascripts/content_editor/services/create_content_editor.js +++ b/app/assets/javascripts/content_editor/services/create_content_editor.js @@ -2,19 +2,26 @@ import { Editor } from '@tiptap/vue-2'; import { isFunction } from 'lodash'; import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants'; import Attachment from '../extensions/attachment'; +import Audio from '../extensions/audio'; import Blockquote from '../extensions/blockquote'; import Bold from '../extensions/bold'; import BulletList from '../extensions/bullet_list'; import Code from '../extensions/code'; import CodeBlockHighlight from '../extensions/code_block_highlight'; +import DescriptionItem from '../extensions/description_item'; +import DescriptionList from '../extensions/description_list'; +import Division from '../extensions/division'; import Document from '../extensions/document'; import Dropcursor from '../extensions/dropcursor'; import Emoji from '../extensions/emoji'; +import Figure from '../extensions/figure'; +import FigureCaption from '../extensions/figure_caption'; import Gapcursor from '../extensions/gapcursor'; import HardBreak from '../extensions/hard_break'; import Heading from '../extensions/heading'; import History from '../extensions/history'; import HorizontalRule from '../extensions/horizontal_rule'; +import HTMLMarks from '../extensions/html_marks'; import Image from '../extensions/image'; import InlineDiff from '../extensions/inline_diff'; import Italic from '../extensions/italic'; @@ -34,6 +41,7 @@ import TableRow from '../extensions/table_row'; import TaskItem from '../extensions/task_item'; import TaskList from '../extensions/task_list'; import Text from '../extensions/text'; +import Video from '../extensions/video'; import { ContentEditor } from './content_editor'; import createMarkdownSerializer from './markdown_serializer'; import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts'; @@ -62,19 +70,26 @@ export const createContentEditor = ({ const builtInContentEditorExtensions = [ Attachment.configure({ uploadsPath, renderMarkdown }), + Audio, Blockquote, Bold, BulletList, Code, CodeBlockHighlight, + DescriptionItem, + DescriptionList, Document, + Division, Dropcursor, Emoji, + Figure, + FigureCaption, Gapcursor, HardBreak, Heading, History, HorizontalRule, + ...HTMLMarks, Image, InlineDiff, Italic, @@ -94,6 +109,7 @@ export const createContentEditor = ({ TaskItem, TaskList, Text, + Video, ]; const allExtensions = [...builtInContentEditorExtensions, ...extensions]; diff --git a/app/assets/javascripts/content_editor/services/feature_flags.js b/app/assets/javascripts/content_editor/services/feature_flags.js new file mode 100644 index 00000000000..5f7a4595938 --- /dev/null +++ b/app/assets/javascripts/content_editor/services/feature_flags.js @@ -0,0 +1,3 @@ +export function isBlockTablesFeatureEnabled() { + return gon.features?.contentEditorBlockTables; +} diff --git a/app/assets/javascripts/content_editor/services/mark_utils.js b/app/assets/javascripts/content_editor/services/mark_utils.js new file mode 100644 index 00000000000..6ccfed7810a --- /dev/null +++ b/app/assets/javascripts/content_editor/services/mark_utils.js @@ -0,0 +1,17 @@ +export const markInputRegex = (tag) => + new RegExp(`(<(${tag})((?: \\w+=".+?")+)?>([^<]+)</${tag}>)$`, 'gm'); + +export const extractMarkAttributesFromMatch = ([, , , attrsString]) => { + const attrRegex = /(\w+)="(.+?)"/g; + const attrs = {}; + + let key; + let value; + + do { + [, key, value] = attrRegex.exec(attrsString) || []; + if (key) attrs[key] = value; + } while (key); + + return attrs; +}; diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js index df4d31c3d7f..bc6d98511f9 100644 --- a/app/assets/javascripts/content_editor/services/markdown_serializer.js +++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js @@ -3,15 +3,22 @@ import { defaultMarkdownSerializer, } from 'prosemirror-markdown/src/to_markdown'; import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model'; +import Audio from '../extensions/audio'; import Blockquote from '../extensions/blockquote'; import Bold from '../extensions/bold'; import BulletList from '../extensions/bullet_list'; import Code from '../extensions/code'; import CodeBlockHighlight from '../extensions/code_block_highlight'; +import DescriptionItem from '../extensions/description_item'; +import DescriptionList from '../extensions/description_list'; +import Division from '../extensions/division'; import Emoji from '../extensions/emoji'; +import Figure from '../extensions/figure'; +import FigureCaption from '../extensions/figure_caption'; import HardBreak from '../extensions/hard_break'; import Heading from '../extensions/heading'; import HorizontalRule from '../extensions/horizontal_rule'; +import HTMLMarks from '../extensions/html_marks'; import Image from '../extensions/image'; import InlineDiff from '../extensions/inline_diff'; import Italic from '../extensions/italic'; @@ -30,6 +37,20 @@ import TableRow from '../extensions/table_row'; import TaskItem from '../extensions/task_item'; import TaskList from '../extensions/task_list'; import Text from '../extensions/text'; +import Video from '../extensions/video'; +import { + isPlainURL, + renderHardBreak, + renderTable, + renderTableCell, + renderTableRow, + openTag, + closeTag, + renderOrderedList, + renderImage, + renderPlayable, + renderHTMLNode, +} from './serialization_helpers'; const defaultSerializerConfig = { marks: { @@ -48,14 +69,15 @@ const defaultSerializerConfig = { }, }, [Link.name]: { - open() { - return '['; + open(state, mark, parent, index) { + return isPlainURL(mark, parent, index, 1) ? '<' : '['; }, - close(state, mark) { + close(state, mark, parent, index) { const href = mark.attrs.canonicalSrc || mark.attrs.href; - return `](${state.esc(href)}${ - mark.attrs.title ? ` ${state.quote(mark.attrs.title)}` : '' - })`; + + return isPlainURL(mark, parent, index, -1) + ? '>' + : `](${state.esc(href)}${mark.attrs.title ? ` ${state.quote(mark.attrs.title)}` : ''})`; }, }, [Strike.name]: { @@ -64,9 +86,35 @@ const defaultSerializerConfig = { mixable: true, expelEnclosingWhitespace: true, }, + ...HTMLMarks.reduce( + (acc, { name }) => ({ + ...acc, + [name]: { + mixable: true, + open(state, node) { + return openTag(name, node.attrs); + }, + close: closeTag(name), + }, + }), + {}, + ), }, + nodes: { - [Blockquote.name]: defaultMarkdownSerializer.nodes.blockquote, + [Audio.name]: renderPlayable, + [Blockquote.name]: (state, node) => { + if (node.attrs.multiline) { + state.write('>>>'); + state.ensureNewLine(); + state.renderContent(node); + state.ensureNewLine(); + state.write('>>>'); + state.closeBlock(node); + } else { + state.wrapBlock('> ', null, node, () => state.renderContent(node)); + } + }, [BulletList.name]: defaultMarkdownSerializer.nodes.bullet_list, [CodeBlockHighlight.name]: (state, node) => { state.write(`\`\`\`${node.attrs.language || ''}\n`); @@ -75,94 +123,47 @@ const defaultSerializerConfig = { state.write('```'); state.closeBlock(node); }, + [Division.name]: renderHTMLNode('div'), + [DescriptionList.name]: renderHTMLNode('dl', true), + [DescriptionItem.name]: (state, node, parent, index) => { + if (index === 1) state.ensureNewLine(); + renderHTMLNode(node.attrs.isTerm ? 'dt' : 'dd')(state, node); + if (index === parent.childCount - 1) state.ensureNewLine(); + }, [Emoji.name]: (state, node) => { const { name } = node.attrs; state.write(`:${name}:`); }, - [HardBreak.name]: defaultMarkdownSerializer.nodes.hard_break, + [Figure.name]: renderHTMLNode('figure'), + [FigureCaption.name]: renderHTMLNode('figcaption'), + [HardBreak.name]: renderHardBreak, [Heading.name]: defaultMarkdownSerializer.nodes.heading, [HorizontalRule.name]: defaultMarkdownSerializer.nodes.horizontal_rule, - [Image.name]: (state, node) => { - const { alt, canonicalSrc, src, title } = node.attrs; - const quotedTitle = title ? ` ${state.quote(title)}` : ''; - - state.write(`![${state.esc(alt || '')}](${state.esc(canonicalSrc || src)}${quotedTitle})`); - }, + [Image.name]: renderImage, [ListItem.name]: defaultMarkdownSerializer.nodes.list_item, - [OrderedList.name]: defaultMarkdownSerializer.nodes.ordered_list, + [OrderedList.name]: renderOrderedList, [Paragraph.name]: defaultMarkdownSerializer.nodes.paragraph, [Reference.name]: (state, node) => { state.write(node.attrs.originalText || node.attrs.text); }, - [Table.name]: (state, node) => { - state.renderContent(node); - }, - [TableCell.name]: (state, node) => { - state.renderInline(node); - }, - [TableHeader.name]: (state, node) => { - state.renderInline(node); - }, - [TableRow.name]: (state, node) => { - const isHeaderRow = node.child(0).type.name === 'tableHeader'; - - const renderRow = () => { - const cellWidths = []; - - state.flushClose(1); - - state.write('| '); - node.forEach((cell, _, i) => { - if (i) state.write(' | '); - - const { length } = state.out; - state.render(cell, node, i); - cellWidths.push(state.out.length - length); - }); - state.write(' |'); - - state.closeBlock(node); - - return cellWidths; - }; - - const renderHeaderRow = (cellWidths) => { - state.flushClose(1); - - state.write('|'); - node.forEach((cell, _, i) => { - if (i) state.write('|'); - - state.write(cell.attrs.align === 'center' ? ':' : '-'); - state.write(state.repeat('-', cellWidths[i])); - state.write(cell.attrs.align === 'center' || cell.attrs.align === 'right' ? ':' : '-'); - }); - state.write('|'); - - state.closeBlock(node); - }; - - if (isHeaderRow) { - renderHeaderRow(renderRow()); - } else { - renderRow(); - } - }, + [Table.name]: renderTable, + [TableCell.name]: renderTableCell, + [TableHeader.name]: renderTableCell, + [TableRow.name]: renderTableRow, [TaskItem.name]: (state, node) => { state.write(`[${node.attrs.checked ? 'x' : ' '}] `); state.renderContent(node); }, [TaskList.name]: (state, node) => { - if (node.attrs.type === 'ul') defaultMarkdownSerializer.nodes.bullet_list(state, node); - else defaultMarkdownSerializer.nodes.ordered_list(state, node); + if (node.attrs.numeric) renderOrderedList(state, node); + else defaultMarkdownSerializer.nodes.bullet_list(state, node); }, [Text.name]: defaultMarkdownSerializer.nodes.text, + [Video.name]: renderPlayable, }, }; -const wrapHtmlPayload = (payload) => `<div>${payload}</div>`; - /** * A markdown serializer converts arbitrary Markdown content * into a ProseMirror document and viceversa. To convert Markdown @@ -175,7 +176,7 @@ const wrapHtmlPayload = (payload) => `<div>${payload}</div>`; * that parses the Markdown and converts it into HTML. * @returns a markdown serializer */ -export default ({ render = () => null, serializerConfig }) => ({ +export default ({ render = () => null, serializerConfig = {} } = {}) => ({ /** * Converts a Markdown string into a ProseMirror JSONDocument based * on a ProseMirror schema. @@ -187,15 +188,15 @@ export default ({ render = () => null, serializerConfig }) => ({ deserialize: async ({ schema, content }) => { const html = await render(content); - if (!html) { - return null; - } + if (!html) return null; const parser = new DOMParser(); - const { - body: { firstElementChild }, - } = parser.parseFromString(wrapHtmlPayload(html), 'text/html'); - const state = ProseMirrorDOMParser.fromSchema(schema).parse(firstElementChild); + const { body } = parser.parseFromString(html, 'text/html'); + + // append original source as a comment that nodes can access + body.append(document.createComment(content)); + + const state = ProseMirrorDOMParser.fromSchema(schema).parse(body); return state.toJSON(); }, diff --git a/app/assets/javascripts/content_editor/services/markdown_sourcemap.js b/app/assets/javascripts/content_editor/services/markdown_sourcemap.js new file mode 100644 index 00000000000..a1199589c9b --- /dev/null +++ b/app/assets/javascripts/content_editor/services/markdown_sourcemap.js @@ -0,0 +1,40 @@ +const getFullSource = (element) => { + const commentNode = element.ownerDocument.body.lastChild; + + if (commentNode.nodeName === '#comment') { + return commentNode.textContent.split('\n'); + } + + return []; +}; + +const getRangeFromSourcePos = (sourcePos) => { + const [start, end] = sourcePos.split('-'); + const [startRow, startCol] = start.split(':'); + const [endRow, endCol] = end.split(':'); + + return { + start: { row: Number(startRow) - 1, col: Number(startCol) - 1 }, + end: { row: Number(endRow) - 1, col: Number(endCol) - 1 }, + }; +}; + +export const getMarkdownSource = (element) => { + if (!element.dataset.sourcepos) return undefined; + + const source = getFullSource(element); + const range = getRangeFromSourcePos(element.dataset.sourcepos); + let elSource = ''; + + for (let i = range.start.row; i <= range.end.row; i += 1) { + if (i === range.start.row) { + elSource += source[i]?.substring(range.start.col); + } else if (i === range.end.row) { + elSource += `\n${source[i]?.substring(0, range.start.col)}`; + } else { + elSource += `\n${source[i]}` || ''; + } + } + + return elSource.trim(); +}; diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js new file mode 100644 index 00000000000..b2327555b45 --- /dev/null +++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js @@ -0,0 +1,345 @@ +import { uniq } from 'lodash'; +import { isBlockTablesFeatureEnabled } from './feature_flags'; + +const defaultAttrs = { + td: { colspan: 1, rowspan: 1, colwidth: null }, + th: { colspan: 1, rowspan: 1, colwidth: null }, +}; + +const ignoreAttrs = { + dd: ['isTerm'], + dt: ['isTerm'], +}; + +const tableMap = new WeakMap(); + +// Source taken from +// prosemirror-markdown/src/to_markdown.js +export function isPlainURL(link, parent, index, side) { + if (link.attrs.title || !/^\w+:/.test(link.attrs.href)) return false; + const content = parent.child(index + (side < 0 ? -1 : 0)); + if ( + !content.isText || + content.text !== link.attrs.href || + content.marks[content.marks.length - 1] !== link + ) + return false; + if (index === (side < 0 ? 1 : parent.childCount - 1)) return true; + const next = parent.child(index + (side < 0 ? -2 : 1)); + return !link.isInSet(next.marks); +} + +function containsOnlyText(node) { + if (node.childCount === 1) { + const child = node.child(0); + return child.isText && child.marks.length === 0; + } + + return false; +} + +function containsParagraphWithOnlyText(cell) { + if (cell.childCount === 1) { + const child = cell.child(0); + if (child.type.name === 'paragraph') { + return containsOnlyText(child); + } + } + + return false; +} + +function getRowsAndCells(table) { + const cells = []; + const rows = []; + table.descendants((n) => { + if (n.type.name === 'tableCell' || n.type.name === 'tableHeader') { + cells.push(n); + return false; + } + + if (n.type.name === 'tableRow') { + rows.push(n); + } + + return true; + }); + return { rows, cells }; +} + +function getChildren(node) { + const children = []; + for (let i = 0; i < node.childCount; i += 1) { + children.push(node.child(i)); + } + return children; +} + +function shouldRenderHTMLTable(table) { + const { rows, cells } = getRowsAndCells(table); + + const cellChildCount = Math.max(...cells.map((cell) => cell.childCount)); + const maxColspan = Math.max(...cells.map((cell) => cell.attrs.colspan)); + const maxRowspan = Math.max(...cells.map((cell) => cell.attrs.rowspan)); + + const rowChildren = rows.map((row) => uniq(getChildren(row).map((cell) => cell.type.name))); + const cellTypeInFirstRow = rowChildren[0]; + const cellTypesInOtherRows = uniq(rowChildren.slice(1).map(([type]) => type)); + + // if the first row has headers, and there are no headers anywhere else, render markdown table + if ( + !( + cellTypeInFirstRow.length === 1 && + cellTypeInFirstRow[0] === 'tableHeader' && + cellTypesInOtherRows.length === 1 && + cellTypesInOtherRows[0] === 'tableCell' + ) + ) { + return true; + } + + if (cellChildCount === 1 && maxColspan === 1 && maxRowspan === 1) { + // if all rows contain only one paragraph each and no rowspan/colspan, render markdown table + const children = uniq(cells.map((cell) => cell.child(0).type.name)); + if (children.length === 1 && children[0] === 'paragraph') { + return false; + } + } + + return true; +} + +function htmlEncode(str = '') { + return str + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/'/g, ''') + .replace(/"/g, '"'); +} + +export function openTag(tagName, attrs) { + let str = `<${tagName}`; + + str += Object.entries(attrs || {}) + .map(([key, value]) => { + if ((ignoreAttrs[tagName] || []).includes(key) || defaultAttrs[tagName]?.[key] === value) + return ''; + + return ` ${key}="${htmlEncode(value?.toString())}"`; + }) + .join(''); + + return `${str}>`; +} + +export function closeTag(tagName) { + return `</${tagName}>`; +} + +function isInBlockTable(node) { + return tableMap.get(node); +} + +function isInTable(node) { + return tableMap.has(node); +} + +function setIsInBlockTable(table, value) { + tableMap.set(table, value); + + const { rows, cells } = getRowsAndCells(table); + rows.forEach((row) => tableMap.set(row, value)); + cells.forEach((cell) => { + tableMap.set(cell, value); + if (cell.childCount && cell.child(0).type.name === 'paragraph') + tableMap.set(cell.child(0), value); + }); +} + +function unsetIsInBlockTable(table) { + tableMap.delete(table); + + const { rows, cells } = getRowsAndCells(table); + rows.forEach((row) => tableMap.delete(row)); + cells.forEach((cell) => { + tableMap.delete(cell); + if (cell.childCount) tableMap.delete(cell.child(0)); + }); +} + +function renderTagOpen(state, tagName, attrs) { + state.ensureNewLine(); + state.write(openTag(tagName, attrs)); +} + +function renderTagClose(state, tagName, insertNewline = true) { + state.write(closeTag(tagName)); + if (insertNewline) state.ensureNewLine(); +} + +function renderTableHeaderRowAsMarkdown(state, node, cellWidths) { + state.flushClose(1); + + state.write('|'); + node.forEach((cell, _, i) => { + if (i) state.write('|'); + + state.write(cell.attrs.align === 'center' ? ':' : '-'); + state.write(state.repeat('-', cellWidths[i])); + state.write(cell.attrs.align === 'center' || cell.attrs.align === 'right' ? ':' : '-'); + }); + state.write('|'); + + state.closeBlock(node); +} + +function renderTableRowAsMarkdown(state, node, isHeaderRow = false) { + const cellWidths = []; + + state.flushClose(1); + + state.write('| '); + node.forEach((cell, _, i) => { + if (i) state.write(' | '); + + const { length } = state.out; + state.render(cell, node, i); + cellWidths.push(state.out.length - length); + }); + state.write(' |'); + + state.closeBlock(node); + + if (isHeaderRow) renderTableHeaderRowAsMarkdown(state, node, cellWidths); +} + +function renderTableRowAsHTML(state, node) { + renderTagOpen(state, 'tr'); + + node.forEach((cell, _, i) => { + const tag = cell.type.name === 'tableHeader' ? 'th' : 'td'; + + renderTagOpen(state, tag, cell.attrs); + + if (!containsParagraphWithOnlyText(cell)) { + state.closeBlock(node); + state.flushClose(); + } + + state.render(cell, node, i); + state.flushClose(1); + + renderTagClose(state, tag); + }); + + renderTagClose(state, 'tr'); +} + +export function renderContent(state, node, forceRenderInline) { + if (node.type.inlineContent) { + if (containsOnlyText(node)) { + state.renderInline(node); + } else { + state.closeBlock(node); + state.flushClose(); + state.renderInline(node); + state.closeBlock(node); + state.flushClose(); + } + } else { + const renderInline = forceRenderInline || containsParagraphWithOnlyText(node); + if (!renderInline) { + state.closeBlock(node); + state.flushClose(); + state.renderContent(node); + state.ensureNewLine(); + } else { + state.renderInline(forceRenderInline ? node : node.child(0)); + } + } +} + +export function renderHTMLNode(tagName, forceRenderInline = false) { + return (state, node) => { + renderTagOpen(state, tagName, node.attrs); + renderContent(state, node, forceRenderInline); + renderTagClose(state, tagName, false); + }; +} + +export function renderOrderedList(state, node) { + const { parens } = node.attrs; + const start = node.attrs.start || 1; + const maxW = String(start + node.childCount - 1).length; + const space = state.repeat(' ', maxW + 2); + const delimiter = parens ? ')' : '.'; + + state.renderList(node, space, (i) => { + const nStr = String(start + i); + return `${state.repeat(' ', maxW - nStr.length) + nStr}${delimiter} `; + }); +} + +export function renderTableCell(state, node) { + if (!isBlockTablesFeatureEnabled()) { + state.renderInline(node); + return; + } + + if (!isInBlockTable(node) || containsParagraphWithOnlyText(node)) { + state.renderInline(node.child(0)); + } else { + state.renderContent(node); + } +} + +export function renderTableRow(state, node) { + if (isInBlockTable(node)) { + renderTableRowAsHTML(state, node); + } else { + renderTableRowAsMarkdown(state, node, node.child(0).type.name === 'tableHeader'); + } +} + +export function renderTable(state, node) { + if (isBlockTablesFeatureEnabled()) { + setIsInBlockTable(node, shouldRenderHTMLTable(node)); + } + + if (isInBlockTable(node)) renderTagOpen(state, 'table'); + + state.renderContent(node); + + if (isInBlockTable(node)) renderTagClose(state, 'table'); + + // ensure at least one blank line after any table + state.closeBlock(node); + state.flushClose(); + + if (isBlockTablesFeatureEnabled()) { + unsetIsInBlockTable(node); + } +} + +export function renderHardBreak(state, node, parent, index) { + const br = isInTable(parent) ? '<br>' : '\\\n'; + + for (let i = index + 1; i < parent.childCount; i += 1) { + if (parent.child(i).type !== node.type) { + state.write(br); + return; + } + } +} + +export function renderImage(state, node) { + const { alt, canonicalSrc, src, title } = node.attrs; + const quotedTitle = title ? ` ${state.quote(title)}` : ''; + + state.write(`![${state.esc(alt || '')}](${state.esc(canonicalSrc || src)}${quotedTitle})`); +} + +export function renderPlayable(state, node) { + renderImage(state, node); +} |