/**
* @class Spread.selection.RangeModel
*
* The instance of this selection model can be fetched by a call of the method
* getSelectionModel() on a spreadsheet's grid or view instance.
*
* This selection model is able to focus cells and select ranges of cells.
* It implements the logic of selection by:
* <ul>
* <li>click-mouse-drag cell range selection</li>
* <li>click-mouse, press shift key, click cell range selection</li>
* <li>navigate and focus by keys UP, DOWN, LEFT, RIGHT, TAB, ENTER</li>
* <li>press shift key and navigate by key cell range selection</li>
* <li>...and all of them in combination</li>
* </ul>
*
* Using the interceptable eventing of this selection model, it's possible
* to extend the selection and focussing logic.
*/
Ext.define('Spread.selection.RangeModel', {
'extend': 'Ext.selection.Model',
'requires': ['Spread.selection.Range'],
alias: 'selection.range',
isRangeModel: true,
initialViewRefresh: true,
dataChangedRecently: false,
keyNav: null,
keyNavigation: false,
mayRangeSelecting: false,
/**
* @property {Spread.selection.Position}
* Dynamically calculated root position (initial focus position)
*/
rootPosition: null,
/**
* @cfg {Boolean} autoFocusRootPosition
* Automatically focusses the root position initially
*/
autoFocusRootPosition: true,
/**
* @cfg {Boolean} enableKeyNav
* Turns on/off keyboard navigation within the grid.
*/
enableKeyNav: true,
/**
* @property {Spread.selection.Range}
* Internal array which contains all
* position objects, identifying the current
* range of selected cells.
*/
currentSelectionRange: null,
/**
* @property {Spread.selection.Position}
* Internal reference to the origin
* selection position object identifying the cell:
* - where the user clicked without shift pressed
* - clicked the first time, before extending the range via shift + mouse drag
* - moved to via key (up, down, left, right) without shift pressed
* - moved to the first time via key before shift + key was pressed to extend the range
*/
originSelectionPosition: null,
/**
* @property {Spread.selection.Position}
* Internal reference to the last/current focus position,
* which means the positioning object of the cell,
* an event like mouseup or keyup was fired on the last time.
*/
currentFocusPosition: null,
/**
* @property {Spread.grid.View} Internal view instance reference
*/
view: null,
/**
* @property {Spread.grid.Panel} Internal grid reference
*/
grid: null,
/**
* @private
*/
constructor: function() {
this.addEvents(
/**
* @event deselect
* Fired after a cell range is deselected
* @param {Spread.selection.RangeModel} this
* @param {Array} range Array of selection positions identifying cells
*/
'deselect',
/**
* @event select
* Fired after a cell range is selected
* @param {Spread.selection.RangeModel} this
* @param {Array} range Array of selection positions identifying cells
*/
'select',
/**
* @event beforecellfocus
* Fired before a cell gets focussed
* @param {Spread.selection.RangeModel} this
* @param {Spread.selection.Position} position Cell position object reference
*/
'beforecellfocus',
/**
* @event cellfocus
* Fired after a cell has been focussed
* @param {Spread.selection.RangeModel} this
* @param {Spread.selection.Position} position Cell position object reference
*/
'cellfocus',
/**
* @event cellblur
* Fired when a cell blur event happens
* @param {Spread.selection.RangeModel} this
* @param {Ext.dom.Element} el Element clicked on
*/
'cellblur',
/**
* @event tabselect
* Fired after TAB has been pressed by user to focus (next) cell
* @param {Spread.selection.RangeModel} this
* @param {Ext.EventObject} evt Key event
*/
'tabselect',
/**
* @event enterselect
* Fired after ENTER has been pressed by user to focus (next) cell (below, left below)
* @param {Spread.selection.RangeModel} this
* @param {Ext.EventObject} evt Key event
*/
'enterselect',
/**
* @event keynavigate
* Fired after key navigation happened (up, down, left, right)
* @param {Spread.selection.RangeModel} this
* @param {String} direction Direction name
* @param {Ext.EventObject} evt Key event
*/
'keynavigate'
);
this.callParent(arguments);
// Set current selection range
this.currentSelectionRange = new Spread.selection.Range(this.getSpreadPanel());
},
// --- Initialization
/**
* @protected
* Binds the view instance events to be handled inside of this class
* @param {Spread.grid.View} view View instance
* @return void
*/
bindComponent: function(view) {
var me = this;
// Internal references
me.view = view;
me.grid = me.view.ownerCt;
// Call parent
me.callParent(arguments);
// Initialize the root position
me.initRootPosition();
// Bind UI events for interaction handling
me.bindUIEvents();
// Initialize key nav if needed
if (me.enableKeyNav) {
me.initKeyNav(view);
}
},
/**
* @protected
* Builds a root position to point to the top- and left-most cell available
* @return void
*/
initRootPosition: function() {
var columnIndex = 0,
currentColumnIsNotHeaderColumn = false,
noNonHeaderColumnFound = false;
// Look-ahead and increment starting column position until a column is
// found which is not a header column
while (!currentColumnIsNotHeaderColumn) {
if (!this.view.getHeaderAtIndex(columnIndex)) {
currentColumnIsNotHeaderColumn = true;
noNonHeaderColumnFound = true;
}
if (this.view.getHeaderAtIndex(columnIndex) &&
this.view.getHeaderAtIndex(columnIndex).selectable) {
currentColumnIsNotHeaderColumn = true;
} else {
columnIndex++;
}
}
// Create an instance of a root position object
this.rootPosition = new Spread.selection.Position(
this.view,
columnIndex,
0,
this.view.getStore().getAt(0)
);
},
/**
* @protected
* Binds the view instance events to be handled inside of this class
* @return void
*/
bindUIEvents: function() {
var me = this;
// Catch the view's cell dbl click event
me.view.on({
cellmouseevents: me.onCellMouseEvents,
refresh: me.onViewRefresh,
scope: me
});
// Catch grid's events
me.view.ownerCt.on({
columnhide: me.reinitialize,
columnmove: me.reinitialize,
columnshow: me.reinitialize,
scope: me
});
// On data change (e.g. filtering)
me.view.store.on('datachanged', function() {
//console.log('datachanged!');
// Set indicator flag to reinitialize after store data has been changed
me.dataChangedRecently = true;
});
// Register edit blur handler
me.initEditBlurHandler();
},
/**
* @protected
* Registers and un-registers a document.body event listener
* for clicks outside of the view area to stop editing.
* @return void
*/
initEditBlurHandler: function() {
var me = this;
// Un-register on grid destroy
me.grid.on('destroy', function() {
Ext.EventManager.un(document.body, 'mouseup', me.onCellMouseUp);
});
// Listen for mouseup globally (stable method to fetch mouseup)
Ext.EventManager.on(document.body, 'mouseup', me.onCellMouseUp, me/*, {
buffer: 50
}*/);
},
/**
* Re-initializes focus and selection so that column
* moving, showing and hiding isn't an issue.
* @return void
*/
reinitialize: function() {
//console.log('reinitialize!');
// Reset root position
this.initRootPosition();
// Auto-focus the root position initially
try {
// This may fail due to non-rendered circumstances
this.setCurrentFocusPosition(this.rootPosition);
} catch (e) {}
// Set the origin to the root position too
this.setOriginSelectionPosition(this.rootPosition);
},
/**
* @protected
* Initializes the key navigation for single cell or range selection
* @param {Spread.grid.View} view View instance
* @return void
*/
initKeyNav: function(view) {
var me = this;
// Handle not-already-rendered circumstances
if (!view.rendered) {
view.on('render', Ext.Function.bind(me.initKeyNav, me, [view], 0), me, {single: true});
return;
}
// view.el has tabIndex -1 to allow for
// keyboard events to be passed to it.
view.el.set({
tabIndex: -1
});
// DO NOT handle input field events to allow
// left & right caret positioning while editing
me.keyNav = new Ext.util.KeyNav({
target: view.el,
eventName: 'keydown',
ignoreInputFields: true,
right: me.onKeyRight,
left: me.onKeyLeft,
scope: me
});
// DO handle input field events
me.keyNav = new Ext.util.KeyNav({
target: view.el,
eventName: 'keydown',
ignoreInputFields: false,
up: me.onKeyUp,
down: me.onKeyDown,
tab: me.onKeyTab,
enter: me.onKeyEnter,
scope: me
});
},
// --- Event handler
/**
* @protected
* Gets called when view gets refereshed
* @return void
*/
onViewRefresh: function() {
//console.log('view refresh happened');
if (this.dataChangedRecently) {
// Reset root position
this.reinitialize();
// Reset flag
this.dataChangedRecently = false;
} else {
// Update root position / record reference
this.rootPosition.validate();
try {
// Try re-focussing
this.view.getEl().focus();
} catch(e) {}
}
// May auto-focus root position
if (this.autoFocusRootPosition && this.initialViewRefresh) {
// Auto-focus the root position initially
try {
// This may fail due to non-rendered circumstances
this.setCurrentFocusPosition(this.rootPosition);
} catch (e) {}
// Set the origin to the root position too
this.setOriginSelectionPosition(this.rootPosition);
// Update indicator flag
this.initialViewRefresh = false;
}
},
/**
* @protected
* Cell mouse event processing
*/
onCellMouseEvents: function(type, view, cell, rowIndex, cellIndex, evt, record, row) {
var me = this, args = arguments;
switch(type) {
case "mouseover":
// type, view, cell, rowIndex, cellIndex, evt, record, row
me.onCellMouseOver.apply(me, args);
break;
case "mousedown":
me.onCellMouseDown.apply(me, args);
break;
}
},
/**
* @protected
* Gets called when mouse down is detected
* @param {String} type UI Event type (e.g. 'mousedown')
* @param {Spread.grid.View} view Spread view instance reference
* @param {HTMLElement} cell Cell HTML element reference (<td>)
* @param {Number} rowIndex Row index
* @param {Number} cellIndex Cell index
* @param {Ext.EventObject} evt Event instance
* @param {Ext.data.Model} record Data record instance
* @param {HTMLElement} row Row HTML element reference (<tr>)
* @param {Object} eOpts Event options
* @return void
*/
onCellMouseDown: function(type, view, cell, rowIndex, cellIndex, evt, record, row, eOpts) {
//console.log('mouse down happened', arguments);
// Without eOpts, click wasn't detected on a cell/row
if (!eOpts) {
return;
}
var position = new Spread.selection.Position(view, cellIndex, rowIndex, record, row, cell);
// Set current focus position
if (
this.setCurrentFocusPosition(position)
) {
// Try to select range, if special key was pressed too
if (evt.shiftKey && !Spread.util.Key.isStartEditKey(evt)) {
this.selectFocusRange();
} else {
// Set origin position
this.setOriginSelectionPosition(position);
// Set the indicator flag that a range may be selected in the future (see onCellMouseOver)
this.mayRangeSelecting = true;
}
}
},
/**
* Returns the next valid row index.
* If row index may be greater than store size,
* it returns the last valid row index.
* @param {Ext.data.Store} store Data store
* @param {Number} rowIndex Current row index
* @return {Number}
*/
getNextRowIndex: function(store, rowIndex) {
if ((rowIndex+1) < store.getCount()) {
++rowIndex;
}
return rowIndex;
},
/**
* @protected
* Gets called when mouse hovers a cell
* @param {String} type UI Event type (e.g. 'mousedown')
* @param {Spread.grid.View} view Spread view instance reference
* @param {HTMLElement} cell Cell HTML element reference (<td>)
* @param {Number} rowIndex Row index
* @param {Number} cellIndex Cell index
* @param {Ext.EventObject} evt Event instance
* @param {Ext.data.Model} record Data record instance
* @param {HTMLElement} row Row HTML element reference (<tr>)
* @return void
*/
onCellMouseOver: function(type, view, cell, rowIndex, cellIndex, evt, record, row) {
// When range selection is happening,
// it's of interest to select responsive
if (this.mayRangeSelecting) {
// Set last position
if (
this.setCurrentFocusPosition(
new Spread.selection.Position(view, cellIndex, rowIndex, record, row, cell)
)
) {
// Try selecting a range
this.selectFocusRange();
}
}
},
/**
* @protected
* Gets called when mouseup happens on a grid cell.
* This handler breaks mouse-dragged range selection by setting the this.mayRangeSelecting flag.
* @return void
*/
onCellMouseUp: function(evt, el) {
this.mayRangeSelecting = false;
//console.log('cell mouse up -> blur', Ext.get(el));
// Fire cellblur event
if (!Ext.get(el).hasCls('spreadsheet-cell-cover') &&
!Ext.get(el).hasCls('x-grid-cell-inner') &&
!Ext.get(el).hasCls('spreadsheet-cell-cover-edit-field')) {
this.fireEvent('cellblur', this, Ext.get(el));
}
},
/**
* @protected
* Gets called when arrow UP key was pressed
* @param {Ext.EventObject} evt Key event object
* @return void
*/
onKeyUp: function(evt) {
if (!this.getCurrentFocusPosition()) return;
this.keyNavigation = true;
this.processKeyNavigation('up', evt);
this.keyNavigation = false;
},
/**
* @protected
* Gets called when arrow DOWN key was pressed
* @param {Ext.EventObject} evt Key event object
* @return void
*/
onKeyDown: function(evt) {
if (!this.getCurrentFocusPosition()) return;
this.keyNavigation = true;
this.processKeyNavigation('down', evt);
this.keyNavigation = false;
},
/**
* @protected
* Gets called when arrow LEFT key was pressed
* @param {Ext.EventObject} evt Key event object
* @return void
*/
onKeyLeft: function(evt) {
if (Ext.get(evt.getTarget()).hasCls('spreadsheet-cell-cover-edit-field')) {
return;
}
if (!this.getCurrentFocusPosition()) return;
this.keyNavigation = true;
this.processKeyNavigation('left', evt);
this.keyNavigation = false;
},
/**
* @protected
* Gets called when arrow RIGHT key was pressed
* @param {Ext.EventObject} evt Key event object
* @return void
*/
onKeyRight: function(evt) {
if (Ext.get(evt.getTarget()).hasCls('spreadsheet-cell-cover-edit-field')) {
return;
}
if (!this.getCurrentFocusPosition()) return;
this.keyNavigation = true;
this.processKeyNavigation('right', evt);
this.keyNavigation = false;
},
/**
* @protected
* Gets called when TAB key was pressed
* @param {Ext.EventObject} evt Key event object
* @return void
*/
onKeyTab: function(evt) {
// Do not handle key event if no focus is given
if (!this.getCurrentFocusPosition() || !evt) return;
// Fire event
this.fireEvent('tabselect', this, evt);
// Set tab pressed flag
//this.tabPressedRecently = true;
//console.log('onKeyTab', evt);
this.keyNavigation = true;
if (!evt.shiftKey) {
this.processKeyNavigation('right', evt);
} else {
this.processKeyNavigation('left', evt);
}
this.keyNavigation = false;
},
/**
* @protected
* Gets called when ENTER key was pressed
* @param {Ext.EventObject} evt Key event object
* @return void
*/
onKeyEnter: function(evt) {
// Do not handle key event if no focus is given
if (!this.getCurrentFocusPosition()) return;
// Fire event
this.fireEvent('enterselect', this, evt);
// Standard move-down on ENTER
this.keyNavigation = true;
this.processKeyNavigation('down', evt);
this.keyNavigation = false;
},
// --- Selection logic / algorithms
/**
* Tries to focus a cell position
* @param {Spread.selection.Position} position Position object reference
* @return void
*/
setCurrentFocusPosition: function(position) {
//console.log('setCurrentFocusPosition');
// Remove last focus reference
if (!position) {
this.currentFocusPosition = null;
return false;
}
// Never allow to focus a cell/position which resists inside a header column
if (!position.isSelectable()) {
return false;
}
//console.log('[before] setCurrentFocusPosition ', position);
// Focus is stoppable if a listener returns false / stops the event
if (this.fireEvent('beforecellfocus', position) !== false) {
//console.log('setCurrentFocusPosition ', position);
// Set internal reference
this.currentFocusPosition = position;
//console.log('try to focus the position: ', position);
//console.log('FOCUS ', this.getCurrentFocusPosition().row + ',' + this.getCurrentFocusPosition().column);
// Reset current selection range
this.currentSelectionRange = new Spread.selection.Range(this.getSpreadPanel());
// Inform the view to focus the cell
this.view.coverCell(position);
// Fire event
this.fireEvent('cellfocus', position);
return true;
}
return false;
},
/**
* Returns the last focus position
* @return {Spread.selection.Position}
*/
getCurrentFocusPosition: function() {
return this.currentFocusPosition;
},
/**
* Sets the origin selection position
* @param {Spread.selection.Position} position Position object reference
* @return void
*/
setOriginSelectionPosition: function(position) {
//console.log('setORIGINSelectionPosition', position);
// Set internal reference
this.originSelectionPosition = position;
},
/**
* Returns the origin selection position
* @return {Spread.selection.Position}
*/
getOriginSelectionPosition: function() {
return this.originSelectionPosition;
},
/**
* @protected
* Processes any key navigation. Therefore receives a (already filtered) key event
* and a direction to move to (from already given this.currentFocusPosition and this.originSelectionPosition).
* @param {String} direction Direction to jump/extend range to
* @param {Ext.EventObject} evt Key event object
* @return void
*/
processKeyNavigation: function(direction, evt) {
setTimeout(Ext.Function.bind(function() {
// Fire event
this.fireEvent('keynavigate', this, direction, evt);
//console.log('processKeyNavigation: ', direction);
var newCurrentFocusPosition = this.tryMoveToPosition(
this.getCurrentFocusPosition(), direction, evt
);
//console.log('Focus single cell; Reset current range selection.');
// Focus a new position
if (
this.setCurrentFocusPosition(newCurrentFocusPosition)
) {
// Try to select range, if special key was pressed too
// Shift + Tab is special navigation behaviour (left navigation without selection)
if (evt.shiftKey && evt.getKey() !== evt.TAB) {
this.selectFocusRange();
} else {
// Set origin position
this.setOriginSelectionPosition(
newCurrentFocusPosition
);
}
}
}, this), 50);
},
/**
* @protected
* Tries to move to a position starting at this.currentFocusPosition
* and moving on into direction (next cell). If this isn't possible,
* this method returns the this.currentFocusPosition, otherwise, it
* returns the position object of the next cell.
* @param {Object} currentFocusPosition Position object reference
* @param {String} direction Direction to jump/extend range to
* @param {Ext.EventObject} evt Key event object
* @return {Object}
*/
tryMoveToPosition: function(currentFocusPosition, direction, evt) {
var newPosition = this.view.walkCells(currentFocusPosition, direction, evt, true);
//console.log('tryMoveToPosition, newPosition: ', newPosition);
// When right end is reached, go to row below, first allowed column
if (!newPosition && !evt.shiftKey && direction === 'right') {
// Jump to next row, starting column
newPosition = new Spread.selection.Position(
currentFocusPosition.view,
this.rootPosition.column,
this.getNextRowIndex(
currentFocusPosition.view.getStore(),
currentFocusPosition.row
)
);
} else if (!newPosition) {
// But normally, stay where you are
newPosition = currentFocusPosition;
}
// Build a valid position object
return new Spread.selection.Position(this.view, newPosition.column, newPosition.row);
},
/**
* @protected
* Creates a range of positions from _previously_ internally set
* this.originSelectionPosition and (to) this.currentFocusPosition.
* @return {Spread.selection.Range}
*/
createFocusRange: function() {
// private method to interpolate numbers and return them as index array
var interpolate = function(startIdx, endIdx) {
var indexes = [];
do {
indexes.push(startIdx);
startIdx++;
} while(startIdx <= endIdx);
return indexes;
};
//console.log('createFocusRange: ', this.getOriginSelectionPosition(), ' to ', this.getCurrentFocusPosition());
/*
console.log('SELECT RANGE FROM ', this.getOriginSelectionPosition().row + ',' + this.getOriginSelectionPosition().column,
' TO ', this.getCurrentFocusPosition().row + ',' + this.getCurrentFocusPosition().column);
*/
var rowCount = this.view.getStore().getCount(),
originRow = this.getOriginSelectionPosition().row,
focusRow = this.getCurrentFocusPosition().row,
originColumn = this.getOriginSelectionPosition().column,
focusColumn = this.getCurrentFocusPosition().column,
rowIndexes = [],
columnIndexes = [],
selectedPositions = [],
selPosition = null;
if (Ext.versions.extjs.major === 4 && Ext.versions.extjs.minor < 2) {
var columnCount = this.view.headerCt.getGridColumns(true).length;
} else {
var columnCount = this.view.getGridColumns().length;
}
// Interpolate selected row indexes
if (focusRow <= originRow) {
rowIndexes = interpolate(focusRow, originRow);
} else {
rowIndexes = interpolate(originRow, focusRow);
}
// Interpolate selected column indexes
if (focusColumn <= originColumn) {
columnIndexes = interpolate(focusColumn, originColumn);
} else {
columnIndexes = interpolate(originColumn, focusColumn);
}
//console.log('selectedRows', rowIndexes, 'selectedColumns', columnIndexes);
// Walk cells of grid and check for being in selected range
for (var rowIndex=0; rowIndex<rowCount; rowIndex++) {
for (var colIndex=0; colIndex<columnCount; colIndex++) {
// Match positioning indexes
if (Ext.Array.indexOf(rowIndexes, rowIndex) > -1 &&
Ext.Array.indexOf(columnIndexes, colIndex) > -1) {
// Fetch already-updated position instance
selPosition = new Spread.selection.Position(this.view, colIndex, rowIndex).validate();
// Only add position to selection if column isn't hidden currently
if (!selPosition.columnHeader.hidden) {
selectedPositions.push(selPosition);
}
}
}
}
return new Spread.selection.Range(this.getSpreadPanel(), selectedPositions);
},
/**
* @protected
* Tries to select a range by information from _previously_ internally set
* this.originSelectionPosition and (to) this.currentFocusPosition.
* @param {Boolean} [virtual=false] Virtual calculation but no UI change
* @return void
*/
selectFocusRange: function(virtual) {
// Update local selection range cache
this.currentSelectionRange = this.createFocusRange();
// Select the range
this.currentSelectionRange.select(virtual);
},
/**
* Returns the currently focussed cell data or selected range data
* (like represented in grid itself).
* @return {Array}
*/
getSelectedPositionData: function() {
var selectionToTransform;
if (this.currentSelectionRange.count() === 0) {
selectionToTransform = [this.currentFocusPosition];
} else {
selectionToTransform = this.currentSelectionRange.toArray();
}
//console.log('transform to array', selectionToTransform);
return selectionToTransform;
},
/**
* Returns the current selection range
* @return {Spread.selection.Range}
*/
getCurrentSelectionRange: function() {
return this.currentSelectionRange;
},
/**
* Returns the spread grid panel reference
* @return {Spread.grid.Panel}
*/
getSpreadPanel: function() {
return this.grid;
}
});