From cafabdf4c6f3e370bc85921693a2b3cbf3dcd467 Mon Sep 17 00:00:00 2001 From: Ivan Ledyaev Date: Mon, 10 Jun 2024 17:12:26 +0200 Subject: [PATCH] feat(table): add support for row/col spans in multiline tables --- src/scss/_table.scss | 32 ++ src/scss/yfm.scss | 1 + src/transform/plugins/table/index.ts | 184 +++++++- test/table.test.ts | 599 +++++++++++++++++++++++++++ 4 files changed, 810 insertions(+), 6 deletions(-) create mode 100644 src/scss/_table.scss diff --git a/src/scss/_table.scss b/src/scss/_table.scss new file mode 100644 index 00000000..1e402dae --- /dev/null +++ b/src/scss/_table.scss @@ -0,0 +1,32 @@ +.yfm table td { + &.cell-align-top-left, + &.cell-align-bottom-left { + text-align: start; + } + + &.cell-align-top-center, + &.cell-align-center { + text-align: center; + } + + &.cell-align-top-right, + &.cell-align-bottom-right { + text-align: end; + } + + &.cell-align-top-left, + &.cell-align-top-center, + &.cell-align-top-right { + vertical-align: top; + } + + &.cell-align-center { + vertical-align: middle; + } + + &.cell-align-bottom-left, + &.cell-align-bottom-right { + vertical-align: bottom; + } +} + diff --git a/src/scss/yfm.scss b/src/scss/yfm.scss index fdd8c80e..5fcade8c 100644 --- a/src/scss/yfm.scss +++ b/src/scss/yfm.scss @@ -6,5 +6,6 @@ @import 'cut'; @import 'file'; @import 'term'; +@import 'table'; @import '@diplodoc/tabs-extension/runtime'; diff --git a/src/transform/plugins/table/index.ts b/src/transform/plugins/table/index.ts index 16d3955a..85f53b9c 100644 --- a/src/transform/plugins/table/index.ts +++ b/src/transform/plugins/table/index.ts @@ -1,5 +1,6 @@ import StateBlock from 'markdown-it/lib/rules_block/state_block'; import {MarkdownItPluginCb} from '../typings'; +import Token from 'markdown-it/lib/token'; const pluginName = 'yfm_table'; const pipeChar = 0x7c; // | @@ -91,12 +92,17 @@ class StateIterator { } } -function getTableRows( +interface RowPositions { + rows: [number, number, [Stats, Stats][]][]; + endOfTable: number | null; +} + +function getTableRowPositions( state: StateBlock, startPosition: number, endPosition: number, startLine: number, -) { +): RowPositions { let endOfTable = null; let tableLevel = 0; let currentRow: [Stats, Stats][] = []; @@ -210,6 +216,144 @@ function getTableRows( return {rows, endOfTable}; } +/** + * Removes the specified attribute from attributes in the content of a token. + * + * @param {Token} contentToken - The target token. + * @param {string} attr - The attribute to be removed from the token content. + * + * @return {void} + */ +function removeAttrFromTokenContent(contentToken: Token, attr: string): void { + // Replace the attribute in the token content with an empty string. + const blockRegex = /\s*\{[^}]*}/; + const allAttrs = contentToken.content.match(blockRegex); + if (!allAttrs) { + return; + } + let replacedContent = allAttrs[0].replace(`.${attr}`, ''); + if (replacedContent.trim() === '{}') { + replacedContent = ''; + } + contentToken.content = contentToken.content.replace(allAttrs[0], replacedContent); +} + +/** + * Extracts the class attribute from the given content token and applies it to the tdOpenToken. + * Preserves other attributes. + * + * @param {Token} contentToken - Search the content of this token for the class. + * @param {Token} tdOpenToken - Parent td_open token. Extracted class is applied to this token. + * @returns {void} + */ +function extractAndApplyClassFromToken(contentToken: Token, tdOpenToken: Token): void { + // Regex to find class attribute in any position within brackets + const classAttrRegex = /(?<=\{[^}]*)\.([-_a-zA-Z0-9]+)/g; + const classAttrMatch = classAttrRegex.exec(contentToken.content); + if (classAttrMatch) { + const classAttr = classAttrMatch[1]; + tdOpenToken.attrSet('class', classAttr); + removeAttrFromTokenContent(contentToken, classAttr); + } +} + +const COLSPAN_SYMBOL = '>'; +const ROWSPAN_SYMBOL = '^'; + +/** + * Traverses through the content map, applying row/colspan attributes and marking the special cells for deletion. + * Upon encountering a symbol denoting a row span or a column span, proceed backwards in row or column + * until text cell is found. Upon finding the text cell, store the colspan or rowspan value. + * During the backward traversal, if the same symbol is encountered, increment the value of rowspan/colspan. + * Colspan symbol is ignored for the first column. Rowspan symbol is ignored for the first row + * + * @param contentMap string[][] + * @param tokenMap Token[][] + * @return {void} + */ +const applySpans = (contentMap: string[][], tokenMap: Token[][]): void => { + for (let i = 0; i < contentMap.length; i++) { + for (let j = 0; j < contentMap[0].length; j++) { + if (contentMap[i][j] === COLSPAN_SYMBOL) { + // skip the first column + if (j === 0) { + continue; + } + tokenMap[i][j].meta = {markForDeletion: true}; + let colspanFactor = 2; + // traverse columns backwards + for (let col = j - 1; col >= 0; col--) { + if (contentMap[i][col] === COLSPAN_SYMBOL) { + colspanFactor++; + tokenMap[i][col].meta = {markForDeletion: true}; + } else if (contentMap[i][col] === ROWSPAN_SYMBOL) { + // Do nothing, this should be applied on the row that's being extended + break; + } else { + tokenMap[i][col].attrSet('colspan', colspanFactor.toString()); + break; + } + } + } + + if (contentMap[i][j] === ROWSPAN_SYMBOL) { + // skip the first row + if (i === 0) { + continue; + } + tokenMap[i][j].meta = {markForDeletion: true}; + let rowSpanFactor = 2; + // traverse rows upward + for (let row = i - 1; row >= 0; row--) { + if (contentMap[row][j] === ROWSPAN_SYMBOL) { + rowSpanFactor++; + tokenMap[row][j].meta = {markForDeletion: true}; + } else if (contentMap[row][j] === COLSPAN_SYMBOL) { + break; + } else { + tokenMap[row][j].attrSet('rowspan', rowSpanFactor.toString()); + break; + } + } + } + } + } +}; + +/** + * Removes td_open and matching td_close tokens and the content within them + * + * @param {number} tableStart - The index of the start of the table in the state tokens array. + * @param {Token[]} tokens - The array of tokens from state. + * @returns {void} + */ +const clearTokens = (tableStart: number, tokens: Token[]): void => { + // use splices array to avoid modifying the tokens array during iteration + const splices: number[][] = []; + for (let i = tableStart; i < tokens.length; i++) { + if (tokens[i].meta?.markForDeletion) { + // Use unshift instead of push so that the splices indexes are in reverse order. + // Reverse order guarantees that we don't mess up the indexes while removing the items. + splices.unshift([i]); + const level = tokens[i].level; + // find matching td_close with the same level + for (let j = i + 1; j < tokens.length; j++) { + if (tokens[j].type === 'yfm_td_close' && tokens[j].level === level) { + splices[0].push(j); + break; + } + } + } + } + splices.forEach(([start, end]) => { + // check that we have both start and end defined + // it's possible we didn't find td_close index + if (start && end) { + tokens.splice(start, end - start + 1); + } + }); +}; + const yfmTable: MarkdownItPluginCb = (md) => { md.block.ruler.before( 'code', @@ -232,7 +376,12 @@ const yfmTable: MarkdownItPluginCb = (md) => { return true; } - const {rows, endOfTable} = getTableRows(state, startPosition, endPosition, startLine); + const {rows, endOfTable} = getTableRowPositions( + state, + startPosition, + endPosition, + startLine, + ); if (!endOfTable) { token = state.push('__yfm_lint', '', 0); @@ -247,6 +396,7 @@ const yfmTable: MarkdownItPluginCb = (md) => { state.lineMax = endOfTable; state.line = startLine; + const tableStart = state.tokens.length; token = state.push('yfm_table_open', 'table', 1); token.map = [startLine, endOfTable]; @@ -255,9 +405,18 @@ const yfmTable: MarkdownItPluginCb = (md) => { const maxRowLength = Math.max(...rows.map(([, , cols]) => cols.length)); + // cellsMaps is a 2-D map of all td_open tokens in the table. + // cellsMap is used to access the table cells by [row][column] coordinates + const cellsMap: Token[][] = []; + + // contentMap is a 2-D map of the text content within cells in the table. + // To apply spans, traverse the contentMap and modify the cells from cellsMap + const contentMap: string[][] = []; + for (let i = 0; i < rows.length; i++) { const [rowLineStarts, rowLineEnds, cols] = rows[i]; - + cellsMap.push([]); + contentMap.push([]); const rowLength = cols.length; token = state.push('yfm_tr_open', 'tr', 1); @@ -266,6 +425,7 @@ const yfmTable: MarkdownItPluginCb = (md) => { for (let j = 0; j < cols.length; j++) { const [begin, end] = cols[j]; token = state.push('yfm_td_open', 'td', 1); + cellsMap[i].push(token); token.map = [begin.line, end.line]; const oldTshift = state.tShift[begin.line]; @@ -279,14 +439,23 @@ const yfmTable: MarkdownItPluginCb = (md) => { state.lineMax = end.line + 1; state.md.block.tokenize(state, begin.line, end.line + 1); + const contentToken = state.tokens[state.tokens.length - 2]; + + // In case of ">" within a cell without whitespace it gets consumed as a blockquote. + // To handle that, check markup as well + const content = contentToken.content.trim() || contentToken.markup.trim(); + contentMap[i].push(content); + + token = state.push('yfm_td_close', 'td', -1); + state.tokens[state.tokens.length - 1].map = [end.line, end.line + 1]; state.lineMax = oldLineMax; state.tShift[begin.line] = oldTshift; state.bMarks[begin.line] = oldBMark; state.eMarks[end.line] = oldEMark; - token = state.push('yfm_td_close', 'td', -1); - state.tokens[state.tokens.length - 1].map = [end.line, end.line + 1]; + const rowTokens = cellsMap[cellsMap.length - 1]; + extractAndApplyClassFromToken(contentToken, rowTokens[rowTokens.length - 1]); } if (rowLength < maxRowLength) { @@ -300,6 +469,9 @@ const yfmTable: MarkdownItPluginCb = (md) => { token = state.push('yfm_tr_close', 'tr', -1); } + applySpans(contentMap, cellsMap); + clearTokens(tableStart, state.tokens); + token = state.push('yfm_tbody_close', 'tbody', -1); token = state.push('yfm_table_close', 'table', -1); diff --git a/test/table.test.ts b/test/table.test.ts index e53d5dd1..f53bbee7 100644 --- a/test/table.test.ts +++ b/test/table.test.ts @@ -659,4 +659,603 @@ describe('Table plugin', () => { ); expect(actual).toMatchSnapshot(); }); + + describe('rowspans', () => { + it('should add rowspan for marked cells', () => { + expect( + transformYfm( + '#|\n' + + '|| Heading1 | Heading2 ||\n' + + '|| Text here | Text there ||\n' + + '|| ^ | More text ||\n' + + '|#', + ), + ).toEqual( + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '
\n' + + '

