diff --git a/web/app/views/main.scala.html b/web/app/views/main.scala.html index cad557a847..c3cc5e61b0 100644 --- a/web/app/views/main.scala.html +++ b/web/app/views/main.scala.html @@ -11,6 +11,7 @@ + @@ -25,7 +26,7 @@ - + diff --git a/web/public/stylesheets/wherehows.css b/web/public/stylesheets/wherehows.css index 06957d14c2..346091a0cd 100644 --- a/web/public/stylesheets/wherehows.css +++ b/web/public/stylesheets/wherehows.css @@ -20,7 +20,7 @@ a.navbar-brand { letter-spacing: 0.2em; text-shadow: none; - margin-left: 63px; + /*margin-left: 63px;*/ margin-top: 4px; font-size: 1.2em; } @@ -93,10 +93,11 @@ a.dropdown-toggle:hover { } .navbar-header:before { - content: url(/assets/images/icons/logo-white.png); + /*content: url(/assets/images/icons/logo-white.png); position: absolute; margin-top: 13px; margin-left: 20px; + */ } .navbar-inverse .navbar-nav li { diff --git a/web/public/vendors/fancytree/src/jquery.fancytree.wherehows.js b/web/public/vendors/fancytree/src/jquery.fancytree.wherehows.js new file mode 100644 index 0000000000..956831167d --- /dev/null +++ b/web/public/vendors/fancytree/src/jquery.fancytree.wherehows.js @@ -0,0 +1,4115 @@ +/*! + * jquery.fancytree.js + * Dynamic tree view control, with support for lazy loading of branches. + * https://github.com/mar10/fancytree/ + * + * Copyright (c) 2006-2014, Martin Wendt (http://wwWendt.de) + * Released under the MIT license + * https://github.com/mar10/fancytree/wiki/LicenseInfo + * + * @version @VERSION + * @date @DATE + */ + +/** Core Fancytree module. + */ + + +// Start of local namespace +;(function($, window, document, undefined) { +"use strict"; + +// prevent duplicate loading +if ( $.ui.fancytree && $.ui.fancytree.version ) { + $.ui.fancytree.warn("Fancytree: ignored duplicate include"); + return; +} + + +/* ***************************************************************************** + * Private functions and variables + */ + +function _raiseNotImplemented(msg){ + msg = msg || ""; + $.error("Not implemented: " + msg); +} + +function _assert(cond, msg){ + // TODO: see qunit.js extractStacktrace() + if(!cond){ + msg = msg ? ": " + msg : ""; + $.error("Assertion failed" + msg); + } +} + +function consoleApply(method, args){ + var i, s, + fn = window.console ? window.console[method] : null; + + if(fn){ + if(fn.apply){ + fn.apply(window.console, args); + }else{ + // IE? + s = ""; + for( i=0; i t ); + } + } + return true; +} + +/** Return a wrapper that calls sub.methodName() and exposes + * this : tree + * this._local : tree.ext.EXTNAME + * this._super : base.methodName() + */ +function _makeVirtualFunction(methodName, tree, base, extension, extName){ + // $.ui.fancytree.debug("_makeVirtualFunction", methodName, tree, base, extension, extName); + // if(rexTestSuper && !rexTestSuper.test(func)){ + // // extension.methodName() doesn't call _super(), so no wrapper required + // return func; + // } + // Use an immediate function as closure + var proxy = (function(){ + var prevFunc = tree[methodName], // org. tree method or prev. proxy + baseFunc = extension[methodName], // + _local = tree.ext[extName], + _super = function(){ + return prevFunc.apply(tree, arguments); + }; + + // Return the wrapper function + return function(){ + var prevLocal = tree._local, + prevSuper = tree._super; + try{ + tree._local = _local; + tree._super = _super; + return baseFunc.apply(tree, arguments); + }finally{ + tree._local = prevLocal; + tree._super = prevSuper; + } + }; + })(); // end of Immediate Function + return proxy; +} + +/** + * Subclass `base` by creating proxy functions + */ +function _subclassObject(tree, base, extension, extName){ + // $.ui.fancytree.debug("_subclassObject", tree, base, extension, extName); + for(var attrName in extension){ + if(typeof extension[attrName] === "function"){ + if(typeof tree[attrName] === "function"){ + // override existing method + tree[attrName] = _makeVirtualFunction(attrName, tree, base, extension, extName); + }else if(attrName.charAt(0) === "_"){ + // Create private methods in tree.ext.EXTENSION namespace + tree.ext[extName][attrName] = _makeVirtualFunction(attrName, tree, base, extension, extName); + }else{ + $.error("Could not override tree." + attrName + ". Use prefix '_' to create tree." + extName + "._" + attrName); + } + }else{ + // Create member variables in tree.ext.EXTENSION namespace + if(attrName !== "options"){ + tree.ext[extName][attrName] = extension[attrName]; + } + } + } +} + + +function _getResolvedPromise(context, argArray){ + if(context === undefined){ + return $.Deferred(function(){this.resolve();}).promise(); + }else{ + return $.Deferred(function(){this.resolveWith(context, argArray);}).promise(); + } +} + + +function _getRejectedPromise(context, argArray){ + if(context === undefined){ + return $.Deferred(function(){this.reject();}).promise(); + }else{ + return $.Deferred(function(){this.rejectWith(context, argArray);}).promise(); + } +} + + +function _makeResolveFunc(deferred, context){ + return function(){ + deferred.resolveWith(context); + }; +} + + +function _getElementDataAsDict($el){ + // Evaluate 'data-NAME' attributes with special treatment for 'data-json'. + var d = $.extend({}, $el.data()), + json = d.json; + delete d.fancytree; // added to container by widget factory + if( json ) { + delete d.json; + //
  • is already returned as object (http://api.jquery.com/data/#data-html5) + d = $.extend(d, json); + } + return d; +} + + +// TODO: use currying +function _makeNodeTitleMatcher(s){ + s = s.toLowerCase(); + return function(node){ + return node.title.toLowerCase().indexOf(s) >= 0; + }; +} + +var i, + FT = null, // initialized below + ENTITY_MAP = {"&": "&", "<": "<", ">": ">", "\"": """, "'": "'", "/": "/"}, + //boolean attributes that can be set with equivalent class names in the LI tags + CLASS_ATTRS = "active expanded focus folder hideCheckbox lazy selected unselectable".split(" "), + CLASS_ATTR_MAP = {}, + // Top-level Fancytree node attributes, that can be set by dict + NODE_ATTRS = "expanded extraClasses folder hideCheckbox key lazy refKey selected title tooltip unselectable".split(" "), + NODE_ATTR_MAP = {}, + // Attribute names that should NOT be added to node.data + NONE_NODE_DATA_MAP = {"active": true, "children": true, "data": true, "focus": true}; + +for(i=0; i + * For lazy nodes, null or undefined means 'not yet loaded'. Use an empty array + * to define a node that has no children. + * @property {boolean} expanded Use isExpanded(), setExpanded() to access this property. + * @property {string} extraClasses Addtional CSS classes, added to the node's `<span>` + * @property {boolean} folder Folder nodes have different default icons and click behavior.
    + * Note: Also non-folders may have children. + * @property {string} statusNodeType null or type of temporarily generated system node like 'loading', or 'error'. + * @property {boolean} lazy True if this node is loaded on demand, i.e. on first expansion. + * @property {boolean} selected Use isSelected(), setSelected() to access this property. + * @property {string} tooltip Alternative description used as hover banner + */ +function FancytreeNode(parent, obj){ + var i, l, name, cl; + + this.parent = parent; + this.tree = parent.tree; + this.ul = null; + this.li = null; //
  • tag + this.statusNodeType = null; // if this is a temp. node to display the status of its parent + this._isLoading = false; // if this node itself is loading + this._error = null; // {message: '...'} if a load error occured + this.data = {}; + + // TODO: merge this code with node.toDict() + // copy attributes from obj object + for(i=0, l=NODE_ATTRS.length; i= 0, "insertBefore must be an existing child"); + // insert nodeList after children[pos] + this.children.splice.apply(this.children, [pos, 0].concat(nodeList)); + } + if( !this.parent || this.parent.ul || this.tr ){ + // render if the parent was rendered (or this is a root node) + this.render(); + } + if( this.tree.options.selectMode === 3 ){ + this.fixSelection3FromEndNodes(); + } + return firstNode; + }, + /** + * Append or prepend a node, or append a child node. + * + * This a convenience function that calls addChildren() + * + * @param {NodeData} node node definition + * @param {string} [mode=child] 'before', 'after', or 'child' ('over' is a synonym for 'child') + * @returns {FancytreeNode} new node + */ + addNode: function(node, mode){ + if(mode === undefined || mode === "over"){ + mode = "child"; + } + switch(mode){ + case "after": + return this.getParent().addChildren(node, this.getNextSibling()); + case "before": + return this.getParent().addChildren(node, this); + case "child": + case "over": + return this.addChildren(node); + } + _assert(false, "Invalid mode: " + mode); + }, + /** + * Append new node after this. + * + * This a convenience function that calls addNode(node, 'after') + * + * @param {NodeData} node node definition + * @returns {FancytreeNode} new node + */ + appendSibling: function(node){ + return this.addNode(node, "after"); + }, + /** + * Modify existing child nodes. + * + * @param {NodePatch} patch + * @returns {$.Promise} + * @see FancytreeNode#addChildren + */ + applyPatch: function(patch) { + // patch [key, null] means 'remove' + if(patch === null){ + this.remove(); + return _getResolvedPromise(this); + } + // TODO: make sure that root node is not collapsed or modified + // copy (most) attributes to node.ATTR or node.data.ATTR + var name, promise, v, + IGNORE_MAP = { children: true, expanded: true, parent: true }; // TODO: should be global + + for(name in patch){ + v = patch[name]; + if( !IGNORE_MAP[name] && !$.isFunction(v)){ + if(NODE_ATTR_MAP[name]){ + this[name] = v; + }else{ + this.data[name] = v; + } + } + } + // Remove and/or create children + if(patch.hasOwnProperty("children")){ + this.removeChildren(); + if(patch.children){ // only if not null and not empty list + // TODO: addChildren instead? + this._setChildren(patch.children); + } + // TODO: how can we APPEND or INSERT child nodes? + } + if(this.isVisible()){ + this.renderTitle(); + this.renderStatus(); + } + // Expand collapse (final step, since this may be async) + if(patch.hasOwnProperty("expanded")){ + promise = this.setExpanded(patch.expanded); + }else{ + promise = _getResolvedPromise(this); + } + return promise; + }, + /** Collapse all sibling nodes. + * @returns {$.Promise} + */ + collapseSiblings: function() { + return this.tree._callHook("nodeCollapseSiblings", this); + }, + /** Copy this node as sibling or child of `node`. + * + * @param {FancytreeNode} node source node + * @param {string} mode 'before' | 'after' | 'child' + * @param {Function} [map] callback function(NodeData) that could modify the new node + * @returns {FancytreeNode} new + */ + copyTo: function(node, mode, map) { + return node.addNode(this.toDict(true, map), mode); + }, + /** Count direct and indirect children. + * + * @param {boolean} [deep=true] pass 'false' to only count direct children + * @returns {int} number of child nodes + */ + countChildren: function(deep) { + var cl = this.children, i, l, n; + if( !cl ){ + return 0; + } + n = cl.length; + if(deep !== false){ + for(i=0, l=n; i= 2 (prepending node info) + * + * @param {*} msg string or object or array of such + */ + debug: function(msg){ + if( this.tree.options.debugLevel >= 2 ) { + Array.prototype.unshift.call(arguments, this.toString()); + consoleApply("debug", arguments); + } + }, + /** Deprecated. + * @deprecated since 2014-02-16. Use resetLazy() instead. + */ + discard: function(){ + this.warn("FancytreeNode.discard() is deprecated since 2014-02-16. Use .resetLazy() instead."); + return this.resetLazy(); + }, + // TODO: expand(flag) + /**Find all nodes that contain `match` in the title. + * + * @param {string | function(node)} match string to search for, of a function that + * returns `true` if a node is matched. + * @returns {FancytreeNode[]} array of nodes (may be empty) + * @see FancytreeNode#findAll + */ + findAll: function(match) { + match = $.isFunction(match) ? match : _makeNodeTitleMatcher(match); + var res = []; + this.visit(function(n){ + if(match(n)){ + res.push(n); + } + }); + return res; + }, + /**Find first node that contains `match` in the title (not including self). + * + * @param {string | function(node)} match string to search for, of a function that + * returns `true` if a node is matched. + * @returns {FancytreeNode} matching node or null + * @example + * fat text + */ + findFirst: function(match) { + match = $.isFunction(match) ? match : _makeNodeTitleMatcher(match); + var res = null; + this.visit(function(n){ + if(match(n)){ + res = n; + return false; + } + }); + return res; + }, + /* Apply selection state (internal use only) */ + _changeSelectStatusAttrs: function (state) { + var changed = false; + + switch(state){ + case false: + changed = ( this.selected || this.partsel ); + this.selected = false; + this.partsel = false; + break; + case true: + changed = ( !this.selected || !this.partsel ); + this.selected = true; + this.partsel = true; + break; + case undefined: + changed = ( this.selected || !this.partsel ); + this.selected = false; + this.partsel = true; + break; + default: + _assert(false, "invalid state: " + state); + } + // this.debug("fixSelection3AfterLoad() _changeSelectStatusAttrs()", state, changed); + if( changed ){ + this.renderStatus(); + } + return changed; + }, + /** + * Fix selection status, after this node was (de)selected in multi-hier mode. + * This includes (de)selecting all children. + */ + fixSelection3AfterClick: function() { + var flag = this.isSelected(); + +// this.debug("fixSelection3AfterClick()"); + + this.visit(function(node){ + node._changeSelectStatusAttrs(flag); + }); + this.fixSelection3FromEndNodes(); + }, + /** + * Fix selection status for multi-hier mode. + * Only end-nodes are considered to update the descendants branch and parents. + * Should be called after this node has loaded new children or after + * children have been modified using the API. + */ + fixSelection3FromEndNodes: function() { +// this.debug("fixSelection3FromEndNodes()"); + _assert(this.tree.options.selectMode === 3, "expected selectMode 3"); + + // Visit all end nodes and adjust their parent's `selected` and `partsel` + // attributes. Return selection state true, false, or undefined. + function _walk(node){ + var i, l, child, s, state, allSelected,someSelected, + children = node.children; + + if( children && children.length ){ + // check all children recursively + allSelected = true; + someSelected = false; + + for( i=0, l=children.length; i= 1 (prepending node info) + * + * @param {*} msg string or object or array of such + */ + info: function(msg){ + if( this.tree.options.debugLevel >= 1 ) { + Array.prototype.unshift.call(arguments, this.toString()); + consoleApply("info", arguments); + } + }, + /** Return true if node is active (see also FancytreeNode#isSelected). + * @returns {boolean} + */ + isActive: function() { + return (this.tree.activeNode === this); + }, + /** Return true if node is a direct child of otherNode. + * @param {FancytreeNode} otherNode + * @returns {boolean} + */ + isChildOf: function(otherNode) { + return (this.parent && this.parent === otherNode); + }, + /** Return true, if node is a direct or indirect sub node of otherNode. + * @param {FancytreeNode} otherNode + * @returns {boolean} + */ + isDescendantOf: function(otherNode) { + if(!otherNode || otherNode.tree !== this.tree){ + return false; + } + var p = this.parent; + while( p ) { + if( p === otherNode ){ + return true; + } + p = p.parent; + } + return false; + }, + /** Return true if node is expanded. + * @returns {boolean} + */ + isExpanded: function() { + return !!this.expanded; + }, + /** Return true if node is the first node of its parent's children. + * @returns {boolean} + */ + isFirstSibling: function() { + var p = this.parent; + return !p || p.children[0] === this; + }, + /** Return true if node is a folder, i.e. has the node.folder attribute set. + * @returns {boolean} + */ + isFolder: function() { + return !!this.folder; + }, + /** Return true if node is the last node of its parent's children. + * @returns {boolean} + */ + isLastSibling: function() { + var p = this.parent; + return !p || p.children[p.children.length-1] === this; + }, + /** Return true if node is lazy (even if data was already loaded) + * @returns {boolean} + */ + isLazy: function() { + return !!this.lazy; + }, + /** Return true if node is lazy and loaded. For non-lazy nodes always return true. + * @returns {boolean} + */ + isLoaded: function() { + return !this.lazy || this.hasChildren() !== undefined; // Also checks if the only child is a status node + }, + /** Return true if children are currently beeing loaded, i.e. a Ajax request is pending. + * @returns {boolean} + */ + isLoading: function() { + return !!this._isLoading; + }, + /** Return true if this is the (invisible) system root node. + * @returns {boolean} + */ + isRoot: function() { + return (this.tree.rootNode === this); + }, + /** Return true if node is selected, i.e. has a checkmark set (see also FancytreeNode#isActive). + * @returns {boolean} + */ + isSelected: function() { + return !!this.selected; + }, + /** Return true if this node is a temporarily generated system node like + * 'loading', or 'error' (node.statusNodeType contains the type). + * @returns {boolean} + */ + isStatusNode: function() { + return !!this.statusNodeType; + }, + /** Return true if node is lazy and not yet loaded. For non-lazy nodes always return false. + * @returns {boolean} + */ + isUndefined: function() { + return this.hasChildren() === undefined; // also checks if the only child is a status node + }, + /** Return true if all parent nodes are expanded. Note: this does not check + * whether the node is scrolled into the visible part of the screen. + * @returns {boolean} + */ + isVisible: function() { + var i, l, + parents = this.getParentList(false, false); + + for(i=0, l=parents.length; i= 0; i--){ + // that.debug("pushexpand" + parents[i]); + deferreds.push(parents[i].setExpanded(true, opts)); + } + $.when.apply($, deferreds).done(function(){ + // All expands have finished + // that.debug("expand DONE", scroll); + if( scroll ){ + that.scrollIntoView(effects).done(function(){ + // that.debug("scroll DONE"); + dfd.resolve(); + }); + } else { + dfd.resolve(); + } + }); + return dfd.promise(); + }, + /** Move this node to targetNode. + * @param {FancytreeNode} targetNode + * @param {string} mode
    +	 *      'child': append this node as last child of targetNode.
    +	 *               This is the default. To be compatble with the D'n'd
    +	 *               hitMode, we also accept 'over'.
    +	 *      'before': add this node as sibling before targetNode.
    +	 *      'after': add this node as sibling after targetNode.
    + * @param {function} [map] optional callback(FancytreeNode) to allow modifcations + */ + moveTo: function(targetNode, mode, map) { + if(mode === undefined || mode === "over"){ + mode = "child"; + } + var pos, + prevParent = this.parent, + targetParent = (mode === "child") ? targetNode : targetNode.parent; + + if(this === targetNode){ + return; + }else if( !this.parent ){ + throw "Cannot move system root"; + }else if( targetParent.isDescendantOf(this) ){ + throw "Cannot move a node to its own descendant"; + } + // Unlink this node from current parent + if( this.parent.children.length === 1 ) { + if( this.parent === targetParent ){ + return; // #258 + } + this.parent.children = this.parent.lazy ? [] : null; + this.parent.expanded = false; + } else { + pos = $.inArray(this, this.parent.children); + _assert(pos >= 0); + this.parent.children.splice(pos, 1); + } + // Remove from source DOM parent +// if(this.parent.ul){ +// this.parent.ul.removeChild(this.li); +// } + + // Insert this node to target parent's child list + this.parent = targetParent; + if( targetParent.hasChildren() ) { + switch(mode) { + case "child": + // Append to existing target children + targetParent.children.push(this); + break; + case "before": + // Insert this node before target node + pos = $.inArray(targetNode, targetParent.children); + _assert(pos >= 0); + targetParent.children.splice(pos, 0, this); + break; + case "after": + // Insert this node after target node + pos = $.inArray(targetNode, targetParent.children); + _assert(pos >= 0); + targetParent.children.splice(pos+1, 0, this); + break; + default: + throw "Invalid mode " + mode; + } + } else { + targetParent.children = [ this ]; + } + // Parent has no
      tag yet: +// if( !targetParent.ul ) { +// // This is the parent's first child: create UL tag +// // (Hidden, because it will be +// targetParent.ul = document.createElement("ul"); +// targetParent.ul.style.display = "none"; +// targetParent.li.appendChild(targetParent.ul); +// } +// // Issue 319: Add to target DOM parent (only if node was already rendered(expanded)) +// if(this.li){ +// targetParent.ul.appendChild(this.li); +// }^ + + // Let caller modify the nodes + if( map ){ + targetNode.visit(map, true); + } + // Handle cross-tree moves + if( this.tree !== targetNode.tree ) { + // Fix node.tree for all source nodes +// _assert(false, "Cross-tree move is not yet implemented."); + this.warn("Cross-tree moveTo is experimantal!"); + this.visit(function(n){ + // TODO: fix selection state and activation, ... + n.tree = targetNode.tree; + }, true); + } + + // A collaposed node won't re-render children, so we have to remove it manually + // if( !targetParent.expanded ){ + // prevParent.ul.removeChild(this.li); + // } + + // Update HTML markup + if( !prevParent.isDescendantOf(targetParent)) { + prevParent.render(); + } + if( !targetParent.isDescendantOf(prevParent) && targetParent !== prevParent) { + targetParent.render(); + } + // TODO: fix selection state + // TODO: fix active state + +/* + var tree = this.tree; + var opts = tree.options; + var pers = tree.persistence; + + + // Always expand, if it's below minExpandLevel +// tree.logDebug ("%s._addChildNode(%o), l=%o", this, ftnode, ftnode.getLevel()); + if ( opts.minExpandLevel >= ftnode.getLevel() ) { +// tree.logDebug ("Force expand for %o", ftnode); + this.bExpanded = true; + } + + // In multi-hier mode, update the parents selection state + // DT issue #82: only if not initializing, because the children may not exist yet +// if( !ftnode.data.isStatusNode() && opts.selectMode==3 && !isInitializing ) +// ftnode._fixSelectionState(); + + // In multi-hier mode, update the parents selection state + if( ftnode.bSelected && opts.selectMode==3 ) { + var p = this; + while( p ) { + if( !p.hasSubSel ) + p._setSubSel(true); + p = p.parent; + } + } + // render this node and the new child + if ( tree.bEnableUpdate ) + this.render(); + + return ftnode; + +*/ + }, + /** Set focus relative to this node and optionally activate. + * + * @param {number} where The keyCode that would normally trigger this move, + * e.g. `$.ui.keyCode.LEFT` would collapse the node if it + * is expanded or move to the parent oterwise. + * @param {boolean} [activate=true] + * @returns {$.Promise} + */ + navigate: function(where, activate) { + var i, parents, + handled = true, + KC = $.ui.keyCode, + sib = null; + + // Navigate to node + function _goto(n){ + if( n ){ + try { n.makeVisible(); } catch(e) {} // #272 + // Node may still be hidden by a filter + if( ! $(n.span).is(":visible") ) { + n.debug("Navigate: skipping hidden node"); + n.navigate(where, activate); + return; + } + return activate === false ? n.setFocus() : n.setActive(); + } + } + + switch( where ) { + case KC.BACKSPACE: + if( this.parent && this.parent.parent ) { + _goto(this.parent); + } + break; + case KC.LEFT: + if( this.expanded ) { + this.setExpanded(false); + _goto(this); + } else if( this.parent && this.parent.parent ) { + _goto(this.parent); + } + break; + case KC.RIGHT: + if( !this.expanded && (this.children || this.lazy) ) { + this.setExpanded(); + _goto(this); + } else if( this.children && this.children.length ) { + _goto(this.children[0]); + } + break; + case KC.UP: + sib = this.getPrevSibling(); + while( sib && sib.expanded && sib.children && sib.children.length ){ + sib = sib.children[sib.children.length - 1]; + } + if( !sib && this.parent && this.parent.parent ){ + sib = this.parent; + } + _goto(sib); + break; + case KC.DOWN: + if( this.expanded && this.children && this.children.length ) { + sib = this.children[0]; + } else { + parents = this.getParentList(false, true); + for(i=parents.length-1; i>=0; i--) { + sib = parents[i].getNextSibling(); + if( sib ){ break; } + } + } + _goto(sib); + break; + default: + handled = false; + } + }, + /** + * Remove this node (not allowed for system root). + */ + remove: function() { + return this.parent.removeChild(this); + }, + /** + * Remove childNode from list of direct children. + * @param {FancytreeNode} childNode + */ + removeChild: function(childNode) { + return this.tree._callHook("nodeRemoveChild", this, childNode); + }, + /** + * Remove all child nodes and descendents. This converts the node into a leaf.
      + * If this was a lazy node, it is still considered 'loaded'; call node.resetLazy() + * in order to trigger lazyLoad on next expand. + */ + removeChildren: function() { + return this.tree._callHook("nodeRemoveChildren", this); + }, + /** + * This method renders and updates all HTML markup that is required + * to display this node in its current state.
      + * Note: + *
        + *
      • It should only be neccessary to call this method after the node object + * was modified by direct access to its properties, because the common + * API methods (node.setTitle(), moveTo(), addChildren(), remove(), ...) + * already handle this. + *
      • {@link FancytreeNode#renderTitle} and {@link FancytreeNode#renderStatus} + * are implied. If changes are more local, calling only renderTitle() or + * renderStatus() may be sufficient and faster. + *
      • If a node was created/removed, node.render() must be called on the parent. + *
      + * + * @param {boolean} [force=false] re-render, even if html markup was already created + * @param {boolean} [deep=false] also render all descendants, even if parent is collapsed + */ + render: function(force, deep) { + return this.tree._callHook("nodeRender", this, force, deep); + }, + /** Create HTML markup for the node's outer (expander, checkbox, icon, and title). + * @see Fancytree_Hooks#nodeRenderTitle + */ + renderTitle: function() { + return this.tree._callHook("nodeRenderTitle", this); + }, + /** Update element's CSS classes according to node state. + * @see Fancytree_Hooks#nodeRenderStatus + */ + renderStatus: function() { + return this.tree._callHook("nodeRenderStatus", this); + }, + /** + * Remove all children, collapse, and set the lazy-flag, so that the lazyLoad + * event is triggered on next expand. + */ + resetLazy: function() { + this.removeChildren(); + this.expanded = false; + this.lazy = true; + this.children = undefined; + this.renderStatus(); + }, + /** Schedule activity for delayed execution (cancel any pending request). + * scheduleAction('cancel') will only cancel a pending request (if any). + * @param {string} mode + * @param {number} ms + */ + scheduleAction: function(mode, ms) { + if( this.tree.timer ) { + clearTimeout(this.tree.timer); +// this.tree.debug("clearTimeout(%o)", this.tree.timer); + } + this.tree.timer = null; + var self = this; // required for closures + switch (mode) { + case "cancel": + // Simply made sure that timer was cleared + break; + case "expand": + this.tree.timer = setTimeout(function(){ + self.tree.debug("setTimeout: trigger expand"); + self.setExpanded(true); + }, ms); + break; + case "activate": + this.tree.timer = setTimeout(function(){ + self.tree.debug("setTimeout: trigger activate"); + self.setActive(true); + }, ms); + break; + default: + throw "Invalid mode " + mode; + } +// this.tree.debug("setTimeout(%s, %s): %s", mode, ms, this.tree.timer); + }, + /** + * + * @param {boolean | PlainObject} [effects=false] animation options. + * @param {object} [options=null] {topNode: null, effects: ..., parent: ...} this node will remain visible in + * any case, even if `this` is outside the scroll pane. + * @returns {$.Promise} + */ + scrollIntoView: function(effects, options) { + if( options !== undefined && _isNode(options) ) { + this.warn("scrollIntoView() with 'topNode' option is deprecated since 2014-05-08. Use 'options.topNode' instead."); + options = {topNode: options}; + } + // this.$scrollParent = (this.options.scrollParent === "auto") ? $ul.scrollParent() : $(this.options.scrollParent); + // this.$scrollParent = this.$scrollParent.length ? this.$scrollParent || this.$container; + + var topNodeY, nodeY, horzScrollbarHeight, containerOffsetTop, + opts = $.extend({ + effects: (effects === true) ? {duration: 200, queue: false} : effects, + scrollOfs: this.tree.options.scrollOfs, + scrollParent: this.tree.options.scrollParent || this.tree.$container, + topNode: null + }, options), + dfd = new $.Deferred(), + that = this, + nodeHeight = $(this.span).height(), + $container = $(opts.scrollParent), + topOfs = opts.scrollOfs.top || 0, + bottomOfs = opts.scrollOfs.bottom || 0, + containerHeight = $container.height(),// - topOfs - bottomOfs, + scrollTop = $container.scrollTop(), + $animateTarget = $container, + isParentWindow = $container[0] === window, + topNode = opts.topNode || null, + newScrollTop = null; + + // this.debug("scrollIntoView(), scrollTop=", scrollTop, opts.scrollOfs); + _assert($(this.span).is(":visible"), "scrollIntoView node is invisible"); // otherwise we cannot calc offsets + + if( isParentWindow ) { + nodeY = $(this.span).offset().top; + topNodeY = topNode ? $(topNode.span).offset().top : 0; + $animateTarget = $("html,body"); + + } else { + _assert($container[0] !== document && $container[0] !== document.body, "scrollParent should be an simple element or `window`, not document or body."); + + containerOffsetTop = $container.offset().top, + nodeY = $(this.span).offset().top - containerOffsetTop + scrollTop; // relative to scroll parent + topNodeY = topNode ? $(topNode.span).offset().top - containerOffsetTop + scrollTop : 0; + horzScrollbarHeight = Math.max(0, ($container.innerHeight() - $container[0].clientHeight)); + containerHeight -= horzScrollbarHeight; + } + + // this.debug(" scrollIntoView(), nodeY=", nodeY, "containerHeight=", containerHeight); + if( nodeY < (scrollTop + topOfs) ){ + // Node is above visible container area + newScrollTop = nodeY - topOfs; + // this.debug(" scrollIntoView(), UPPER newScrollTop=", newScrollTop); + + }else if((nodeY + nodeHeight) > (scrollTop + containerHeight - bottomOfs)){ + newScrollTop = nodeY + nodeHeight - containerHeight + bottomOfs; + // this.debug(" scrollIntoView(), LOWER newScrollTop=", newScrollTop); + // If a topNode was passed, make sure that it is never scrolled + // outside the upper border + if(topNode){ + _assert($(topNode.span).is(":visible")); + if( topNodeY < newScrollTop ){ + newScrollTop = topNodeY - topOfs; + // this.debug(" scrollIntoView(), TOP newScrollTop=", newScrollTop); + } + } + } + + if(newScrollTop !== null){ + // this.debug(" scrollIntoView(), SET newScrollTop=", newScrollTop); + if(opts.effects){ + opts.effects.complete = function(){ + dfd.resolveWith(that); + }; + $animateTarget.stop(true).animate({ + scrollTop: newScrollTop + }, opts.effects); + }else{ + $animateTarget[0].scrollTop = newScrollTop; + dfd.resolveWith(this); + } + }else{ + dfd.resolveWith(this); + } + return dfd.promise(); + }, + + /**Activate this node. + * @param {boolean} [flag=true] pass false to deactivate + * @param {object} [opts] additional options. Defaults to {noEvents: false} + */ + setActive: function(flag, opts){ + return this.tree._callHook("nodeSetActive", this, flag, opts); + }, + /**Expand or collapse this node. Promise is resolved, when lazy loading and animations are done. + * @param {boolean} [flag=true] pass false to collapse + * @param {object} [opts] additional options. Defaults to {noAnimation: false, noEvents: false} + * @returns {$.Promise} + */ + setExpanded: function(flag, opts){ + return this.tree._callHook("nodeSetExpanded", this, flag, opts); + }, + /**Set keyboard focus to this node. + * @param {boolean} [flag=true] pass false to blur + * @see Fancytree#setFocus + */ + setFocus: function(flag){ + return this.tree._callHook("nodeSetFocus", this, flag); + }, + /**Select this node, i.e. check the checkbox. + * @param {boolean} [flag=true] pass false to deselect + */ + setSelected: function(flag){ + return this.tree._callHook("nodeSetSelected", this, flag); + }, + /**Mark a lazy node as 'error', 'loading', or 'ok'. + * @param {string} status 'error', 'ok' + * @param {string} [message] + * @param {string} [details] + */ + setStatus: function(status, message, details){ + return this.tree._callHook("nodeSetStatus", this, status, message, details); + }, + /**Rename this node. + * @param {string} title + */ + setTitle: function(title){ + this.title = title; + this.renderTitle(); + }, + /**Sort child list by title. + * @param {function} [cmp] custom compare function(a, b) that returns -1, 0, or 1 (defaults to sort by title). + * @param {boolean} [deep=false] pass true to sort all descendant nodes + */ + sortChildren: function(cmp, deep) { + var i,l, + cl = this.children; + + if( !cl ){ + return; + } + cmp = cmp || function(a, b) { + var x = a.title.toLowerCase(), + y = b.title.toLowerCase(); + return x === y ? 0 : x > y ? 1 : -1; + }; + cl.sort(cmp); + if( deep ){ + for(i=0, l=cl.length; i"; + }, + /** Call fn(node) for all child nodes.
      + * Stop iteration, if fn() returns false. Skip current branch, if fn() returns "skip".
      + * Return false if iteration was stopped. + * + * @param {function} fn the callback function. + * Return false to stop iteration, return "skip" to skip this node and children only. + * @param {boolean} [includeSelf=false] + * @returns {boolean} + */ + visit: function(fn, includeSelf) { + var i, l, + res = true, + children = this.children; + + if( includeSelf === true ) { + res = fn(this); + if( res === false || res === "skip" ){ + return res; + } + } + if(children){ + for(i=0, l=children.length; i + * Stop iteration, if fn() returns false.
      + * Return false if iteration was stopped. + * + * @param {function} fn the callback function. + * Return false to stop iteration, return "skip" to skip this node and children only. + * @param {boolean} [includeSelf=false] + * @returns {boolean} + */ + visitParents: function(fn, includeSelf) { + // Visit parent nodes (bottom up) + if(includeSelf && fn(this) === false){ + return false; + } + var p = this.parent; + while( p ) { + if(fn(p) === false){ + return false; + } + p = p.parent; + } + return true; + }, + /** Write warning to browser console (prepending node info) + * + * @param {*} msg string or object or array of such + */ + warn: function(msg){ + Array.prototype.unshift.call(arguments, this.toString()); + consoleApply("warn", arguments); + } +}; + + +/* ***************************************************************************** + * Fancytree + */ +/** + * Construct a new tree object. + * + * @class Fancytree + * @classdesc The controller behind a fancytree. + * This class also contains 'hook methods': see {@link Fancytree_Hooks}. + * + * @param {Widget} widget + * + * @property {FancytreeOptions} options + * @property {FancytreeNode} rootNode + * @property {FancytreeNode} activeNode + * @property {FancytreeNode} focusNode + * @property {jQueryObject} $div + * @property {object} widget + * @property {object} ext + * @property {object} data + * @property {object} options + * @property {string} _id + * @property {string} statusClassPropName + * @property {string} ariaPropName + * @property {string} nodeContainerAttrName + * @property {string} $container + * @property {FancytreeNode} lastSelectedNode + */ +function Fancytree(widget) { + this.widget = widget; + this.$div = widget.element; + this.options = widget.options; + if( this.options && $.isFunction(this.options.lazyload) ) { + if( ! $.isFunction(this.options.lazyLoad ) ) { + this.options.lazyLoad = function() { + FT.warn("The 'lazyload' event is deprecated since 2014-02-25. Use 'lazyLoad' (with uppercase L) instead."); + widget.options.lazyload.apply(this, arguments); + }; + } + } + if( this.options && $.isFunction(this.options.loaderror) ) { + $.error("The 'loaderror' event was renamed since 2014-07-03. Use 'loadError' (with uppercase E) instead."); + } + this.ext = {}; // Active extension instances + // allow to init tree.data.foo from
      + this.data = _getElementDataAsDict(this.$div); + this._id = $.ui.fancytree._nextId++; + this._ns = ".fancytree-" + this._id; // append for namespaced events + this.activeNode = null; + this.focusNode = null; + this._hasFocus = null; + this.lastSelectedNode = null; + this.systemFocusElement = null; + + this.statusClassPropName = "span"; + this.ariaPropName = "li"; + this.nodeContainerAttrName = "li"; + + // Remove previous markup if any + this.$div.find(">ul.fancytree-container").remove(); + + // Create a node without parent. + var fakeParent = { tree: this }, + $ul; + this.rootNode = new FancytreeNode(fakeParent, { + title: "root", + key: "root_" + this._id, + children: null, + expanded: true + }); + this.rootNode.parent = null; + + // Create root markup + $ul = $("
        ", { + "class": "ui-fancytree fancytree-container" + }).appendTo(this.$div); + this.$container = $ul; + this.rootNode.ul = $ul[0]; + + if(this.options.debugLevel == null){ + this.options.debugLevel = FT.debugLevel; + } + // Add container to the TAB chain + // See http://www.w3.org/TR/wai-aria-practices/#focus_activedescendant + this.$container.attr("tabindex", this.options.tabbable ? "0" : "-1"); + if(this.options.aria){ + this.$container + .attr("role", "tree") + .attr("aria-multiselectable", true); + } +} + + +Fancytree.prototype = /** @lends Fancytree# */{ + /* Return a context object that can be re-used for _callHook(). + * @param {Fancytree | FancytreeNode | EventData} obj + * @param {Event} originalEvent + * @param {Object} extra + * @returns {EventData} + */ + _makeHookContext: function(obj, originalEvent, extra) { + var ctx, tree; + if(obj.node !== undefined){ + // obj is already a context object + if(originalEvent && obj.originalEvent !== originalEvent){ + $.error("invalid args"); + } + ctx = obj; + }else if(obj.tree){ + // obj is a FancytreeNode + tree = obj.tree; + ctx = { node: obj, tree: tree, widget: tree.widget, options: tree.widget.options, originalEvent: originalEvent }; + }else if(obj.widget){ + // obj is a Fancytree + ctx = { node: null, tree: obj, widget: obj.widget, options: obj.widget.options, originalEvent: originalEvent }; + }else{ + $.error("invalid args"); + } + if(extra){ + $.extend(ctx, extra); + } + return ctx; + }, + /* Trigger a hook function: funcName(ctx, [...]). + * + * @param {string} funcName + * @param {Fancytree|FancytreeNode|EventData} contextObject + * @param {any} [_extraArgs] optional additional arguments + * @returns {any} + */ + _callHook: function(funcName, contextObject, _extraArgs) { + var ctx = this._makeHookContext(contextObject), + fn = this[funcName], + args = Array.prototype.slice.call(arguments, 2); + if(!$.isFunction(fn)){ + $.error("_callHook('" + funcName + "') is not a function"); + } + args.unshift(ctx); +// this.debug("_hook", funcName, ctx.node && ctx.node.toString() || ctx.tree.toString(), args); + return fn.apply(this, args); + }, + /* Check if current extensions dependencies are met and throw an error if not. + * + * This method may be called inside the `treeInit` hook for custom extensions. + * + * @param {string} extension name of the required extension + * @param {boolean} [required=true] pass `false` if the extension is optional, but we want to check for order if it is present + * @param {boolean} [before] `true` if `name` must be included before this, `false` otherwise (use `null` if order doesn't matter) + * @param {string} [message] optional error message (defaults to a descriptve error message) + */ + _requireExtension: function(name, required, before, message) { + before = !!before; + var thisName = this._local.name, + extList = this.options.extensions, + isBefore = $.inArray(name, extList) < $.inArray(thisName, extList), + isMissing = required && this.ext[name] == null, + badOrder = !isMissing && before != null && (before !== isBefore); + + _assert(thisName && thisName !== name); + + if( isMissing || badOrder ){ + if( !message ){ + if( isMissing || required ){ + message = "'" + thisName + "' extension requires '" + name + "'"; + if( badOrder ){ + message += " to be registered " + (before ? "before" : "after") + " itself"; + } + }else{ + message = "If used together, `" + name + "` must be registered " + (before ? "before" : "after") + " `" + thisName + "`"; + } + } + $.error(message); + return false; + } + return true; + }, + /** Activate node with a given key and fire focus and activate events. + * + * A prevously activated node will be deactivated. + * If activeVisible option is set, all parents will be expanded as necessary. + * Pass key = false, to deactivate the current node only. + * @param {string} key + * @returns {FancytreeNode} activated node (null, if not found) + */ + activateKey: function(key) { + var node = this.getNodeByKey(key); + if(node){ + node.setActive(); + }else if(this.activeNode){ + this.activeNode.setActive(false); + } + return node; + }, + /** (experimental) + * + * @param {Array} patchList array of [key, NodePatch] arrays + * @returns {$.Promise} resolved, when all patches have been applied + * @see TreePatch + */ + applyPatch: function(patchList) { + var dfd, i, p2, key, patch, node, + patchCount = patchList.length, + deferredList = []; + + for(i=0; i= 2 (prepending tree name) + * + * @param {*} msg string or object or array of such + */ + debug: function(msg){ + if( this.options.debugLevel >= 2 ) { + Array.prototype.unshift.call(arguments, this.toString()); + consoleApply("debug", arguments); + } + }, + // TODO: disable() + // TODO: enable() + // TODO: enableUpdate() + // TODO: fromDict + /** + * Generate INPUT elements that can be submitted with html forms. + * + * In selectMode 3 only the topmost selected nodes are considered. + * + * @param {boolean | string} [selected=true] + * @param {boolean | string} [active=true] + */ + generateFormElements: function(selected, active) { + // TODO: test case + var nodeList, + selectedName = (selected !== false) ? "ft_" + this._id + "[]" : selected, + activeName = (active !== false) ? "ft_" + this._id + "_active" : active, + id = "fancytree_result_" + this._id, + $result = $("#" + id); + + if($result.length){ + $result.empty(); + }else{ + $result = $("
        ", { + id: id + }).hide().insertAfter(this.$container); + } + if(selectedName){ + nodeList = this.getSelectedNodes( this.options.selectMode === 3 ); + $.each(nodeList, function(idx, node){ + $result.append($("", { + type: "checkbox", + name: selectedName, + value: node.key, + checked: true + })); + }); + } + if(activeName && this.activeNode){ + $result.append($("", { + type: "radio", + name: activeName, + value: this.activeNode.key, + checked: true + })); + } + }, + /** + * Return the currently active node or null. + * @returns {FancytreeNode} + */ + getActiveNode: function() { + return this.activeNode; + }, + /** Return the first top level node if any (not the invisible root node). + * @returns {FancytreeNode | null} + */ + getFirstChild: function() { + return this.rootNode.getFirstChild(); + }, + /** + * Return node that has keyboard focus. + * @param {boolean} [ifTreeHasFocus=false] (not yet implemented) + * @returns {FancytreeNode} + */ + getFocusNode: function(ifTreeHasFocus) { + // TODO: implement ifTreeHasFocus + return this.focusNode; + }, + /** + * Return node with a given key or null if not found. + * @param {string} key + * @param {FancytreeNode} [searchRoot] only search below this node + * @returns {FancytreeNode | null} + */ + getNodeByKey: function(key, searchRoot) { + // Search the DOM by element ID (assuming this is faster than traversing all nodes). + // $("#...") has problems, if the key contains '.', so we use getElementById() + var el, match; + if(!searchRoot){ + el = document.getElementById(this.options.idPrefix + key); + if( el ){ + return el.ftnode ? el.ftnode : null; + } + } + // Not found in the DOM, but still may be in an unrendered part of tree + // TODO: optimize with specialized loop + // TODO: consider keyMap? + searchRoot = searchRoot || this.rootNode; + match = null; + searchRoot.visit(function(node){ +// window.console.log("getNodeByKey(" + key + "): ", node.key); + if(node.key === key) { + match = node; + return false; + } + }, true); + return match; + }, + /** Return the invisible system root node. + * @returns {FancytreeNode} + */ + getRootNode: function() { + return this.rootNode; + }, + /** + * Return an array of selected nodes. + * @param {boolean} [stopOnParents=false] only return the topmost selected + * node (useful with selectMode 3) + * @returns {FancytreeNode[]} + */ + getSelectedNodes: function(stopOnParents) { + var nodeList = []; + this.rootNode.visit(function(node){ + if( node.selected ) { + nodeList.push(node); + if( stopOnParents === true ){ + return "skip"; // stop processing this branch + } + } + }); + return nodeList; + }, + /** Return true if the tree control has keyboard focus + * @returns {boolean} + */ + hasFocus: function(){ + return !!this._hasFocus; + }, + /** Write to browser console if debugLevel >= 1 (prepending tree name) + * @param {*} msg string or object or array of such + */ + info: function(msg){ + if( this.options.debugLevel >= 1 ) { + Array.prototype.unshift.call(arguments, this.toString()); + consoleApply("info", arguments); + } + }, +/* + TODO: isInitializing: function() { + return ( this.phase=="init" || this.phase=="postInit" ); + }, + TODO: isReloading: function() { + return ( this.phase=="init" || this.phase=="postInit" ) && this.options.persist && this.persistence.cookiesFound; + }, + TODO: isUserEvent: function() { + return ( this.phase=="userEvent" ); + }, +*/ + + /** + * Make sure that a node with a given ID is loaded, by traversing - and + * loading - its parents. This method is ment for lazy hierarchies. + * A callback is executed for every node as we go. + * @example + * tree.loadKeyPath("/_3/_23/_26/_27", function(node, status){ + * if(status === "loaded") { + * console.log("loaded intermiediate node " + node); + * }else if(status === "ok") { + * node.activate(); + * } + * }); + * + * @param {string | string[]} keyPathList one or more key paths (e.g. '/3/2_1/7') + * @param {function} callback callback(node, status) is called for every visited node ('loading', 'loaded', 'ok', 'error') + * @returns {$.Promise} + */ + loadKeyPath: function(keyPathList, callback, _rootNode) { + var deferredList, dfd, i, path, key, loadMap, node, segList, + root = _rootNode || this.rootNode, + sep = this.options.keyPathSeparator, + self = this; + + if(!$.isArray(keyPathList)){ + keyPathList = [keyPathList]; + } + // Pass 1: handle all path segments for nodes that are already loaded + // Collect distinct top-most lazy nodes in a map + loadMap = {}; + + for(i=0; i