@reactual/handsontable
Version:
Spreadsheet-like data grid editor
741 lines (625 loc) • 22.4 kB
JavaScript
import BasePlugin from './../_base.js';
import Hooks from './../../pluginHooks';
import {arrayEach} from './../../helpers/array';
import {addClass, removeClass, offset} from './../../helpers/dom/element';
import {rangeEach} from './../../helpers/number';
import EventManager from './../../eventManager';
import {registerPlugin} from './../../plugins';
import ColumnsMapper from './columnsMapper';
import BacklightUI from './ui/backlight';
import GuidelineUI from './ui/guideline';
import {CellCoords} from './../../3rdparty/walkontable/src';
import './manualColumnMove.css';
Hooks.getSingleton().register('beforeColumnMove');
Hooks.getSingleton().register('afterColumnMove');
Hooks.getSingleton().register('unmodifyCol');
const privatePool = new WeakMap();
const CSS_PLUGIN = 'ht__manualColumnMove';
const CSS_SHOW_UI = 'show-ui';
const CSS_ON_MOVING = 'on-moving--columns';
const CSS_AFTER_SELECTION = 'after-selection--columns';
/**
* @plugin ManualColumnMove
*
* @description
* This plugin allows to change columns order.
*
* API:
* - moveColumn - move single column to the new position.
* - moveColumns - move many columns (as an array of indexes) to the new position.
*
* If you want apply visual changes, you have to call manually the render() method on the instance of Handsontable.
*
* UI components:
* - backlight - highlight of selected columns.
* - guideline - line which shows where rows has been moved.
*
* @class ManualColumnMove
* @plugin ManualColumnMove
*/
class ManualColumnMove extends BasePlugin {
constructor(hotInstance) {
super(hotInstance);
/**
* Set up WeakMap of plugin to sharing private parameters;
*/
privatePool.set(this, {
columnsToMove: [],
countCols: 0,
fixedColumns: 0,
pressed: void 0,
disallowMoving: void 0,
target: {
eventPageX: void 0,
coords: void 0,
TD: void 0,
col: void 0
}
});
/**
* List of last removed row indexes.
*
* @type {Array}
*/
this.removedColumns = [];
/**
* Object containing visual row indexes mapped to data source indexes.
*
* @type {RowsMapper}
*/
this.columnsMapper = new ColumnsMapper(this);
/**
* Event Manager object.
*
* @type {Object}
*/
this.eventManager = new EventManager(this);
/**
* Backlight UI object.
*
* @type {Object}
*/
this.backlight = new BacklightUI(hotInstance);
/**
* Guideline UI object.
*
* @type {Object}
*/
this.guideline = new GuidelineUI(hotInstance);
}
/**
* Check if plugin is enabled.
*
* @returns {Boolean}
*/
isEnabled() {
return !!this.hot.getSettings().manualColumnMove;
}
/**
* Enable the plugin.
*/
enablePlugin() {
if (this.enabled) {
return;
}
this.addHook('beforeOnCellMouseDown', (event, coords, TD, blockCalculations) => this.onBeforeOnCellMouseDown(event, coords, TD, blockCalculations));
this.addHook('beforeOnCellMouseOver', (event, coords, TD, blockCalculations) => this.onBeforeOnCellMouseOver(event, coords, TD, blockCalculations));
this.addHook('afterScrollVertically', () => this.onAfterScrollVertically());
this.addHook('modifyCol', (row, source) => this.onModifyCol(row, source));
this.addHook('beforeRemoveCol', (index, amount) => this.onBeforeRemoveCol(index, amount));
this.addHook('afterRemoveCol', () => this.onAfterRemoveCol());
this.addHook('afterCreateCol', (index, amount) => this.onAfterCreateCol(index, amount));
this.addHook('afterLoadData', () => this.onAfterLoadData());
this.addHook('unmodifyCol', (column) => this.onUnmodifyCol(column));
this.registerEvents();
// TODO: move adding plugin classname to BasePlugin.
addClass(this.hot.rootElement, CSS_PLUGIN);
super.enablePlugin();
}
/**
* Updates the plugin to use the latest options you have specified.
*/
updatePlugin() {
this.disablePlugin();
this.enablePlugin();
this.onAfterPluginsInitialized();
super.updatePlugin();
}
/**
* Disable plugin for this Handsontable instance.
*/
disablePlugin() {
let pluginSettings = this.hot.getSettings().manualColumnMove;
if (Array.isArray(pluginSettings)) {
this.columnsMapper.clearMap();
}
removeClass(this.hot.rootElement, CSS_PLUGIN);
this.unregisterEvents();
this.backlight.destroy();
this.guideline.destroy();
super.disablePlugin();
}
/**
* Move a single column.
*
* @param {Number} column Visual column index to be moved.
* @param {Number} target Visual column index being a target for the moved column.
*/
moveColumn(column, target) {
this.moveColumns([column], target);
}
/**
* Move multiple columns.
*
* @param {Array} columns Array of visual column indexes to be moved.
* @param {Number} target Visual column index being a target for the moved columns.
*/
moveColumns(columns, target) {
let priv = privatePool.get(this);
let beforeColumnHook = this.hot.runHooks('beforeColumnMove', columns, target);
priv.disallowMoving = !beforeColumnHook;
if (beforeColumnHook !== false) {
// first we need to rewrite an visual indexes to physical for save reference after move
arrayEach(columns, (column, index, array) => {
array[index] = this.columnsMapper.getValueByIndex(column);
});
// next, when we have got an physical indexes, we can move columns
arrayEach(columns, (column, index) => {
let actualPosition = this.columnsMapper.getIndexByValue(column);
if (actualPosition !== target) {
this.columnsMapper.moveColumn(actualPosition, target + index);
}
});
// after moving we have to clear columnsMapper from null entries
this.columnsMapper.clearNull();
}
this.hot.runHooks('afterColumnMove', columns, target);
}
/**
* Correct the cell selection after the move action. Fired only when action was made with a mouse.
* That means that changing the column order using the API won't correct the selection.
*
* @private
* @param {Number} startColumn Visual column index for the start of the selection.
* @param {Number} endColumn Visual column index for the end of the selection.
*/
changeSelection(startColumn, endColumn) {
let selection = this.hot.selection;
let lastRowIndex = this.hot.countRows() - 1;
selection.setRangeStartOnly(new CellCoords(0, startColumn));
selection.setRangeEnd(new CellCoords(lastRowIndex, endColumn), false);
}
/**
* Get the sum of the widths of columns in the provided range.
*
* @private
* @param {Number} from Visual column index.
* @param {Number} to Visual column index.
* @returns {Number}
*/
getColumnsWidth(from, to) {
let width = 0;
for (let i = from; i < to; i++) {
let columnWidth = 0;
if (i < 0) {
columnWidth = this.hot.view.wt.wtViewport.getRowHeaderWidth() || 0;
} else {
columnWidth = this.hot.view.wt.wtTable.getStretchedColumnWidth(i) || 0;
}
width += columnWidth;
}
return width;
}
/**
* Load initial settings when persistent state is saved or when plugin was initialized as an array.
*
* @private
*/
initialSettings() {
let pluginSettings = this.hot.getSettings().manualColumnMove;
if (Array.isArray(pluginSettings)) {
this.moveColumns(pluginSettings, 0);
} else if (pluginSettings !== void 0) {
this.persistentStateLoad();
}
}
/**
* Check if the provided column is in the fixedColumnsLeft section.
*
* @private
* @param {Number} column Visual column index to check.
* @returns {Boolean}
*/
isFixedColumnsLeft(column) {
return column < this.hot.getSettings().fixedColumnsLeft;
}
/**
* Save the manual column positions to the persistent state.
*
* @private
*/
persistentStateSave() {
this.hot.runHooks('persistentStateSave', 'manualColumnMove', this.columnsMapper._arrayMap);
}
/**
* Load the manual column positions from the persistent state.
*
* @private
*/
persistentStateLoad() {
let storedState = {};
this.hot.runHooks('persistentStateLoad', 'manualColumnMove', storedState);
if (storedState.value) {
this.columnsMapper._arrayMap = storedState.value;
}
}
/**
* Prepare array of indexes based on actual selection.
*
* @private
* @returns {Array}
*/
prepareColumnsToMoving(start, end) {
let selectedColumns = [];
rangeEach(start, end, (i) => {
selectedColumns.push(i);
});
return selectedColumns;
}
/**
* Update the UI visual position.
*
* @private
*/
refreshPositions() {
let priv = privatePool.get(this);
let firstVisible = this.hot.view.wt.wtTable.getFirstVisibleColumn();
let lastVisible = this.hot.view.wt.wtTable.getLastVisibleColumn();
let wtTable = this.hot.view.wt.wtTable;
let scrollableElement = this.hot.view.wt.wtOverlays.scrollableElement;
let scrollLeft = typeof scrollableElement.scrollX === 'number' ? scrollableElement.scrollX : scrollableElement.scrollLeft;
let tdOffsetLeft = this.hot.view.THEAD.offsetLeft + this.getColumnsWidth(0, priv.coordsColumn);
let mouseOffsetLeft = priv.target.eventPageX - (priv.rootElementOffset - (scrollableElement.scrollX === void 0 ? scrollLeft : 0));
let hiderWidth = wtTable.hider.offsetWidth;
let tbodyOffsetLeft = wtTable.TBODY.offsetLeft;
let backlightElemMarginLeft = this.backlight.getOffset().left;
let backlightElemWidth = this.backlight.getSize().width;
let rowHeaderWidth = 0;
if ((priv.rootElementOffset + wtTable.holder.offsetWidth + scrollLeft) < priv.target.eventPageX) {
if (priv.coordsColumn < priv.countCols) {
priv.coordsColumn++;
}
}
if (priv.hasRowHeaders) {
rowHeaderWidth = this.hot.view.wt.wtOverlays.leftOverlay.clone.wtTable.getColumnHeader(-1).offsetWidth;
}
if (this.isFixedColumnsLeft(priv.coordsColumn)) {
tdOffsetLeft += scrollLeft;
}
tdOffsetLeft += rowHeaderWidth;
if (priv.coordsColumn < 0) {
// if hover on rowHeader
if (priv.fixedColumns > 0) {
priv.target.col = 0;
} else {
priv.target.col = firstVisible > 0 ? firstVisible - 1 : firstVisible;
}
} else if (((priv.target.TD.offsetWidth / 2) + tdOffsetLeft) <= mouseOffsetLeft) {
let newCoordsCol = priv.coordsColumn >= priv.countCols ? priv.countCols - 1 : priv.coordsColumn;
// if hover on right part of TD
priv.target.col = newCoordsCol + 1;
// unfortunately first column is bigger than rest
tdOffsetLeft += priv.target.TD.offsetWidth;
if (priv.target.col > lastVisible) {
this.hot.scrollViewportTo(void 0, lastVisible + 1, void 0, true);
}
} else {
// elsewhere on table
priv.target.col = priv.coordsColumn;
if (priv.target.col <= firstVisible && priv.target.col >= priv.fixedColumns) {
this.hot.scrollViewportTo(void 0, firstVisible - 1);
}
}
if (priv.target.col <= firstVisible && priv.target.col >= priv.fixedColumns) {
this.hot.scrollViewportTo(void 0, firstVisible - 1);
}
let backlightLeft = mouseOffsetLeft;
let guidelineLeft = tdOffsetLeft;
if (mouseOffsetLeft + backlightElemWidth + backlightElemMarginLeft >= hiderWidth) {
// prevent display backlight on the right side of the table
backlightLeft = hiderWidth - backlightElemWidth - backlightElemMarginLeft;
} else if (mouseOffsetLeft + backlightElemMarginLeft < tbodyOffsetLeft + rowHeaderWidth) {
// prevent display backlight on the left side of the table
backlightLeft = tbodyOffsetLeft + rowHeaderWidth + Math.abs(backlightElemMarginLeft);
}
if (tdOffsetLeft >= hiderWidth - 1) {
// prevent display guideline outside the table
guidelineLeft = hiderWidth - 1;
} else if (guidelineLeft === 0) {
// guideline has got `margin-left: -1px` as default
guidelineLeft = 1;
} else if (scrollableElement.scrollX !== void 0 && priv.coordsColumn < priv.fixedColumns) {
guidelineLeft -= ((priv.rootElementOffset <= scrollableElement.scrollX) ? priv.rootElementOffset : 0);
}
this.backlight.setPosition(null, backlightLeft);
this.guideline.setPosition(null, guidelineLeft);
}
/**
* This method checks arrayMap from columnsMapper and updates the columnsMapper if it's necessary.
*
* @private
*/
updateColumnsMapper() {
let countCols = this.hot.countSourceCols();
let columnsMapperLen = this.columnsMapper._arrayMap.length;
if (columnsMapperLen === 0) {
this.columnsMapper.createMap(countCols || this.hot.getSettings().startCols);
} else if (columnsMapperLen < countCols) {
let diff = countCols - columnsMapperLen;
this.columnsMapper.insertItems(columnsMapperLen, diff);
} else if (columnsMapperLen > countCols) {
let maxIndex = countCols - 1;
let columnsToRemove = [];
arrayEach(this.columnsMapper._arrayMap, (value, index) => {
if (value > maxIndex) {
columnsToRemove.push(index);
}
});
this.columnsMapper.removeItems(columnsToRemove);
}
}
/**
* Bind the events used by the plugin.
*
* @private
*/
registerEvents() {
this.eventManager.addEventListener(document.documentElement, 'mousemove', (event) => this.onMouseMove(event));
this.eventManager.addEventListener(document.documentElement, 'mouseup', () => this.onMouseUp());
}
/**
* Unbind the events used by the plugin.
*
* @private
*/
unregisterEvents() {
this.eventManager.clear();
}
/**
* Change the behavior of selection / dragging.
*
* @private
* @param {MouseEvent} event `mousedown` event properties.
* @param {CellCoords} coords Visual cell coordinates where was fired event.
* @param {HTMLElement} TD Cell represented as HTMLElement.
* @param {Object} blockCalculations Object which contains information about blockCalculation for row, column or cells.
*/
onBeforeOnCellMouseDown(event, coords, TD, blockCalculations) {
let wtTable = this.hot.view.wt.wtTable;
let isHeaderSelection = this.hot.selection.selectedHeader.cols;
let selection = this.hot.getSelectedRange();
let priv = privatePool.get(this);
let isSortingElement = event.realTarget.className.indexOf('columnSorting') > -1;
if (!selection || !isHeaderSelection || priv.pressed || event.button !== 0 || isSortingElement) {
priv.pressed = false;
priv.columnsToMove.length = 0;
removeClass(this.hot.rootElement, [CSS_ON_MOVING, CSS_SHOW_UI]);
return;
}
let guidelineIsNotReady = this.guideline.isBuilt() && !this.guideline.isAppended();
let backlightIsNotReady = this.backlight.isBuilt() && !this.backlight.isAppended();
if (guidelineIsNotReady && backlightIsNotReady) {
this.guideline.appendTo(wtTable.hider);
this.backlight.appendTo(wtTable.hider);
}
let {from, to} = selection;
let start = Math.min(from.col, to.col);
let end = Math.max(from.col, to.col);
if (coords.row < 0 && (coords.col >= start && coords.col <= end)) {
blockCalculations.column = true;
priv.pressed = true;
priv.target.eventPageX = event.pageX;
priv.coordsColumn = coords.col;
priv.target.TD = TD;
priv.target.col = coords.col;
priv.columnsToMove = this.prepareColumnsToMoving(start, end);
priv.hasRowHeaders = !!this.hot.getSettings().rowHeaders;
priv.countCols = this.hot.countCols();
priv.fixedColumns = this.hot.getSettings().fixedColumnsLeft;
priv.rootElementOffset = offset(this.hot.rootElement).left;
let countColumnsFrom = priv.hasRowHeaders ? -1 : 0;
let topPos = wtTable.holder.scrollTop + wtTable.getColumnHeaderHeight(0) + 1;
let fixedColumns = coords.col < priv.fixedColumns;
let scrollableElement = this.hot.view.wt.wtOverlays.scrollableElement;
let wrapperIsWindow = scrollableElement.scrollX ? scrollableElement.scrollX - priv.rootElementOffset : 0;
let mouseOffset = event.layerX - (fixedColumns ? wrapperIsWindow : 0);
let leftOffset = Math.abs(this.getColumnsWidth(start, coords.col) + mouseOffset);
this.backlight.setPosition(topPos, this.getColumnsWidth(countColumnsFrom, start) + leftOffset);
this.backlight.setSize(this.getColumnsWidth(start, end + 1), wtTable.hider.offsetHeight - topPos);
this.backlight.setOffset(null, leftOffset * -1);
addClass(this.hot.rootElement, CSS_ON_MOVING);
} else {
removeClass(this.hot.rootElement, CSS_AFTER_SELECTION);
priv.pressed = false;
priv.columnsToMove.length = 0;
}
}
/**
* 'mouseMove' event callback. Fired when pointer move on document.documentElement.
*
* @private
* @param {MouseEvent} event `mousemove` event properties.
*/
onMouseMove(event) {
let priv = privatePool.get(this);
if (!priv.pressed) {
return;
}
// callback for browser which doesn't supports CSS pointer-event: none
if (event.realTarget === this.backlight.element) {
let width = this.backlight.getSize().width;
this.backlight.setSize(0);
setTimeout(function() {
this.backlight.setPosition(width);
});
}
priv.target.eventPageX = event.pageX;
this.refreshPositions();
}
/**
* 'beforeOnCellMouseOver' hook callback. Fired when pointer was over cell.
*
* @private
* @param {MouseEvent} event `mouseover` event properties.
* @param {CellCoords} coords Visual cell coordinates where was fired event.
* @param {HTMLElement} TD Cell represented as HTMLElement.
* @param {Object} blockCalculations Object which contains information about blockCalculation for row, column or cells.
*/
onBeforeOnCellMouseOver(event, coords, TD, blockCalculations) {
let selectedRange = this.hot.getSelectedRange();
let priv = privatePool.get(this);
if (!selectedRange || !priv.pressed) {
return;
}
if (priv.columnsToMove.indexOf(coords.col) > -1) {
removeClass(this.hot.rootElement, CSS_SHOW_UI);
} else {
addClass(this.hot.rootElement, CSS_SHOW_UI);
}
blockCalculations.row = true;
blockCalculations.column = true;
blockCalculations.cell = true;
priv.coordsColumn = coords.col;
priv.target.TD = TD;
}
/**
* `onMouseUp` hook callback.
*
* @private
*/
onMouseUp() {
let priv = privatePool.get(this);
priv.coordsColumn = void 0;
priv.pressed = false;
priv.backlightWidth = 0;
removeClass(this.hot.rootElement, [CSS_ON_MOVING, CSS_SHOW_UI, CSS_AFTER_SELECTION]);
if (this.hot.selection.selectedHeader.cols) {
addClass(this.hot.rootElement, CSS_AFTER_SELECTION);
}
if (priv.columnsToMove.length < 1 || priv.target.col === void 0 || priv.columnsToMove.indexOf(priv.target.col) > -1) {
return;
}
this.moveColumns(priv.columnsToMove, priv.target.col);
this.persistentStateSave();
this.hot.render();
this.hot.view.wt.wtOverlays.adjustElementsSize(true);
if (!priv.disallowMoving) {
let selectionStart = this.columnsMapper.getIndexByValue(priv.columnsToMove[0]);
let selectionEnd = this.columnsMapper.getIndexByValue(priv.columnsToMove[priv.columnsToMove.length - 1]);
this.changeSelection(selectionStart, selectionEnd);
}
priv.columnsToMove.length = 0;
}
/**
* `afterScrollHorizontally` hook callback. Fired the table was scrolled horizontally.
*
* @private
*/
onAfterScrollVertically() {
let wtTable = this.hot.view.wt.wtTable;
let headerHeight = wtTable.getColumnHeaderHeight(0) + 1;
let scrollTop = wtTable.holder.scrollTop;
let posTop = headerHeight + scrollTop;
this.backlight.setPosition(posTop);
this.backlight.setSize(null, wtTable.hider.offsetHeight - posTop);
}
/**
* `afterCreateCol` hook callback.
*
* @private
* @param {Number} index Visual index of the created column.
* @param {Number} amount Amount of created columns.
*/
onAfterCreateCol(index, amount) {
this.columnsMapper.shiftItems(index, amount);
}
/**
* On before remove column listener.
*
* @private
* @param {Number} index Visual column index.
* @param {Number} amount Defines how many columns removed.
*/
onBeforeRemoveCol(index, amount) {
this.removedColumns.length = 0;
if (index !== false) {
// Collect physical row index.
rangeEach(index, index + amount - 1, (removedIndex) => {
this.removedColumns.push(this.hot.runHooks('modifyCol', removedIndex, this.pluginName));
});
}
}
/**
* `afterRemoveCol` hook callback.
*
* @private
*/
onAfterRemoveCol() {
this.columnsMapper.unshiftItems(this.removedColumns);
}
/**
* `afterLoadData` hook callback.
*
* @private
*/
onAfterLoadData() {
this.updateColumnsMapper();
}
/**
* 'modifyRow' hook callback.
*
* @private
* @param {Number} column Visual column index.
* @returns {Number} Physical column index.
*/
onModifyCol(column, source) {
if (source !== this.pluginName) {
// ugly fix for try to insert new, needed columns after pasting data
let columnInMapper = this.columnsMapper.getValueByIndex(column);
column = columnInMapper === null ? column : columnInMapper;
}
return column;
}
/**
* 'unmodifyCol' hook callback.
*
* @private
* @param {Number} column Physical column index.
* @returns {Number} Visual column index.
*/
onUnmodifyCol(column) {
let indexInMapper = this.columnsMapper.getIndexByValue(column);
return indexInMapper === null ? column : indexInMapper;
}
/**
* `afterPluginsInitialized` hook callback.
*
* @private
*/
onAfterPluginsInitialized() {
this.updateColumnsMapper();
this.initialSettings();
this.backlight.build();
this.guideline.build();
}
/**
* Destroy plugin instance.
*/
destroy() {
this.backlight.destroy();
this.guideline.destroy();
super.destroy();
}
}
registerPlugin('ManualColumnMove', ManualColumnMove);
export default ManualColumnMove;