comindware.core.ui
Version:
Comindware Core UI provides the basic components like editors, lists, dropdowns, popups that we so desperately need while creating Marionette-based single-page applications.
658 lines (572 loc) • 25.5 kB
text/typescript
import { keyCode, helpers } from '../../utils';
import { configurationConstants } from '../meta';
import GlobalEventService from '../../services/GlobalEventService';
import _ from 'underscore';
import Backbone from 'backbone';
import Marionette from "backbone.marionette";
/*
Public interface:
This view produce:
trigger: positionChanged (sender, { oldPosition, position })
trigger: viewportHeightChanged (sender, { oldViewportHeight, viewportHeight })
This view react on:
collection change (via Backbone.Collection events)
position change (when we scroll with scrollbar for example): updatePosition(newPosition)
*/
const heightOptions = {
AUTO: 'auto',
FIXED: 'fixed'
};
const defaultOptions = {
height: heightOptions.FIXED,
columnSort: true,
maxRows: 100,
defaultElHeight: 300,
useSlidingWindow: true,
disableKeydownHandler: false,
customHeight: false,
showHeader: true,
childHeight: 30
};
/**
* Some description for initializer
* @name ListView
* @memberof module:core.list.views
* @class ListView
* @constructor
* @description View контента списка
* @extends Marionette.View
* @param {Object} options Constructor options
* @param {Array} options.collection массив элементов списка
* @param {Number} options.childHeight высота строки списка (childView)
* @param {Backbone.View} options.childView view строки списка
* @param {Backbone.View} options.childViewOptions опции для childView
* @param {Function} options.childViewSelector ?
* @param {Backbone.View} options.emptyView View для отображения пустого списка (нет строк)
* @param {Backbone.View} options.emptyView View для отображения пустого списка (нет строк)
* @param {Function} [options.filter] Фильтрующая функция Marionette. Пример: (child, index, collection) => child.get('value') % 2 === 0
* @param {String} options.height задает как определяется высота строки, значения: fixed, auto
* @param {Backbone.View} options.loadingChildView view-лоадер, показывается при подгрузке строк
* @param {Number} options.maxRows максимальное количество отображаемых строк (используется с опцией height: auto)
* @param {Boolean} options.useDefaultRowView использовать RowView по умолчанию. В случае, если true - обязательно
* должны быть указаны cellView для каждой колонки.
* */
export default Marionette.CollectionView.extend({
initialize(options) {
if (this.collection === undefined) {
helpers.throwInvalidOperationError("ListView: you must specify a 'collection' option.");
}
this.gridEventAggregator = options.gridEventAggregator;
options.childViewOptions && (this.childViewOptions = options.childViewOptions);
options.emptyView && (this.emptyView = options.emptyView);
options.emptyViewOptions && (this.emptyViewOptions = options.emptyViewOptions);
options.childView && (this.childView = options.childView);
options.childViewSelector && (this.childViewSelector = options.childViewSelector);
options.loadingChildView && (this.loadingChildView = options.loadingChildView);
options.filter && (this.filter = options.filter);
this.listenTo(this.gridEventAggregator, 'toggle:collapse:all', this.__toggleCollapseAll);
this.listenTo(this.collection, 'toggle:collapse', this.__updateCollapseAll);
this.maxRows = options.maxRows || defaultOptions.maxRows;
this.useSlidingWindow = options.useSlidingWindow || defaultOptions.useSlidingWindow;
this.height = options.height;
this.minimumVisibleRows = this.getOption('minimumVisibleRows') || 0;
if (options.height === undefined) {
this.height = defaultOptions.height;
}
this.childHeight = options.childHeight || defaultOptions.childHeight;
this.state = {
position: 0
};
if (this.collection.getState().position !== 0 && this.collection.isSliding) {
this.collection.updatePosition(0);
}
this.debouncedHandleResizeLong = _.debounce(() => this.handleResize(false), 100);
this.debouncedHandleResizeShort = _.debounce((...rest) => this.handleResize(...rest), 20);
this.listenTo(GlobalEventService, 'window:resize', this.debouncedHandleResizeLong);
this.listenTo(this.collection.parentCollection, 'add remove reset ', (model, collection, opt) => {
if (collection?.diff?.length) {
return this.debouncedHandleResizeShort(true, collection.diff[0], collection.diff[0].collection, Object.assign({}, opt, { add: true })); //magic from prod collection
}
return this.debouncedHandleResizeShort(true, model, collection, Object.assign({}, opt, { scroll: collection.scroll })); //magic from prod collection
});
if (!this.__isChildHeightSpecified) {
this.listenTo(this.collection.parentCollection, 'add remove reset ', () => requestAnimationFrame(() => this.__specifyChildHeight()));
}
this.listenTo(this.collection, 'filter', this.__handleFilter);
this.listenTo(this.collection, 'nextModel', () => this.moveCursorBy(1, { isLoop: true }));
this.listenTo(this.collection, 'prevModel', () => this.moveCursorBy(-1, { isLoop: true }));
this.listenTo(this.collection, 'reorder', this.reorder);
this.listenTo(this.collection, 'moveCursorBy', this.moveCursorBy);
this.listenTo(this.collection, 'scrollTo', this.scrollTo);
if (this.options.draggable) {
this.__setDraggableListeners();
}
},
attributes() {
return {
tabindex: 1
};
},
events() {
const events: {[key: string]: string} = {
dragstart: '__handleDragStart',
dragend: '__handleDragEnd',
dragover: '__handleDragOver'
};
if (!this.options.disableKeydownHandler) {
events.keydown = '__handleKeydown';
}
return events;
},
onBeforeRenderChildren() {
this.activeElement = this.el.contains(document.activeElement) ? document.activeElement : null;
},
onRenderChildren() {
if (this.activeElement) {
this.activeElement.focus();
delete this.activeElement;
}
},
__handleDragStart(event: { originalEvent: DragEvent }) {
const checkedModels = this.collection.getCheckedModels();
if (!checkedModels.length) {
return;
}
this.collection.draggingModels = checkedModels;
const originalEvent = event.originalEvent;
if (!originalEvent.dataTransfer) {
return;
}
originalEvent.dataTransfer.setData('Text', this.cid); // fix for FireFox
},
__handleDragEnd() {
delete this.collection.draggingModels;
this.collection.dragoverModel?.trigger('dragleave');
},
__handleDragOver(event: MouseEvent) {
// prevent default to allow drop
event.preventDefault();
},
className() {
return `js-visible-collection visible-collection ${this.options.class || ''}`;
},
tagName: 'tbody',
onAttach() {
this.parent$el = this.options.parent$el;
this.__oldParentScrollLeft = this.options.parentEl.scrollLeft;
this.__specifyChildHeight();
this.handleResize(false);
this.listenTo(this.collection, 'update:child', model => this.__updateChildTop(model));
},
setDraggable(draggable: boolean) {
if (draggable) {
this.__setDraggableListeners();
} else {
this.__unsetDraggableListeners();
}
},
__specifyChildHeight() {
if (this.__isChildHeightSpecified || this.collection.length === 0) {
return;
}
const firstChild = this.el.children[0];
if (!(firstChild && firstChild.tagName === 'TR')) { // TODO: remove?
return;
}
let childHeight = firstChild.getBoundingClientRect().height;
const borderTop = parseInt(getComputedStyle(firstChild).borderTopWidth?.replace('px', ''));
if (!childHeight) {
const element = document.createElement('div');
const rowClone = firstChild.cloneNode(true);
element.appendChild(rowClone);
document.body.appendChild(element);
childHeight = rowClone.getBoundingClientRect().height;
document.body.removeChild(element);
}
if (childHeight > 0) {
this.childHeight = this.options.showHeader || !borderTop ? childHeight : childHeight - 1; // first item border
this.__isChildHeightSpecified = true;
this.stopListening(this.collection.parentCollection, 'add remove reset ', this.__specifyChildHeight);
}
},
_addChildModels(models) {
const modelsToAdd = this.collection.models === models ? this.collection.visibleModels : models;
return Marionette.CollectionView.prototype._addChildModels.call(this, modelsToAdd);
},
onAddChild(view, child) {
this.__updateChildTop(child.model);
},
onBeforeReorder() {
if (this.el.contains(document.activeElement)) {
this.lastActiveElement = document.activeElement;
} else {
delete this.lastActiveElement;
}
},
onReorder() {
if (this.lastActiveElement && document.contains(this.lastActiveElement)) {
this.lastActiveElement.focus?.();
}
},
__updateChildTop(model) {
requestAnimationFrame(() => {
const childView = this.children.findByModel(model);
if (!childView || !this.collection.length) {
return;
}
if (this.getOption('showRowIndex') && this.getOption('showCheckbox')) {
const index = model.collection.indexOf(model) + 1;
childView.updateIndex && childView.updateIndex(index);
}
if (this.getOption('isTree') && typeof childView.insertFirstCellHtml === 'function') {
childView.insertFirstCellHtml();
}
});
},
childView(child) {
if (child.get('isLoadingRowModel')) {
return this.getOption('loadingChildView');
}
const childViewSelector = this.getOption('childViewSelector');
if (childViewSelector) {
return childViewSelector(child);
}
const childView = this.getOption('childView');
if (!childView) {
helpers.throwInvalidOperationError("ListView: you must specify either 'childView' or 'childViewSelector' option.");
}
return childView;
},
__handleKeydown(e: KeyboardEvent) {
if (!e
|| [keyCode.CTRL, keyCode.SHIFT].includes(e.keyCode)
|| !e.target
|| ((e.target.tagName === 'INPUT' || e.target.classList.contains('editor'))
&& ![keyCode.ENTER, keyCode.ESCAPE, keyCode.TAB].includes(e.keyCode))) {
return;
}
e.stopPropagation();
let delta;
// const isGrid = Boolean(this.gridEventAggregator);
const isEditable = this.getOption('isEditable');
// handle = isGrid && isEditable ? e.target.classList.contains('cell') || e.ctrlKey : true;
const handle = this.collection.isSliding;
switch (e.keyCode) {
case keyCode.UP:
if (handle) {
this.moveCursorBy(-1, { shiftPressed: e.shiftKey, isLoop: true });
}
return !handle;
case keyCode.DOWN:
if (handle) {
this.moveCursorBy(1, { shiftPressed: e.shiftKey, isLoop: true });
}
return !handle;
case keyCode.PAGE_UP:
delta = Math.floor(this.state.viewportHeight);
this.moveCursorBy(-delta, { shiftPressed: e.shiftKey });
return false;
case keyCode.PAGE_DOWN:
delta = Math.floor(this.state.viewportHeight);
this.moveCursorBy(delta, { shiftPressed: e.shiftKey });
return false;
case keyCode.SPACE:
if (handle) {
const selectedModels = this.collection.selected instanceof Backbone.Model ? [this.collection.selected] : Object.values(this.collection.selected || {});
selectedModels.forEach(model => model.toggleChecked());
}
return !handle;
case keyCode.LEFT:
if (handle) {
if (isEditable) {
this.collection.trigger('move:left');
} else {
const model = this.collection.at(this.__getIndexSelectedModel());
model.collapse();
}
}
return !handle;
case keyCode.RIGHT:
if (handle) {
if (isEditable) {
this.collection.trigger('move:right');
} else {
const model = this.collection.at(this.__getIndexSelectedModel());
model.expand();
}
}
return !handle;
case keyCode.TAB:
this.collection.trigger(e.shiftKey ? 'move:left' : 'move:right');
return false;
case keyCode.ENTER:
if (isEditable) {
//duplicate down arrow
delta = (e.shiftKey) ? -1 : 1;
this.moveCursorBy(delta, { shiftPressed: false });
} else if (!Core.services.MobileService.isMobile) {
//duplicate dblclick
const model = this.collection.at(this.__getIndexSelectedModel());
this.gridEventAggregator.trigger('row:pointer:down', model);
this.gridEventAggregator.trigger('dblclick', model);
}
return false;
case keyCode.ESCAPE:
if (isEditable && handle) {
this.collection.trigger('keydown:escape', e);
}
return false;
case keyCode.DELETE:
case keyCode.BACKSPACE:
case keyCode.SHIFT:
break;
case keyCode.HOME:
if (handle) {
this.__moveCursorTo(0, { shiftPressed: e.shiftKey });
}
return !handle;
case keyCode.END:
if (handle) {
this.__moveCursorTo(this.collection.length - 1, { shiftPressed: e.shiftKey });
}
return !handle;
case keyCode.A:
if (handle && e.ctrlKey) {
this.collection.toggleCheckAll();
}
return !handle;
default:
if (isEditable && handle) {
this.collection.trigger('keydown:default', e);
}
break;
}
},
__getIndexSelectedModel() {
const model = this.collection.get(this.options.selectOnCursor === false ? this.collection.lastPointedModel : this.collection.lastSelectedModel);
return this.collection.indexOf(model);
},
// Move the cursor to a new position [cursorIndex + positionDelta] (like when user changes selected item using keyboard)
moveCursorBy(cursorIndexDelta, { shiftPressed, isLoop = false }) {
if (!this.collection.length) {
return;
}
const indexCurrentModel = this.__getIndexSelectedModel();
//if not cursor, set cursor on first (0).
const nextIndex = indexCurrentModel >= 0 ? indexCurrentModel + cursorIndexDelta : 0;
this.__moveCursorTo(nextIndex, {
shiftPressed,
isPositiveDelta: cursorIndexDelta > 0,
indexCurrentModel,
isLoop
});
},
__moveCursorTo(newCursorIndex, { shiftPressed, isPositiveDelta = false, indexCurrentModel = this.__getIndexSelectedModel(), isLoop = false }) {
let correctIndex;
let isOverflow;
if (isLoop) {
({ correctIndex, isOverflow } = this.__checkLoopOverflow(newCursorIndex));
} else {
correctIndex = this.__checkMaxMinIndex(newCursorIndex);
}
const isInverseScrollLogic = isOverflow;
if (correctIndex !== indexCurrentModel) {
if (this.__getIsModelInScrollByIndex(correctIndex)) {
(isInverseScrollLogic ? !isPositiveDelta : isPositiveDelta) ? this.scrollToByLast(correctIndex) : this.scrollToByFirst(correctIndex);
}
this.__selectModelByIndex(correctIndex, shiftPressed);
}
},
__getIsModelInScrollByIndex(modelIndex: number) {
const modelTopOffset = modelIndex * this.childHeight;
const modelBottomOffset = (modelIndex + 1) * this.childHeight;
const scrollTop = this.parent$el.scrollTop();
return scrollTop > modelTopOffset || modelBottomOffset > scrollTop + this.state.viewportHeight * this.childHeight;
},
scrollTo(topIndex, shouldScrollElement = false) {
this.trigger('update:position:internal', { topIndex, shouldScrollElement });
},
scrollToByFirst(topIndex) {
this.scrollTo(topIndex, true);
},
scrollToByLast(bottomIndex) {
//strange that size is equal index
const topIndex = this.__checkMaxMinIndex(bottomIndex + 1 - this.state.viewportHeight);
this.scrollTo(topIndex, true);
},
__selectModelByIndex(index, shiftPressed) {
const model = this.collection.at(index);
const selectFn = this.collection.selectSmart || this.collection.select;
if (selectFn) {
selectFn.call(this.collection, model, false, shiftPressed, this.getOption('selectOnCursor'));
}
},
// normalized the index so that it fits in range [0, this.collection.length - 1] with loop
__checkLoopOverflow(index) {
const maxIndex = this.collection.length - 1;
let isOverflow = false;
let correctIndex = index;
if (index < 0) {
isOverflow = true;
correctIndex = maxIndex;
}
if (index > maxIndex) {
isOverflow = true;
correctIndex = 0;
}
//notOverflow
return {
correctIndex,
isOverflow
};
},
// normalized the index so that it fits in range [0, this.collection.length - 1]
__checkMaxMinIndex(index) {
const maxIndex = this.collection.length - 1;
const normalizeIndex = Math.max(0, Math.min(maxIndex, index));
return normalizeIndex;
},
handleResize(shouldUpdateScroll, model, collection, options = {}) {
if (!this.collection.isSliding) {
return;
}
const oldViewportHeight = this.state.viewportHeight;
const oldAllItemsHeight = this.state.allItemsHeight;
if (this.collection.length) {
this.state.allItemsHeight = this.childHeight * this.collection.length + this.options.headerHeight + configurationConstants.HEIGHT_STOCK_TO_SCROLL;
} else {
this.state.allItemsHeight = 'auto';
}
if (!this.options.customHeight && this.state.allItemsHeight !== oldAllItemsHeight) {
this.options.table$el.parent().css({ height: this.state.allItemsHeight || '' }); //todo optimizae it
if (this.gridEventAggregator) {
this.gridEventAggregator.trigger('update:height', this.state.allItemsHeight);
} else {
this.trigger('update:height', this.state.allItemsHeight);
}
}
//@ts-ignore
const parentElHeight = this.options.parentEl?.clientHeight;
const availableHeight = parentElHeight && parentElHeight !== this.childHeight * this.collection.length
? parentElHeight
: window.innerHeight;
this.state.viewportHeight = Math.max(1, Math.floor(Math.min(availableHeight - (this.options.showHeader ? this.options.headerHeight : 1), window.innerHeight) / this.childHeight));
const isAllItemHeightLessAvailable = typeof this.state.allItemsHeight === 'number' && this.state.allItemsHeight <= availableHeight;
if (this.state.viewportHeight === oldViewportHeight) {
if (shouldUpdateScroll === false && !isAllItemHeightLessAvailable) {
return;
}
// scroll in case of search, do not scroll in case of collapse
if (options.add) {
const rowIndex = collection.indexOf(model);
const view = this.children.findByModel(model);
if (!view) {
this.scrollTo(rowIndex, true);
}
if (options.blink){
model.trigger('blink');
}
} else if (options.scroll !== false) {
this.scrollTo(0, true);
}
return;
}
if (isAllItemHeightLessAvailable) {
this.scrollTo(0, true);
}
this.collection.updateWindowSize(Math.max(this.minimumVisibleRows, this.state.viewportHeight + configurationConstants.VISIBLE_COLLECTION_RESERVE));
if (this.getOption('showRowIndex') && this.gridEventAggregator) {
this.gridEventAggregator.trigger('update:index');
}
},
__toggleCollapseAll(collapsed) {
this.__updateTreeCollapse(this.collection, collapsed);
if (this.gridEventAggregator) {
this.gridEventAggregator.trigger('collapse:change');
}
this.collection.rebuild();
this.debouncedHandleResizeShort();
},
__updateTreeCollapse(collection, collapsed) {
collection.forEach(model => {
model.collapsed = collapsed;
model.trigger('toggle:collapse', model);
if (model.children && model.children.length) {
this.__updateTreeCollapse(model.children, collapsed);
}
});
},
__getParentCollapsed(model) {
let collapsed = false;
let parentModel = model.parentModel;
while (parentModel) {
if (parentModel.collapsed !== false) {
collapsed = true;
break;
}
parentModel = parentModel.parentModel;
}
return collapsed;
},
__updateCollapseAll() {
const collapsed = !this.collection.parentCollection.some(model => model.collapsed === false);
if (this.gridEventAggregator) {
this.gridEventAggregator.trigger('update:collapse:all', collapsed);
this.gridEventAggregator.trigger('collapse:change');
}
this.debouncedHandleResizeShort(false);
},
__handleFilter() {
this.scrollTo(0, true);
this.debouncedHandleResizeShort();
},
__onChecked() {
if (this.__checkedNone) {
this.__setCheckedNone(false);
}
this.__updateDraggableForChecked();
},
__onCheckedNone() {
this.__setCheckedNone(true);
},
__setCheckedNone(state: boolean) {
this.__checkedNone = state;
this.collection.__allDraggable = state;
this.gridEventAggregator.trigger('set:draggable', state);
if (state) {
this.stopListening(this.collection, 'unchecked', this.__onUncheckedOne);
} else {
this.listenTo(this.collection, 'unchecked', this.__onUncheckedOne);
}
},
__onUncheckedOne(model: Backbone.Model) {
model.trigger('set:draggable', false);
},
__updateDraggableForChecked() {
const checked = this.collection.getCheckedModels();
const draggable = this.__areSequencial(checked);
checked.forEach((model: Backbone.Model) => model.trigger('set:draggable', draggable));
},
__areSequencial(models: Array<Backbone.Model>) {
const gridIndexes = models.map(model => this.collection.indexOf(model));
return gridIndexes
.sort((a, b) => a - b)
.every((index, i) => {
if (i === 0) {
return true;
}
const previousIndex = gridIndexes[i - 1];
return index - previousIndex === 1;
});
},
__setDraggableListeners() {
this.__onCheckedNone();
this.listenTo(this.collection, 'check:none', this.__onCheckedNone);
this.listenTo(this.collection, 'check:some check:all', this.__onChecked);
},
__unsetDraggableListeners() {
this.__setCheckedNone(false);
this.stopListening(this.collection, 'check:none', this.__onCheckedNone);
this.stopListening(this.collection, 'check:some check:all', this.__onChecked);
},
});