/* Copyright (c) 2011, Geert Bergman (geert@scrivo.nl) * All rights reserved. * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: * * 1. Redistributions of source code must retain the above copyright notice, * this list of conditions and the following disclaimer. * 2. Redistributions in binary form must reproduce the above copyright notice, * this list of conditions and the following disclaimer in the documentation * and/or other materials provided with the distribution. * 3. Neither the name of "Scrivo" nor the names of its contributors may be * used to endorse or promote products derived from this software without * specific prior written permission. * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE * POSSIBILITY OF SUCH DAMAGE. * * $Id: TreeView.js 616 2013-04-22 23:48:38Z geert $ */ "use strict"; SUI.TreeView = SUI.defineClass( /** @lends SUI.TreeView.prototype */{ /** @ignore */ baseClass: SUI.Box, /** * @class * SUI.TreeView is an asynchronious tree control. In a tree control a set * of items is displayed in a hierarchical manner, allowing the user to * expand sections to dig deeper into the hierarchy. This control is always * loaded with server side data, so it will not work on your desktop alone. * * @augments SUI.Box * * @description * Create a treeview contol. If you want to do your own xhr because of * extra error handling f.i. you can pass in your own function. * * @constructs * @param see base class * @param {Function} arg.xhr User supplied xml http request function in the * form of function({String} url, {Function} fn) (optional) * @param {String} arg.dataUrl Url to load the root node(s) */ initializer: function(arg) { if (!arg.anchor) { // anchor by default to all sides arg.anchor = { right:true,left:true,top:true,bottom:true }; } SUI.TreeView.initializeBase(this, arg); // set user specific xhr function if given this._xhr = arg.xhr || this._xhr; this._dataUrl = arg.dataUrl || null; // allow scroll bars in the main div and set the style this.el().style.overflow = "auto"; this.addClass("sui-tv"); // array of open nodes with indices the ids of the data (not DOM ids) this._openNodes = []; }, /** * Height of a node row (CSS, thus including borders and padding) */ ROW_HEIGHT: 19, /** * Height of a node row (CSS, thus including borders) */ ROW_PADDING_TOP: 1, /** * Top and bottom border row width of a node */ ROW_BORDER_WIDTH: 1, /** * Left padding of the text in a node row */ TEXT_PADDING_LEFT: 2, /** * Right padding of the text in a node row */ TEXT_PADDING_RIGHT: 4, /** * Size of the icon (and the indent) */ ICONS_SIZE: 16, /** * Return the data that was selected at the last context menu selection. * Note that this data persist after the context menu is removed and * stays there till the next context menu is requested. * @return {Object} a An object with the current node data (a node * from the xhr request) */ contextMenuData: function() { return this._contextData; }, /** * Set a custom function for the icons to uses. It operates on the type * set in the data. * @param {Function} fn An function that returns the location of the icon, * this function has one parameter indicating the icon's type. */ iconFunction: function(fn) { this._iconFunc = fn; }, /** * Load the data into the tree control * @param {Object} arg Object in which the follow entries can be set * - {int[]|string[]} openNodes An object array containing the (data) ids * of the nodes that are initially open * - {String} dataUrl Url to load the initial node(s) * - {int|string} selected (Data) id of the currently selected node */ loadData: function(arg) { // initially the parent is the main box arg.parent = this.el(); // if there is already a tree rendered, remove it if (arg.parent.firstChild) { arg.parent.removeChild(arg.parent.firstChild); } // start at depth 0 arg.depth = 0; // if given, choose the data url from the argument in favor of // the one given in the constructor arg.dataUrl = this._dataUrl || arg.dataUrl; // fill the open nodes array if (arg.openNodes) { for (var i=0; i<arg.openNodes.length; i++) { this._openNodes[arg.openNodes[i]] = true; } } // load the data this._loadData(arg); }, /** * onContextMenu event handler: is executed when the user uses the context * menu click on a node. * @param {int} x The x location of the click * @param {int} y The y location of the click */ onContextMenu: function(x, y) {}, /** * onSelect event handler: is executed when the user uses selects a node. * Use this to add an action to your selection. */ onSelect: function() {}, /** * onSelectionChange event handler: is executed when the selection changes * to another node. Use this to update your GUI (like enabling buttons) */ onSelectionChange: function() {}, /** * Transfer the tree selection to the node that was selected by the * context menu. This will also execute the onSelect handler. */ selectContextMenuNode: function() { this._selectNode(this._contextNode, this._contextData); }, /** * Return the data that is currently selected. * @return {Object} a An object with the current node data (a node * from the xhr request) */ selectedData: function() { return this._selectedData; }, // the currently selected node data _selectedData: null, // the currently selected HTML node element _selectedNode: null, // the last (current) selected node data by the context menu _contextData: null, // the last (current) selected HTML node element by the context menu _contextNode: null, /* Set the event handlers of a node row. * On the row-div set: * onclick => _selectNode * onmouseover => _addHighlight * onmouseout => _removeHighlight * oncontextmenu => _handleContextMenu * On the icon set: * onclick => _openCloseNode * Disable the context menu event on child elements of the row */ _addEventListeners: function(div, exp, icon, span, arg, dat) { // that div, dat, arg are closure variables var that = this; // Do _selectNode on the click event of a node-row. SUI.browser.addEventListener(div, "click", function(e) { if (!that._selectNode(div, dat)) { SUI.browser.noPropagation(e); } } ); // Do _addHighlight on the mouseover event of a node-row. SUI.browser.addEventListener(div, "mouseover", function(e) { if (!that._addHighlight(div)) { SUI.browser.noPropagation(e); } } ); // Do _removeHighlight on the mouseout event of a node-row. SUI.browser.addEventListener(div, "mouseout", function(e) { if (!that._removeHighlight(div)) { SUI.browser.noPropagation(e); } } ); // Do _handleContextMenu on the contextmenu event of a node-row. SUI.browser.addEventListener(div, "contextmenu", function(e) { if (!that._handleContextMenu( new SUI.Event(this, e), div, dat)) { SUI.browser.noPropagation(e); that._preventDefault(e); } } ); // Do _openCloseNode on the click event of the open/close icon. if (exp.className.indexOf("sui-tv-exp") !== -1) { SUI.browser.addEventListener(exp, "click", function(e) { if (!that._openCloseNode(div, arg, dat)) { SUI.browser.noPropagation(e); } } ); } // Disable the contextmenu event on the two icons and node-row text. SUI.browser.addEventListener(exp, "contextmenu", function(e) { that._preventDefault(e); }); SUI.browser.addEventListener(span, "contextmenu", function(e) { that._preventDefault(e); }); SUI.browser.addEventListener(icon, "contextmenu", function(e) { that._preventDefault(e); }); }, /* Add the highlight to a tree node, except for the selected node */ _addHighlight: function(div) { if (div !== this._selectedNode) { // set the highlight + little patch SUI.style.addClass(div, "sui-tv-row-highlight"); this._sizeToNodeContents(div); } }, /* Create the list of node-rows for one level of the tree. */ _createNodeList: function(arg, dat) { // create a static UL and append it the the parent var ul = SUI.browser.createElement("UL"); ul.style.position = "static"; arg.parent.appendChild(ul); // if a least one node row as rendered ... if (arg.depth) { // ... replace the icon of the parent with a wait icon ... var ocIcn = arg.parent.firstChild.firstChild; ocIcn.nextSibling.src = SUI.imgDir+"/"+ this._iconFunc(arg.pType); ocIcn.nextSibling.style.backgroundImage = "none"; // ... and set the open close node to open ocIcn.src = SUI.imgDir+"/"+SUI.resource.tvOpen; } // loop through all the nodes from the list for (var i=0; i<dat.length; i++) { // create a static LI var li = SUI.browser.createElement("LI"); li.style.position = "static"; // create a static DIV for the node row var div = SUI.browser.createElement("DIV"); div.style.position = "static"; div.style.height = (this.ROW_HEIGHT - 2*this.ROW_BORDER_WIDTH) + "px"; div.style.borderTopWidth = div.style.borderBottomWidth = this.ROW_BORDER_WIDTH + "px"; div.style.borderLeftWidth = div.style.borderRightWidth = "0px"; div.style.paddingTop = this.ROW_PADDING_TOP + "px"; div.style.paddingLeft = (arg.depth * this.ICONS_SIZE) + "px"; SUI.style.addClass(div, "sui-tv-row"); // create a static image of the icon var icon = SUI.browser.createElement("IMG"); icon.style.position = "static"; icon.src = SUI.imgDir+"/"+this._iconFunc(dat[i].type); // create a static for the open/close icon var exp = SUI.browser.createElement("IMG"); exp.style.position = "static"; if (dat[i].childListUrl && dat[i].childListUrl !== "") { if (this._openNodes[dat[i].id]) { exp.src = SUI.imgDir+"/"+SUI.resource.tvOpen; } else { exp.src = SUI.imgDir+"/"+SUI.resource.tvClosed; } SUI.style.addClass(exp, "sui-tv-exp"); } else { exp.src = SUI.imgDir+"/"+SUI.resource.tvNone; } // create a static span for the text var span = SUI.browser.createElement("SPAN"); // TODO refactor: ugly hack span.style.cssText = dat[i].style; span.style.position = "static"; span.style.paddingLeft = this.TEXT_PADDING_LEFT + "px"; span.style.paddingRight = this.TEXT_PADDING_RIGHT + "px"; span.innerHTML = dat[i].title; // append all the items to the DOM UL list div.appendChild(exp); div.appendChild(icon); div.appendChild(span); li.appendChild(div); ul.appendChild(li); // if this is the selected node, select it if (dat[i].id == arg.selected) { SUI.style.addClass(div, "sui-tv-row-selected"); this._selectedData = dat[i]; // was the selection changed (or more likely in this case: on // the initial selection) call the onSelectionChange listener if (this._selectedNode !== div) { this.callListener("onSelectionChange"); } this._selectedNode = div; } // add the event listers for the node row this._addEventListeners(div, exp, icon, span, arg, dat[i]); // if the current node is an open node ... if (this._openNodes[dat[i].id]) { // ... and the node has children ... if (dat[i].childListUrl && dat[i].childListUrl !== "") { // ... then load the children too this._loadData({ parent: li, dataUrl: dat[i].childListUrl, pType: dat[i].type, depth: arg.depth + 1, selected: arg.selected }); } } } }, /* Store node data when the user uses the context menu and call the * context menu handler. */ _handleContextMenu: function(e, div, dat) { this._contextNode = div; this._contextData = dat; this.callListener("onContextMenu", SUI.browser.getX(e.event), SUI.browser.getY(e.event)); }, /* Default implementation of the icon function. */ _iconFunc: function(type) { return type == 1 ? SUI.resource.tvPage : SUI.resource.tvFolder; }, /* Actual data loading function. Arguments is an object with the following * menbers: * - selected: data id of the selected node * - parent: parent HTML element of the list to create * - dataUrl: location the get the json data for the list * - pType: type op the parent node * - depth: depth of the list */ _loadData: function(arg) { // if there is a row renderd if (arg.parent.firstChild) { // get a reference to the icon ... var prnt = arg.parent.firstChild.firstChild.nextSibling; // TODO check this, seem like a bad bug fix //if (!prnt) return; // ... and set the loading image prnt.src = SUI.imgDir+"/"+SUI.resource.tvLoadingAni; prnt.style.backgroundImage = "url(" + SUI.imgDir + "/" + SUI.resource.tvLoadingBg + ")"; } // load the data and render a list with it var that = this; this._xhr(arg.dataUrl, function(res) { that._createNodeList(arg, res.data); } ); }, /* Open or close a node: it is assumed that it has children. */ _openCloseNode: function(div, a, dat) { // if the node was not opened before if (!div.nextSibling) { // no: open the node: set it in the open nodes array ... this._openNodes[dat.id] = true; // ... and load the child data. this._loadData({ parent: div.parentNode, dataUrl: dat.childListUrl, pType: dat.type, depth: a.depth + 1 }); } else { // yes, the node was opened before: toggle the display, icon and // entry in the open nodes array. if (div.nextSibling.style.display == "none") { div.nextSibling.style.display = "block"; div.firstChild.src = SUI.imgDir+"/"+SUI.resource.tvOpen; this._openNodes[dat.id] = true; } else { div.nextSibling.style.display = "none"; div.firstChild.src = SUI.imgDir+"/"+SUI.resource.tvClosed; this._openNodes[dat.id] = false; } } }, /* Prevent the default event handling */ _preventDefault: function(e) { if (SUI.browser.isIE) { if (!e) { e = window.event; } e.returnValue = false; } else { e.preventDefault(); } }, /* Remove highlight to from a tree node */ _removeHighlight: function(div) { // Remove CSS class SUI.style.removeClass(div, "sui-tv-row-highlight"); }, /* Select a tree node, set highlights and the selected node, then call * the listeners. */ _selectNode: function(div, dat) { // remove the highlight SUI.style.removeClass(div, "sui-tv-row-highlight"); // if there was a previous selection, remove the selection highlight if (this._selectedNode) { SUI.style.removeClass(this._selectedNode, "sui-tv-row-selected"); } // set the selection highlight + little patch SUI.style.addClass(div, "sui-tv-row-selected"); this._sizeToNodeContents(div); // store the selection ... this._selectedData = dat; // ... call the onSelectionChange handler if the selection was changed if (div !== this._selectedNode) { this.callListener("onSelectionChange"); } // store the selected HTML node and call the select handler this._selectedNode = div; this.callListener("onSelect"); }, /* Set the width of the node div to its content width. This is a patch to * ensure that the background color if the selected node is set to the * width of the div (the offsreen part of a background is often not * rendered when there is a scroll bar). */ _sizeToNodeContents: function(div) { var p = parseInt(div.style.paddingLeft, 10); div.style.width = (this.el().scrollWidth -p) + "px"; }, /* Default xml-http-request function is the one of the library, but may be * overwritten by a user supplied one */ _xhr: function(url, cb) { SUI.xhr.doGet(url, null, cb); } });