Commit 0b5df713 authored by Paul Slaughter's avatar Paul Slaughter

Merge branch '335844-block-tables' into 'master'

Allow rendering block content in tables

See merge request gitlab-org/gitlab!66187
parents e690cc79 9034aab3
import { TableCell } from '@tiptap/extension-table-cell'; import { TableCell } from '@tiptap/extension-table-cell';
import { isBlockTablesFeatureEnabled } from '../services/feature_flags';
export default TableCell.extend({ export default TableCell.extend({
content: 'inline*', content: isBlockTablesFeatureEnabled() ? 'block+' : 'inline*',
}); });
import { TableHeader } from '@tiptap/extension-table-header'; import { TableHeader } from '@tiptap/extension-table-header';
import { isBlockTablesFeatureEnabled } from '../services/feature_flags';
export default TableHeader.extend({ export default TableHeader.extend({
content: 'inline*', content: isBlockTablesFeatureEnabled() ? 'block+' : 'inline*',
}); });
export function isBlockTablesFeatureEnabled() {
return gon.features?.contentEditorBlockTables;
}
...@@ -30,6 +30,12 @@ import TableRow from '../extensions/table_row'; ...@@ -30,6 +30,12 @@ import TableRow from '../extensions/table_row';
import TaskItem from '../extensions/task_item'; import TaskItem from '../extensions/task_item';
import TaskList from '../extensions/task_list'; import TaskList from '../extensions/task_list';
import Text from '../extensions/text'; import Text from '../extensions/text';
import {
renderHardBreak,
renderTable,
renderTableCell,
renderTableRow,
} from './serialization_helpers';
const defaultSerializerConfig = { const defaultSerializerConfig = {
marks: { marks: {
...@@ -65,6 +71,7 @@ const defaultSerializerConfig = { ...@@ -65,6 +71,7 @@ const defaultSerializerConfig = {
expelEnclosingWhitespace: true, expelEnclosingWhitespace: true,
}, },
}, },
nodes: { nodes: {
[Blockquote.name]: defaultMarkdownSerializer.nodes.blockquote, [Blockquote.name]: defaultMarkdownSerializer.nodes.blockquote,
[BulletList.name]: defaultMarkdownSerializer.nodes.bullet_list, [BulletList.name]: defaultMarkdownSerializer.nodes.bullet_list,
...@@ -80,7 +87,7 @@ const defaultSerializerConfig = { ...@@ -80,7 +87,7 @@ const defaultSerializerConfig = {
state.write(`:${name}:`); state.write(`:${name}:`);
}, },
[HardBreak.name]: defaultMarkdownSerializer.nodes.hard_break, [HardBreak.name]: renderHardBreak,
[Heading.name]: defaultMarkdownSerializer.nodes.heading, [Heading.name]: defaultMarkdownSerializer.nodes.heading,
[HorizontalRule.name]: defaultMarkdownSerializer.nodes.horizontal_rule, [HorizontalRule.name]: defaultMarkdownSerializer.nodes.horizontal_rule,
[Image.name]: (state, node) => { [Image.name]: (state, node) => {
...@@ -95,60 +102,10 @@ const defaultSerializerConfig = { ...@@ -95,60 +102,10 @@ const defaultSerializerConfig = {
[Reference.name]: (state, node) => { [Reference.name]: (state, node) => {
state.write(node.attrs.originalText || node.attrs.text); state.write(node.attrs.originalText || node.attrs.text);
}, },
[Table.name]: (state, node) => { [Table.name]: renderTable,
state.renderContent(node); [TableCell.name]: renderTableCell,
}, [TableHeader.name]: renderTableCell,
[TableCell.name]: (state, node) => { [TableRow.name]: renderTableRow,
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();
}
},
[TaskItem.name]: (state, node) => { [TaskItem.name]: (state, node) => {
state.write(`[${node.attrs.checked ? 'x' : ' '}] `); state.write(`[${node.attrs.checked ? 'x' : ' '}] `);
state.renderContent(node); state.renderContent(node);
...@@ -175,7 +132,7 @@ const wrapHtmlPayload = (payload) => `<div>${payload}</div>`; ...@@ -175,7 +132,7 @@ const wrapHtmlPayload = (payload) => `<div>${payload}</div>`;
* that parses the Markdown and converts it into HTML. * that parses the Markdown and converts it into HTML.
* @returns a markdown serializer * @returns a markdown serializer
*/ */
export default ({ render = () => null, serializerConfig }) => ({ export default ({ render = () => null, serializerConfig = {} } = {}) => ({
/** /**
* Converts a Markdown string into a ProseMirror JSONDocument based * Converts a Markdown string into a ProseMirror JSONDocument based
* on a ProseMirror schema. * on a ProseMirror schema.
......
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 tableMap = new WeakMap();
function shouldRenderCellInline(cell) {
if (cell.childCount === 1) {
const parent = cell.child(0);
if (parent.type.name === 'paragraph' && parent.childCount === 1) {
const child = parent.child(0);
return child.isText && child.marks.length === 0;
}
}
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 openTag(state, tagName, attrs) {
let str = `<${tagName}`;
str += Object.entries(attrs || {})
.map(([key, value]) => {
if (defaultAttrs[tagName]?.[key] === value) return '';
return ` ${key}=${state.quote(value?.toString() || '')}`;
})
.join('');
return `${str}>`;
}
function closeTag(state, 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(state, tagName, attrs));
}
function renderTagClose(state, tagName, insertNewline = true) {
state.write(closeTag(state, 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 (!shouldRenderCellInline(cell)) {
state.closeBlock(node);
state.flushClose();
}
state.render(cell, node, i);
state.flushClose(1);
renderTagClose(state, tag);
});
renderTagClose(state, 'tr');
}
export function renderTableCell(state, node) {
if (!isBlockTablesFeatureEnabled()) {
state.renderInline(node);
return;
}
if (!isInBlockTable(node) || shouldRenderCellInline(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;
}
}
}
...@@ -5,5 +5,9 @@ class Projects::WikisController < Projects::ApplicationController ...@@ -5,5 +5,9 @@ class Projects::WikisController < Projects::ApplicationController
alias_method :container, :project alias_method :container, :project
before_action do
push_frontend_feature_flag(:content_editor_block_tables, @project, default_enabled: :yaml)
end
feature_category :wiki feature_category :wiki
end end
---
name: content_editor_block_tables
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/66187
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/338937
milestone: '14.3'
type: development
group: group::editor
default_enabled: false
import Blockquote from '~/content_editor/extensions/blockquote';
import Bold from '~/content_editor/extensions/bold';
import BulletList from '~/content_editor/extensions/bullet_list';
import Code from '~/content_editor/extensions/code';
import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight';
import Emoji from '~/content_editor/extensions/emoji';
import HardBreak from '~/content_editor/extensions/hard_break';
import Heading from '~/content_editor/extensions/heading';
import HorizontalRule from '~/content_editor/extensions/horizontal_rule';
import Image from '~/content_editor/extensions/image';
import Italic from '~/content_editor/extensions/italic';
import Link from '~/content_editor/extensions/link';
import ListItem from '~/content_editor/extensions/list_item';
import OrderedList from '~/content_editor/extensions/ordered_list';
import Paragraph from '~/content_editor/extensions/paragraph';
import Strike from '~/content_editor/extensions/strike';
import Table from '~/content_editor/extensions/table';
import TableCell from '~/content_editor/extensions/table_cell';
import TableHeader from '~/content_editor/extensions/table_header';
import TableRow from '~/content_editor/extensions/table_row';
import Text from '~/content_editor/extensions/text';
import markdownSerializer from '~/content_editor/services/markdown_serializer';
import { createTestEditor, createDocBuilder } from '../test_utils';
jest.mock('~/emoji');
jest.mock('~/content_editor/services/feature_flags', () => ({
isBlockTablesFeatureEnabled: jest.fn().mockReturnValue(true),
}));
const tiptapEditor = createTestEditor({
extensions: [
Blockquote,
Bold,
BulletList,
Code,
CodeBlockHighlight,
Emoji,
HardBreak,
Heading,
HorizontalRule,
Image,
Italic,
Link,
ListItem,
OrderedList,
Paragraph,
Strike,
Table,
TableCell,
TableHeader,
TableRow,
Text,
],
});
const {
builders: {
doc,
blockquote,
bold,
bulletList,
code,
codeBlock,
emoji,
heading,
hardBreak,
horizontalRule,
image,
italic,
link,
listItem,
orderedList,
paragraph,
strike,
table,
tableCell,
tableHeader,
tableRow,
},
} = createDocBuilder({
tiptapEditor,
names: {
blockquote: { nodeType: Blockquote.name },
bold: { markType: Bold.name },
bulletList: { nodeType: BulletList.name },
code: { markType: Code.name },
codeBlock: { nodeType: CodeBlockHighlight.name },
emoji: { markType: Emoji.name },
hardBreak: { nodeType: HardBreak.name },
heading: { nodeType: Heading.name },
horizontalRule: { nodeType: HorizontalRule.name },
image: { nodeType: Image.name },
italic: { nodeType: Italic.name },
link: { markType: Link.name },
listItem: { nodeType: ListItem.name },
orderedList: { nodeType: OrderedList.name },
paragraph: { nodeType: Paragraph.name },
strike: { markType: Strike.name },
table: { nodeType: Table.name },
tableCell: { nodeType: TableCell.name },
tableHeader: { nodeType: TableHeader.name },
tableRow: { nodeType: TableRow.name },
},
});
const serialize = (...content) =>
markdownSerializer({}).serialize({
schema: tiptapEditor.schema,
content: doc(...content).toJSON(),
});
describe('markdownSerializer', () => {
it('correctly serializes a line break', () => {
expect(serialize(paragraph('hello', hardBreak(), 'world'))).toBe('hello\\\nworld');
});
it('correctly serializes a table with inline content', () => {
expect(
serialize(
table(
// each table cell must contain at least one paragraph
tableRow(
tableHeader(paragraph('header')),
tableHeader(paragraph('header')),
tableHeader(paragraph('header')),
),
tableRow(
tableCell(paragraph('cell')),
tableCell(paragraph('cell')),
tableCell(paragraph('cell')),
),
tableRow(
tableCell(paragraph('cell')),
tableCell(paragraph('cell')),
tableCell(paragraph('cell')),
),
),
).trim(),
).toBe(
`
| header | header | header |
|--------|--------|--------|
| cell | cell | cell |
| cell | cell | cell |
`.trim(),
);
});
it('correctly serializes a table with line breaks', () => {
expect(
serialize(
table(
tableRow(tableHeader(paragraph('header')), tableHeader(paragraph('header'))),
tableRow(
tableCell(paragraph('cell with', hardBreak(), 'line', hardBreak(), 'breaks')),
tableCell(paragraph('cell')),
),
tableRow(tableCell(paragraph('cell')), tableCell(paragraph('cell'))),
),
).trim(),
).toBe(
`
| header | header |
|--------|--------|
| cell with<br>line<br>breaks | cell |
| cell | cell |
`.trim(),
);
});
it('correctly serializes two consecutive tables', () => {
expect(
serialize(
table(
tableRow(tableHeader(paragraph('header')), tableHeader(paragraph('header'))),
tableRow(tableCell(paragraph('cell')), tableCell(paragraph('cell'))),
tableRow(tableCell(paragraph('cell')), tableCell(paragraph('cell'))),
),
table(
tableRow(tableHeader(paragraph('header')), tableHeader(paragraph('header'))),
tableRow(tableCell(paragraph('cell')), tableCell(paragraph('cell'))),
tableRow(tableCell(paragraph('cell')), tableCell(paragraph('cell'))),
),
).trim(),
).toBe(
`
| header | header |
|--------|--------|
| cell | cell |
| cell | cell |
| header | header |
|--------|--------|
| cell | cell |
| cell | cell |
`.trim(),
);
});
it('correctly serializes a table with block content', () => {
expect(
serialize(
table(
tableRow(
tableHeader(paragraph('examples of')),
tableHeader(paragraph('block content')),
tableHeader(paragraph('in tables')),
tableHeader(paragraph('in content editor')),
),
tableRow(
tableCell(heading({ level: 1 }, 'heading 1')),
tableCell(heading({ level: 2 }, 'heading 2')),
tableCell(paragraph(bold('just bold'))),
tableCell(paragraph(bold('bold'), ' ', italic('italic'), ' ', code('code'))),
),
tableRow(
tableCell(
paragraph('all marks in three paragraphs:'),
paragraph('the ', bold('quick'), ' ', italic('brown'), ' ', code('fox')),
paragraph(
link({ href: '/home' }, 'jumps'),
' over the ',
strike('lazy'),
' ',
emoji({ name: 'dog' }),
),
),
tableCell(
paragraph(image({ src: 'img.jpg', alt: 'some image' }), hardBreak(), 'image content'),
),
tableCell(
blockquote('some text', hardBreak(), hardBreak(), 'in a multiline blockquote'),
),
tableCell(
codeBlock(
{ language: 'javascript' },
'var a = 2;\nvar b = 3;\nvar c = a + d;\n\nconsole.log(c);',
),
),
),
tableRow(
tableCell(bulletList(listItem('item 1'), listItem('item 2'), listItem('item 2'))),
tableCell(orderedList(listItem('item 1'), listItem('item 2'), listItem('item 2'))),
tableCell(
paragraph('paragraphs separated by'),
horizontalRule(),
paragraph('a horizontal rule'),
),
tableCell(
table(
tableRow(tableHeader(paragraph('table')), tableHeader(paragraph('inside'))),
tableRow(tableCell(paragraph('another')), tableCell(paragraph('table'))),
),
),
),
),
).trim(),
).toBe(
`
<table>
<tr>
<th>examples of</th>
<th>block content</th>
<th>in tables</th>
<th>in content editor</th>
</tr>
<tr>
<td>
# heading 1
</td>
<td>
## heading 2
</td>
<td>
**just bold**
</td>
<td>
**bold** _italic_ \`code\`
</td>
</tr>
<tr>
<td>
all marks in three paragraphs:
the **quick** _brown_ \`fox\`
[jumps](/home) over the ~~lazy~~ :dog:
</td>
<td>
![some image](img.jpg)<br>image content
</td>
<td>
> some text\\
> \\
> in a multiline blockquote
</td>
<td>
\`\`\`javascript
var a = 2;
var b = 3;
var c = a + d;
console.log(c);
\`\`\`
</td>
</tr>
<tr>
<td>
* item 1
* item 2
* item 2
</td>
<td>
1. item 1
2. item 2
3. item 2
</td>
<td>
paragraphs separated by
---
a horizontal rule
</td>
<td>
| table | inside |
|-------|--------|
| another | table |
</td>
</tr>
</table>
`.trim(),
);
});
it('correctly renders content after a markdown table', () => {
expect(
serialize(
table(tableRow(tableHeader(paragraph('header'))), tableRow(tableCell(paragraph('cell')))),
heading({ level: 1 }, 'this is a heading'),
).trim(),
).toBe(
`
| header |
|--------|
| cell |
# this is a heading
`.trim(),
);
});
it('correctly renders content after an html table', () => {
expect(
serialize(
table(
tableRow(tableHeader(paragraph('header'))),
tableRow(tableCell(blockquote('hi'), paragraph('there'))),
),
heading({ level: 1 }, 'this is a heading'),
).trim(),
).toBe(
`
<table>
<tr>
<th>header</th>
</tr>
<tr>
<td>
> hi
there
</td>
</tr>
</table>
# this is a heading
`.trim(),
);
});
it('correctly serializes tables with misplaced header cells', () => {
expect(
serialize(
table(
tableRow(tableHeader(paragraph('cell')), tableCell(paragraph('cell'))),
tableRow(tableCell(paragraph('cell')), tableHeader(paragraph('cell'))),
),
).trim(),
).toBe(
`
<table>
<tr>
<th>cell</th>
<td>cell</td>
</tr>
<tr>
<td>cell</td>
<th>cell</th>
</tr>
</table>
`.trim(),
);
});
it('correctly serializes table without any headers', () => {
expect(
serialize(
table(
tableRow(tableCell(paragraph('cell')), tableCell(paragraph('cell'))),
tableRow(tableCell(paragraph('cell')), tableCell(paragraph('cell'))),
),
).trim(),
).toBe(
`
<table>
<tr>
<td>cell</td>
<td>cell</td>
</tr>
<tr>
<td>cell</td>
<td>cell</td>
</tr>
</table>
`.trim(),
);
});
it('correctly serializes table with rowspan and colspan', () => {
expect(
serialize(
table(
tableRow(
tableHeader(paragraph('header')),
tableHeader(paragraph('header')),
tableHeader(paragraph('header')),
),
tableRow(
tableCell({ colspan: 2 }, paragraph('cell with rowspan: 2')),
tableCell({ rowspan: 2 }, paragraph('cell')),
),
tableRow(tableCell({ colspan: 2 }, paragraph('cell with rowspan: 2'))),
),
).trim(),
).toBe(
`
<table>
<tr>
<th>header</th>
<th>header</th>
<th>header</th>
</tr>
<tr>
<td colspan="2">cell with rowspan: 2</td>
<td rowspan="2">cell</td>
</tr>
<tr>
<td colspan="2">cell with rowspan: 2</td>
</tr>
</table>
`.trim(),
);
});
});
...@@ -102,14 +102,10 @@ ...@@ -102,14 +102,10 @@
markdown: |- markdown: |-
| header | header | | header | header |
|--------|--------| |--------|--------|
| cell | cell | | `code` | cell with **bold** |
| cell | cell | | ~~strike~~ | cell with _italic_ |
- name: table_with_alignment
markdown: |- # content after table
| header | : header : | header : |
|--------|------------|----------|
| cell | cell | cell |
| cell | cell | cell |
- name: emoji - name: emoji
markdown: ':sparkles: :heart: :100:' markdown: ':sparkles: :heart: :100:'
- name: reference - name: reference
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment