diff --git a/app/assets/javascripts/behaviors/markdown/paste_markdown_table.js b/app/assets/javascripts/behaviors/markdown/paste_markdown_table.js index d14799c976bb5389b7e7e3253608d2e297fddcff..665a72164241601e442038fbcf8ff96dcecbeed6 100644 --- a/app/assets/javascripts/behaviors/markdown/paste_markdown_table.js +++ b/app/assets/javascripts/behaviors/markdown/paste_markdown_table.js @@ -1,58 +1,81 @@ +const maxColumnWidth = (rows, columnIndex) => Math.max(...rows.map(row => row[columnIndex].length)); + export default class PasteMarkdownTable { constructor(clipboardData) { this.data = clipboardData; + this.columnWidths = []; + this.rows = []; + this.tableFound = this.parseTable(); + } + + isTable() { + return this.tableFound; } - static maxColumnWidth(rows, columnIndex) { - return Math.max.apply(null, rows.map(row => row[columnIndex].length)); + convertToTableMarkdown() { + this.calculateColumnWidths(); + + const markdownRows = this.rows.map( + row => + // | Name | Title | Email Address | + // |--------------|-------|----------------| + // | Jane Atler | CEO | jane@acme.com | + // | John Doherty | CTO | john@acme.com | + // | Sally Smith | CFO | sally@acme.com | + `| ${row.map((column, index) => this.formatColumn(column, index)).join(' | ')} |`, + ); + + // Insert a header break (e.g. -----) to the second row + markdownRows.splice(1, 0, this.generateHeaderBreak()); + + return markdownRows.join('\n'); } + // Private methods below + // To determine whether the cut data is a table, the following criteria // must be satisfied with the clipboard data: // // 1. MIME types "text/plain" and "text/html" exist // 2. The "text/html" data must have a single <table> element - static isTable(data) { - const types = new Set(data.types); - - if (!types.has('text/html') || !types.has('text/plain')) { + // 3. The number of rows in the "text/plain" data matches that of the "text/html" data + // 4. The max number of columns in "text/plain" matches that of the "text/html" data + parseTable() { + if (!this.data.types.includes('text/html') || !this.data.types.includes('text/plain')) { return false; } - const htmlData = data.getData('text/html'); - const doc = new DOMParser().parseFromString(htmlData, 'text/html'); + const htmlData = this.data.getData('text/html'); + this.doc = new DOMParser().parseFromString(htmlData, 'text/html'); + const tables = this.doc.querySelectorAll('table'); // We're only looking for exactly one table. If there happens to be // multiple tables, it's possible an application copied data into // the clipboard that is not related to a simple table. It may also be // complicated converting multiple tables into Markdown. - if (doc.querySelectorAll('table').length === 1) { - return true; + if (tables.length !== 1) { + return false; } - return false; - } - - convertToTableMarkdown() { const text = this.data.getData('text/plain').trim(); - this.rows = text.split(/[\n\u0085\u2028\u2029]|\r\n?/g).map(row => row.split('\t')); - this.normalizeRows(); - this.calculateColumnWidths(); + const splitRows = text.split(/[\n\u0085\u2028\u2029]|\r\n?/g); - const markdownRows = this.rows.map( - row => - // | Name | Title | Email Address | - // |--------------|-------|----------------| - // | Jane Atler | CEO | jane@acme.com | - // | John Doherty | CTO | john@acme.com | - // | Sally Smith | CFO | sally@acme.com | - `| ${row.map((column, index) => this.formatColumn(column, index)).join(' | ')} |`, - ); + // Now check that the number of rows matches between HTML and text + if (this.doc.querySelectorAll('tr').length !== splitRows.length) { + return false; + } - // Insert a header break (e.g. -----) to the second row - markdownRows.splice(1, 0, this.generateHeaderBreak()); + this.rows = splitRows.map(row => row.split('\t')); + this.normalizeRows(); - return markdownRows.join('\n'); + // Check that the max number of columns in the HTML matches the number of + // columns in the text. GitHub, for example, copies a line number and the + // line itself into the HTML data. + if (!this.columnCountsMatch()) { + return false; + } + + return true; } // Ensure each row has the same number of columns @@ -69,10 +92,21 @@ export default class PasteMarkdownTable { calculateColumnWidths() { this.columnWidths = this.rows[0].map((_column, columnIndex) => - PasteMarkdownTable.maxColumnWidth(this.rows, columnIndex), + maxColumnWidth(this.rows, columnIndex), ); } + columnCountsMatch() { + const textColumnCount = this.rows[0].length; + let htmlColumnCount = 0; + + this.doc.querySelectorAll('table tr').forEach(row => { + htmlColumnCount = Math.max(row.cells.length, htmlColumnCount); + }); + + return textColumnCount === htmlColumnCount; + } + formatColumn(column, index) { const spaces = Array(this.columnWidths[index] - column.length + 1).join(' '); return column + spaces; diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js index 79739072abb73b8c8f7be844ca25333c16a3fc94..865908658926fa8fd479180de98efe47e5f44e1e 100644 --- a/app/assets/javascripts/dropzone_input.js +++ b/app/assets/javascripts/dropzone_input.js @@ -176,11 +176,11 @@ export default function dropzoneInput(form) { const pasteEvent = event.originalEvent; const { clipboardData } = pasteEvent; if (clipboardData && clipboardData.items) { + const converter = new PasteMarkdownTable(clipboardData); // Apple Numbers copies a table as an image, HTML, and text, so // we need to check for the presence of a table first. - if (PasteMarkdownTable.isTable(clipboardData)) { + if (converter.isTable()) { event.preventDefault(); - const converter = new PasteMarkdownTable(clipboardData); const text = converter.convertToTableMarkdown(); pasteText(text); } else { diff --git a/spec/frontend/behaviors/markdown/paste_markdown_table_spec.js b/spec/frontend/behaviors/markdown/paste_markdown_table_spec.js index a8177a5ad39316edffa28d97912fbd23ef8d1e21..a98919e21135357fa96002399fa92a4fa613b84e 100644 --- a/spec/frontend/behaviors/markdown/paste_markdown_table_spec.js +++ b/spec/frontend/behaviors/markdown/paste_markdown_table_spec.js @@ -10,9 +10,9 @@ describe('PasteMarkdownTable', () => { value: { getData: jest.fn().mockImplementation(type => { if (type === 'text/html') { - return '<table><tr><td></td></tr></table>'; + return '<table><tr><td>First</td><td>Second</td></tr></table>'; } - return 'hello world'; + return 'First\tSecond'; }), }, }); @@ -24,39 +24,48 @@ describe('PasteMarkdownTable', () => { it('return false when no HTML data is provided', () => { data.types = ['text/plain']; - expect(PasteMarkdownTable.isTable(data)).toBe(false); + expect(new PasteMarkdownTable(data).isTable()).toBe(false); }); it('returns false when no text data is provided', () => { data.types = ['text/html']; - expect(PasteMarkdownTable.isTable(data)).toBe(false); + expect(new PasteMarkdownTable(data).isTable()).toBe(false); }); it('returns true when a table is provided in both text and HTML', () => { data.types = ['text/html', 'text/plain']; - expect(PasteMarkdownTable.isTable(data)).toBe(true); + expect(new PasteMarkdownTable(data).isTable()).toBe(true); }); it('returns false when no HTML table is included', () => { data.types = ['text/html', 'text/plain']; data.getData = jest.fn().mockImplementation(() => 'nothing'); - expect(PasteMarkdownTable.isTable(data)).toBe(false); + expect(new PasteMarkdownTable(data).isTable()).toBe(false); }); - }); - describe('convertToTableMarkdown', () => { - let converter; + it('returns false when the number of rows are not consistent', () => { + data.types = ['text/html', 'text/plain']; + data.getData = jest.fn().mockImplementation(mimeType => { + if (mimeType === 'text/html') { + return '<table><tr><td>def test<td></tr></table>'; + } + return "def test\n 'hello'\n"; + }); - beforeEach(() => { - converter = new PasteMarkdownTable(data); + expect(new PasteMarkdownTable(data).isTable()).toBe(false); }); + }); + describe('convertToTableMarkdown', () => { it('returns a Markdown table', () => { + data.types = ['text/html', 'text/plain']; data.getData = jest.fn().mockImplementation(type => { - if (type === 'text/plain') { + if (type === 'text/html') { + return '<table><tr><td>First</td><td>Last</td><tr><td>John</td><td>Doe</td><tr><td>Jane</td><td>Doe</td></table>'; + } else if (type === 'text/plain') { return 'First\tLast\nJohn\tDoe\nJane\tDoe'; } @@ -70,12 +79,18 @@ describe('PasteMarkdownTable', () => { '| Jane | Doe |', ].join('\n'); + const converter = new PasteMarkdownTable(data); + + expect(converter.isTable()).toBe(true); expect(converter.convertToTableMarkdown()).toBe(expected); }); it('returns a Markdown table with rows normalized', () => { + data.types = ['text/html', 'text/plain']; data.getData = jest.fn().mockImplementation(type => { - if (type === 'text/plain') { + if (type === 'text/html') { + return '<table><tr><td>First</td><td>Last</td><tr><td>John</td><td>Doe</td><tr><td>Jane</td><td>/td></table>'; + } else if (type === 'text/plain') { return 'First\tLast\nJohn\tDoe\nJane'; } @@ -89,6 +104,9 @@ describe('PasteMarkdownTable', () => { '| Jane | |', ].join('\n'); + const converter = new PasteMarkdownTable(data); + + expect(converter.isTable()).toBe(true); expect(converter.convertToTableMarkdown()).toBe(expected); }); }); diff --git a/spec/javascripts/dropzone_input_spec.js b/spec/javascripts/dropzone_input_spec.js index 44a11097815bfe8eb91617ee279d095e9d0235de..6f6f20ccca258f1d7169a456dfbd11e14f7722dc 100644 --- a/spec/javascripts/dropzone_input_spec.js +++ b/spec/javascripts/dropzone_input_spec.js @@ -39,17 +39,17 @@ describe('dropzone_input', () => { const event = $.Event('paste'); const origEvent = new Event('paste'); const pasteData = new DataTransfer(); - pasteData.setData('text/plain', 'hello world'); - pasteData.setData('text/html', '<table></table>'); + pasteData.setData('text/plain', 'Hello World'); + pasteData.setData('text/html', '<table><tr><td>Hello World</td></tr></table>'); origEvent.clipboardData = pasteData; event.originalEvent = origEvent; - spyOn(PasteMarkdownTable, 'isTable').and.callThrough(); + spyOn(PasteMarkdownTable.prototype, 'isTable').and.callThrough(); spyOn(PasteMarkdownTable.prototype, 'convertToTableMarkdown').and.callThrough(); $('.js-gfm-input').trigger(event); - expect(PasteMarkdownTable.isTable).toHaveBeenCalled(); + expect(PasteMarkdownTable.prototype.isTable).toHaveBeenCalled(); expect(PasteMarkdownTable.prototype.convertToTableMarkdown).toHaveBeenCalled(); }); });