From 4a157e70861fc8b77d639208b124f57264a335b0 Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Wed, 6 Sep 2023 15:05:59 +0100 Subject: [PATCH] Merge pull request #14664 from overleaf/ae-paste-rowspan [visual] Handle rowspan on pasted table cells GitOrigin-RevId: 53dda99ae1c300b9c4ec8f711b70b16ef66392c8 --- .../extensions/visual/paste-html.ts | 96 +++++++++++++++---- ...demirror-editor-visual-paste-html.spec.tsx | 44 ++++++++- 2 files changed, 121 insertions(+), 19 deletions(-) diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/paste-html.ts b/services/web/frontend/js/features/source-editor/extensions/visual/paste-html.ts index a232f97a88..7173cfe6ca 100644 --- a/services/web/frontend/js/features/source-editor/extensions/visual/paste-html.ts +++ b/services/web/frontend/js/features/source-editor/extensions/visual/paste-html.ts @@ -229,9 +229,58 @@ const processTables = (element: HTMLElement) => { // move the table into the container container.append(table) + + // add empty cells to account for rowspan + for (const cell of table.querySelectorAll( + 'th[rowspan],td[rowspan]' + )) { + const rowspan = Number(cell.getAttribute('rowspan') || '1') + const colspan = Number(cell.getAttribute('colspan') || '1') + + let row: HTMLTableRowElement | null = cell.closest('tr') + if (row) { + let position = 0 + for (const child of row.cells) { + if (child === cell) { + break + } + position += Number(child.getAttribute('colspan') || '1') + } + for (let i = 1; i < rowspan; i++) { + const nextElement: Element | null = row?.nextElementSibling + if (!isTableRow(nextElement)) { + break + } + row = nextElement + + let targetCell: HTMLTableCellElement | undefined + let targetPosition = 0 + for (const child of row.cells) { + if (targetPosition === position) { + targetCell = child + break + } + targetPosition += Number(child.getAttribute('colspan') || '1') + } + + const fillerCells = Array.from({ length: colspan }, () => + document.createElement('td') + ) + + if (targetCell) { + targetCell.before(...fillerCells) + } else { + row.append(...fillerCells) + } + } + } + } } } +const isTableRow = (element: Element | null): element is HTMLTableRowElement => + element?.tagName === 'TR' + const cellAlignment = new Map([ ['left', 'l'], ['center', 'c'], @@ -370,9 +419,15 @@ const nextRowHasBorderStyle = ( } const startMulticolumn = (element: HTMLTableCellElement): string => { - const colspan = element.getAttribute('colspan') ?? 1 + const colspan = Number(element.getAttribute('colspan') || 1) const alignment = cellAlignment.get(element.style.textAlign) ?? 'l' - return `\\multicolumn{${Number(colspan)}}{${alignment}}{` + return `\\multicolumn{${colspan}}{${alignment}}{` +} + +const startMultirow = (element: HTMLTableCellElement): string => { + const rowspan = Number(element.getAttribute('rowspan') || 1) + // NOTE: it would be useful to read cell width if specified, using `*` as a starting point + return `\\multirow{${rowspan}}{*}{` } const selectors = [ @@ -539,25 +594,30 @@ const selectors = [ }, }), createSelector({ - selector: 'tr > td:not(:last-child), tr > th:not(:last-child)', + selector: 'tr > td, tr > th', start: (element: HTMLTableCellElement) => { - const colspan = element.getAttribute('colspan') - return colspan ? startMulticolumn(element) : '' + let output = '' + if (element.getAttribute('colspan')) { + output += startMulticolumn(element) + } + // NOTE: multirow is nested inside multicolumn + if (element.getAttribute('rowspan')) { + output += startMultirow(element) + } + return output }, end: element => { - const colspan = element.getAttribute('colspan') - return colspan ? `} & ` : ` & ` - }, - }), - createSelector({ - selector: 'tr > td:last-child, tr > th:last-child', - start: (element: HTMLTableCellElement) => { - const colspan = element.getAttribute('colspan') - return colspan ? startMulticolumn(element) : '' - }, - end: element => { - const colspan = element.getAttribute('colspan') - return colspan ? `} \\\\` : ` \\\\` + let output = '' + // NOTE: multirow is nested inside multicolumn + if (element.getAttribute('rowspan')) { + output += '}' + } + if (element.getAttribute('colspan')) { + output += '}' + } + const row = element.parentElement as HTMLTableRowElement + const isLastChild = row.cells.item(row.cells.length - 1) === element + return output + (isLastChild ? ' \\\\' : ' & ') }, }), createSelector({ diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-paste-html.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-paste-html.spec.tsx index 651c97bc14..276e133dbb 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-paste-html.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-paste-html.spec.tsx @@ -161,7 +161,7 @@ describe(' paste HTML in Visual mode', function () { ) }) - it('handles a pasted table with merged cells', function () { + it('handles a pasted table with merged columns', function () { mountEditor() const data = [ @@ -182,6 +182,48 @@ describe(' paste HTML in Visual mode', function () { ) }) + it('handles a pasted table with merged rows', function () { + mountEditor() + + const data = [ + ``, + ``, + ``, + ``, + `
testtesttest
testtesttest
testtest
`, + ].join('') + + const clipboardData = new DataTransfer() + clipboardData.setData('text/html', data) + cy.get('@content').trigger('paste', { clipboardData }) + + cy.get('@content').should( + 'have.text', + '\\begin{tabular}{l l l}test & test & test ↩\\multirow{2}{*}{test} & test & test ↩ & test & test ↩\\end{tabular}' + ) + }) + + it('handles a pasted table with merged rows and columns', function () { + mountEditor() + + const data = [ + ``, + ``, + ``, + ``, + `
testtest
test
testtesttest
`, + ].join('') + + const clipboardData = new DataTransfer() + clipboardData.setData('text/html', data) + cy.get('@content').trigger('paste', { clipboardData }) + + cy.get('@content').should( + 'have.text', + '\\begin{tabular}{l l l}\\multicolumn{2}{l}{\\multirow{2}{*}{test}} & test ↩ & & test ↩test & test & test ↩\\end{tabular}' + ) + }) + it('handles a pasted table with adjacent borders and merged cells', function () { mountEditor()