Heading1

\n' + + '
\n' + + '

Heading2

\n' + + '
\n' + + '

Text here

\n' + + '
\n' + + '

Text there

\n' + + '
\n' + + '

More text

\n' + + '
\n', + ); + }); + + it('should work without whitespace', () => { + expect( + transformYfm( + '#|\n' + + '|| Heading1 | Heading2 ||\n' + + '|| Text here | Text there ||\n' + + '||^| More text ||\n' + + '|#', + ), + ).toEqual( + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '
\n' + + '

Heading1

\n' + + '
\n' + + '

Heading2

\n' + + '
\n' + + '

Text here

\n' + + '
\n' + + '

Text there

\n' + + '
\n' + + '

More text

\n' + + '
\n', + ); + }); + + it('consecutive rowspans should apply to correct cell', () => { + expect( + transformYfm( + '#|\n' + + '|| Heading1 | Heading2 ||\n' + + '|| Text here | Text there ||\n' + + '|| ^ | More text ||\n' + + '|| ^ | Even more text ||\n' + + '|| ^ | Some more text ||\n' + + '|#', + ), + ).toEqual( + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '
\n' + + '

Heading1

\n' + + '
\n' + + '

Heading2

\n' + + '
\n' + + '

Text here

\n' + + '
\n' + + '

