summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/behaviors/markdown/paste_markdown_table.js
blob: 278dd857ab8e6d96537a252ddde2ef1b47f6ce01 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
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;
  }

  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
  // 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 = this.data.getData('text/html');
    this.doc = new DOMParser().parseFromString(htmlData, 'text/html');
    // Avoid formatting lines that were copied from a diff
    const tables = this.doc.querySelectorAll('table:not(.diff-wrap-lines)');

    // 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 (tables.length !== 1) {
      return false;
    }

    const text = this.data.getData('text/plain').trim();
    const splitRows = text.split(/[\n\u0085\u2028\u2029]|\r\n?/g);

    // Now check that the number of rows matches between HTML and text
    if (this.doc.querySelectorAll('tr').length !== splitRows.length) {
      return false;
    }

    this.rows = splitRows.map(row => row.split('\t'));
    this.normalizeRows();

    // 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
  normalizeRows() {
    const rowLengths = this.rows.map(row => row.length);
    const maxLength = Math.max(...rowLengths);

    this.rows.forEach(row => {
      while (row.length < maxLength) {
        row.push('');
      }
    });
  }

  calculateColumnWidths() {
    this.columnWidths = this.rows[0].map((_column, 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;
  }

  generateHeaderBreak() {
    // Add 3 dashes to line things up: there is additional spacing for the pipe characters
    const dashes = this.columnWidths.map((width, index) =>
      Array(this.columnWidths[index] + 3).join('-'),
    );
    return `|${dashes.join('|')}|`;
  }
}