UNPKG

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
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); }, });