Text there

\n' + + '
\n' + + '

More text

\n' + + '
\n' + + '

Even more text

\n' + + '
\n' + + '

Some more text

\n' + + '
\n', + ); + }); + + it('multiple row spans in a table should work correctly', () => { + expect( + transformYfm( + '#|\n' + + '|| Heading1 | Heading2 | Heading3||\n' + + '|| Text here | Text there | Text even here ||\n' + + '|| ^ | More text | ^ ||\n' + + '|| Another rowspan | Even more text | Out of example ideas||\n' + + '|| ^ | Some more text | Test ||\n' + + '|#', + ), + ).toEqual( + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '
\n' + + '

Heading1

\n' + + '
\n' + + '

Heading2

\n' + + '
\n' + + '

Heading3

\n' + + '
\n' + + '

Text here

\n' + + '
\n' + + '

Text there

\n' + + '
\n' + + '

Text even here

\n' + + '
\n' + + '

More text

\n' + + '
\n' + + '

Another rowspan

\n' + + '
\n' + + '

Even more text

\n' + + '
\n' + + '

Out of example ideas

\n' + + '
\n' + + '

Some more text

\n' + + '
\n' + + '

Test

\n' + + '
\n', + ); + }); + }); + + describe('colspans', () => { + it('should add colspans for marked cells', () => { + expect( + transformYfm( + '#|\n' + + '|| Heading1 | Heading2 ||\n' + + '|| Text here | Text there ||\n' + + '|| More text | > ||\n' + + '|#', + ), + ).toEqual( + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '
\n' + + '

