diff --git a/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue b/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue index e7a1b0583418d44713aedaa238927739755f5dde..11ac024b799b744a9d4d26815540d5cddbb54ad0 100644 --- a/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue +++ b/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue @@ -7,7 +7,7 @@ import { __, n__ } from '~/locale'; const TABLE_CELL_HEADER = 'th'; const TABLE_CELL_BODY = 'td'; -function getDropdownItems({ selectedRect, cellType, rowspan = 1, colspan = 1 }) { +function getDropdownItems({ selectedRect, cellType, rowspan = 1, colspan = 1, align = 'left' }) { const totalRows = selectedRect?.map.height; const totalCols = selectedRect?.map.width; const isTableBodyCell = cellType === TABLE_CELL_BODY; @@ -20,7 +20,19 @@ function getDropdownItems({ selectedRect, cellType, rowspan = 1, colspan = 1 }) const showDeleteRowOption = totalRows > selectedRows + 1 && isTableBodyCell; const showDeleteColumnOption = totalCols > selectedCols; + const isTableBodyHeader = cellType === TABLE_CELL_HEADER; + const showAlignLeftOption = isTableBodyHeader && (align === 'center' || align === 'right'); + const showAlignCenterOption = isTableBodyHeader && align !== 'center'; + const showAlignRightOption = isTableBodyHeader && align !== 'right'; + return [ + { + items: [ + showAlignLeftOption && { text: __('Align column left'), value: 'alignColumnLeft' }, + showAlignCenterOption && { text: __('Align column center'), value: 'alignColumnCenter' }, + showAlignRightOption && { text: __('Align column right'), value: 'alignColumnRight' }, + ].filter(Boolean), + }, { items: [ { text: __('Insert column before'), value: 'addColumnBefore' }, @@ -93,6 +105,7 @@ export default { cellType: this.cellType, rowspan: this.node.attrs.rowspan, colspan: this.node.attrs.colspan, + align: this.node.attrs.align, }); }, }, @@ -129,7 +142,7 @@ export default { runCommand({ value: command }) { this.hideDropdown(); - this.editor.chain()[command]().run(); + this.editor.chain()[command](this.getPos()).run(); }, hideDropdown() { @@ -143,6 +156,7 @@ export default { :as="cellType" :rowspan="node.attrs.rowspan || 1" :colspan="node.attrs.colspan || 1" + :align="node.attrs.align || 'left'" dir="auto" class="gl-m-0! gl-p-0! gl-relative" @click="hideDropdown" @@ -168,6 +182,10 @@ export default { @action="runCommand" /> </span> - <node-view-content as="div" class="gl-p-5 gl-min-w-10" /> + <node-view-content + as="div" + class="gl-p-5 gl-min-w-10" + :style="{ 'text-align': node.attrs.align || 'left' }" + /> </node-view-wrapper> </template> diff --git a/app/assets/javascripts/content_editor/extensions/table_cell.js b/app/assets/javascripts/content_editor/extensions/table_cell.js index 9f437ce066cf9acdc2758b7b1ab7aaab107493b9..53dba4fd960d7b2a8d080bbf7707981b9a8998ea 100644 --- a/app/assets/javascripts/content_editor/extensions/table_cell.js +++ b/app/assets/javascripts/content_editor/extensions/table_cell.js @@ -5,6 +5,17 @@ import TableCellBodyWrapper from '../components/wrappers/table_cell_body.vue'; export default TableCell.extend({ content: 'block+', + addAttributes() { + return { + ...this.parent?.(), + align: { + default: 'left', + parseHTML: (element) => element.getAttribute('align') || element.style.textAlign || 'left', + renderHTML: () => '', + }, + }; + }, + addNodeView() { return VueNodeViewRenderer(TableCellBodyWrapper); }, diff --git a/app/assets/javascripts/content_editor/extensions/table_header.js b/app/assets/javascripts/content_editor/extensions/table_header.js index 045fd03199bea8d45ab22308152e09a2ddb478f0..ca2a0eb5cfda5d5889f8b9cdb614f6feaaef323a 100644 --- a/app/assets/javascripts/content_editor/extensions/table_header.js +++ b/app/assets/javascripts/content_editor/extensions/table_header.js @@ -1,9 +1,45 @@ import { TableHeader } from '@tiptap/extension-table-header'; import { VueNodeViewRenderer } from '@tiptap/vue-2'; +import { CellSelection } from '@tiptap/pm/tables'; import TableCellHeaderWrapper from '../components/wrappers/table_cell_header.vue'; export default TableHeader.extend({ content: 'block+', + + addAttributes() { + return { + ...this.parent?.(), + align: { + default: 'left', + parseHTML: (element) => element.getAttribute('align') || element.style.textAlign || 'left', + renderHTML: () => '', + }, + }; + }, + + addCommands() { + return { + ...this.parent?.(), + alignColumn: (pos, align) => ({ commands }) => { + commands.selectColumn(pos); + commands.updateAttributes('tableHeader', { align }); + commands.updateAttributes('tableCell', { align }); + }, + alignColumnLeft: (pos) => ({ commands }) => commands.alignColumn(pos, 'left'), + alignColumnCenter: (pos) => ({ commands }) => commands.alignColumn(pos, 'center'), + alignColumnRight: (pos) => ({ commands }) => commands.alignColumn(pos, 'right'), + selectColumn: (pos) => ({ tr, dispatch }) => { + if (dispatch) { + const position = tr.doc.resolve(pos); + const colSelection = CellSelection.colSelection(position); + tr.setSelection(colSelection); + } + + return true; + }, + }; + }, + addNodeView() { return VueNodeViewRenderer(TableCellHeaderWrapper); }, diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js index 87959a44560f5e493fceac3d4e231d97d160318b..2734879e4c4263d6d9803d119f210f28246d12ab 100644 --- a/app/assets/javascripts/content_editor/services/serialization_helpers.js +++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js @@ -2,8 +2,8 @@ import { uniq, isString, omit, isFunction } from 'lodash'; import { removeLastSlashInUrlPath, removeUrlProtocol } from '../../lib/utils/url_utility'; const defaultAttrs = { - td: { colspan: 1, rowspan: 1, colwidth: null }, - th: { colspan: 1, rowspan: 1, colwidth: null }, + td: { colspan: 1, rowspan: 1, colwidth: null, align: 'left' }, + th: { colspan: 1, rowspan: 1, colwidth: null, align: 'left' }, }; const defaultIgnoreAttrs = ['sourceMarkdown', 'sourceMapKey']; diff --git a/app/assets/stylesheets/components/content_editor.scss b/app/assets/stylesheets/components/content_editor.scss index 97f2add4e7771947259d1b2b2d9673d63f84ac4a..c46eb12f4c714f8ea30f90847efef3e0890c4085 100644 --- a/app/assets/stylesheets/components/content_editor.scss +++ b/app/assets/stylesheets/components/content_editor.scss @@ -35,6 +35,10 @@ background-color: transparent; } + th[align] *, td[align] * { + text-align: inherit; + } + td, th, li, diff --git a/glfm_specification/output_example_snapshots/prosemirror_json.yml b/glfm_specification/output_example_snapshots/prosemirror_json.yml index 95e7003a202affa32d91e17a734bf228f8b55649..bec8624ebe7ab7d51f1a437120251fc68db0dfa6 100644 --- a/glfm_specification/output_example_snapshots/prosemirror_json.yml +++ b/glfm_specification/output_example_snapshots/prosemirror_json.yml @@ -2947,7 +2947,8 @@ "attrs": { "colspan": 1, "rowspan": 1, - "colwidth": null + "colwidth": null, + "align": "left" }, "content": [ { @@ -3011,7 +3012,8 @@ "attrs": { "colspan": 1, "rowspan": 1, - "colwidth": null + "colwidth": null, + "align": "left" }, "content": [ { @@ -3229,7 +3231,8 @@ "attrs": { "colspan": 1, "rowspan": 1, - "colwidth": null + "colwidth": null, + "align": "left" }, "content": [ { @@ -3881,7 +3884,8 @@ "attrs": { "colspan": 1, "rowspan": 1, - "colwidth": null + "colwidth": null, + "align": "left" }, "content": [ { @@ -5244,7 +5248,8 @@ "attrs": { "colspan": 1, "rowspan": 1, - "colwidth": null + "colwidth": null, + "align": "left" }, "content": [ { @@ -5263,7 +5268,8 @@ "attrs": { "colspan": 1, "rowspan": 1, - "colwidth": null + "colwidth": null, + "align": "left" }, "content": [ { @@ -5287,7 +5293,8 @@ "attrs": { "colspan": 1, "rowspan": 1, - "colwidth": null + "colwidth": null, + "align": "left" }, "content": [ { @@ -5306,7 +5313,8 @@ "attrs": { "colspan": 1, "rowspan": 1, - "colwidth": null + "colwidth": null, + "align": "left" }, "content": [ { @@ -5344,7 +5352,8 @@ "attrs": { "colspan": 1, "rowspan": 1, - "colwidth": null + "colwidth": null, + "align": "left" }, "content": [ { @@ -5363,7 +5372,8 @@ "attrs": { "colspan": 1, "rowspan": 1, - "colwidth": null + "colwidth": null, + "align": "left" }, "content": [ { @@ -5387,7 +5397,8 @@ "attrs": { "colspan": 1, "rowspan": 1, - "colwidth": null + "colwidth": null, + "align": "left" }, "content": [ { @@ -5406,7 +5417,8 @@ "attrs": { "colspan": 1, "rowspan": 1, - "colwidth": null + "colwidth": null, + "align": "left" }, "content": [ { @@ -5444,7 +5456,8 @@ "attrs": { "colspan": 1, "rowspan": 1, - "colwidth": null + "colwidth": null, + "align": "left" }, "content": [ { @@ -5468,7 +5481,8 @@ "attrs": { "colspan": 1, "rowspan": 1, - "colwidth": null + "colwidth": null, + "align": "left" }, "content": [ { @@ -5505,7 +5519,8 @@ "attrs": { "colspan": 1, "rowspan": 1, - "colwidth": null + "colwidth": null, + "align": "left" }, "content": [ { @@ -5556,7 +5571,8 @@ "attrs": { "colspan": 1, "rowspan": 1, - "colwidth": null + "colwidth": null, + "align": "left" }, "content": [ { @@ -5575,7 +5591,8 @@ "attrs": { "colspan": 1, "rowspan": 1, - "colwidth": null + "colwidth": null, + "align": "left" }, "content": [ { @@ -5599,7 +5616,8 @@ "attrs": { "colspan": 1, "rowspan": 1, - "colwidth": null + "colwidth": null, + "align": "left" }, "content": [ { @@ -5618,7 +5636,8 @@ "attrs": { "colspan": 1, "rowspan": 1, - "colwidth": null + "colwidth": null, + "align": "left" }, "content": [ { @@ -5673,7 +5692,8 @@ "attrs": { "colspan": 1, "rowspan": 1, - "colwidth": null + "colwidth": null, + "align": "left" }, "content": [ { @@ -5692,7 +5712,8 @@ "attrs": { "colspan": 1, "rowspan": 1, - "colwidth": null + "colwidth": null, + "align": "left" }, "content": [ { @@ -5716,7 +5737,8 @@ "attrs": { "colspan": 1, "rowspan": 1, - "colwidth": null + "colwidth": null, + "align": "left" }, "content": [ { @@ -5735,7 +5757,8 @@ "attrs": { "colspan": 1, "rowspan": 1, - "colwidth": null + "colwidth": null, + "align": "left" }, "content": [ { @@ -5759,7 +5782,8 @@ "attrs": { "colspan": 1, "rowspan": 1, - "colwidth": null + "colwidth": null, + "align": "left" }, "content": [ { @@ -5778,7 +5802,8 @@ "attrs": { "colspan": 1, "rowspan": 1, - "colwidth": null + "colwidth": null, + "align": "left" }, "content": [ { @@ -5834,7 +5859,8 @@ "attrs": { "colspan": 1, "rowspan": 1, - "colwidth": null + "colwidth": null, + "align": "left" }, "content": [ { @@ -5853,7 +5879,8 @@ "attrs": { "colspan": 1, "rowspan": 1, - "colwidth": null + "colwidth": null, + "align": "left" }, "content": [ { @@ -5877,7 +5904,8 @@ "attrs": { "colspan": 1, "rowspan": 1, - "colwidth": null + "colwidth": null, + "align": "left" }, "content": [ { @@ -5896,7 +5924,8 @@ "attrs": { "colspan": 1, "rowspan": 1, - "colwidth": null + "colwidth": null, + "align": "left" }, "content": [ { @@ -5914,7 +5943,8 @@ "attrs": { "colspan": 1, "rowspan": 1, - "colwidth": null + "colwidth": null, + "align": "left" }, "content": [ { @@ -5933,7 +5963,8 @@ "attrs": { "colspan": 1, "rowspan": 1, - "colwidth": null + "colwidth": null, + "align": "left" }, "content": [ { @@ -5971,7 +6002,8 @@ "attrs": { "colspan": 1, "rowspan": 1, - "colwidth": null + "colwidth": null, + "align": "left" }, "content": [ { @@ -5990,7 +6022,8 @@ "attrs": { "colspan": 1, "rowspan": 1, - "colwidth": null + "colwidth": null, + "align": "left" }, "content": [ { @@ -23111,7 +23144,8 @@ "attrs": { "colspan": 1, "rowspan": 1, - "colwidth": null + "colwidth": null, + "align": "left" }, "content": [ { @@ -23130,7 +23164,8 @@ "attrs": { "colspan": 1, "rowspan": 1, - "colwidth": null + "colwidth": null, + "align": "left" }, "content": [ { @@ -23154,7 +23189,8 @@ "attrs": { "colspan": 1, "rowspan": 1, - "colwidth": null + "colwidth": null, + "align": "left" }, "content": [ { @@ -23178,7 +23214,8 @@ "attrs": { "colspan": 1, "rowspan": 1, - "colwidth": null + "colwidth": null, + "align": "left" }, "content": [ { @@ -23211,7 +23248,8 @@ "attrs": { "colspan": 1, "rowspan": 1, - "colwidth": null + "colwidth": null, + "align": "left" }, "content": [ { @@ -23235,7 +23273,8 @@ "attrs": { "colspan": 1, "rowspan": 1, - "colwidth": null + "colwidth": null, + "align": "left" }, "content": [ { @@ -23775,7 +23814,8 @@ "attrs": { "colspan": 1, "rowspan": 1, - "colwidth": null + "colwidth": null, + "align": "left" }, "content": [ { @@ -23794,7 +23834,8 @@ "attrs": { "colspan": 1, "rowspan": 1, - "colwidth": null + "colwidth": null, + "align": "left" }, "content": [ { @@ -23818,7 +23859,8 @@ "attrs": { "colspan": 1, "rowspan": 1, - "colwidth": null + "colwidth": null, + "align": "left" }, "content": [ { @@ -23865,7 +23907,8 @@ "attrs": { "colspan": 1, "rowspan": 1, - "colwidth": null + "colwidth": null, + "align": "left" }, "content": [ { diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 28cfe4f90a6a1448c0cf18c4af2c8b21fb652845..56834765d76093636f9d231243b62e2bc5a9d836 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -4803,6 +4803,15 @@ msgstr "" msgid "Algorithm" msgstr "" +msgid "Align column center" +msgstr "" + +msgid "Align column left" +msgstr "" + +msgid "Align column right" +msgstr "" + msgid "All" msgstr "" diff --git a/spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js b/spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js index 94628f2b2c581cd8290c4b5661d391d10034e953..9f233f2f4121b5e976c2d95a01d193198c3c24f1 100644 --- a/spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js +++ b/spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js @@ -149,6 +149,10 @@ describe('content/components/wrappers/table_cell_base', () => { }, ); + it('does not show alignment options for table cells', () => { + expect(findDropdown().text()).not.toContain('Align'); + }); + describe("when current row is the table's header", () => { beforeEach(async () => { // Remove 2 rows condition @@ -179,6 +183,44 @@ describe('content/components/wrappers/table_cell_base', () => { }); }); + describe.each` + currentAlignment | visibleOptions | newAlignment | command + ${'left'} | ${['center', 'right']} | ${'center'} | ${'alignColumnCenter'} + ${'center'} | ${['left', 'right']} | ${'right'} | ${'alignColumnRight'} + ${'right'} | ${['left', 'center']} | ${'left'} | ${'alignColumnLeft'} + `( + 'when align=$currentAlignment', + ({ currentAlignment, visibleOptions, newAlignment, command }) => { + beforeEach(async () => { + Object.assign(node.attrs, { align: currentAlignment }); + + createWrapper({ cellType: 'th' }); + + await nextTick(); + }); + + visibleOptions.forEach((alignment) => { + it(`shows "Align column ${alignment}" option`, () => { + expect(findDropdown().text()).toContain(`Align column ${alignment}`); + }); + }); + + it(`does not show "Align column ${currentAlignment}" option`, () => { + expect(findDropdown().text()).not.toContain(`Align column ${currentAlignment}`); + }); + + it('allows changing alignment', async () => { + const mocks = mockChainedCommands(editor, [command, 'run']); + + await wrapper + .findByRole('button', { name: `Align column ${newAlignment}` }) + .trigger('click'); + + expect(mocks[command]).toHaveBeenCalled(); + }); + }, + ); + describe.each` attrs | rect ${{ rowspan: 2 }} | ${{ top: 0, left: 0, bottom: 2, right: 1 }} diff --git a/spec/frontend/content_editor/services/markdown_serializer_spec.js b/spec/frontend/content_editor/services/markdown_serializer_spec.js index c329a12bcc4aff008ed0e638f57920c468ab60fc..16f8fc23ce7a9f58d0346872bc070d72dc207590 100644 --- a/spec/frontend/content_editor/services/markdown_serializer_spec.js +++ b/spec/frontend/content_editor/services/markdown_serializer_spec.js @@ -1080,6 +1080,38 @@ _An elephant at sunset_ ); }); + it('correctly serializes a table with inline content with alignment', () => { + expect( + serialize( + table( + // each table cell must contain at least one paragraph + tableRow( + tableHeader({ align: 'center' }, paragraph('header')), + tableHeader({ align: 'right' }, paragraph('header')), + tableHeader({ align: 'left' }, 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 a pipe in a cell', () => { expect( serialize(