/** * Very simple and fast tree grid/table, compatible with dynamic loading and stickyheaders.js * License: MPL 2.0+, (c) Vitaliy Filippov 2016+ * Version: 2016-08-15 */ /** * 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 * renderer = function(node) { return [ { innerHTML, style, className, title }, ... ] } * * items: [ node={ * key: , * children: [ node... ], * leaf: true/false, * collapsed: true/false, * 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 * // HTMLElement restrictToNode: the element to listen for mousedown events * TG.initCellSelection(restrictToNode); * * 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, colIndex, td) { return ; } * // called when a cell is deselected with mouse * TG.onCellDeselect = function(node, colIndex, td) {} * // called before a cell is edited * TG.onStartCellEdit = function(node, colIndex, td) { return { * abort: , * value: * } } * // called before a cell is stopped being edited * TG.onStopCellEdit = function (node, colIndex, value, td) { return { html: } } * */ function TreeGrid(options) { this.renderer = options.renderer; this.bindProps = { 'style': 1, 'className': 1, 'title': 1, 'innerHTML': 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); } TreeGrid.prototype.initStickyHeaders = function(options) { this.sticky = true; this.noStickyResize = options.noWindowResize; if (this.header && this.header.length) this._makeStickyHeaders(); } TreeGrid.prototype._makeStickyHeaders = function() { var self = this; makeStickyHeaders(this.table); this.stickyInit = true; this.table.className += ' stickyheaders'; 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) el[j] = ''; else if (typeof props == 'string') el.innerHTML = props; else for (var j in this.bindProps) el[j] = props[j] !== undefined ? props[j] : ''; } // Simple cell editing TreeGrid.prototype._getCellIndex = function(n) { var i = 0; while ((n = n.previousSibling)) i += (n.nodeType != 3 ? 1 : 0); return i; } 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 != 'celleditor') { var params = self.onStartCellEdit && self.onStartCellEdit(td.parentNode._node, self._getCellIndex(td), td) || {}; if (params.abort) return; self.editedCells.push(td); td.className += ' celleditor'; if (params.value === undefined) params.value = td.innerHTML; td.innerHTML = ''; addListener(td.firstChild, 'keydown', function(evt) { if (evt.keyCode == 13 || evt.keyCode == 10) self.stopCellEditing(td); }); td.firstChild.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); var node = td.parentNode._node; node._oldCells[i] = undefined; var params = this.onStopCellEdit && this.onStopCellEdit(node, i, td.firstChild ? td.firstChild.value : null, td) || {}; node.render(i); 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(restrictToNode) { var startDrag, dragDiv; var self = this; self.table.className += ' disable-text-select'; self.selectCell = function(cell) { if (!cell.parentNode._node || cell.className.indexOf(' selected') >= 0) return; var i = self._getCellIndex(cell); if (!self.onCellSelect || self.onCellSelect(cell.parentNode._node, i, cell)) cell.className += ' selected'; }; self.deselectCell = function(cell) { self.onCellDeselect && self.onCellDeselect(cell.parentNode._node, self._getCellIndex(cell), cell); cell.className = cell.className.replace(' selected', ''); }; addListener(restrictToNode || document.body, 'mousedown', function(evt) { evt = getEventCoord(evt); if (!evt.shiftKey) { var els = self.table.querySelectorAll('td.selected'); for (var i = 0; i < els.length; i++) self.deselectCell(els[i]); } 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 = getOffset(self.tbody); 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 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 = document.createElement('tr'); if (startHidden) this.tr.style.display = 'none'; this.tr._node = this; this._oldCells = []; this.render(); insertBefore ? grid.tbody.insertBefore(this.tr, insertBefore) : grid.tbody.appendChild(this.tr); } 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(child.tr); } if (trs.length) fixStickyRows(grid.table, trs); } } 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.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(i, cell) { var ri = this.stickyInit ? (i > 0 ? i+1 : 0) : i; if (!this.tr.cells[ri]) this.tr.appendChild(document.createElement('td')); // virtualDOM-like approach: compare old HTML properties if (!this._oldCells[i] || cell && !this._hashEqual(this._oldCells[i], cell)) { this.grid._setProps(this.tr.cells[ri], cell); if (i == 0) this._addCollapser(); } } TreeGridNode.prototype.render = function(colidx) { var cells = this.grid.renderer(this, colidx); if (colidx !== null && colidx !== undefined && colidx >= 0 && colidx < this.grid.header.length) { this._renderCell(colidx, cells[colidx]); this._oldCells[colidx] = cells[colidx]; } else { for (var i = 0; i < this.grid.header.length; i++) this._renderCell(i, cells[i]); this._oldCells = cells; } } 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.cells[0].firstChild.className = this.collapsed ? 'collapser collapser-collapsed' : 'collapser collapser-expanded'; if (this.collapsed) { // collapse all children var c = this.tr.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(); e.tr.style.display = ''; if (!e.collapsed) st = st.concat(e.children); if (this.grid.stickyInit) trs.push(e.tr); } if (trs.length) initStickyRows(this.grid.table, trs, true); } if (this.grid.stickyInit) fixStickyColumnSizes(this.grid.table); } TreeGridNode.prototype.setChildren = function(isLeaf, newChildren) { if (!this.tr) this.grid.tbody.innerHTML = ''; else { while (this.tr.nextSibling && this.tr.nextSibling._node.level > this.level) this.grid.tbody.removeChild(this.tr.nextSibling); if (this.leaf != isLeaf) { if (isLeaf) { this.tr.cells[0].firstChild.className = 'collapser collapser-inactive'; removeListener(this.tr.cells[0].firstChild, 'click', this._getToggleHandler()); } else { this.tr.cells[0].firstChild.className = this.collapsed ? 'collapser collapser-collapsed' : 'collapser collapser-expanded'; addListener(this.tr.cells[0].firstChild, 'click', this._getToggleHandler()); } } } this.leaf = isLeaf; this.children = []; this.childrenByKey = {}; this.addChildren(newChildren); } /*// experimental & broken TreeGridNode.prototype._syncChildren = function() { var i, j; for (var i = 0; i < this.children.length; i++) { delete this.children[i]._rownum; } var rows = []; var e = this.tr ? this.tr.nextSibling : this.grid.tbody.firstChild; i = 0; while (e && e._node.level > this.level) { if (e._node.level == this.level+1) { if (rows.length > 0) rows[rows.length-1][2] = i; e._node._rownum = rows.length; rows.push([ e, i, i ]); } e = e.nextSibling; i++; } if (rows.length > 0) rows[rows.length-1][2] = i; e = this.tr ? this.tr.nextSibling : this.grid.tbody.firstChild; for (var i = 0; i < this.children.length; i++) { if (this.children[i]._rownum !== undefined) { var k = rows[this.children[i]._rownum][0]; var n = rows[this.children[i]._rownum][2]-rows[this.children[i]._rownum][1]; delete this.children[i]._rownum; var m; while (n > 0) { // Ставим k перед e m = k; k = k.nextSibling; if (!e) this.grid.tbody.appendChild(k); else if (e == k) e = e.nextSibling; else e.parentNode.insertBefore(k, e); n--; } } else { this.children[i] = new TreeGridNode(this.children[i], this.grid, this.level+1, e, this.collapsed); } } if (e) { while (e._node.level > this.level) { k = e; e = e.nextSibling; k.parentNode.removeChild(k); } } }*/ 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; 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.nextSibling : null; } else e = this.children[insertBefore].tr; 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(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); } ;