UNPKG

@qooxdoo/framework

Version:

The JS Framework for Coders

1,323 lines (1,093 loc) 32.9 kB
/* ************************************************************************ qooxdoo - the new era of web development http://qooxdoo.org Copyright: 2004-2011 1&1 Internet AG, Germany, http://www.1und1.de License: MIT: https://opensource.org/licenses/MIT See the LICENSE file in the project's top-level directory for details. Authors: * Christian Hagendorn (chris_schmidt) ************************************************************************ */ /* * Virtual tree implementation. * * The virtual tree can be used to render node and leafs. Nodes and leafs are * both items for a tree. The difference between a node and a leaf is that a * node has child items, but a leaf not. * * With the {@link qx.ui.tree.core.IVirtualTreeDelegate} interface it is possible * to configure the tree's behavior (item renderer configuration, etc.). * * Here's an example of how to use the widget, including using a model * property to open/close branches. See the two timers at the end. The first * one opens all branches after two seconds; the second cleans up the tree * after five seconds. * * <pre class="javascript"> * var nodes = * [ * { * name : "Root", * open : false, * children : * [ * { * name : "Branch 1", * open : false, * children : * [ * { * name : "Leaf 1.1" * }, * { * name : "Leaf 1.2" * }, * { * name : "Branch 1.3", * open : false, * children : * [ * { * name : "Branch 1.3.1", * open : false, * children : * [ * { * name : "Leaf 1.3.1.1" * } * ] * } * ] * } * ] * } * ] * } * ]; * * // convert the raw nodes to qooxdoo objects * nodes = qx.data.marshal.Json.createModel(nodes, true); * * // create the tree and synchronize the model property 'open' * // to nodes being open * var tree = * new qx.ui.tree.VirtualTree( * nodes.getItem(0), "name", "children", "open").set({ * width : 200, * height : 400 * }); * * //log selection changes * tree.getSelection().addListener("change", function(e) { * this.debug("Selection: " + tree.getSelection().getItem(0).getName()); * }, this); * * tree.set( * { * width : 200, * height : 400, * showTopLevelOpenCloseIcons : true * }); * * var doc = this.getRoot(); * doc.add(tree, * { * left : 100, * top : 50 * }); * * // After two seconds, open up all branches by setting their open * // property to true. * qx.event.Timer.once( * function() * { * ;(function allOpen(root) * { * if (root.setOpen) root.setOpen(true); * if (root.getChildren) root.getChildren().forEach(allOpen); * })(nodes.getItem(0)); * }, * this, * 2000); * * // After five seconds, remove and dispose the tree. * qx.event.Timer.once( * function() * { * doc.remove(tree); * tree.dispose(); * console.warn("All cleaned up."); * }, * this, * 5000); * </pre> */ qx.Class.define("qx.ui.tree.VirtualTree", { extend : qx.ui.virtual.core.Scroller, implement : [qx.ui.tree.core.IVirtualTree, qx.data.controller.ISelection], include : [ qx.ui.virtual.selection.MModel, qx.ui.core.MContentPadding ], /** * @param rootModel {qx.core.Object?null} The model structure representing * the root of the tree, for more details have a look at the 'model' * property. * @param labelPath {String?null} The name of the label property, for more * details have a look at the 'labelPath' property. * @param childProperty {String?null} The name of the child property, for * more details have a look at the 'childProperty' property. * @param openProperty {String|null} the name of the model property which * represents the open state of a branch. If this value is provided, so, * too, must be rootModel. */ construct : function( rootModel, labelPath, childProperty, openProperty) { this.base(arguments, 0, 1, 20, 100); this._init(); if (labelPath != null) { this.setLabelPath(labelPath); } if (childProperty != null) { this.setChildProperty(childProperty); } if(rootModel != null) { this.initModel(rootModel); } this.initItemHeight(); this.initOpenMode(); this.addListener("keypress", this._onKeyPress, this); // If an open property and root model are provided, start up the open-close controller. if (openProperty && rootModel) { this.openViaModelChanges(openProperty); } }, events : { /** * Fired when a node is opened. */ open : "qx.event.type.Data", /** * Fired when a node is closed. */ close : "qx.event.type.Data" }, properties : { // overridden appearance : { refine: true, init: "virtual-tree" }, // overridden focusable : { refine: true, init: true }, // overridden width : { refine : true, init : 100 }, // overridden height : { refine : true, init : 200 }, /** Default item height. */ itemHeight : { check : "Integer", init : 25, apply : "_applyRowHeight", themeable : true }, /** * Control whether tap or double tap should open or close the tapped * item. */ openMode : { check: ["tap", "dbltap", "none"], init: "dbltap", apply: "_applyOpenMode", event: "changeOpenMode", themeable: true }, /** * Hides *only* the root node, not the node's children when the property is * set to <code>true</code>. */ hideRoot : { check: "Boolean", init: false, apply:"_applyHideRoot" }, /** * Whether top level items should have an open/close button. The top level * item item is normally the root item, but when the root is hidden, the * root children are the top level items. */ showTopLevelOpenCloseIcons : { check : "Boolean", init : false, apply : "_applyShowTopLevelOpenCloseIcons" }, /** * Configures the tree to show also the leafs. When the property is set to * <code>false</code> *only* the nodes are shown. */ showLeafs : { check: "Boolean", init: true, apply: "_applyShowLeafs" }, /** * The name of the property, where the children are stored in the model. * Instead of the {@link #labelPath} must the child property a direct * property form the model instance. */ childProperty : { check: "String", apply: "_applyChildProperty", nullable: true }, /** * The name of the property, where the value for the tree folders label * is stored in the model classes. */ labelPath : { check: "String", apply: "_applyLabelPath", nullable: true }, /** * The path to the property which holds the information that should be * shown as an icon. */ iconPath : { check: "String", apply: "_applyIconPath", nullable: true }, /** * A map containing the options for the label binding. The possible keys * can be found in the {@link qx.data.SingleValueBinding} documentation. */ labelOptions : { apply: "_applyLabelOptions", nullable: true }, /** * A map containing the options for the icon binding. The possible keys * can be found in the {@link qx.data.SingleValueBinding} documentation. */ iconOptions : { apply: "_applyIconOptions", nullable: true }, /** * The model containing the data (nodes and/or leafs) which should be shown * in the tree. */ model : { check : "qx.core.Object", apply : "_applyModel", event: "changeModel", nullable : true, deferredInit : true }, /** * Delegation object, which can have one or more functions defined by the * {@link qx.ui.tree.core.IVirtualTreeDelegate} interface. */ delegate : { event: "changeDelegate", apply: "_applyDelegate", init: null, nullable: true } }, members : { /** @type {qx.ui.tree.provider.WidgetProvider} Provider for widget rendering. */ _provider : null, /** @type {qx.ui.virtual.layer.Abstract} Layer which contains the items. */ _layer : null, /** * @type {qx.data.Array} The internal lookup table data structure to get the model item * from a row. */ __lookupTable : null, /** @type {Array} HashMap which contains all open nodes. */ __openNodes : null, /** * @type {Array} The internal data structure to get the nesting level from a * row. */ __nestingLevel : null, /** * @type {qx.util.DeferredCall} Adds this instance to the widget queue on a * deferred call. */ __deferredCall : null, /** @type {Integer} Holds the max item width from a rendered widget. */ _itemWidth : 0, /** @type {Array} internal parent chain form the last selected node */ __parentChain : null, /** * @type {String|null} the name of the model property which represents the * open state of a branch. */ __openProperty : null, /* --------------------------------------------------------------------------- PUBLIC API --------------------------------------------------------------------------- */ // overridden syncWidget : function(jobs) { var firstRow = this._layer.getFirstRow(); var rowSize = this._layer.getRowSizes().length; for (var row = firstRow; row < firstRow + rowSize; row++) { var widget = this._layer.getRenderedCellWidget(row, 0); if (widget != null) { this._itemWidth = Math.max(this._itemWidth, widget.getSizeHint().width); } } var paneWidth = this.getPane().getInnerSize().width; this.getPane().getColumnConfig().setItemSize(0, Math.max(this._itemWidth, paneWidth)); }, // Interface implementation openNode : function(node) { this.__openNode(node); this.buildLookupTable(); }, // Interface implementation openNodeWithoutScrolling : function(node) { var autoscroll = this.getAutoScrollIntoView(); // suspend automatically scrolling selection into view this.setAutoScrollIntoView(false); this.openNode(node); // re set to original value this.setAutoScrollIntoView(autoscroll); }, /** * Trigger a rebuild from the internal data structure. */ refresh : function() { this.buildLookupTable(); }, /** * Opens the passed node and all his parents. *Note!* The algorithm * implements a depth-first search with a complexity: <code>O(n)</code> and * <code>n</code> are all model items. * * @param node {qx.core.Object} Node to open. */ openNodeAndParents : function(node) { this.__openNodeAndAllParents(this.getModel(), node); this.buildLookupTable(); }, // Interface implementation closeNode : function(node) { if (this.__openNodes.includes(node)) { qx.lang.Array.remove(this.__openNodes, node); this.fireDataEvent("close", node); this.buildLookupTable(); } }, // Interface implementation closeNodeWithoutScrolling : function(node) { var autoscroll = this.getAutoScrollIntoView(); // suspend automatically scrolling selection into view this.setAutoScrollIntoView(false); this.closeNode(node); // re set to original value this.setAutoScrollIntoView(autoscroll); }, // Interface implementation isNodeOpen : function(node) { return this.__openNodes.includes(node); }, /** * Open and close branches via changes to a property in the model. * * @param openProperty {String|null} * The name of the open property, which determines the open state of a * branch in the tree. If null, turn off opening and closing branches * via changes to the model. */ openViaModelChanges : function(openProperty) { // Save the open property this.__openProperty = openProperty; // if no name is provided, just remove any prior open-close controller if (! openProperty) { if (this._openCloseController) { this._openCloseController.dispose(); this._openCloseController = null; } return; } // we have a property name, so create controller this._openCloseController = new qx.ui.tree.core.OpenCloseController(this, this.getModel(), openProperty); }, /** * Getter for the open property */ getOpenProperty : function() { return this.__openProperty; }, /* --------------------------------------------------------------------------- INTERNAL API --------------------------------------------------------------------------- */ /** * Initializes the virtual tree. */ _init : function() { this.__lookupTable = new qx.data.Array(); this.__openNodes = []; this.__nestingLevel = []; this._initLayer(); }, /** * Initializes the virtual tree layer. */ _initLayer : function() { this._provider = new qx.ui.tree.provider.WidgetProvider(this); this._layer = this._provider.createLayer(); this._layer.addListener("updated", this._onUpdated, this); this.getPane().addLayer(this._layer); this.getPane().addListenerOnce("resize", function(e) { // apply width to pane on first rendering pass // to avoid visible flickering this.getPane().getColumnConfig().setItemSize(0, e.getData().width); }, this); }, // Interface implementation getLookupTable : function() { return this.__lookupTable; }, // Interface implementation isShowTopLevelOpenCloseIcons : function() { return true; }, /** * Performs a lookup from model index to row. * * @param index {Number} The index to look at. * @return {Number} The row or <code>-1</code> * if the index is not a model index. */ _reverseLookup : function(index) { return index; }, /** * Returns the model data for the given row. * * @param row {Integer} row to get data for. * @return {var|null} the row's model data. */ _getDataFromRow : function(row) { return this.__lookupTable.getItem(row); }, /** * Returns the selectable model items. * * @return {qx.data.Array} The selectable items. */ _getSelectables : function() { return this.__lookupTable; }, /** * Returns all open nodes. * * @internal * @return {Array} All open nodes. */ getOpenNodes : function() { return this.__openNodes; }, // Interface implementation isNode : function(item) { return qx.ui.tree.core.Util.isNode(item, this.getChildProperty()); }, // Interface implementation getLevel : function(row) { return this.__nestingLevel[row]; }, // Interface implementation hasChildren : function(node) { return qx.ui.tree.core.Util.hasChildren(node, this.getChildProperty(), !this.isShowLeafs()); }, /** * Returns the element, to which the content padding should be applied. * * @return {qx.ui.core.Widget} The content padding target. */ _getContentPaddingTarget : function() { return this.getPane(); }, /* --------------------------------------------------------------------------- PROPERTY APPLY METHODS --------------------------------------------------------------------------- */ // property apply _applyRowHeight : function(value, old) { this.getPane().getRowConfig().setDefaultItemSize(value); }, // property apply _applyOpenMode : function(value, old) { var pane = this.getPane(); //"tap", "dbltap", "none" if (value === "dbltap") { pane.addListener("cellDbltap", this._onOpen, this); } else if (value === "tap") { pane.addListener("cellTap", this._onOpen, this); } if (old === "dbltap") { pane.removeListener("cellDbltap", this._onOpen, this); } else if (old === "tap") { pane.removeListener("cellTap", this._onOpen, this); } }, // property apply _applyHideRoot : function(value, old) { this.buildLookupTable(); }, // property apply _applyShowTopLevelOpenCloseIcons : function(value, old) { // force rebuild of the lookup table // fixes https://github.com/qooxdoo/qooxdoo/issues/9128 this.getLookupTable().removeAll(); this.buildLookupTable(); }, // property apply _applyShowLeafs : function(value, old) { // force rebuild of the lookup table // fixes https://github.com/qooxdoo/qooxdoo/issues/9128 this.getLookupTable().removeAll(); this.buildLookupTable(); }, // property apply _applyChildProperty : function(value, old) { this._provider.setChildProperty(value); }, // property apply _applyLabelPath : function(value, old) { this._provider.setLabelPath(value); }, // property apply _applyIconPath : function(value, old) { this._provider.setIconPath(value); }, // property apply _applyLabelOptions : function(value, old) { this._provider.setLabelOptions(value); }, // property apply _applyIconOptions : function(value, old) { this._provider.setIconOptions(value); }, // property apply _applyModel : function(value, old) { this.__openNodes = []; if (value != null) { if (qx.core.Environment.get("qx.debug")) { if (!qx.Class.hasMixin(value.constructor, qx.data.marshal.MEventBubbling)) { this.warn("The model item doesn't support the Mixin 'qx.data." + "marshal.MEventBubbling'. Therefore the tree can not update " + "the view automatically on model changes."); } } value.addListener("changeBubble", this._onChangeBubble, this); this.__openNode(value); } // If the model changes, an existing OpenCloseController is no longer // valid, so dispose it. The user should call openViaModelChanges again. if (this._openCloseController) { this._openCloseController.dispose(); this._openCloseController = null; } if (old != null) { old.removeListener("changeBubble", this._onChangeBubble, this); } this.__applyModelChanges(); }, // property apply _applyDelegate : function(value, old) { this._provider.setDelegate(value); this.buildLookupTable(); }, /* --------------------------------------------------------------------------- EVENT HANDLERS --------------------------------------------------------------------------- */ /** * Event handler for the changeBubble event. The handler rebuild the lookup * table when the child structure changed. * * @param event {qx.event.type.Data} The data event. */ _onChangeBubble : function(event) { var data = event.getData(); var propertyName = data.name; var index = propertyName.lastIndexOf("."); if (index != -1) { propertyName = propertyName.substr(index + 1, propertyName.length); } // only continue when the effected property is the child property if ( propertyName.startsWith(this.getChildProperty()) ) { var item = data.item; if (qx.Class.isSubClassOf(item.constructor, qx.data.Array)) { if (index === -1) { item = this.getModel(); } else { var propertyChain = data.name.substr(0, index); item = qx.data.SingleValueBinding.resolvePropertyChain(this.getModel(), propertyChain); } } if (this.__lookupTable.indexOf(item) != -1) { this.__applyModelChanges(); } } }, /** * Event handler for the update event. * * @param event {qx.event.type.Event} The event. */ _onUpdated : function(event) { if (this.__deferredCall == null) { this.__deferredCall = new qx.util.DeferredCall(function() { qx.ui.core.queue.Widget.add(this); }, this); } this.__deferredCall.schedule(); }, /** * Event handler to open/close tapped nodes. * * @param event {qx.ui.virtual.core.CellEvent} The cell tap event. */ _onOpen : function(event) { var row = event.getRow(); var item = this.__lookupTable.getItem(row); if (this.isNode(item)) { if (this.isNodeOpen(item)) { this.closeNode(item); } else { this.openNode(item); } } }, /** * Event handler for key press events. Open and close the current selected * item on key left and right press. Jump to parent on key left if already * closed. * * @param e {qx.event.type.KeySequence} key event. */ _onKeyPress : function(e) { var selection = this.getSelection(); if (selection.getLength() > 0) { var item = selection.getItem(0); var isNode = this.isNode(item); switch(e.getKeyIdentifier()) { case "Left": if (isNode && this.isNodeOpen(item)) { this.closeNode(item); } else { var parent = this.getParent(item); if (parent != null) { selection.splice(0, 1, parent); } } break; case "Right": if (isNode && !this.isNodeOpen(item)) { this.openNode(item); } else { if (isNode) { var children = item.get(this.getChildProperty()); if (children != null && children.getLength() > 0) { selection.splice(0, 1, children.getItem(0)); } } } break; case "Enter": case "Space": if (!isNode) { return; } if (this.isNodeOpen(item)) { this.closeNode(item); } else { this.openNode(item); } break; } } }, /* --------------------------------------------------------------------------- SELECTION HOOK METHODS --------------------------------------------------------------------------- */ /** * Hook method which is called from the {@link qx.ui.virtual.selection.MModel}. * The hook method sets the first visible parent not as new selection when * the current selection is empty and the selection mode is one selection. * * @param newSelection {Array} The newSelection which will be set to the selection manager. */ _beforeApplySelection : function(newSelection) { if (newSelection.length === 0 && this.getSelectionMode() === "one") { var visibleParent = this.__getVisibleParent(); var row = this.getLookupTable().indexOf(visibleParent); if (row >= 0) { newSelection.push(row); } } }, /** * Hook method which is called from the {@link qx.ui.virtual.selection.MModel}. * The hook method builds the parent chain form the current selected item. */ _afterApplySelection : function() { var selection = this.getSelection(); if (selection.getLength() > 0 && this.getSelectionMode() === "one") { this.__buildParentChain(selection.getItem(0)); } else { this.__parentChain = []; } }, /* --------------------------------------------------------------------------- HELPER METHODS --------------------------------------------------------------------------- */ /** * Helper method to apply model changes. Normally build the lookup table and * apply the default selection. */ __applyModelChanges : function() { this.buildLookupTable(); this._applyDefaultSelection(); }, /** * Helper method to build the internal data structure. * * @internal */ buildLookupTable : function() { if ( this.getModel() != null && (this.getChildProperty() == null || this.getLabelPath() == null) ) { throw new Error("Could not build tree, because 'childProperty' and/" + "or 'labelPath' is 'null'!"); } this._itemWidth = 0; var lookupTable = []; this.__nestingLevel = []; var nestedLevel = -1; var root = this.getModel(); if (root != null) { if (!this.isHideRoot()) { nestedLevel++; lookupTable.push(root); this.__nestingLevel.push(nestedLevel); } if (this.isNodeOpen(root)) { var visibleChildren = this.__getVisibleChildrenFrom(root, nestedLevel); lookupTable = lookupTable.concat(visibleChildren); } } if (!qx.lang.Array.equals(this.__lookupTable.toArray(), lookupTable)) { this._provider.removeBindings(); this.__lookupTable.removeAll(); this.__lookupTable.append(lookupTable); this.__updateRowCount(); this._updateSelection(); } }, /** * Helper method to get all visible children form the passed parent node. * The algorithm implements a depth-first search with a complexity: * <code>O(n)</code> and <code>n</code> are all visible items. * * @param node {qx.core.Object} The start node to start search. * @param nestedLevel {Integer} The nested level from the start node. * @return {Array} All visible children form the parent. */ __getVisibleChildrenFrom : function(node, nestedLevel) { var visible = []; nestedLevel++; if (!this.isNode(node)) { return visible; } var children = node.get(this.getChildProperty()); if (children == null) { return visible; } // clone children to keep original model unmodified children = children.copy(); var delegate = this.getDelegate(); var filter = qx.util.Delegate.getMethod(delegate, "filter"); var sorter = qx.util.Delegate.getMethod(delegate, "sorter"); if (sorter != null) { children.sort(sorter); } for (var i = 0; i < children.getLength(); i++) { var child = children.getItem(i); if (filter && !filter(child)) { continue; } if (this.isNode(child)) { this.__nestingLevel.push(nestedLevel); visible.push(child); if (this.isNodeOpen(child)) { var visibleChildren = this.__getVisibleChildrenFrom(child, nestedLevel); visible = visible.concat(visibleChildren); } } else { if (this.isShowLeafs()) { this.__nestingLevel.push(nestedLevel); visible.push(child); } } } // dispose children clone children.dispose(); return visible; }, /** * Helper method to set the node to the open nodes data structure when it * is not included. * * @param node {qx.core.Object} Node to set to open nodes. */ __openNode : function(node) { if (!this.__openNodes.includes(node)) { this.__openNodes.push(node); this.fireDataEvent("open", node); } }, /** * Helper method to set the target node and all his parents to the open * nodes data structure. The algorithm implements a depth-first search with * a complexity: <code>O(n)</code> and <code>n</code> are all model items. * * @param startNode {qx.core.Object} Start (root) node to search. * @param targetNode {qx.core.Object} Target node to open (and his parents). * @return {Boolean} <code>True</code> when the targetNode and his * parents could opened, <code>false</code> otherwise. */ __openNodeAndAllParents : function(startNode, targetNode) { if (startNode === targetNode) { this.__openNode(targetNode); return true; } if (!this.isNode(startNode)) { return false; } var children = startNode.get(this.getChildProperty()); if (children == null) { return false; } for (var i = 0; i < children.getLength(); i++) { var child = children.getItem(i); var result = this.__openNodeAndAllParents(child, targetNode); if (result === true) { this.__openNode(child); return true; } } return false; }, /** * Helper method to update the row count. */ __updateRowCount : function() { this.getPane().getRowConfig().setItemCount(this.__lookupTable.getLength()); this.getPane().fullUpdate(); }, /** * Helper method to get the parent node. Node! This only works with leaf and * nodes which are in the internal lookup table. * * @param item {qx.core.Object} Node or leaf to get parent. * @return {qx.core.Object|null} The parent note or <code>null</code> when * no parent found. * * @internal */ getParent : function(item) { var index = this.__lookupTable.indexOf(item); if (index < 0) { return null; } var level = this.__nestingLevel[index]; while(index > 0) { index--; var levelBefore = this.__nestingLevel[index]; if (levelBefore < level) { return this.__lookupTable.getItem(index); } } return null; }, /** * Builds the parent chain form the passed item. * * @param item {var} Item to build parent chain. */ __buildParentChain : function(item) { this.__parentChain = []; var parent = this.getParent(item); while(parent != null) { this.__parentChain.unshift(parent); parent = this.getParent(parent); } }, /** * Return the first visible parent node from the last selected node. * * @return {var} The first visible node. */ __getVisibleParent : function() { if (this.__parentChain == null) { return this.getModel(); } var lookupTable = this.getLookupTable(); var parent = this.__parentChain.pop(); while(parent != null) { if (lookupTable.contains(parent)) { return parent; } parent = this.__parentChain.pop(); } return this.getModel(); } }, destruct : function() { if (this._openCloseController) { this._openCloseController.dispose(); } var pane = this.getPane(); if (pane != null) { if (pane.hasListener("cellDbltap")) { pane.removeListener("cellDbltap", this._onOpen, this); } if (pane.hasListener("cellTap")) { pane.removeListener("cellTap", this._onOpen, this); } } if (!qx.core.ObjectRegistry.inShutDown && this.__deferredCall != null) { this.__deferredCall.cancel(); this.__deferredCall.dispose(); } var model = this.getModel(); if (model != null) { model.removeListener("changeBubble", this._onChangeBubble, this); } this._layer.removeListener("updated", this._onUpdated, this); this._layer.destroy(); this._provider.dispose(); this.__lookupTable.dispose(); this._layer = this._provider = this.__lookupTable = this.__openNodes = this.__deferredCall = null; } });