Commit 51035cfa authored by Himanshu Kapoor's avatar Himanshu Kapoor Committed by Enrique Alcántara

Add support for rendering tables in content editor

Add support for rendering and editing inline content inside content
editor. Also adds a table creator that lets you create a custom sized
table with MxN rows where M, N range between 1 and 8.

Changelog: added
parent 57b5ee6b
<script>
import { GlDropdown, GlDropdownDivider, GlDropdownForm, GlButton } from '@gitlab/ui';
import { Editor as TiptapEditor } from '@tiptap/vue-2';
import { __, sprintf } from '~/locale';
import { clamp } from '../services/utils';
export const tableContentType = 'table';
const MIN_ROWS = 3;
const MIN_COLS = 3;
const MAX_ROWS = 8;
const MAX_COLS = 8;
export default {
components: {
GlDropdown,
GlDropdownDivider,
GlDropdownForm,
GlButton,
},
props: {
tiptapEditor: {
type: TiptapEditor,
required: true,
},
},
data() {
return {
maxRows: MIN_ROWS,
maxCols: MIN_COLS,
rows: 1,
cols: 1,
};
},
methods: {
list(n) {
return new Array(n).fill().map((_, i) => i + 1);
},
setRowsAndCols(rows, cols) {
this.rows = rows;
this.cols = cols;
this.maxRows = clamp(rows + 1, MIN_ROWS, MAX_ROWS);
this.maxCols = clamp(cols + 1, MIN_COLS, MAX_COLS);
},
resetState() {
this.rows = 1;
this.cols = 1;
},
insertTable() {
this.tiptapEditor
.chain()
.focus()
.insertTable({
rows: this.rows,
cols: this.cols,
withHeaderRow: true,
})
.run();
this.resetState();
this.$emit('execute', { contentType: 'table' });
},
getButtonLabel(rows, cols) {
return sprintf(__('Insert a %{rows}x%{cols} table.'), { rows, cols });
},
},
};
</script>
<template>
<gl-dropdown size="small" category="tertiary" icon="table">
<gl-dropdown-form class="gl-px-3! gl-w-auto!">
<div class="gl-w-auto!">
<div v-for="c of list(maxCols)" :key="c" class="gl-display-flex">
<gl-button
v-for="r of list(maxRows)"
:key="r"
:data-testid="`table-${r}-${c}`"
:class="{ 'gl-bg-blue-50!': r <= rows && c <= cols }"
:aria-label="getButtonLabel(r, c)"
class="gl-display-inline! gl-px-0! gl-w-5! gl-h-5! gl-rounded-0!"
@mouseover="setRowsAndCols(r, c)"
@click="insertTable()"
/>
</div>
<gl-dropdown-divider />
{{ getButtonLabel(rows, cols) }}
</div>
</gl-dropdown-form>
</gl-dropdown>
</template>
......@@ -5,6 +5,7 @@ import { ContentEditor } from '../services/content_editor';
import Divider from './divider.vue';
import ToolbarButton from './toolbar_button.vue';
import ToolbarLinkButton from './toolbar_link_button.vue';
import ToolbarTableButton from './toolbar_table_button.vue';
import ToolbarTextStyleDropdown from './toolbar_text_style_dropdown.vue';
const trackingMixin = Tracking.mixin({
......@@ -16,6 +17,7 @@ export default {
ToolbarButton,
ToolbarTextStyleDropdown,
ToolbarLinkButton,
ToolbarTableButton,
Divider,
},
mixins: [trackingMixin],
......@@ -132,5 +134,9 @@ export default {
:tiptap-editor="contentEditor.tiptapEditor"
@execute="trackToolbarControlExecution"
/>
<toolbar-table-button
:tiptap-editor="contentEditor.tiptapEditor"
@execute="trackToolbarControlExecution"
/>
</div>
</template>
import { Table } from '@tiptap/extension-table';
export const tiptapExtension = Table;
export function serializer(state, node) {
state.renderContent(node);
}
import { TableCell } from '@tiptap/extension-table-cell';
export const tiptapExtension = TableCell.extend({
content: 'inline*',
});
export function serializer(state, node) {
state.renderInline(node);
}
import { TableHeader } from '@tiptap/extension-table-header';
export const tiptapExtension = TableHeader.extend({
content: 'inline*',
});
export function serializer(state, node) {
state.renderInline(node);
}
import { TableRow } from '@tiptap/extension-table-row';
export const tiptapExtension = TableRow.extend({
allowGapCursor: false,
});
export function serializer(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();
}
}
......@@ -20,6 +20,10 @@ import * as ListItem from '../extensions/list_item';
import * as OrderedList from '../extensions/ordered_list';
import * as Paragraph from '../extensions/paragraph';
import * as Strike from '../extensions/strike';
import * as Table from '../extensions/table';
import * as TableCell from '../extensions/table_cell';
import * as TableHeader from '../extensions/table_header';
import * as TableRow from '../extensions/table_row';
import * as Text from '../extensions/text';
import buildSerializerConfig from './build_serializer_config';
import { ContentEditor } from './content_editor';
......@@ -70,6 +74,10 @@ export const createContentEditor = ({
OrderedList,
Paragraph,
Strike,
TableCell,
TableHeader,
TableRow,
Table,
Text,
];
......
......@@ -15,3 +15,5 @@ export const readFileAsDataURL = (file) => {
reader.readAsDataURL(file);
});
};
export const clamp = (n, min, max) => Math.max(Math.min(n, max), min);
......@@ -17501,6 +17501,9 @@ msgstr ""
msgid "Input the remote repository URL"
msgstr ""
msgid "Insert a %{rows}x%{cols} table."
msgstr ""
msgid "Insert a code block"
msgstr ""
......
import { GlDropdown, GlButton } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import ToolbarTableButton from '~/content_editor/components/toolbar_table_button.vue';
import { tiptapExtension as Table } from '~/content_editor/extensions/table';
import { tiptapExtension as TableCell } from '~/content_editor/extensions/table_cell';
import { tiptapExtension as TableHeader } from '~/content_editor/extensions/table_header';
import { tiptapExtension as TableRow } from '~/content_editor/extensions/table_row';
import { createTestEditor, mockChainedCommands } from '../test_utils';
describe('content_editor/components/toolbar_table_button', () => {
let wrapper;
let editor;
const buildWrapper = () => {
wrapper = mountExtended(ToolbarTableButton, {
propsData: {
tiptapEditor: editor,
},
});
};
const findDropdown = () => wrapper.findComponent(GlDropdown);
const getNumButtons = () => findDropdown().findAllComponents(GlButton).length;
beforeEach(() => {
editor = createTestEditor({
extensions: [Table, TableCell, TableRow, TableHeader],
});
buildWrapper();
});
afterEach(() => {
editor.destroy();
wrapper.destroy();
});
it('renders a grid of 3x3 buttons to create a table', () => {
expect(getNumButtons()).toBe(9); // 3 x 3
});
describe.each`
row | col | numButtons | tableSize
${1} | ${2} | ${9} | ${'1x2'}
${2} | ${2} | ${9} | ${'2x2'}
${2} | ${3} | ${12} | ${'2x3'}
${3} | ${2} | ${12} | ${'3x2'}
${3} | ${3} | ${16} | ${'3x3'}
`('button($row, $col) in the table creator grid', ({ row, col, numButtons, tableSize }) => {
describe('on mouse over', () => {
beforeEach(async () => {
const button = wrapper.findByTestId(`table-${row}-${col}`);
await button.trigger('mouseover');
});
it('marks all rows and cols before it as active', () => {
const prevRow = Math.max(1, row - 1);
const prevCol = Math.max(1, col - 1);
expect(wrapper.findByTestId(`table-${prevRow}-${prevCol}`).element).toHaveClass(
'gl-bg-blue-50!',
);
});
it('shows a help text indicating the size of the table being inserted', () => {
expect(findDropdown().element).toHaveText(`Insert a ${tableSize} table.`);
});
it('adds another row and col of buttons to create a bigger table', () => {
expect(getNumButtons()).toBe(numButtons);
});
});
describe('on click', () => {
let commands;
beforeEach(async () => {
commands = mockChainedCommands(editor, ['focus', 'insertTable', 'run']);
const button = wrapper.findByTestId(`table-${row}-${col}`);
await button.trigger('mouseover');
await button.trigger('click');
});
it('inserts a table with $tableSize rows and cols', () => {
expect(commands.focus).toHaveBeenCalled();
expect(commands.insertTable).toHaveBeenCalledWith({
rows: row,
cols: col,
withHeaderRow: true,
});
expect(commands.run).toHaveBeenCalled();
expect(wrapper.emitted().execute).toHaveLength(1);
});
});
});
it('does not create more buttons than a 8x8 grid', async () => {
for (let i = 3; i < 8; i += 1) {
expect(getNumButtons()).toBe(i * i);
// eslint-disable-next-line no-await-in-loop
await wrapper.findByTestId(`table-${i}-${i}`).trigger('mouseover');
expect(findDropdown().element).toHaveText(`Insert a ${i}x${i} table.`);
}
expect(getNumButtons()).toBe(64); // 8x8 (and not 9x9)
});
});
......@@ -74,3 +74,16 @@
markdown: |-
This is a line after a\
hard break
- name: table
markdown: |-
| header | header |
|--------|--------|
| cell | cell |
| cell | cell |
- name: table_with_alignment
markdown: |-
| header | : header : | header : |
|--------|------------|----------|
| cell | cell | cell |
| cell | cell | cell |
......@@ -1475,6 +1475,29 @@
resolved "https://registry.yarnpkg.com/@tiptap/extension-strike/-/extension-strike-2.0.0-beta.15.tgz#c274ae85b1067f80d45a1cb30d0cad24733c9be7"
integrity sha512-8R6L4jVxeGabItZ2a4B8lvcy60yhD95nRkO4ruH4iBQ5qlyGwShRbvuJQQGT/2j2RY7W793nXZ/Uohcd/5gJCw==
"@tiptap/extension-table-cell@^2.0.0-beta.13":
version "2.0.0-beta.13"
resolved "https://registry.yarnpkg.com/@tiptap/extension-table-cell/-/extension-table-cell-2.0.0-beta.13.tgz#c01eada4859d5ea487d61e68cc7fab7ed2e4842a"
integrity sha512-dnPMsBySCbOLG9irQt+cle44y6RxNVwEdknpVocjME6wAgyLpyiShHpS4Tq1j6jAhTYcXR29lNCVMcsIwzSK0A==
"@tiptap/extension-table-header@^2.0.0-beta.15":
version "2.0.0-beta.15"
resolved "https://registry.yarnpkg.com/@tiptap/extension-table-header/-/extension-table-header-2.0.0-beta.15.tgz#884d16f104671ee672f1f629f4e4fef0b096bfbb"
integrity sha512-8K0YXFG7bcM1iMZ1pAVcshXdchDQv17a1jGjKXC1+e1NjH1eb/Ya8eyFWlyxC0ZjAFNzQF4r3mGzmGTbWoI6cA==
"@tiptap/extension-table-row@^2.0.0-beta.13":
version "2.0.0-beta.13"
resolved "https://registry.yarnpkg.com/@tiptap/extension-table-row/-/extension-table-row-2.0.0-beta.13.tgz#3f9a61112afcde750228f4437ae3cd7b82d02f74"
integrity sha512-tGE3/ADBaVgpBYXgdx5YkAs7waYLKDRormUXKNnTpR+4qVHKUmXrDUTdJ2urXaANaB95F8R5Lj146h+EYiLxgw==
"@tiptap/extension-table@^2.0.0-beta.23":
version "2.0.0-beta.23"
resolved "https://registry.yarnpkg.com/@tiptap/extension-table/-/extension-table-2.0.0-beta.23.tgz#12b4b586654874b86f8ceb93b0536e846744a04e"
integrity sha512-o8V7MTCQuf0iXoKpF7q6HWTjCRu1HnpDJoKzyJN6AB1ecVDPNOce72L2EjceRgziYJy0QDBHK1xm/+C4t8h44Q==
dependencies:
prosemirror-tables "^1.1.1"
prosemirror-view "^1.18.7"
"@tiptap/extension-text@^2.0.0-beta.12":
version "2.0.0-beta.12"
resolved "https://registry.yarnpkg.com/@tiptap/extension-text/-/extension-text-2.0.0-beta.12.tgz#b857f36dda5e8cedd350f9bad7115e4060f8d9c0"
......
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