diff options
Diffstat (limited to 'app/assets/javascripts/content_editor/services/serialization_helpers.js')
-rw-r--r-- | app/assets/javascripts/content_editor/services/serialization_helpers.js | 345 |
1 files changed, 345 insertions, 0 deletions
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); +} |