@quantlab/handsontable
Version:
Spreadsheet-like data grid editor that provides copy/paste functionality compatible with Excel/Google Docs
635 lines (548 loc) • 22.5 kB
JavaScript
import Hooks from './../../pluginHooks';
import {registerPlugin} from './../../plugins';
import {stopImmediatePropagation} from './../../helpers/dom/event';
import {CellCoords, CellRange, Table} from './../../3rdparty/walkontable/src';
function CellInfoCollection() {
var collection = [];
collection.getInfo = function(row, col) {
for (var i = 0, ilen = this.length; i < ilen; i++) {
if (this[i].row <= row && this[i].row + this[i].rowspan - 1 >= row &&
this[i].col <= col && this[i].col + this[i].colspan - 1 >= col) {
return this[i];
}
}
};
collection.setInfo = function(info) {
for (var i = 0, ilen = this.length; i < ilen; i++) {
if (this[i].row === info.row && this[i].col === info.col) {
this[i] = info;
return;
}
}
this.push(info);
};
collection.removeInfo = function(row, col) {
for (var i = 0, ilen = this.length; i < ilen; i++) {
if (this[i].row === row && this[i].col === col) {
this.splice(i, 1);
break;
}
}
};
return collection;
}
/**
* Plugin used to merge cells in Handsontable.
*
* @private
* @plugin MergeCells
* @class MergeCells
*/
function MergeCells(mergeCellsSetting) {
this.mergedCellInfoCollection = new CellInfoCollection();
if (Array.isArray(mergeCellsSetting)) {
for (var i = 0, ilen = mergeCellsSetting.length; i < ilen; i++) {
this.mergedCellInfoCollection.setInfo(mergeCellsSetting[i]);
}
}
}
/**
* @param cellRange (CellRange)
*/
MergeCells.prototype.canMergeRange = function(cellRange) {
// is more than one cell selected
return !cellRange.isSingle();
};
MergeCells.prototype.mergeRange = function(cellRange) {
if (!this.canMergeRange(cellRange)) {
return;
}
// normalize top left corner
var topLeft = cellRange.getTopLeftCorner();
var bottomRight = cellRange.getBottomRightCorner();
var mergeParent = {};
mergeParent.row = topLeft.row;
mergeParent.col = topLeft.col;
// TD has rowspan == 1 by default. rowspan == 2 means spread over 2 cells
mergeParent.rowspan = bottomRight.row - topLeft.row + 1;
mergeParent.colspan = bottomRight.col - topLeft.col + 1;
this.mergedCellInfoCollection.setInfo(mergeParent);
};
MergeCells.prototype.mergeOrUnmergeSelection = function(cellRange) {
var info = this.mergedCellInfoCollection.getInfo(cellRange.from.row, cellRange.from.col);
if (info) {
// unmerge
this.unmergeSelection(cellRange.from);
} else {
// merge
this.mergeSelection(cellRange);
}
};
MergeCells.prototype.mergeSelection = function(cellRange) {
this.mergeRange(cellRange);
};
MergeCells.prototype.unmergeSelection = function(cellRange) {
var info = this.mergedCellInfoCollection.getInfo(cellRange.row, cellRange.col);
this.mergedCellInfoCollection.removeInfo(info.row, info.col);
};
MergeCells.prototype.applySpanProperties = function(TD, row, col) {
var info = this.mergedCellInfoCollection.getInfo(row, col);
if (info) {
if (info.row === row && info.col === col) {
TD.setAttribute('rowspan', info.rowspan);
TD.setAttribute('colspan', info.colspan);
} else {
TD.removeAttribute('rowspan');
TD.removeAttribute('colspan');
TD.style.display = 'none';
}
} else {
TD.removeAttribute('rowspan');
TD.removeAttribute('colspan');
}
};
MergeCells.prototype.modifyTransform = function(hook, currentSelectedRange, delta) {
var sameRowspan = function(merged, coords) {
if (coords.row >= merged.row && coords.row <= (merged.row + merged.rowspan - 1)) {
return true;
}
return false;
},
sameColspan = function(merged, coords) {
if (coords.col >= merged.col && coords.col <= (merged.col + merged.colspan - 1)) {
return true;
}
return false;
},
getNextPosition = function(newDelta) {
return new CellCoords(currentSelectedRange.to.row + newDelta.row, currentSelectedRange.to.col + newDelta.col);
};
var newDelta = {
row: delta.row,
col: delta.col,
};
if (hook == 'modifyTransformStart') {
/* eslint-disable block-scoped-var */
var nextPosition;
if (!this.lastDesiredCoords) {
this.lastDesiredCoords = new CellCoords(null, null);
}
var currentPosition = new CellCoords(currentSelectedRange.highlight.row, currentSelectedRange.highlight.col),
// if current position's parent is a merged range, returns it
mergedParent = this.mergedCellInfoCollection.getInfo(currentPosition.row, currentPosition.col),
currentRangeContainsMerge; // if current range contains a merged range
for (var i = 0, mergesLength = this.mergedCellInfoCollection.length; i < mergesLength; i++) {
var range = this.mergedCellInfoCollection[i];
range = new CellCoords(range.row + range.rowspan - 1, range.col + range.colspan - 1);
if (currentSelectedRange.includes(range)) {
currentRangeContainsMerge = true;
break;
}
}
if (mergedParent) { // only merge selected
let mergeTopLeft = new CellCoords(mergedParent.row, mergedParent.col);
let mergeBottomRight = new CellCoords(mergedParent.row + mergedParent.rowspan - 1, mergedParent.col + mergedParent.colspan - 1);
let mergeRange = new CellRange(mergeTopLeft, mergeTopLeft, mergeBottomRight);
if (!mergeRange.includes(this.lastDesiredCoords)) {
this.lastDesiredCoords = new CellCoords(null, null); // reset outdated version of lastDesiredCoords
}
newDelta.row = this.lastDesiredCoords.row ? this.lastDesiredCoords.row - currentPosition.row : newDelta.row;
newDelta.col = this.lastDesiredCoords.col ? this.lastDesiredCoords.col - currentPosition.col : newDelta.col;
if (delta.row > 0) { // moving down
newDelta.row = mergedParent.row + mergedParent.rowspan - 1 - currentPosition.row + delta.row;
} else if (delta.row < 0) { // moving up
newDelta.row = currentPosition.row - mergedParent.row + delta.row;
}
if (delta.col > 0) { // moving right
newDelta.col = mergedParent.col + mergedParent.colspan - 1 - currentPosition.col + delta.col;
} else if (delta.col < 0) { // moving left
newDelta.col = currentPosition.col - mergedParent.col + delta.col;
}
}
nextPosition = new CellCoords(currentSelectedRange.highlight.row + newDelta.row, currentSelectedRange.highlight.col + newDelta.col);
var nextParentIsMerged = this.mergedCellInfoCollection.getInfo(nextPosition.row, nextPosition.col);
if (nextParentIsMerged) { // skipping the invisible cells in the merge range
this.lastDesiredCoords = nextPosition;
newDelta = {
row: nextParentIsMerged.row - currentPosition.row,
col: nextParentIsMerged.col - currentPosition.col
};
}
} else if (hook == 'modifyTransformEnd') {
for (let i = 0, mergesLength = this.mergedCellInfoCollection.length; i < mergesLength; i++) {
let currentMerge = this.mergedCellInfoCollection[i];
let mergeTopLeft = new CellCoords(currentMerge.row, currentMerge.col);
let mergeBottomRight = new CellCoords(currentMerge.row + currentMerge.rowspan - 1, currentMerge.col + currentMerge.colspan - 1);
let mergedRange = new CellRange(mergeTopLeft, mergeTopLeft, mergeBottomRight);
let sharedBorders = currentSelectedRange.getBordersSharedWith(mergedRange);
if (mergedRange.isEqual(currentSelectedRange)) { // only the merged range is selected
currentSelectedRange.setDirection('NW-SE');
} else if (sharedBorders.length > 0) {
var mergeHighlighted = (currentSelectedRange.highlight.isEqual(mergedRange.from));
if (sharedBorders.indexOf('top') > -1) { // if range shares a border with the merged section, change range direction accordingly
if (currentSelectedRange.to.isSouthEastOf(mergedRange.from) && mergeHighlighted) {
currentSelectedRange.setDirection('NW-SE');
} else if (currentSelectedRange.to.isSouthWestOf(mergedRange.from) && mergeHighlighted) {
currentSelectedRange.setDirection('NE-SW');
}
} else if (sharedBorders.indexOf('bottom') > -1) {
if (currentSelectedRange.to.isNorthEastOf(mergedRange.from) && mergeHighlighted) {
currentSelectedRange.setDirection('SW-NE');
} else if (currentSelectedRange.to.isNorthWestOf(mergedRange.from) && mergeHighlighted) {
currentSelectedRange.setDirection('SE-NW');
}
}
}
nextPosition = getNextPosition(newDelta);
var
withinRowspan = sameRowspan(currentMerge, nextPosition),
withinColspan = sameColspan(currentMerge, nextPosition);
if (currentSelectedRange.includesRange(mergedRange) && (mergedRange.includes(nextPosition) ||
withinRowspan || withinColspan)) { // if next step overlaps a merged range, jump past it
if (withinRowspan) {
if (newDelta.row < 0) {
newDelta.row -= currentMerge.rowspan - 1;
} else if (newDelta.row > 0) {
newDelta.row += currentMerge.rowspan - 1;
}
}
if (withinColspan) {
if (newDelta.col < 0) {
newDelta.col -= currentMerge.colspan - 1;
} else if (newDelta.col > 0) {
newDelta.col += currentMerge.colspan - 1;
}
}
}
}
}
if (newDelta.row !== 0) {
delta.row = newDelta.row;
}
if (newDelta.col !== 0) {
delta.col = newDelta.col;
}
};
MergeCells.prototype.shiftCollection = function(direction, index, count) {
var shiftVector = [0, 0];
switch (direction) {
case 'right':
shiftVector[0] += 1;
break;
case 'left':
shiftVector[0] -= 1;
break;
case 'down':
shiftVector[1] += 1;
break;
case 'up':
shiftVector[1] -= 1;
break;
default:
break;
}
for (var i = 0; i < this.mergedCellInfoCollection.length; i++) {
var currentMerge = this.mergedCellInfoCollection[i];
if (direction === 'right' || direction === 'left') {
if (index <= currentMerge.col) {
currentMerge.col += shiftVector[0];
}
} else if (index <= currentMerge.row) {
currentMerge.row += shiftVector[1];
}
}
};
var beforeInit = function() {
var instance = this;
var mergeCellsSetting = instance.getSettings().mergeCells;
if (mergeCellsSetting) {
if (!instance.mergeCells) {
instance.mergeCells = new MergeCells(mergeCellsSetting);
}
}
};
var afterInit = function() {
var instance = this;
if (instance.mergeCells) {
/**
* Monkey patch Table.prototype.getCell to return TD for merged cell parent if asked for TD of a cell that is
* invisible due to the merge. This is not the cleanest solution but there is a test case for it (merged cells scroll) so feel free to refactor it!
*/
instance.view.wt.wtTable.getCell = function(coords) {
if (instance.getSettings().mergeCells) {
var mergeParent = instance.mergeCells.mergedCellInfoCollection.getInfo(coords.row, coords.col);
if (mergeParent) {
coords = mergeParent;
}
}
return Table.prototype.getCell.call(this, coords);
};
}
};
var afterUpdateSettings = function() {
var instance = this;
var mergeCellsSetting = instance.getSettings().mergeCells;
if (mergeCellsSetting) {
if (instance.mergeCells) {
instance.mergeCells.mergedCellInfoCollection = new CellInfoCollection();
if (Array.isArray(mergeCellsSetting)) {
for (var i = 0, ilen = mergeCellsSetting.length; i < ilen; i++) {
instance.mergeCells.mergedCellInfoCollection.setInfo(mergeCellsSetting[i]);
}
}
} else {
instance.mergeCells = new MergeCells(mergeCellsSetting);
}
} else if (instance.mergeCells) { // it doesn't actually turn off the plugin, just resets the settings. Need to refactor.
instance.mergeCells.mergedCellInfoCollection = new CellInfoCollection();
}
};
var onBeforeKeyDown = function(event) {
if (!this.mergeCells) {
return;
}
var ctrlDown = (event.ctrlKey || event.metaKey) && !event.altKey;
if (ctrlDown) {
if (event.keyCode === 77) { // CTRL + M
this.mergeCells.mergeOrUnmergeSelection(this.getSelectedRange());
this.render();
stopImmediatePropagation(event);
}
}
};
var addMergeActionsToContextMenu = function(defaultOptions) {
if (!this.getSettings().mergeCells) {
return;
}
defaultOptions.items.push({name: '---------'});
defaultOptions.items.push({
key: 'mergeCells',
name() {
var sel = this.getSelected();
var info = this.mergeCells.mergedCellInfoCollection.getInfo(sel[0], sel[1]);
if (info) {
return 'Unmerge cells';
}
return 'Merge cells';
},
callback() {
this.mergeCells.mergeOrUnmergeSelection(this.getSelectedRange());
this.render();
},
disabled() {
return this.selection.selectedHeader.corner;
},
});
};
var afterRenderer = function(TD, row, col, prop, value, cellProperties) {
if (this.mergeCells) {
this.mergeCells.applySpanProperties(TD, row, col);
}
};
var modifyTransformFactory = function(hook) {
return function(delta) {
var mergeCellsSetting = this.getSettings().mergeCells;
if (mergeCellsSetting) {
var currentSelectedRange = this.getSelectedRange();
this.mergeCells.modifyTransform(hook, currentSelectedRange, delta);
if (hook === 'modifyTransformEnd') {
// sanitize "from" (core.js will sanitize to)
var totalRows = this.countRows();
var totalCols = this.countCols();
if (currentSelectedRange.from.row < 0) {
currentSelectedRange.from.row = 0;
} else if (currentSelectedRange.from.row > 0 && currentSelectedRange.from.row >= totalRows) {
currentSelectedRange.from.row = currentSelectedRange.from - 1;
}
if (currentSelectedRange.from.col < 0) {
currentSelectedRange.from.col = 0;
} else if (currentSelectedRange.from.col > 0 && currentSelectedRange.from.col >= totalCols) {
currentSelectedRange.from.col = totalCols - 1;
}
}
}
};
};
/**
* While selecting cells with keyboard or mouse, make sure that rectangular area is expanded to the extent of the merged cell
* @param coords
*/
var beforeSetRangeEnd = function(coords) {
this.lastDesiredCoords = null; // unset lastDesiredCoords when selection is changed with mouse
var mergeCellsSetting = this.getSettings().mergeCells;
if (mergeCellsSetting) {
var selRange = this.getSelectedRange();
selRange.highlight = new CellCoords(selRange.highlight.row, selRange.highlight.col); // clone in case we will modify its reference
selRange.to = coords;
var rangeExpanded = false;
do {
rangeExpanded = false;
for (var i = 0, ilen = this.mergeCells.mergedCellInfoCollection.length; i < ilen; i++) {
var cellInfo = this.mergeCells.mergedCellInfoCollection[i];
var mergedCellTopLeft = new CellCoords(cellInfo.row, cellInfo.col);
var mergedCellBottomRight = new CellCoords(cellInfo.row + cellInfo.rowspan - 1, cellInfo.col + cellInfo.colspan - 1);
var mergedCellRange = new CellRange(mergedCellTopLeft, mergedCellTopLeft, mergedCellBottomRight);
if (selRange.expandByRange(mergedCellRange)) {
coords.row = selRange.to.row;
coords.col = selRange.to.col;
rangeExpanded = true;
}
}
} while (rangeExpanded);
}
};
/**
* Returns correct coordinates for merged start / end cells in selection for area borders
* @param corners
* @param className
*/
var beforeDrawAreaBorders = function(corners, className) {
if (className && className == 'area') {
var mergeCellsSetting = this.getSettings().mergeCells;
if (mergeCellsSetting) {
var selRange = this.getSelectedRange();
var startRange = new CellRange(selRange.from, selRange.from, selRange.from);
var stopRange = new CellRange(selRange.to, selRange.to, selRange.to);
for (var i = 0, ilen = this.mergeCells.mergedCellInfoCollection.length; i < ilen; i++) {
var cellInfo = this.mergeCells.mergedCellInfoCollection[i];
var mergedCellTopLeft = new CellCoords(cellInfo.row, cellInfo.col);
var mergedCellBottomRight = new CellCoords(cellInfo.row + cellInfo.rowspan - 1, cellInfo.col + cellInfo.colspan - 1);
var mergedCellRange = new CellRange(mergedCellTopLeft, mergedCellTopLeft, mergedCellBottomRight);
if (startRange.expandByRange(mergedCellRange)) {
corners[0] = startRange.from.row;
corners[1] = startRange.from.col;
}
if (stopRange.expandByRange(mergedCellRange)) {
corners[2] = stopRange.from.row;
corners[3] = stopRange.from.col;
}
}
}
}
};
var afterGetCellMeta = function(row, col, cellProperties) {
var mergeCellsSetting = this.getSettings().mergeCells;
if (mergeCellsSetting) {
var mergeParent = this.mergeCells.mergedCellInfoCollection.getInfo(row, col);
if (mergeParent && (mergeParent.row != row || mergeParent.col != col)) {
cellProperties.copyable = false;
}
}
};
var afterViewportRowCalculatorOverride = function(calc) {
var mergeCellsSetting = this.getSettings().mergeCells;
if (mergeCellsSetting) {
var colCount = this.countCols();
var mergeParent;
for (var c = 0; c < colCount; c++) {
mergeParent = this.mergeCells.mergedCellInfoCollection.getInfo(calc.startRow, c);
if (mergeParent) {
if (mergeParent.row < calc.startRow) {
calc.startRow = mergeParent.row;
return afterViewportRowCalculatorOverride.call(this, calc); // recursively search upwards
}
}
mergeParent = this.mergeCells.mergedCellInfoCollection.getInfo(calc.endRow, c);
if (mergeParent) {
var mergeEnd = mergeParent.row + mergeParent.rowspan - 1;
if (mergeEnd > calc.endRow) {
calc.endRow = mergeEnd;
return afterViewportRowCalculatorOverride.call(this, calc); // recursively search upwards
}
}
}
}
};
var afterViewportColumnCalculatorOverride = function(calc) {
var mergeCellsSetting = this.getSettings().mergeCells;
if (mergeCellsSetting) {
var rowCount = this.countRows();
var mergeParent;
for (var r = 0; r < rowCount; r++) {
mergeParent = this.mergeCells.mergedCellInfoCollection.getInfo(r, calc.startColumn);
if (mergeParent) {
if (mergeParent.col < calc.startColumn) {
calc.startColumn = mergeParent.col;
return afterViewportColumnCalculatorOverride.call(this, calc); // recursively search upwards
}
}
mergeParent = this.mergeCells.mergedCellInfoCollection.getInfo(r, calc.endColumn);
if (mergeParent) {
var mergeEnd = mergeParent.col + mergeParent.colspan - 1;
if (mergeEnd > calc.endColumn) {
calc.endColumn = mergeEnd;
return afterViewportColumnCalculatorOverride.call(this, calc); // recursively search upwards
}
}
}
}
};
var isMultipleSelection = function(isMultiple) {
if (isMultiple && this.mergeCells) {
var mergedCells = this.mergeCells.mergedCellInfoCollection,
selectionRange = this.getSelectedRange();
for (var group in mergedCells) {
if (selectionRange.highlight.row == mergedCells[group].row &&
selectionRange.highlight.col == mergedCells[group].col &&
selectionRange.to.row == mergedCells[group].row + mergedCells[group].rowspan - 1 &&
selectionRange.to.col == mergedCells[group].col + mergedCells[group].colspan - 1) {
return false;
}
}
}
return isMultiple;
};
function modifyAutofillRange(select, drag) {
var mergeCellsSetting = this.getSettings().mergeCells;
if (!mergeCellsSetting || this.selection.isMultiple()) {
return;
}
var info = this.mergeCells.mergedCellInfoCollection.getInfo(select[0], select[1]);
if (info) {
select[0] = info.row;
select[1] = info.col;
select[2] = info.row + info.rowspan - 1;
select[3] = info.col + info.colspan - 1;
}
}
function onAfterCreateCol(col, count) {
if (this.mergeCells) {
this.mergeCells.shiftCollection('right', col, count);
}
}
function onAfterRemoveCol(col, count) {
if (this.mergeCells) {
this.mergeCells.shiftCollection('left', col, count);
}
}
function onAfterCreateRow(row, count) {
if (this.mergeCells) {
this.mergeCells.shiftCollection('down', row, count);
}
}
function onAfterRemoveRow(row, count) {
if (this.mergeCells) {
this.mergeCells.shiftCollection('up', row, count);
}
}
const hook = Hooks.getSingleton();
hook.add('beforeInit', beforeInit);
hook.add('afterInit', afterInit);
hook.add('afterUpdateSettings', afterUpdateSettings);
hook.add('beforeKeyDown', onBeforeKeyDown);
hook.add('modifyTransformStart', modifyTransformFactory('modifyTransformStart'));
hook.add('modifyTransformEnd', modifyTransformFactory('modifyTransformEnd'));
hook.add('beforeSetRangeEnd', beforeSetRangeEnd);
hook.add('beforeDrawBorders', beforeDrawAreaBorders);
hook.add('afterIsMultipleSelection', isMultipleSelection);
hook.add('afterRenderer', afterRenderer);
hook.add('afterContextMenuDefaultOptions', addMergeActionsToContextMenu);
hook.add('afterGetCellMeta', afterGetCellMeta);
hook.add('afterViewportRowCalculatorOverride', afterViewportRowCalculatorOverride);
hook.add('afterViewportColumnCalculatorOverride', afterViewportColumnCalculatorOverride);
hook.add('modifyAutofillRange', modifyAutofillRange);
hook.add('afterCreateCol', onAfterCreateCol);
hook.add('afterRemoveCol', onAfterRemoveCol);
hook.add('afterCreateRow', onAfterCreateRow);
hook.add('afterRemoveRow', onAfterRemoveRow);
export default MergeCells;