/** * Very simple and fast tree grid/table, compatible with dynamic loading and stickyheaders.js * License: MPL 2.0+, (c) Vitaliy Filippov 2016+ * Version: 2016-12-06 */ /** * USAGE: * * var TG = new TreeGrid({ items: items, header: header, renderer: renderer }); * document.body.appendChild(TG.table); * * // renderer is a function that generates cell HTML properties for a node * // for multi-row nodes renderer must return array of multiple rows * renderer = function(node) { return [ { innerHTML, style, className, title }, ... ] } * * items: [ node={ * key: , * children: [ node... ], * leaf: true/false, * collapsed: true/false, * [ rows: ], * data: * }, ... ] * * header: [ 'html' or { innerHTML, style, className, title }, ... ] * * Readonly properties: * * TG.table - grid element * TG.root - root node * * Methods: * * // change header and clear grid contents * TG.setHeader(header); * * // change "leaf" status of a node and its children * node.setChildren(isLeaf, newChildren); * * // expand/collapse a node * node.toggle(); * * // change node.data and re-render node * node.render(); * * // use simple cell editing * TG.initCellEditing(); * * // use simple mouse cell selection * // boolean useMultiple: whether to initialise multi-cell selection (true) or single-cell (false) * TG.initCellSelection(useMultiple); * * Callbacks: * * // called before expanding node. should return false if you want to prevent expanding the node * TG.onExpand = function(node) {}; * // called before a cell is selected with mouse * TG.onCellSelect = function(node, subrowIndex, colIndex, td) { return ; } * // called when all currently selected cells are deselected * TG.onDeselectAllCells = function() {} * // called before a cell is edited * TG.onStartCellEdit = function(node, subrowIndex, colIndex, td) { return { * abort: , * value: * } } * // called before a cell is stopped being edited * TG.onStopCellEdit = function (node, subrowIndex, colIndex, value, td) { return { html: } } * */ function TreeGrid(options) { this.renderer = options.renderer; this.bindProps = { 'style': 1, 'className': 1, 'title': 1, 'innerHTML': 1 }; if (options.bind) for (var i in options.bind) this.bindProps[i] = 1; this.levelIndent = 20; this.table = document.createElement('table'); this.table.className = 'grid'; this.thead = document.createElement('thead'); this.tbody = document.createElement('tbody'); this.table.appendChild(this.thead); this.table.appendChild(this.tbody); this.thead.appendChild(document.createElement('tr')); this.setHeader(options.header); new TreeGridNode({ children: options.items }, this); } function TreeGridNode(node, grid, level, insertBefore, startHidden) { this.grid = grid; this.level = level; this.key = node.key; this.data = node.data; if (this.level === undefined) this.level = -1; if (!grid.root) grid.root = this; else { this.leaf = node.leaf; this.collapsed = node.collapsed || !node.leaf && (!node.children || !node.children.length); this.tr = []; for (var i = 0; i < (node.rows || 1); i++) { var tr = document.createElement('tr'); if (startHidden) tr.style.display = 'none'; tr._node = this; this.tr.push(tr); } this._oldCells = []; this.render(undefined, undefined, false, true); for (var i = 0; i < this.tr.length; i++) { insertBefore ? grid.tbody.insertBefore(this.tr[i], insertBefore) : grid.tbody.appendChild(this.tr[i]); } } this.children = []; this.childrenByKey = {}; if (node.children) { var trs = []; for (var i = 0; i < node.children.length; i++) { var child = new TreeGridNode(node.children[i], grid, this.level+1, insertBefore, this.collapsed); child._index = i; this.children.push(child); if (child.key !== undefined) this.childrenByKey[child.key] = child; if (grid.stickyInit && !this.collapsed) trs.push.apply(trs, child.tr); } if (trs.length) fixStickyRows(grid.table, trs); } } (function() { TreeGrid.prototype.initStickyHeaders = function(options) { this.sticky = true; this.noStickyResize = options.noWindowResize; if (this.header && this.header.length) this._makeStickyHeaders(); } TreeGrid.prototype.checkStickyHeaders = function() { if (this.stickyInit) checkStickyHeaders(this.table); } TreeGrid.prototype._makeStickyHeaders = function() { var self = this; this.table.className += ' stickyheaders'; makeStickyHeaders(this.table); this.stickyInit = true; if (!this.noStickyResize) { addListener(window, 'resize', function() { if (self.stickyInit) fixStickyColumnSizes(self.table); }); } } TreeGrid.prototype.setHeader = function(newHeader) { var tr = this.thead.rows[0]; tr.innerHTML = ''; for (var i = 0; i < newHeader.length; i++) { var th = document.createElement('th'); this._setProps(th, newHeader[i]); tr.appendChild(th); } this.header = newHeader; this.editedCells = []; // header change clears the whole grid by now if (this.root) this.root.setChildren(false, []); if (this.sticky) { if (!this.stickyInit) this._makeStickyHeaders(); else fixStickyHeader(this.table); } } TreeGrid.prototype.onExpand = function(node) { // handle node expand/collapse here return true; } TreeGrid.prototype._setProps = function(el, props) { if (props === undefined || props === null) { for (var j in this.bindProps) { if (j == 'innerHTML' || j == 'className') el[j] = ''; else el.setAttribute(j, ''); } } else if (typeof props == 'string') { el.innerHTML = props; } else { for (var j in this.bindProps) { if (j == 'innerHTML' || j == 'className') el[j] = (props[j] !== undefined ? props[j] : ''); else el.setAttribute(j, (props[j] !== undefined ? props[j] : '')); } } } // Simple cell editing TreeGrid.prototype._getCellIndex = function(n, sticky) { var i = 0; while ((n = n.previousSibling)) i += (n.nodeType != 3 ? 1 : 0); if (sticky && this.stickyInit) i = (i > 0 ? i-1 : i); return i; } TreeGrid.prototype._getSubrow = function(cell) { var row = cell.parentNode, subrow = 0; while (row.previousSibling && row.previousSibling._node == row._node) { row = row.previousSibling; subrow++; } return subrow; } TreeGrid.prototype.initCellEditing = function() { var self = this; self.editedCells = []; // FIXME maybe remove and use just class selector addListener(this.table, 'dblclick', function(evt) { evt = evt||window.event; var td = evt.target||evt.srcElement; while (td.nodeName != 'TABLE' && td.nodeName != 'TD') td = td.parentNode; if (td.nodeName == 'TD' && td.parentNode._node && td.previousSibling && td.className.indexOf('celleditor') < 0) { var params = self.onStartCellEdit && self.onStartCellEdit(td.parentNode._node, self._getSubrow(td), self._getCellIndex(td, true), td) || {}; if (params.abort) return; self.editedCells.push(td); if (params.value === undefined) params.value = td.innerHTML; td._origWidth = td.style.width; td.style.width = td.offsetWidth+'px'; td.className += ' celleditor'; if ('ActiveXObject' in window) { td.innerHTML = '
'; td.style.height = 'inherit'; td.parentNode.style.height = '1px'; } else td.innerHTML = ''; var inp = td.getElementsByTagName('input')[0]; addListener(inp, 'keydown', function(evt) { if (evt.keyCode == 13 || evt.keyCode == 10) self.stopCellEditing(td); }); inp.focus(); } }); addListener(document.body, 'click', function(evt) { if (!self.editedCells.length) return; var td = evt.target||evt.srcElement; while (td && td.nodeName != 'TD') td = td.parentNode; if (td && /\bcelleditor\b/.exec(td.className)) return; for (var i = self.editedCells.length-1; i >= 0; i--) self.stopCellEditing(self.editedCells[i], 1); self.editedCells = []; }); } TreeGrid.prototype.stopCellEditing = function(td, _int) { var i = this._getCellIndex(td, true); var subrow = this._getSubrow(td); var node = td.parentNode._node; node._oldCells[i] = undefined; td.style.width = td._origWidth; var inp = td.getElementsByTagName('input')[0]; var params = this.onStopCellEdit && this.onStopCellEdit(node, subrow, i, inp ? inp.value : null, td) || {}; node.render(i, subrow, true); if (!_int) { for (var i = 0; i < this.editedCells.length; i++) { if (this.editedCells[i] == td) { this.editedCells.splice(i, 1); break; } } } } // Simple cell selection TreeGrid.prototype.initCellSelection = function(useMultiple) { var self = this; self.selectCell = function(cell) { if (!cell.parentNode._node || cell.className.indexOf(' selected') >= 0) return; var i = self._getCellIndex(cell, true); var subrow = self._getSubrow(cell); if (!self.onCellSelect || self.onCellSelect(cell.parentNode._node, subrow, i, cell)) cell.className += ' selected'; }; self.deselectAll = function(cell) { var els = self.table.querySelectorAll('td.selected'); var deselected = []; if (self.onDeselectAllCells) self.onDeselectAllCells(); for (var i = 0; i < els.length; i++) els[i].className = els[i].className.replace(' selected', ''); }; if (useMultiple) initMultiCellSelection(self); else initSingleCellSelection(self); } function initSingleCellSelection(self) { addListener(self.table, 'mousedown', function(evt) { self.deselectAll(); if (evt.which != 1) return; var td = evt.target||evt.srcElement; while (td && (td.nodeName != 'TD' && td.nodeName != 'TH')) td = td.parentNode; if (td) self.selectCell(td); }); } function initMultiCellSelection(self) { var startDrag, dragDiv; self.table.className += ' disable-text-select'; addListener(self.table, 'mousedown', function(evt) { evt = getEventCoord(evt); if (!evt.shiftKey) self.deselectAll(); if (evt.which != 1) return; startDrag = [ evt.pageX, evt.pageY ]; if (!dragDiv) { dragDiv = document.createElement('div'); dragDiv.className = 'selection-rect'; dragDiv.style.display = 'none'; document.body.appendChild(dragDiv); } }); addListener(document.body, 'mousemove', function(evt) { if (startDrag) { evt = getEventCoord(evt); if (dragDiv.style.display == 'none') { if ((startDrag[0] < evt.pageX-10 || startDrag[0] > evt.pageX+10) && (startDrag[1] < evt.pageY-10 || startDrag[1] > evt.pageY+10)) dragDiv.style.display = 'block'; else return; } if (startDrag[0] < evt.pageX) { dragDiv.style.left = startDrag[0]+'px'; dragDiv.style.width = (evt.pageX-startDrag[0])+'px'; } else { dragDiv.style.left = evt.pageX+'px'; dragDiv.style.width = (startDrag[0]-evt.pageX)+'px'; } if (startDrag[1] < evt.pageY) { dragDiv.style.top = startDrag[1]+'px'; dragDiv.style.height = (evt.pageY-startDrag[1])+'px'; } else { dragDiv.style.top = evt.pageY+'px'; dragDiv.style.height = (startDrag[1]-evt.pageY)+'px'; } } }); addListener(document.body, 'mouseup', function(evt) { if (startDrag) { evt = getEventCoord(evt); dragDiv.style.display = 'none'; var x1 = startDrag[0], y1 = startDrag[1], x2 = evt.pageX, y2 = evt.pageY; if (x2 < x1) { var t = x2; x2 = x1; x1 = t; } if (y2 < y1) { var t = y2; y2 = y1; y1 = t; } startDrag = null; if (x2 > x1+10 && y2 > y1+10) { var bodyPos = self.tbody.getBoundingClientRect(); if (x1 < bodyPos.left+self.tbody.offsetWidth && y1 < bodyPos.top+self.tbody.offsetHeight && x2 > bodyPos.left && y2 > bodyPos.top) { if (x1 <= bodyPos.left) x1 = bodyPos.left+1; if (y1 <= bodyPos.top) y1 = bodyPos.top+1; if (x2 >= bodyPos.left+self.tbody.offsetWidth) x2 = bodyPos.left+self.tbody.offsetWidth-1; if (y2 >= bodyPos.top+self.tbody.offsetHeight) y2 = bodyPos.top+self.tbody.offsetHeight-1; var e1 = document.elementFromPoint(x1, y1); var e2 = document.elementFromPoint(x2, y2); while (e1 && e1.nodeName != 'TD') e1 = e1.parentNode; while (e2 && e2.nodeName != 'TD') e2 = e2.parentNode; if (e1 && e2) { var col1 = self._getCellIndex(e1); var col2 = self._getCellIndex(e2); var row = e1.parentNode; do { if (row.style.display != 'none' && row.style.visibility != 'hidden') for (var i = col1; i <= col2; i++) if (!self.stickyInit || i != 1) self.selectCell(row.cells[i]); if (row == e2.parentNode) break; row = row.nextSibling; } while (row); } } } else { var t = evt.target || evt.srcElement; self.selectCell(t); } if (x2 > x1+10 && y2 > y1+10 || evt.shiftKey) { if (document.selection) document.selection.empty(); else window.getSelection().removeAllRanges(); } } }); } // Tree grid node class TreeGridNode.prototype.getKey = function() { return this.key; } TreeGridNode.prototype.getChildByKey = function(key) { return this.childrenByKey[key]; } TreeGridNode.prototype._addCollapser = function() { var collapser = document.createElement('div'); if (this.leaf) collapser.className = 'collapser collapser-inactive'; else { collapser.className = this.collapsed ? 'collapser collapser-collapsed' : 'collapser collapser-expanded'; addListener(collapser, 'click', this._getToggleHandler()); } var c0 = this.tr[0].cells[0]; c0.childNodes.length ? c0.insertBefore(collapser, c0.firstChild) : c0.appendChild(collapser); if (this.grid._initialPadding === undefined) { var p0 = curStyle(c0).paddingLeft || ''; if (p0.substr(p0.length-2) == 'px') p0 = parseInt(p0.substr(0, p0.length-2)); else p0 = 0; this.grid._initialPadding = p0; } c0.style.paddingLeft = (this.grid._initialPadding + this.grid.levelIndent * this.level + 20) + 'px'; } TreeGridNode.prototype._hashEqual = function(a, b) { for (var i in a) if (b[i] !== a[i]) return false; for (var i in b) if (b[i] !== a[i]) return false; return true; } TreeGridNode.prototype._renderCell = function(rowIndex, colIndex, cell, force) { // this may be the first render of a row inside a stickyheaders grid var tr = this.tr[rowIndex]; var isFix = tr.cells.length && tr.cells[0].className.indexOf(' _scri') >= 0; var ri = isFix ? (colIndex > 0 ? colIndex+1 : 0) : colIndex; while (!tr.cells[ri]) tr.appendChild(document.createElement('td')); // virtualDOM-like approach: compare old HTML properties if (force || !this._oldCells[rowIndex] || !this._oldCells[rowIndex][colIndex] || cell && !this._hashEqual(this._oldCells[rowIndex][colIndex], cell)) { var old; if (isFix && colIndex == 0 && rowIndex == 0) { old = { width: tr.cells[ri].style.width, height: tr.cells[ri].style.height, position: tr.cells[ri].style.position, display: tr.cells[ri].style.display, zIndex: tr.cells[ri].style.zIndex }; } this.grid._setProps(tr.cells[ri], cell); if (colIndex == 0) { if (rowIndex == 0) { this._addCollapser(); if (isFix) { // FIXME: this is rather ugly :-( we need to restore stickyheaders styles tr.cells[ri]._oldWidth = tr.cells[ri].style.width; for (var i in old) tr.cells[ri].style[i] = old[i]; tr.cells[ri].className += ' _scri'+this.grid.table._scri; } } else tr.cells[ri].style.paddingLeft = (this.grid._initialPadding + this.grid.levelIndent * this.level + 20) + 'px'; } return true; } return false; } TreeGridNode.prototype.render = function(colidx, rowidx, force, skipStickyFix) { var cells = this.grid.renderer(this, colidx, rowidx); if (this.tr.length == 1) cells = [ cells ]; if (colidx === null || colidx < 0 || colidx >= this.grid.header.length) colidx = undefined; if (rowidx === null || rowidx < 0 || rowidx >= this.tr.length) rowidx = undefined; var modified = false; if (rowidx !== undefined) { if (colidx !== undefined) { modified = this._renderCell(rowidx, colidx, cells[rowidx] && cells[rowidx][colidx], force) || modified; this._oldCells[rowidx] = this._oldCells[rowidx] || []; this._oldCells[rowidx][colidx] = cells[rowidx] && cells[rowidx][colidx]; } else { for (var j = 0; j < this.grid.header.length; j++) modified = this._renderCell(rowidx, j, cells[rowidx] && cells[rowidx][j], force) || modified; this._oldCells[rowidx] = cells[rowidx]; } } else if (colidx !== undefined) { for (var i = 0; i < this.tr.length; i++) { modified = this._renderCell(i, colidx, cells[i] && cells[i][colidx], force) || modified; this._oldCells[i] = this._oldCells[i] || []; this._oldCells[i][colidx] = cells[i] && cells[i][colidx]; } } else { for (var i = 0; i < this.tr.length; i++) for (var j = 0; j < this.grid.header.length; j++) modified = this._renderCell(i, j, cells[i] && cells[i][j], force) || modified; this._oldCells = cells; } if (modified && this.grid.stickyInit && !skipStickyFix) fixStickyColumnSizes(this.grid.table); return modified; } TreeGridNode.prototype._getToggleHandler = function() { var self = this; if (!self._toggleHandlerClosure) self._toggleHandlerClosure = function(e) { self._toggleHandler(); }; return self._toggleHandlerClosure; } TreeGridNode.prototype._toggleHandler = function() { if (!this.grid.onExpand(this)) return; this.toggle(); } TreeGridNode.prototype.toggle = function() { if (this.leaf) return; this.collapsed = !this.collapsed; this.tr[0].cells[0].firstChild.className = this.collapsed ? 'collapser collapser-collapsed' : 'collapser collapser-expanded'; if (this.collapsed) { // collapse all children var c = this.tr[this.tr.length-1].nextSibling; while (c && c._node.level > this.level) { c.style.display = 'none'; c = c.nextSibling; } } else { // expand all children except children of collapsed subnodes var st = this.children.concat([]); var trs = []; while (st.length) { var e = st.pop(); for (var i = 0; i < e.tr.length; i++) { e.tr[i].style.display = ''; if (this.grid.stickyInit) trs.push(e.tr[i]); } if (!e.collapsed) st = st.concat(e.children); } if (trs.length && this.grid.stickyInit) fixStickyRows(this.grid.table, trs); } if (this.grid.stickyInit) fixStickyColumnSizes(this.grid.table); } TreeGridNode.prototype.setChildren = function(isLeaf, newChildren) { if (!this.tr) { // root node this.grid.tbody.innerHTML = ''; } else { var tr = this.tr[this.tr.length-1]; while (tr.nextSibling && tr.nextSibling._node.level > this.level) this.grid.tbody.removeChild(tr.nextSibling); if (this.leaf != isLeaf) { if (isLeaf) { this.tr[0].cells[0].firstChild.className = 'collapser collapser-inactive'; removeListener(this.tr[0].cells[0].firstChild, 'click', this._getToggleHandler()); } else { this.tr[0].cells[0].firstChild.className = this.collapsed ? 'collapser collapser-collapsed' : 'collapser collapser-expanded'; addListener(this.tr[0].cells[0].firstChild, 'click', this._getToggleHandler()); } } } this.leaf = isLeaf; this.children = []; this.childrenByKey = {}; this.addChildren(newChildren); } TreeGridNode.prototype.removeChild = function(nodeOrIndex) { if (nodeOrIndex instanceof TreeGridNode) { if (this.children[nodeOrIndex._index] == nodeOrIndex) nodeOrIndex = nodeOrIndex._index; else return false; } if (!this.children[nodeOrIndex]) return false; var e = this.children[nodeOrIndex].tr[0]; var l = this.children[nodeOrIndex].level; if (this.children[nodeOrIndex].key !== undefined) delete this.childrenByKey[this.children[nodeOrIndex].key]; this.children.splice(nodeOrIndex, 1); for (var i = nodeOrIndex; i < this.children.length; i++) this.children[i]._index--; var k; do { k = e; e = e.nextSibling; k.parentNode.removeChild(k); } while (e && e._node.level > l); return true; } TreeGridNode.prototype.addChildren = function(nodes, insertBefore) { var e; if (insertBefore < 0) insertBefore = 0; if (insertBefore === undefined || insertBefore === null || insertBefore === false || insertBefore >= this.children.length) { insertBefore = this.children.length; e = this; while (e.children.length) e = e.children[e.children.length-1]; e = e.tr ? e.tr[e.tr.length-1].nextSibling : null; } else e = this.children[insertBefore].tr[0]; var trs = []; for (var i = 0; i < nodes.length; i++) { var child = new TreeGridNode(nodes[i], this.grid, this.level+1, e, this.collapsed); child._index = insertBefore+i; this.children.splice(insertBefore+i, 0, child); if (child.key !== undefined) this.childrenByKey[child.key] = child; // TODO: fix sticky only once per whole node batch, including children if (this.grid.stickyInit && !this.collapsed) trs.push.apply(trs, child.tr); } for (var i = insertBefore+nodes.length; i < this.children.length; i++) this.children[i]._index += nodes.length; if (trs.length) fixStickyRows(this.grid.table, trs); return this.children.slice(insertBefore, nodes.length); } })();