Heading1

\n' + + '
\n' + + '

Heading2

\n' + + '
\n' + + '

Text here

\n' + + '
\n' + + '

Text there

\n' + + '
\n' + + '

More text

\n' + + '
\n', + ); + }); + + it('should work without whitespace', () => { + expect( + transformYfm( + '#|\n' + + '|| Heading1 | Heading2 ||\n' + + '|| Text here | Text there ||\n' + + '|| More text |>||\n' + + '|#', + ), + ).toEqual( + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '
\n' + + '

Heading1

\n' + + '
\n' + + '

Heading2

\n' + + '
\n' + + '

Text here

\n' + + '
\n' + + '

Text there

\n' + + '
\n' + + '

More text

\n' + + '
\n', + ); + }); + + it('should work for consecutive colspans', () => { + expect( + transformYfm( + '#|\n' + + '|| Heading1 | Heading2 | Heading3 ||\n' + + '|| Text here | Text there | Some text||\n' + + '|| More text | > | > ||\n' + + '|#', + ), + ).toEqual( + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '
\n' + + '

Heading1

\n' + + '
\n' + + '

Heading2

\n' + + '
\n' + + '

Heading3

\n' + + '
\n' + + '

Text here

\n' + + '
\n' + + '

Text there

\n' + + '
\n' + + '

Some text

\n' + + '
\n' + + '

More text

\n' + + '
\n', + ); + }); + }); + + describe('rowspan and colspan together', () => { + it('should correctly handle a case when rowspan and callspan are used together', () => { + expect( + transformYfm( + '#|\n' + + '|| Heading1 | Heading2 | Heading3 ||\n' + + '|| Text here | > | Some text||\n' + + '|| ^ | > | Other text ||\n' + + '|#', + ), + ).toEqual( + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '
\n' + + '

Heading1

\n' + + '
\n' + + '

