Skip to content

Commit

Permalink
Merge pull request #288 from ndrsn/paste-improvements
Browse files Browse the repository at this point in the history
Paste event improvements
  • Loading branch information
TonyGermaneri committed Sep 3, 2020
2 parents b581e30 + 05537c5 commit fdf9566
Show file tree
Hide file tree
Showing 4 changed files with 253 additions and 64 deletions.
17 changes: 17 additions & 0 deletions lib/docs.js
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,23 @@
* @param {object} e.NativeEvent Native copy event.
* @param {object} e.column Current column object of schema.
*/
/**
* Fires before a paste is performed.
* @event
* @name canvasDatagrid#beforepaste
* @param {object} e Event object
* @param {object} e.ctx Canvas context.
* @param {function} e.preventDefault Prevents the default behavior.
* @param {object} e.NativeEvent Native paste event.
*/
/**
* Fires after a paste is performed.
* @event
* @name canvasDatagrid#afterpaste
* @param {object} e Event object
* @param {object} e.ctx Canvas context.
* @param {array} e.cells Cells affected by the paste action. Each item in the array is a tuple of [rowIndex, columnIndex].
*/
/**
* Fired just before a cell is drawn onto the canvas. `e.preventDefault();` prevents the cell from being drawn.
* You would only use this if you want to completely stop the cell from being drawn and generally muck up everything.
Expand Down
193 changes: 129 additions & 64 deletions lib/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ define([], function () {
});
};
/**
* Fires the given event, padding an event object to the event subscribers.
* Fires the given event, passing an event object to the event subscribers.
* @memberof canvasDatagrid
* @name dispatchEvent
* @method
Expand Down Expand Up @@ -1133,56 +1133,99 @@ define([], function () {
}, 1);
}
};
self.pasteItem = function (clipData, x, y, mimeType) {
var l, s = self.getVisibleSchema(), yi = y - 1, sel = [];
function normalizeRowData(importingRow, existingRow, offsetX, schema, mimeType, rowIndex) {
var r = existingRow;
if (!Array.isArray(importingRow) && importingRow !== null && typeof importingRow === 'object') {
importingRow = Object.keys(importingRow).map(function (colKey) {
return importingRow[colKey];
});
}
if (/^text\/html/.test(mimeType)) {
importingRow = importingRow.substring(4, importingRow.length - 5).split('</td><td>');
}
if (typeof importingRow === 'string') {
importingRow = [importingRow];

self.pasteData = function(pasteValue, mimeType, startRowIndex, startColIndex) {
var schema = self.getVisibleSchema();

const isSupportedHtmlTable = htmlString => /^(<meta[^>]+>)?<table>/.test(htmlString.substring(0, 29));

// TODO: support pasting tables from Excel
if (mimeType === "text/html" && isSupportedHtmlTable(pasteValue) === false) {
console.warn('Unrecognized HTML format. HTML must be a simple table, e.g.: <table><tr><td>data</td></tr></table>.');
console.warn('Data with the mime type text/html not in this format will not be imported as row data.');

return;
}

function parseData(data, mimeType) {
// TODO: use DOMParser
if (mimeType === "text/html") {
// strip table beginning and ending off, then split at rows
var cleanedHtmlData = data.substring(
data.indexOf('<table><tr>') + 11, data.length - 13
).split('</tr><tr>').filter(
// ditch any headers on the table
row => !/^<th>|^<thead>/.test(row)
).map(
// split row into individual cells
row => row.match(/<td>[^<]+/g).map(match => match.replace(/^<td>/, '')
));

return cleanedHtmlData;
}
sel[rowIndex] = [];
importingRow.forEach(function (cellData, colIndex) {
var cName = schema[colIndex + offsetX].name;
if (importingRow[colIndex] === undefined || importingRow[colIndex] === null) {
r[cName] = existingRow[cName];
return;
}
sel[rowIndex].push(colIndex + offsetX);
r[cName] = importingRow[colIndex];
});
return r;

// Default data format is string, so split on new line,
// and then enclose in an array (a row with one cell):
return data.split("\n").map(value => [value]);
}
if (/^text\/html/.test(mimeType)) {
if (!/^(<meta[^>]+>)?<table>/.test(clipData.substring(0, 29))) {
console.warn('Unrecognized HTML format. HTML must be a simple table, e.g.: <table><tr><td>data</td></tr></table>. Data with the mime type text/html not in this format will not be imported as row data.');
return;

var rows = parseData(pasteValue, mimeType);
var selections = [];

for (var rowIndex = 0; rowIndex < rows.length; rowIndex++) {
// Rows may have been moved by user, so get the actual row index
// (instead of the row index at which the row is rendered):
var realRowIndex = self.orders.rows[startRowIndex + rowIndex];
var cells = rows[rowIndex];

var existingRowData = self.data[realRowIndex];
var newRowData = Object.assign({}, existingRowData);

selections[realRowIndex] = [];

for (var colIndex = 0; colIndex < cells.length; colIndex++) {
var column = schema[startColIndex + colIndex];

if (!column) {
console.warn("Paste data exceeded grid bounds. Skipping.");
continue;
}

var columnName = column.name;
var cellData = cells[colIndex];

if (cellData === undefined || cellData === null) {
newRowData[columnName] = existingRowData[columnName];
continue;
}

selections[realRowIndex].push(startColIndex + colIndex);

newRowData[columnName] = cellData;
}
// strip table beginning and ending off, then split at rows
clipData = clipData.substring(clipData.indexOf('<table><tr>') + 11, clipData.length - 13).split('</tr><tr>');
// ditch any headers on the table
clipData = clipData.filter(function (row) {
return !/^<th>|^<thead>/.test(row);

self.data[realRowIndex] = newRowData;
}

self.selections = selections;

// selections is a sparse array, so we condense:
var affectedCells = [];

selections.forEach(function (row, rowIndex) {
if (rowIndex === undefined) return;

row.forEach(function (columnIndex) {
affectedCells.push([rowIndex, columnIndex]);
});
} else {
clipData = clipData.split('\n');
}
l = clipData.length;
clipData.forEach(function (rowData) {
yi += 1;
var i = self.orders.rows[yi];
self.data[i] = normalizeRowData(rowData, self.data[i], x, s, mimeType, i);
});
self.selections = sel;
return l;
};

self.dispatchEvent('afterpaste', {
cells: affectedCells,
});

return rows.length;
}
self.getNextVisibleColumnIndex = function (visibleColumnIndex) {
var x, s = self.getVisibleSchema();
for (x = 0; x < s.length; x += 1) {
Expand All @@ -1201,27 +1244,49 @@ define([], function () {
}
return -1;
};
self.paste = function (e) {
var d;
function getItem(dti) {
var type = dti.type;
dti.getAsString(function (s) {
self.pasteItem(s, self.getVisibleColumnIndexOf(self.activeCell.columnIndex), self.activeCell.rowIndex, type);
self.draw();
});
self.paste = function (event) {
var defaultPrevented = self.dispatchEvent('beforepaste', { NativeEvent: event });

if (defaultPrevented) {
return;
}
d = Array.prototype.filter.call(e.clipboardData.items, function (dti) {
return dti.type === 'text/html';
})[0] || Array.prototype.filter(function (dti) {
return dti.type === 'text/csv';
})[0] || Array.prototype.filter(function (dti) {
return dti.type === 'text/plain';
})[0];
if (!d) {
console.warn('Cannot find supported clipboard data type. Supported types are text/html, text/csv, text/plain.');

var clipboardItems = new Map(
Array.from(event.clipboardData.items).map(item => [item.type, item])
);

// Supported MIME types, in order of preference:
var supportedMimeTypes = ['text/html', 'text/csv', 'text/plain'];

// The clipboard will often contain the same data in multiple formats,
// which can be used depending on the context in which it's pasted. Here
// we'll prefere more structured (HTML/CSV) over less structured, when
// available, so we try to find those first:
var pasteableItems = supportedMimeTypes.map(
mimeType => clipboardItems.get(mimeType)
).filter(item => !!item); // filter out not-found MIME types (= undefined)

if (pasteableItems.length === 0) {
console.warn('Cannot find supported clipboard data type. Supported types are:', supportedMimeTypes.join(', '));
return;
}
getItem(d);

var itemToPaste = pasteableItems[0];

// itemToPaste is cleared once accessed (getData or getAsString),
// so we need to store the type here, before reading its value:
var pasteType = itemToPaste.type;

itemToPaste.getAsString(function (pasteValue) {
self.pasteData(
pasteValue,
pasteType,
self.activeCell.rowIndex,
self.getVisibleColumnIndexOf(self.activeCell.columnIndex),
);

self.draw();
});
};
self.cut = function (e) {
self.copy(e);
Expand Down
1 change: 1 addition & 0 deletions lib/intf.js
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,7 @@ define([], function () {
self.intf.clearPxColorAssertions = self.clearPxColorAssertions;
self.intf.integerToAlpha = self.integerToAlpha;
self.intf.copy = self.copy;
self.intf.paste = self.paste;
self.intf.setStyleProperty = self.setStyleProperty;
Object.defineProperty(self.intf, 'defaults', {
get: function () {
Expand Down
106 changes: 106 additions & 0 deletions test/tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -1518,6 +1518,112 @@
});
}, 1);
});
it('Should paste a value from the clipboard into a cell', function (done) {
var grid = g({
test: this.test,
data: [
{ 'Column A': 'Original value' }
]
});

grid.focus();
grid.setActiveCell(0, 0);

grid.paste({
clipboardData: {
items: [
{
type: 'text/plain',
getAsString: function(callback) {
callback('Paste buffer value');
}
}
]
}
});

setTimeout(function() {
var cellData = grid.data[0]['Column A'];
done(assertIf(cellData !== 'Paste buffer value', 'Value has not been replaced with clipboard data: ' + cellData));
}, 10);
});
it('Should paste an HTML value from the clipboard into a cell', function (done) {
var grid = g({
test: this.test,
data: [
{ 'Column A': 'Original value' }
]
});

grid.focus();
grid.setActiveCell(0, 0);

grid.paste({
clipboardData: {
items: [
{
type: 'text/html',
getAsString: function(callback) {
callback("<meta charset='utf-8'><table><tr><td>Paste buffer value</td></tr></table>");
}
}
]
}
});

setTimeout(function() {
var cellData = grid.data[0]['Column A'];
done(assertIf(cellData !== 'Paste buffer value', 'Value has not been replaced with clipboard data: ' + cellData));
}, 10);
});
it("Should fire a beforepaste event", function (done) {
var grid = g({
test: this.test,
data: [
{ 'Column A': 'Original value' }
]
});

grid.focus();
grid.setActiveCell(0, 0);

grid.addEventListener('beforepaste', function (event) {
event.preventDefault();
done();
});

// Event can be empty, because beforepaste should fire immediately,
// and return from paste function (because preventDefault):
grid.paste({});
});
it("Should fire an afterpaste event", function (done) {
var grid = g({
test: this.test,
data: [
{ 'Column A': 'Original value' }
]
});

grid.focus();
grid.setActiveCell(0, 0);

grid.addEventListener('afterpaste', function (event) {
done();
});

grid.paste({
clipboardData: {
items: [
{
type: 'text/plain',
getAsString: function(callback) {
callback('Paste buffer value');
}
}
]
}
});
});
it('Begin editing, tab to next cell', function (done) {
var ev,
err,
Expand Down

0 comments on commit fdf9566

Please sign in to comment.