UNPKG

luna-data-grid

Version:

Grid for displaying datasets

811 lines (810 loc) 29.1 kB
import $ from 'licia/$'; import stripIndent from 'licia/stripIndent'; import Component from '../share/Component'; import each from 'licia/each'; import map from 'licia/map'; import escape from 'licia/escape'; import h from 'licia/h'; import toStr from 'licia/toStr'; import isEl from 'licia/isEl'; import isUndef from 'licia/isUndef'; import ResizeSensor from 'licia/ResizeSensor'; import throttle from 'licia/throttle'; import defaults from 'licia/defaults'; import naturalSort from 'licia/naturalSort'; import isNull from 'licia/isNull'; import isFn from 'licia/isFn'; import isRegExp from 'licia/isRegExp'; import isArr from 'licia/isArr'; import isStr from 'licia/isStr'; import trim from 'licia/trim'; import contain from 'licia/contain'; import toNum from 'licia/toNum'; import lowerCase from 'licia/lowerCase'; import clamp from 'licia/clamp'; import max from 'licia/max'; import min from 'licia/min'; import isOdd from 'licia/isOdd'; import now from 'licia/now'; import remove from 'licia/remove'; import pointerEvent from 'licia/pointerEvent'; import { exportCjs, eventClient, pxToNum } from '../share/util'; import isHidden from 'licia/isHidden'; import filter from 'licia/filter'; import toBool from 'licia/toBool'; import LunaMenu from 'luna-menu'; import randomId from 'licia/randomId'; const $document = $(document); const MIN_COL_WIDTH = 24; const ROW_HEIGHT = 20; export default class DataGrid extends Component { constructor(container, options) { super(container, { compName: 'data-grid' }, options); this.id = `luna-data-grid-${randomId(6, 'abcdefghijklmnopqrstuvwxyz')}`; this.resizeIdx = 0; this.resizeRightIdx = 0; this.resizeStartX = 0; this.resizeStartLeft = 0; this.resizeDeltaX = 0; this.nodes = []; this.displayNodes = []; this.colWidthsInitialized = false; this.colMap = {}; this.selectedNode = null; this.isAscending = true; this.sorted = false; this.colWidths = []; this.spaceHeight = 0; this.topSpaceHeight = 0; this.lastScrollTop = 0; this.lastTimestamp = 0; this.speedToleranceFactor = 100; this.maxSpeedTolerance = 2000; this.minSpeedTolerance = 100; this.scrollTimer = null; this.onResizeColMove = (e) => { const { resizeIdx, resizeRightIdx, $resizers, colWidths, $colgroup } = this; e = e.origEvent; let deltaX = eventClient('x', e) - this.resizeStartX; const leftColWidth = colWidths[resizeIdx]; const rightColWidth = colWidths[resizeRightIdx]; const lowerBound = min(-leftColWidth + MIN_COL_WIDTH, 0); const upperBound = max(rightColWidth - MIN_COL_WIDTH, 0); deltaX = clamp(deltaX, lowerBound, upperBound); $colgroup.each(function () { const $cols = $(this).find('col'); $cols.eq(resizeIdx).css('width', leftColWidth + deltaX + 'px'); $cols.eq(resizeRightIdx).css('width', rightColWidth - deltaX + 'px'); }); this.resizeDeltaX = deltaX; const newLeft = this.resizeStartLeft + deltaX; $resizers.eq(resizeIdx).css('left', `${newLeft}px`); }; this.onResizeColEnd = (e) => { this.onResizeColMove(e); const { c, colWidths, resizeIdx, resizeRightIdx, resizeDeltaX } = this; const { columns } = this.options; const leftCol = columns[resizeIdx]; const rightCol = columns[resizeRightIdx]; const leftColWidth = colWidths[resizeIdx] + resizeDeltaX; const rightColWidth = colWidths[resizeRightIdx] - resizeDeltaX; const totalWidth = leftColWidth + rightColWidth; const totalWeight = leftCol.weight + rightCol.weight; const leftWeight = totalWeight * (leftColWidth / totalWidth); const rightWeight = totalWeight - leftWeight; leftCol.weight = leftWeight; rightCol.weight = rightWeight; this.emit('changeColumn'); this.updateWeights(); $(document.body).rmClass(c('resizing')); $document.off(pointerEvent('move'), this.onResizeColMove); $document.off(pointerEvent('up'), this.onResizeColEnd); }; this.onScroll = () => { const { scrollHeight, clientHeight, scrollTop } = this .dataContainer; if (scrollTop <= 0) return; if (clientHeight + scrollTop > scrollHeight) return; const lastScrollTop = this.lastScrollTop; const lastTimestamp = this.lastTimestamp; const timestamp = now(); const duration = timestamp - lastTimestamp; const distance = scrollTop - lastScrollTop; const speed = Math.abs(distance / duration); let speedTolerance = speed * this.speedToleranceFactor; if (duration > 1000) { speedTolerance = 1000; } if (speedTolerance > this.maxSpeedTolerance) { speedTolerance = this.maxSpeedTolerance; } if (speedTolerance < this.minSpeedTolerance) { speedTolerance = this.minSpeedTolerance; } this.lastScrollTop = scrollTop; this.lastTimestamp = timestamp; let topTolerance = 0; let bottomTolerance = 0; if (lastScrollTop < scrollTop) { topTolerance = this.minSpeedTolerance; bottomTolerance = speedTolerance; } else { topTolerance = speedTolerance; bottomTolerance = this.minSpeedTolerance; } if (this.topSpaceHeight < scrollTop - topTolerance && this.topSpaceHeight + this.data.offsetHeight > scrollTop + clientHeight + bottomTolerance) { return; } this.renderData({ topTolerance: topTolerance * 2, bottomTolerance: bottomTolerance * 2, }); if (this.scrollTimer) { clearTimeout(this.scrollTimer); } this.scrollTimer = setTimeout(() => { this.renderData(); }, 100); }; this.renderData = throttle(({ topTolerance = 500, bottomTolerance = 500 } = {}) => { if (this.sortId && !this.sorted) { this.sortNodes(this.sortId, this.isAscending); } const { dataContainer, displayNodes, tableBody } = this; const { scrollTop, clientHeight } = dataContainer; const top = scrollTop - topTolerance; const bottom = scrollTop + clientHeight + bottomTolerance; let topSpaceHeight = 0; let currentHeight = 0; const len = displayNodes.length; const renderNodes = []; const height = ROW_HEIGHT; for (let i = 0; i < len; i++) { const node = displayNodes[i]; if (currentHeight <= bottom) { if (currentHeight + height > top) { if (renderNodes.length === 0 && isOdd(i)) { renderNodes.push(displayNodes[i - 1]); topSpaceHeight -= height; } renderNodes.push(node); } else if (currentHeight < top) { topSpaceHeight += height; } } currentHeight += height; } this.updateSpace(currentHeight); this.updateTopSpace(topSpaceHeight); const frag = document.createDocumentFragment(); for (let i = 0, len = renderNodes.length; i < len; i++) { frag.appendChild(renderNodes[i].container); } frag.appendChild(this.fillerRow); tableBody.textContent = ''; tableBody.appendChild(frag); }, 16); this.$container.attr({ tabindex: '0', id: this.id, }); this.resizeSensor = new ResizeSensor(container); this.onResize = throttle(() => { this.updateHeight(); this.updateWeights(); this.renderData(); }, 16); if (options.height) { options.maxHeight = options.height; options.minHeight = options.height; } this.initOptions(options, { minHeight: 41, maxHeight: Infinity, filter: '', selectable: false, headerContextMenu: false, }); const { columns, minHeight, maxHeight } = this.options; each(columns, (column) => { defaults(column, { sortable: false, visible: true, }); this.colMap[column.id] = column; }); if (maxHeight < minHeight) { this.setOption('maxHeight', minHeight); } this.initTpl(); this.$headerRow = this.find('.header').find('tr'); this.$fillerRow = this.find('.filler-row'); this.fillerRow = this.$fillerRow.get(0); this.$data = this.find('.data'); this.data = this.$data.get(0); this.$tableBody = this.$data.find('tbody'); this.tableBody = this.$tableBody.get(0); this.$colgroup = this.$container.find('colgroup'); this.$dataContainer = this.find('.data-container'); this.dataContainer = this.$dataContainer.get(0); this.$space = this.find('.data-space'); this.space = this.$space.get(0); this.$style = this.$container.find('style'); this.renderHeader(); this.renderResizers(); this.initWeights(); this.updateHeight(); this.bindEvent(); } destroy() { super.destroy(); this.resizeSensor.destroy(); this.$container.rmAttr('tabindex'); } remove(node) { const { nodes, displayNodes } = this; remove(nodes, (n) => n === node); remove(displayNodes, (n) => n === node); if (node === this.selectedNode) { this.selectNode(null); } this.renderData(); this.updateHeight(); } append(data, options = {}) { defaults(options, { selectable: this.options.selectable, }); const node = new DataGridNode(this, data, options); this.nodes.push(node); const isVisible = this.filterNode(node); if (isVisible) { this.displayNodes.push(node); } if (this.sortId || isVisible) { if (this.sortId) { this.sorted = false; } this.renderData(); } this.updateHeight(); return node; } setData(data, uniqueId) { const items = map(data, (item) => { if (!isArr(item)) { return [ item, { selectable: this.options.selectable, }, ]; } defaults(item[1], { selectable: this.options.selectable, }); return item; }); if (!uniqueId) { this.clearData(); each(items, (item) => { const node = new DataGridNode(this, item[0], item[1]); this.nodes.push(node); if (this.filterNode(node)) { this.displayNodes.push(node); } }); } else { const nodesMap = {}; each(this.nodes, (node) => { nodesMap[node.data[uniqueId]] = node; }); const nodes = []; const displayNodes = []; each(items, (item) => { const id = item[0][uniqueId]; let node; if (nodesMap[id]) { node = nodesMap[id]; node.data = item[0]; node.render(); } else { node = new DataGridNode(this, item[0], item[1]); } nodes.push(node); if (this.filterNode(node)) { displayNodes.push(node); } }); if (this.selectedNode && !contain(nodes, this.selectedNode)) { this.selectNode(null); } this.nodes = nodes; this.displayNodes = displayNodes; } if (this.sortId) { this.sorted = false; } this.renderData(); this.updateHeight(); } clear() { this.clearData(); this.renderData(); this.updateHeight(); } fit() { if (isHidden(this.container)) { return; } const parent = this.$container.parent().get(0); const style = window.getComputedStyle(parent); const clientHeight = parent.clientHeight; const paddingTop = pxToNum(style.paddingTop); const paddingBottom = pxToNum(style.paddingBottom); const height = clientHeight - paddingTop - paddingBottom; this.setOption({ minHeight: height, maxHeight: height, }); } clearData() { this.nodes = []; this.displayNodes = []; this.selectNode(null); } updateHeight() { const { $fillerRow, $container } = this; let { maxHeight, minHeight } = this.options; const headerHeight = this.$headerRow.offset().height; const borderTopWidth = pxToNum($container.css('border-top-width')); const borderBottomWidth = pxToNum($container.css('border-bottom-width')); const minusHeight = headerHeight + borderTopWidth + borderBottomWidth; minHeight -= minusHeight; if (minHeight < 0) { minHeight = 0; } maxHeight -= minusHeight; const len = this.displayNodes.length; let height = 0; if (len > 0) { height = ROW_HEIGHT * len; } if (height > minHeight) { $fillerRow.hide(); } else { $fillerRow.show(); } if (height < minHeight) { height = minHeight; } else if (height >= maxHeight) { height = maxHeight; } this.$dataContainer.css({ height }); } selectNode(node) { if (!isNull(node) && !node?.selectable) { return; } if (this.selectedNode === node) { return; } if (this.selectedNode) { this.selectedNode.deselect(); this.selectedNode = null; if (isNull(node)) { this.emit('deselect'); } } if (!isNull(node)) { this.selectedNode = node; node.select(); this.emit('select', node); } } getRightIdx(leftIdx) { const { columns } = this.options; const displayColumns = filter(columns, (col) => toBool(col.visible)); const leftCol = columns[leftIdx]; for (let i = 0, len = displayColumns.length; i < len; i++) { if (displayColumns[i] === leftCol) { return columns.indexOf(displayColumns[i + 1]); } } return -1; } onResizeColStart(e) { const { c, resizeIdx, $resizers } = this; e.stopPropagation(); e.preventDefault(); e = e.origEvent; this.resizeStartX = eventClient('x', e); this.resizeStartLeft = pxToNum($resizers.eq(resizeIdx).css('left')); $(document.body).addClass(c('resizing')); $document.on(pointerEvent('move'), this.onResizeColMove); $document.on(pointerEvent('up'), this.onResizeColEnd); } bindEvent() { const { c, $headerRow, $tableBody, $resizers, $dataContainer } = this; this.resizeSensor.addListener(this.onResize); $dataContainer.on('scroll', this.onScroll); const self = this; $tableBody .on('click', c('.node'), function (e) { self.selectNode(this.dataGridNode); setTimeout(() => { if (this.hasDoubleClick) { return; } self.emit('click', e.origEvent, this.dataGridNode); }, 200); }) .on('dblclick', c('.node'), function (e) { e.stopPropagation(); this.hasDoubleClick = true; self.emit('dblclick', e.origEvent, this.dataGridNode); setTimeout(() => { this.hasDoubleClick = false; }, 300); }) .on('contextmenu', c('.node'), function (e) { e.preventDefault(); e.stopPropagation(); self.selectNode(this.dataGridNode); self.emit('contextmenu', e.origEvent, this.dataGridNode); }); $headerRow.on('click', c('.sortable'), function (e) { e.stopPropagation(); const $this = $(this); const id = $this.data('id'); const order = $this.data('order'); const isAscending = order !== 'descending'; $this.data('order', isAscending ? 'descending' : 'ascending'); $headerRow.find(c('.icon-caret-up')).hide(); $headerRow.find(c('.icon-caret-down')).hide(); const $iconUp = $this.find(c('.icon-caret-up')); const $iconDown = $this.find(c('.icon-caret-down')); if (isAscending) { $iconUp.show(); } else { $iconDown.show(); } self.sortNodes(id, isAscending); self.renderData(); $headerRow.find('th').each(function () { const $this = $(this); if ($this.data('id') !== id) { $this.rmAttr('data-order'); } }); }); $headerRow.on('contextmenu', (e) => { e.stopPropagation(); e.preventDefault(); if (!this.options.headerContextMenu) { return; } e = e.origEvent; const { columns } = this.options; let visibleCount = 0; each(columns, (column) => { if (toBool(column.visible)) { visibleCount++; } }); const menu = LunaMenu.build(map(columns, (column) => { const visible = toBool(column.visible); return { label: column.title, type: 'checkbox', checked: visible, enabled: !visible || visibleCount > 1, click() { column.visible = !visible; self.emit('changeColumn'); self.renderHeader(); self.updateWeights(); }, }; })); menu.show(eventClient('x', e), eventClient('y', e)); }); $resizers.on(pointerEvent('down'), function (e) { const $this = $(this); self.resizeIdx = toNum($this.data('idx')); self.resizeRightIdx = self.getRightIdx(self.resizeIdx); self.onResizeColStart(e); }); this.on('changeOption', (name, val, oldVal) => { switch (name) { case 'minHeight': case 'maxHeight': this.updateHeight(); break; case 'filter': this.displayNodes = []; each(this.nodes, (node) => { if (this.filterNode(node)) { this.displayNodes.push(node); } }); if (this.selectedNode && !this.filterNode(this.selectedNode)) { this.selectNode(null); } this.renderData(); this.updateHeight(); break; case 'columns': { const columnsMap = {}; each(val, (column) => { columnsMap[column.id] = column; }); each(oldVal, (oldColumn) => { const column = columnsMap[oldColumn.id]; if (!column) { return; } if (!isUndef(column.visible)) { oldColumn.visible = column.visible; } if (!isUndef(column.weight)) { oldColumn.weight = column.weight; } }); this.options.columns = oldVal; this.renderHeader(); this.updateWeights(); break; } } }); } sortNodes(id, isAscending) { const column = this.colMap[id]; const comparator = column.comparator || naturalSort.comparator; function sortFn(a, b) { let aVal = a.data[id]; let bVal = b.data[id]; if (isEl(aVal)) { aVal = aVal.innerText; } if (isEl(bVal)) { bVal = bVal.innerText; } return isAscending ? comparator(aVal, bVal) : comparator(bVal, aVal); } this.nodes.sort(sortFn); this.displayNodes.sort(sortFn); this.sorted = true; this.sortId = id; this.isAscending = isAscending; } initWeights() { const { container, $headerRow } = this; const { columns } = this.options; const tableWidth = container.offsetWidth; if (!this.colWidthsInitialized && tableWidth) { for (let i = 0, len = columns.length; i < len; i++) { const column = columns[i]; if (!column.weight) { const thWidth = $headerRow.find('th').get(i) .offsetWidth; column.weight = (100 * thWidth) / tableWidth; } } this.colWidthsInitialized = true; } this.updateWeights(); } updateWeights() { const { container, $colgroup } = this; const { columns } = this.options; const tableWidth = container.offsetWidth; if (tableWidth <= 0) { return; } let sumOfWeights = 0; const len = columns.length; for (let i = 0; i < len; i++) { if (columns[i].visible) { sumOfWeights += columns[i].weight; } } let html = ''; let sum = 0; let lastOffset = 0; this.colWidths = []; for (let i = 0; i < len; i++) { const column = columns[i]; if (column.visible) { sum += column.weight; let offset = ((sum * tableWidth) / sumOfWeights) | 0; let width = offset - lastOffset; if (width < MIN_COL_WIDTH) { width = MIN_COL_WIDTH; offset = lastOffset + width; } lastOffset = offset; html += `<col style="width:${width}px"></col>`; this.colWidths[i] = width; } else { html += `<col style="width:0;display:none"></col>`; this.colWidths[i] = 0; } } $colgroup.html(html); this.positionResizers(); } positionResizers() { const { colWidths } = this; const { columns } = this.options; const resizerLeft = []; const len = colWidths.length - 1; for (let i = 0; i < len; i++) { resizerLeft[i] = (resizerLeft[i - 1] || 0) + colWidths[i]; } for (let i = 0; i < len; i++) { const $resizer = this.$resizers.eq(i); if (!columns[i].visible) { $resizer.hide(); } else { $resizer.show(); $resizer.css('left', resizerLeft[i] + 'px'); } } } updateTopSpace(height) { this.topSpaceHeight = height; this.data.style.top = height + 'px'; } updateSpace(height) { if (this.spaceHeight === height) { return; } this.spaceHeight = height; this.space.style.height = height + 'px'; } filterNode(node) { let { filter } = this.options; if (filter) { if (isFn(filter)) { return filter(node); } else if (isRegExp(filter)) { return filter.test(node.text()); } else if (isStr(filter)) { filter = trim(filter); if (filter) { return contain(lowerCase(node.text()), lowerCase(filter)); } } } return true; } renderHeader() { const { c } = this; let html = ''; let style = ''; let fillerRowHtml = ''; let firstVisible = true; each(this.options.columns, (column, idx) => { const title = escape(column.title); if (column.sortable) { html += c(` <th class="sortable" data-id="${column.id}"> ${title} <span class="icon-caret-up"></span> <span class="icon-caret-down"></span> </th>`); } else { html += `<th>${title}</th>`; } fillerRowHtml += '<td></td>'; const nth = idx + 1; const declarations = `${column.visible ? '' : 'display:none;'}${column.visible && firstVisible ? 'border-left:none;' : ''}`; if (declarations) { style += `#${this.id} th:nth-child(${nth}), #${this.id} td:nth-child(${nth}) { ${declarations} }\n`; } if (column.visible && firstVisible) { firstVisible = false; } }); this.$headerRow.html(html); this.$fillerRow.html(fillerRowHtml); this.$style.text(style); } renderResizers() { let resizers = ''; const len = this.options.columns.length - 1; for (let i = 0; i < len; i++) { resizers += this.c(`<div class="resizer" data-idx="${i}"></div>`); } this.$container.append(resizers); this.$resizers = this.find('.resizer'); } initTpl() { this.$container.html(this.c(stripIndent ` <style type="text/css"></style> <div class="header-container"> <table class="header"> <colgroup></colgroup> <tbody> <tr></tr> </tbody> </table> </div> <div class="data-container"> <div class="data-space"> <table class="data"> <colgroup></colgroup> <tbody> <tr class="filler-row"></tr> </tbody> </table> </div> </div> `)); } } export class DataGridNode { constructor(dataGrid, data, options) { this.container = h('tr'); this.selectable = false; ; this.container.dataGridNode = this; this.$container = $(this.container); this.$container.addClass(dataGrid.c('node')); this.dataGrid = dataGrid; this.data = data; if (options.selectable) { this.selectable = options.selectable; this.$container.addClass(dataGrid.c('selectable')); } this.render(); } text() { return this.$container.text(); } select() { this.$container.addClass(this.dataGrid.c('selected')); } deselect() { this.$container.rmClass(this.dataGrid.c('selected')); } render() { const { data, $container, container } = this; const columns = this.dataGrid.getOption('columns'); $container.html(''); each(columns, (column) => { const td = h('td'); const val = data[column.id]; if (!isUndef(val)) { if (isEl(val)) { td.appendChild(val); } else { td.innerText = toStr(val); } } container.appendChild(td); }); } } if (typeof module !== 'undefined') { exportCjs(module, DataGrid); }