Heading2

\n' + + '
\n' + + '

Heading3

\n' + + '
\n' + + '

Text here

\n' + + '
\n' + + '

Some text

\n' + + '
\n' + + '

Other text

\n' + + '
\n', + ); + }); + + it('should not throw on span mismatch', () => { + expect(() => + transformYfm( + '#|\n' + + '|| Heading1 | Heading2 | Heading3 ||\n' + + '|| Text here | > | Some text||\n' + + '|| ^ | more text | Other text ||\n' + + '|#', + ), + ).not.toThrow(); + }); + + it('spans in the middle', () => { + expect( + transformYfm( + '#|\n' + + '|| Heading1 | Heading2 | Heading3 | Heading4 ||\n' + + '|| Text here | Spanned | > | More text ||\n' + + '|| Text there | ^ | > | More text ||\n' + + '|#', + ), + ).toEqual( + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '
\n' + + '

Heading1

\n' + + '
\n' + + '

Heading2

\n' + + '
\n' + + '

Heading3

\n' + + '
\n' + + '

Heading4

\n' + + '
\n' + + '

Text here

\n' + + '
\n' + + '

Spanned

\n' + + '
\n' + + '

More text

\n' + + '
\n' + + '

Text there

\n' + + '
\n' + + '

More text

\n' + + '
\n', + ); + }); + + it('should correctly apply a row span after a mixed span', () => { + expect( + transformYfm(`#| +|| Heading1 | Heading2 | Heading3 || +|| Text | > | Text || +|| ^ | > | ^ || +|#`), + ).toEqual( + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '
\n' + + '

Heading1

\n' + + '
\n' + + '

Heading2

\n' + + '
\n' + + '

Heading3

\n' + + '
\n' + + '

Text

\n' + + '
\n' + + '

Text

\n' + + '
\n', + ); + }); + }); + + it('should allow to escape colspan and rowspan and render symbols as is', () => { + expect( + transformYfm(`#| +|| Heading1 | Heading2 || +|| \\> | \\^ || +|#`), + ).toEqual( + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '
\n' + + '

Heading1

\n' + + '
\n' + + '

Heading2

\n' + + '
\n' + + '

>

\n' + + '
\n' + + '

^

\n' + + '
\n', + ); + }); + + describe('edge cases', () => { + it('colspan in the first cell should not throw', () => { + expect(() => + transformYfm( + `#| +|| > | Text here || +|| More text | Some more text || +|#`, + ), + ).not.toThrow(); + }); + + it('rowspan in the first row should not throw', () => { + expect(() => + transformYfm( + `#| +|| Text here | ^ || +|| More text | Some more text || +|#`, + ), + ).not.toThrow(); + }); + + it('two edge cases together should not throw', () => { + expect(() => + transformYfm( + `#| +|| ^ | > || +|| More text | Some more text || +|#`, + ), + ).not.toThrow(); + }); + }); + + it('two edge cases together should not throw', () => { + expect(() => + transformYfm( + `#| +|| ^ | > || +|| More text | Some more text || +|#`, + ), + ).not.toThrow(); + }); + + describe('with attrs', () => { + it('should correctly add classes to table cell', () => { + expect( + transformYfm(`#| +|| Heading1 | Heading2 | Heading3 | Heading4 || +|| Text | Text {.cell-align-center} | > | More text || +|#`), + ).toEqual( + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '
\n' + + '

Heading1

\n' + + '
\n' + + '

Heading2

\n' + + '
\n' + + '

Heading3

\n' + + '
\n' + + '

Heading4

\n' + + '
\n' + + '

Text

\n' + + '
\n' + + '

Text

\n' + + '
\n' + + '

More text

\n' + + '
\n', + ); + }); + + it('should correctly apply classes in case of last table cell in the row', () => { + expect( + transformYfm(`#| +|| Heading1 | Heading2 || +|| Text {.cell-align-center} | > || +|#`), + ).toEqual( + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '
\n' + + '

Heading1

\n' + + '
\n' + + '

Heading2

\n' + + '
\n' + + '

Text

\n' + + '
\n', + ); + }); + }); });