Commit c1abe977 authored by Stan Hu's avatar Stan Hu

Cut and paste Markdown table from a spreadsheet

Upon pasting to a Markdown input, this commit will try to detect whether
a spreadsheet has been copied to the clipboard and automatically create
a Markdown table.

This is based off code from
https://github.com/jonmagic/copy-excel-paste-markdown.

Closes https://gitlab.com/gitlab-org/gitlab/issues/27205
parent 7809db0c
export default class PasteMarkdownTable {
constructor(clipboardData) {
this.data = clipboardData;
}
static maxColumnWidth(rows, columnIndex) {
return Math.max.apply(null, rows.map(row => row[columnIndex].length));
}
// 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')) {
return false;
}
const htmlData = data.getData('text/html');
const doc = new DOMParser().parseFromString(htmlData, 'text/html');
// 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;
}
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 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');
}
// 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) =>
PasteMarkdownTable.maxColumnWidth(this.rows, columnIndex),
);
}
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('|')}|`;
}
}
......@@ -2,6 +2,7 @@ import $ from 'jquery';
import Dropzone from 'dropzone';
import _ from 'underscore';
import './behaviors/preview_markdown';
import PasteMarkdownTable from './behaviors/markdown/paste_markdown_table';
import csrf from './lib/utils/csrf';
import axios from './lib/utils/axios_utils';
import { n__, __ } from '~/locale';
......@@ -173,8 +174,18 @@ export default function dropzoneInput(form) {
// eslint-disable-next-line consistent-return
handlePaste = event => {
const pasteEvent = event.originalEvent;
if (pasteEvent.clipboardData && pasteEvent.clipboardData.items) {
const { clipboardData } = pasteEvent;
if (clipboardData && clipboardData.items) {
// 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)) {
event.preventDefault();
const converter = new PasteMarkdownTable(clipboardData);
const text = converter.convertToTableMarkdown();
pasteText(text);
} else {
const image = isImage(pasteEvent);
if (image) {
event.preventDefault();
const filename = getFilename(pasteEvent) || 'image.png';
......@@ -183,6 +194,7 @@ export default function dropzoneInput(form) {
return uploadFile(image.getAsFile(), filename);
}
}
}
};
isImage = data => {
......
---
title: Cut and paste Markdown table from a spreadsheet
merge_request: 22290
author:
type: added
import PasteMarkdownTable from '~/behaviors/markdown/paste_markdown_table';
describe('PasteMarkdownTable', () => {
let data;
beforeEach(() => {
const event = new window.Event('paste');
Object.defineProperty(event, 'dataTransfer', {
value: {
getData: jest.fn().mockImplementation(type => {
if (type === 'text/html') {
return '<table><tr><td></td></tr></table>';
}
return 'hello world';
}),
},
});
data = event.dataTransfer;
});
describe('isTable', () => {
it('return false when no HTML data is provided', () => {
data.types = ['text/plain'];
expect(PasteMarkdownTable.isTable(data)).toBe(false);
});
it('returns false when no text data is provided', () => {
data.types = ['text/html'];
expect(PasteMarkdownTable.isTable(data)).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);
});
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);
});
});
describe('convertToTableMarkdown', () => {
let converter;
beforeEach(() => {
converter = new PasteMarkdownTable(data);
});
it('returns a Markdown table', () => {
data.getData = jest.fn().mockImplementation(type => {
if (type === 'text/plain') {
return 'First\tLast\nJohn\tDoe\nJane\tDoe';
}
return '';
});
const expected = [
'| First | Last |',
'|-------|------|',
'| John | Doe |',
'| Jane | Doe |',
].join('\n');
expect(converter.convertToTableMarkdown()).toBe(expected);
});
it('returns a Markdown table with rows normalized', () => {
data.getData = jest.fn().mockImplementation(type => {
if (type === 'text/plain') {
return 'First\tLast\nJohn\tDoe\nJane';
}
return '';
});
const expected = [
'| First | Last |',
'|-------|------|',
'| John | Doe |',
'| Jane | |',
].join('\n');
expect(converter.convertToTableMarkdown()).toBe(expected);
});
});
});
......@@ -23,6 +23,15 @@ describe Projects::IssuesController, '(JavaScript fixtures)', type: :controller
remove_repository(project)
end
it 'issues/new-issue.html' do
get :new, params: {
namespace_id: project.namespace.to_param,
project_id: project
}
expect(response).to be_successful
end
it 'issues/open-issue.html' do
render_issue(create(:issue, project: project))
end
......
import $ from 'jquery';
import { TEST_HOST } from 'spec/test_constants';
import dropzoneInput from '~/dropzone_input';
import PasteMarkdownTable from '~/behaviors/markdown/paste_markdown_table';
const TEST_FILE = new File([], 'somefile.jpg');
TEST_FILE.upload = {};
......@@ -25,6 +26,34 @@ describe('dropzone_input', () => {
expect(dropzone.version).toBeTruthy();
});
describe('handlePaste', () => {
beforeEach(() => {
loadFixtures('issues/new-issue.html');
const form = $('#new_issue');
form.data('uploads-path', TEST_UPLOAD_PATH);
dropzoneInput(form);
});
it('pastes Markdown tables', () => {
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>');
origEvent.clipboardData = pasteData;
event.originalEvent = origEvent;
spyOn(PasteMarkdownTable, 'isTable').and.callThrough();
spyOn(PasteMarkdownTable.prototype, 'convertToTableMarkdown').and.callThrough();
$('.js-gfm-input').trigger(event);
expect(PasteMarkdownTable.isTable).toHaveBeenCalled();
expect(PasteMarkdownTable.prototype.convertToTableMarkdown).toHaveBeenCalled();
});
});
describe('shows error message', () => {
let form;
let dropzone;
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment