// Copyright 2012 Google Inc. All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.google.collide.client.ui.tree; import com.google.collide.client.util.AnimationController; import com.google.collide.client.util.CssUtils; import com.google.collide.client.util.Elements; import com.google.collide.client.util.dom.MouseGestureListener; import com.google.collide.json.shared.JsonArray; import com.google.collide.mvp.CompositeView; import com.google.collide.mvp.UiComponent; import com.google.collide.shared.util.JsonCollections; import com.google.gwt.core.client.Duration; import com.google.gwt.core.client.Scheduler; import com.google.gwt.core.client.Scheduler.ScheduledCommand; import com.google.gwt.resources.client.ClientBundle; import com.google.gwt.resources.client.CssResource; import com.google.gwt.resources.client.ImageResource; import com.google.gwt.user.client.Timer; import org.waveprotocol.wave.client.common.util.SignalEvent; import org.waveprotocol.wave.client.common.util.SignalEventImpl; import elemental.events.Event; import elemental.events.EventListener; import elemental.events.MouseEvent; import elemental.html.DragEvent; import elemental.html.Element; import elemental.js.html.JsElement; /** * A tree widget that is capable of rendering any tree data structure whose node * data type is specified in the class parameterization. * * Users of this widget must specify an appropriate * {@link com.google.collide.client.ui.tree.NodeDataAdapter} and * {@link com.google.collide.client.ui.tree.NodeRenderer}. * * The DOM structure for a tree is a recursive structure of the following form * (note that class names will be obfuscated at runtime, and are just specified * in human readable form for documentation purposes): * * <pre> * * <ul class="treeRoot childrenContainer"> * <li class="treeNode"> * <div class="treeNodeBody"> * <div class="expandControl"></div> * <span class="treeNodeContents"></span> * </div> * <ul class="childrenContainer"> * ... * ... * </ul> * </li> * </ul> * * </pre> */ public class Tree<D> extends UiComponent<Tree.View<D>> { /** * Static factory method for obtaining an instance of the Tree. */ public static <NodeData> Tree<NodeData> create(View<NodeData> view, NodeDataAdapter<NodeData> dataAdapter, NodeRenderer<NodeData> nodeRenderer, Tree.Resources resources) { Model<NodeData> model = new Model<NodeData>(dataAdapter, nodeRenderer, resources); return new Tree<NodeData>(view, model); } /** * Css selectors applied to DOM elements in the tree. */ public interface Css extends CssResource { String active(); String childrenContainer(); String closedIcon(); String expandControl(); String isDropTarget(); String leafIcon(); String openedIcon(); String selected(); String treeNode(); String treeNodeBody(); String treeNodeLabel(); String treeRoot(); } /** * Listener interface for being notified about tree events. */ public interface Listener<D> { void onNodeAction(TreeNodeElement<D> node); void onNodeClosed(TreeNodeElement<D> node); void onNodeContextMenu(int mouseX, int mouseY, TreeNodeElement<D> node); void onNodeDragStart(TreeNodeElement<D> node, DragEvent event); void onNodeDragDrop(TreeNodeElement<D> node, DragEvent event); void onNodeExpanded(TreeNodeElement<D> node); void onRootContextMenu(int mouseX, int mouseY); void onRootDragDrop(DragEvent event); } /** * A visitor interface to visit nodes of the tree. */ public interface Visitor<D> { /** * @return whether to visit a given node. This is useful to prune a subtree * from being visited */ boolean shouldVisit(D node); /** * Called for nodes that pass the {@link #shouldVisit} check. * * @param node the node being iterated * @param willVisitChildren true if the given node has a child that will be * (or has been) visited */ void visit(D node, boolean willVisitChildren); } /** * Instance state for the Tree. */ public static class Model<D> { private final NodeDataAdapter<D> dataAdapter; private Listener<D> externalEventDelegate; private final NodeRenderer<D> nodeRenderer; private D root; private final SelectionModel<D> selectionModel; private final Resources resources; private final AnimationController animator; public Model( NodeDataAdapter<D> dataAdapter, NodeRenderer<D> nodeRenderer, Tree.Resources resources) { this.dataAdapter = dataAdapter; this.nodeRenderer = nodeRenderer; this.resources = resources; this.selectionModel = new SelectionModel<D>(dataAdapter, resources.treeCss()); this.animator = new AnimationController.Builder().setCollapse(true).setFade(true).build(); } public NodeDataAdapter<D> getDataAdapter() { return dataAdapter; } public NodeRenderer<D> getNodeRenderer() { return nodeRenderer; } public D getRoot() { return root; } public void setRoot(D root) { this.root = root; } } /** * Images and Css resources used by the Tree. * * In order to theme the Tree, you extend this interface and override * {@link Tree.Resources#treeCss()}. */ public interface Resources extends ClientBundle { @Source("expansionIcon.png") ImageResource expansionIcon(); // Default Stylesheet. @Source({"com/google/collide/client/common/constants.css", "Tree.css"}) Css treeCss(); } /** * The view for a Tree is simply a thin wrapper around a ULElement. */ public static class View<D> extends CompositeView<ViewEvents<D>> { /** * Base event listener for DOM events fired by elements in the view. */ private abstract class TreeNodeEventListener implements EventListener { private final boolean primaryMouseButtonOnly; /** The {@link Duration#currentTimeMillis()} of the most recent click, or 0 */ private double previousClickMs; private Element previousClickTreeNodeBody; TreeNodeEventListener(boolean primaryMouseButtonOnly) { this.primaryMouseButtonOnly = primaryMouseButtonOnly; } @Override public void handleEvent(Event evt) { // Don't even bother to do anything unless we have someone ready to // handle events. if (getDelegate() == null || (primaryMouseButtonOnly && ((MouseEvent) evt).getButton() != MouseEvent.Button.PRIMARY)) { return; } Element eventTarget = (Element) evt.getTarget(); if (CssUtils.containsClassName(eventTarget, css.expandControl())) { onExpansionControlEvent(evt, eventTarget); } else { Element treeNodeBody = CssUtils.getAncestorOrSelfWithClassName(eventTarget, css.treeNodeBody()); if (treeNodeBody != null) { if (Event.CLICK.equals(evt.getType())) { double currentClickMs = Duration.currentTimeMillis(); if (currentClickMs - previousClickMs < MouseGestureListener.MAX_CLICK_TIMEOUT_MS && treeNodeBody.equals(previousClickTreeNodeBody)) { // Swallow double, triple, etc. clicks on an item's label return; } else { this.previousClickMs = currentClickMs; this.previousClickTreeNodeBody = treeNodeBody; } } onTreeNodeBodyChildEvent(evt, treeNodeBody); } else { onOtherEvent(evt); } } } /** * Catch-all that is called if the target element was not matched to an * element rooted at the TreeNodeBody. */ protected void onOtherEvent(Event evt) { } /** * If an event was dispatched by the TreeNodeBody, or one of its children. * * IMPORTANT: However, if the event target is the expansion control, do * not call this method. */ protected void onTreeNodeBodyChildEvent(Event evt, Element treeNodeBody) { } /** * If an event was dispatched by the ExpansionControl. */ protected void onExpansionControlEvent(Event evt, Element expansionControl) { } } private final Tree.Css css; private final Tree.Resources resources; public View(Tree.Resources resources) { super(Elements.createElement("ul")); this.resources = resources; this.css = resources.treeCss(); getElement().setClassName(resources.treeCss().treeRoot()); attachEventListeners(); } void attachEventListeners() { // There used to be a MOUSEDOWN handler with stopPropagation() and // preventDefault() actions, but this badly affected the inline editing // experience inside the Tree (e.g. debugger's RemoteObjectTree). getElement().addEventListener(Event.CLICK, new TreeNodeEventListener(true) { @Override protected void onTreeNodeBodyChildEvent(Event evt, Element treeNodeBody) { SignalEvent signalEvent = SignalEventImpl.create((com.google.gwt.user.client.Event) evt, true); // Select the node. dispatchNodeSelectedEvent(treeNodeBody, signalEvent, css); // Don't dispatch a node action if there is a modifier key depressed. if (!(signalEvent.getCommandKey() || signalEvent.getShiftKey())) { dispatchNodeActionEvent(treeNodeBody, css); TreeNodeElement<D> node = getTreeNodeFromTreeNodeBody(treeNodeBody, css); if (node.hasChildrenContainer()) { dispatchExpansionEvent(node, css); } } } @Override protected void onExpansionControlEvent(Event evt, Element expansionControl) { if (!CssUtils.containsClassName(expansionControl, css.leafIcon())) { /* * they've clicked on the expand control of a tree node that is a * directory (so expand it) */ TreeNodeElement<D> treeNode = ((JsElement) expansionControl.getParentElement().getParentElement()).< TreeNodeElement<D>>cast(); dispatchExpansionEvent(treeNode, css); } } }, false); getElement().addEventListener(Event.CONTEXTMENU, new TreeNodeEventListener(false) { @Override public void handleEvent(Event evt) { super.handleEvent(evt); evt.stopPropagation(); evt.preventDefault(); } @Override protected void onOtherEvent(Event evt) { MouseEvent mouseEvt = (MouseEvent) evt; // This is a click on the root. dispatchOnRootContextMenuEvent(mouseEvt.getClientX(), mouseEvt.getClientY()); } @Override protected void onTreeNodeBodyChildEvent(Event evt, Element treeNodeBody) { MouseEvent mouseEvt = (MouseEvent) evt; // Dispatch if eventTarget is the treeNodeBody, or if it is a child // of a treeNodeBody. dispatchContextMenuEvent( mouseEvt.getClientX(), mouseEvt.getClientY(), treeNodeBody, css); } }, false); EventListener dragDropEventListener = new EventListener() { @Override public void handleEvent(Event event) { if (getDelegate() != null) { getDelegate().onDragDropEvent((DragEvent) event); } } }; getElement().addEventListener(Event.DROP, dragDropEventListener, false); getElement().addEventListener(Event.DRAGOVER, dragDropEventListener, false); getElement().addEventListener(Event.DRAGENTER, dragDropEventListener, false); getElement().addEventListener(Event.DRAGLEAVE, dragDropEventListener, false); getElement().addEventListener(Event.DRAGSTART, dragDropEventListener, false); } private void dispatchContextMenuEvent(int mouseX, int mouseY, Element treeNodeBody, Css css) { // We assume the click happened on a TreeNodeBody. We walk up one level // to grab the treeNode element. @SuppressWarnings("unchecked") TreeNodeElement<D> treeNode = (TreeNodeElement<D>) treeNodeBody.getParentElement(); assert (CssUtils.containsClassName( treeNode, css.treeNode())) : "Parent of an expandControl wasn't a TreeNode!"; getDelegate().onNodeContextMenu(mouseX, mouseY, treeNode); } @SuppressWarnings("unchecked") private void dispatchExpansionEvent(TreeNodeElement<D> treeNode, Css css) { // Is the node opened or closed? if (treeNode.isOpen()) { getDelegate().onNodeClosed(treeNode); } else { // We might have set the CSS to say it is closed, but the animation // takes a little while. As such, we check to make sure the children // container is set to display:none before trying to dispatch an open. // Otherwise we can get into an inconsistent state if we click really // fast. Element childrenContainer = treeNode.getChildrenContainer(); if (childrenContainer != null && !CssUtils.isVisible(childrenContainer)) { getDelegate().onNodeExpanded(treeNode); } } } private void dispatchNodeActionEvent(Element treeNodeBody, Css css) { getDelegate().onNodeAction(getTreeNodeFromTreeNodeBody(treeNodeBody, css)); } private void dispatchNodeSelectedEvent(Element treeNodeBody, SignalEvent evt, Css css) { getDelegate().onNodeSelected(getTreeNodeFromTreeNodeBody(treeNodeBody, css), evt); } private void dispatchOnRootContextMenuEvent(int mouseX, int mouseY) { getDelegate().onRootContextMenu(mouseX, mouseY); } private TreeNodeElement<D> getTreeNodeFromTreeNodeBody(Element treeNodeBody, Css css) { TreeNodeElement<D> treeNode = ((JsElement) treeNodeBody.getParentElement()).< TreeNodeElement<D>>cast(); assert (CssUtils.containsClassName(treeNode, css.treeNode())) : "Unexpected element when looking for tree node: " + treeNode.toString(); return treeNode; } } /** * Logical events sourced by the Tree's View. Note that these events get * dispatched synchronously in our DOM event handlers. */ private interface ViewEvents<D> { public void onNodeAction(TreeNodeElement<D> node); public void onNodeClosed(TreeNodeElement<D> node); public void onNodeContextMenu(int mouseX, int mouseY, TreeNodeElement<D> node); public void onDragDropEvent(DragEvent event); public void onNodeExpanded(TreeNodeElement<D> node); public void onNodeSelected(TreeNodeElement<D> node, SignalEvent event); public void onRootContextMenu(int mouseX, int mouseY); public void onRootDragDrop(DragEvent event); } private class DragDropController { private TreeNodeElement<D> targetNode; private boolean hadDragEnterEvent; private final ScheduledCommand hadDragEnterEventResetter = new ScheduledCommand() { @Override public void execute() { hadDragEnterEvent = false; } }; private final Timer hoverToExpandTimer = new Timer() { @Override public void run() { expandNode(targetNode, true, true); } }; void handleDragDropEvent(DragEvent evt) { final D rootData = getModel().root; final NodeDataAdapter<D> dataAdapter = getModel().getDataAdapter(); final Css css = getModel().resources.treeCss(); @SuppressWarnings("unchecked") TreeNodeElement<D> node = (TreeNodeElement<D>) CssUtils.getAncestorOrSelfWithClassName((Element) evt.getTarget(), css.treeNode()); D newTargetData = node != null ? dataAdapter.getDragDropTarget(node.getData()) : rootData; TreeNodeElement<D> newTargetNode = dataAdapter.getRenderedTreeNode(newTargetData); String type = evt.getType(); if (Event.DRAGSTART.equals(type)) { if (getModel().externalEventDelegate != null) { D sourceData = node != null ? node.getData() : rootData; // TODO support multiple folder selection. // We do not support dragging without any folder/file selection. if (sourceData != rootData) { TreeNodeElement<D> sourceNode = dataAdapter.getRenderedTreeNode(sourceData); getModel().externalEventDelegate.onNodeDragStart(sourceNode, evt); } } return; } if (Event.DROP.equals(type)) { if (getModel().externalEventDelegate != null) { if (newTargetData == rootData) { getModel().externalEventDelegate.onRootDragDrop(evt); } else { getModel().externalEventDelegate.onNodeDragDrop(newTargetNode, evt); } } clearDropTarget(); } else if (Event.DRAGOVER.equals(type)) { if (newTargetNode != targetNode) { clearDropTarget(); if (newTargetNode != null) { // Highlight the node by setting its drop target property targetNode = newTargetNode; targetNode.setIsDropTarget(true, css); if (dataAdapter.hasChildren(newTargetData) && !targetNode.isOpen()) { hoverToExpandTimer.schedule(HOVER_TO_EXPAND_DELAY_MS); } } } } else if (Event.DRAGLEAVE.equals(type)) { if (!hadDragEnterEvent) { // This wasn't part of a DRAGENTER-DRAGLEAVE pair (see below) clearDropTarget(); } } else if (Event.DRAGENTER.equals(type)) { /* * DRAGENTER comes before DRAGLEAVE, and a deferred command scheduled * here will execute after the DRAGLEAVE. We use hadDragEnter to track a * paired DRAGENTER-DRAGLEAVE so that we can cleanup when we get an * unpaired DRAGLEAVE. */ hadDragEnterEvent = true; Scheduler.get().scheduleDeferred(hadDragEnterEventResetter); } evt.preventDefault(); evt.stopPropagation(); } private void clearDropTarget() { hoverToExpandTimer.cancel(); if (targetNode != null) { targetNode.setIsDropTarget(false, getModel().resources.treeCss()); targetNode = null; } } } private final DragDropController dragDropController = new DragDropController(); /** * Handles logical events sourced by the View. */ private final ViewEvents<D> viewEventHandler = new ViewEvents<D>() { @Override public void onNodeAction(final TreeNodeElement<D> node) { selectSingleNode(node, true); } @Override public void onNodeClosed(TreeNodeElement<D> node) { closeNode(node, true); } @Override public void onNodeContextMenu(int mouseX, int mouseY, TreeNodeElement<D> node) { // We want to select the node if it isn't already selected. getModel().selectionModel.contextSelect(node.getData()); if (getModel().externalEventDelegate != null) { getModel().externalEventDelegate.onNodeContextMenu(mouseX, mouseY, node); } } @Override public void onDragDropEvent(DragEvent event) { dragDropController.handleDragDropEvent(event); } @Override public void onNodeExpanded(TreeNodeElement<D> node) { expandNode(node, true, true); } @Override public void onNodeSelected(TreeNodeElement<D> node, SignalEvent event) { getModel().selectionModel.selectNode(node.getData(), event); } @Override public void onRootContextMenu(int mouseX, int mouseY) { if (getModel().externalEventDelegate != null) { getModel().externalEventDelegate.onRootContextMenu(mouseX, mouseY); } } @Override public void onRootDragDrop(DragEvent event) { if (getModel().externalEventDelegate != null) { getModel().externalEventDelegate.onRootDragDrop(event); } } }; private static final int HOVER_TO_EXPAND_DELAY_MS = 500; private final Tree.Model<D> treeModel; /** * Constructor. */ public Tree(View<D> view, Model<D> model) { super(view); this.treeModel = model; getView().setDelegate(viewEventHandler); } public Tree.Model<D> getModel() { return treeModel; } /** * Selects a node in the tree and auto expands the tree to this node. * * @param nodeData the node we want to select and expand to. * @param dispatchNodeAction whether or not to notify listeners of the node * action for the selected node. */ public void autoExpandAndSelectNode(D nodeData, boolean dispatchNodeAction) { // Expand the tree to the selected element. expandPathRecursive(getModel().root, getModel().dataAdapter.getNodePath(nodeData), false); // By now the node should have a rendered element. TreeNodeElement<D> renderedNode = getModel().dataAdapter.getRenderedTreeNode(nodeData); assert (renderedNode != null) : "Expanded selection has a null rendered node!"; selectSingleNode(renderedNode, dispatchNodeAction); } private void selectSingleNode(TreeNodeElement<D> renderedNode, boolean dispatchNodeAction) { getModel().selectionModel.selectSingleNode(renderedNode.getData()); maybeNotifyNodeActionExternal(renderedNode, dispatchNodeAction); } /** * Creates a {@link TreeNodeElement}. This does NOT attach said node to the * tree. You have to do that manually with {@link TreeNodeElement#addChild}. */ public TreeNodeElement<D> createNode(D nodeData) { return TreeNodeElement.create( nodeData, getModel().dataAdapter, getModel().nodeRenderer, getModel().resources.treeCss()); } /** * @see: {@link #expandNode(TreeNodeElement, boolean, boolean)}. */ public void expandNode(TreeNodeElement<D> treeNode) { expandNode(treeNode, false, false); } /** * Expands a {@link TreeNodeElement} and renders its children if it "needs * to". "Needs to" is defined as whether or not the children have never been * rendered before, or if size of the set of rendered children differs from * the size of children in the underlying model. * * @param treeNode the {@link TreeNodeElement} we are expanding. * @param shouldAnimate whether to animate the expansion * @param dispatchNodeExpanded whether or not to notify listeners of the node * expansion */ private void expandNode(TreeNodeElement<D> treeNode, boolean shouldAnimate, boolean dispatchNodeExpanded) { // This is most likely because someone tried to expand root. Ignore it. if (treeNode == null) { return; } NodeDataAdapter<D> dataAdapter = getModel().dataAdapter; // Nothing to do here. if (!dataAdapter.hasChildren(treeNode.getData())) { return; } // Ensure that the node's children container is birthed. treeNode.ensureChildrenContainer(dataAdapter, getModel().resources.treeCss()); JsonArray<D> children = dataAdapter.getChildren(treeNode.getData()); // Maybe render it's children if they aren't already rendered. if (treeNode.getChildrenContainer().getChildren().getLength() != children.size()) { // Then the model has not been correctly reflected in the UI. // Blank the children and render a single level for each. treeNode.getChildrenContainer().setInnerHTML(""); for (int i = 0, n = children.size(); i < n; i++) { renderRecursive(treeNode.getChildrenContainer(), children.get(i), 0); } } // Render the node as being opened after the children have been added, so that // AnimationController can correctly measure the height of the child container. treeNode.openNode( dataAdapter, getModel().resources.treeCss(), getModel().animator, shouldAnimate); // Notify listeners of the event. if (dispatchNodeExpanded && getModel().externalEventDelegate != null) { getModel().externalEventDelegate.onNodeExpanded(treeNode); } } public void closeNode(TreeNodeElement<D> treeNode) { closeNode(treeNode, false); } private void closeNode(TreeNodeElement<D> treeNode, boolean dispatchNodeClosed) { if (!treeNode.isOpen()) { return; } treeNode.closeNode( getModel().dataAdapter, getModel().resources.treeCss(), getModel().animator, true); if (dispatchNodeClosed && getModel().externalEventDelegate != null) { getModel().externalEventDelegate.onNodeClosed(treeNode); } } /** * Takes in a list of paths relative to the root, that correspond to nodes in * the tree that need to be expanded. * * <p>This will try to expand all the given paths recursively, and return the * array of paths that could not be fully expanded, i.e. when the leaf that * the path points to was not found in the tree. In these cases all the middle * nodes that were found in the tree will be expanded though. * * <p>The returned array of not expanded paths may be used to save and restore * the expansion history. * * @param paths array of paths to expand * @param dispatchNodeExpanded whether to dispatch the NodeExpanded event * @return array of paths that were not expanded, or were partially expanded */ public JsonArray<JsonArray<String>> expandPaths(JsonArray<JsonArray<String>> paths, boolean dispatchNodeExpanded) { JsonArray<JsonArray<String>> notExpanded = JsonCollections.createArray(); for (int i = 0, n = paths.size(); i < n; i++) { if (!expandPathRecursive(getModel().root, paths.get(i), dispatchNodeExpanded)) { notExpanded.add(paths.get(i)); } } return notExpanded; } /** * Gets the associated {@link TreeNodeElement} for a given nodeData. * * If there is no such node rendered in the tree, then {@code null} is * returned. */ public TreeNodeElement<D> getNode(D nodeData) { return getModel().getDataAdapter().getRenderedTreeNode(nodeData); } public Tree.Resources getResources() { return getModel().resources; } public SelectionModel<D> getSelectionModel() { return getModel().selectionModel; } /** * Removes a node from the DOM. Does not mutate the the underlying model. That * should be already done before calling this method. */ public void removeNode(TreeNodeElement<D> node) { if (node == null) { return; } // Remove from the DOM node.removeFromTree(); // Notify the selection model in case it was selected. getModel().selectionModel.removeNode(node.getData()); } /** * Renders the entire tree starting with the root node. */ public void renderTree() { renderTree(-1); } /** * Renders the tree starting with the root node up until the specified depth. * * This will NOT restore any expansions. If you want to re-render the tree * obeying previous expansions then, * * @see: {@link #replaceSubtree(Object, Object)}. * * @param depth integer indicating how deep we should auto-expand. -1 means * render the entire tree. */ public void renderTree(int depth) { // Clear the current view. Element rootElement = getView().getElement(); rootElement.setInnerHTML(""); // If the root is not set, we have nothing to render. D root = getModel().root; if (root == null) { return; } // Root is special in that we don't render a directory for it. Only its // children. JsonArray<D> children = getModel().dataAdapter.getChildren(root); for (int i = 0, n = children.size(); i < n; i++) { renderRecursive(rootElement, children.get(i), depth); } } /** * Replaces the old node in the tree with data representing the subtree rooted * where the old node used to be iff the old node was rendered. * * <p>{@code oldSubtreeData} and {@code incomingSubtreeData} are allowed to be * the same node (it will simply get re-rendered). * * <p>This methods also tries to preserve the original expansion state. Any * path that was expanded before executing this method but could not be * expanded after replacing the subtree, will be returned in the result array, * so that it could be expanded later using the {@link #expandPaths} method, * if needed (for example, if children of the tree are getting populated * asynchronously). * * @param shouldAnimate if true, the subtree will animate open if it is still open * @return array paths that could not be expanded in the new subtree */ public JsonArray<JsonArray<String>> replaceSubtree(D oldSubtreeData, D incomingSubtreeData, boolean shouldAnimate) { // Gather paths that were expanded in this subtree so that we can restore // them later after rendering. JsonArray<JsonArray<String>> expandedPaths = gatherExpandedPaths(oldSubtreeData); boolean wasRoot = (oldSubtreeData == getModel().root); TreeNodeElement<D> oldRenderedNode = null; TreeNodeElement<D> newRenderedNode = null; if (wasRoot) { // We are rendering root! Just render it from the top. We will restore the // expansion later. getModel().setRoot(incomingSubtreeData); renderTree(0); } else { oldRenderedNode = getModel().dataAdapter.getRenderedTreeNode(oldSubtreeData); // If the node does not have a rendered node, then we have nothing to do. if (oldRenderedNode == null) { expandedPaths.clear(); return expandedPaths; } JsElement parentElem = oldRenderedNode.getParentElement(); // The old node may have been moved from a rendered to a non-rendered // state (e.g., into a collapsed folder). In that case, it doesn't have a // parent, and we're done here. if (parentElem == null) { expandedPaths.clear(); return expandedPaths; } // Make a new tree node. newRenderedNode = createNode(incomingSubtreeData); parentElem.insertBefore(newRenderedNode, oldRenderedNode); // Remove the old rendered node from the tree. oldRenderedNode.removeFromParent(); } // If the old node was the root, or if it and its parents were expanded, then we should // attempt to restore expansion. boolean shouldExpand = wasRoot; if (!wasRoot && oldRenderedNode != null) { shouldExpand = true; TreeNodeElement<D> curNode = oldRenderedNode; while (curNode != null) { if (!curNode.isOpen()) { // One of the parents is closed, so we should not expand all paths. shouldExpand = false; break; } D parentData = getModel().dataAdapter.getParent(curNode.getData()); curNode = (parentData == null) ? null : getModel().dataAdapter.getRenderedTreeNode(parentData); } } if (shouldExpand) { // Animate the top node if it was open. If we should not animate, the newRenderedNode will // still be expanded by the call to expandPaths() below. if (shouldAnimate && newRenderedNode != null) { expandNode(newRenderedNode, true, true); } // But if it is open, we need to attempt to restore the expansion. expandedPaths = expandPaths(expandedPaths, true); } else { expandedPaths.clear(); } // TODO: Be more surgical about restoring the selection model. We // are currently recomputing all selected nodes. JsonArray<JsonArray<String>> selectedPaths = getModel().selectionModel.computeSelectedPaths(); restoreSelectionModel(selectedPaths); return expandedPaths; } /** * Populates the selection model from a list of selected paths iff they * resolve to nodes in the data model. */ private void restoreSelectionModel(JsonArray<JsonArray<String>> selectedPaths) { getModel().selectionModel.clearSelections(); for (int i = 0, n = selectedPaths.size(); i < n; i++) { D node = getModel().dataAdapter.getNodeByPath(getModel().root, selectedPaths.get(i)); if (node != null) { getModel().selectionModel.selectNode(node, null); } } } /** * Receive callbacks for node expansion and node selection. * * @param externalEventDelegate The {@link ViewEvents} that will handle the * events. */ public void setTreeEventHandler(Listener<D> externalEventDelegate) { getModel().externalEventDelegate = externalEventDelegate; } private boolean expandPathRecursive(D expandedParentNode, JsonArray<String> pathToExpand, boolean dispatchNodeExpanded) { if (expandedParentNode == null) { return false; } NodeDataAdapter<D> dataAdapter = getModel().dataAdapter; D previousParentNode = expandedParentNode; for (int pathIndex = 0; pathIndex < pathToExpand.size(); ++pathIndex) { if (!getModel().dataAdapter.hasChildren(previousParentNode)) { // Consider this path expanded, even if some path components are left. return true; } // The root is already expanded by default. So we really want to recur the // child that matches the first component. JsonArray<D> children = getModel().dataAdapter.getChildren(previousParentNode); previousParentNode = null; for (int i = 0, n = children.size(); i < n; ++i) { D child = children.get(i); if (dataAdapter.getNodeId(child).equals(pathToExpand.get(pathIndex))) { // We have a match. Look up the rendered element. The parent should // already be expanded, so this must exist. TreeNodeElement<D> renderedNode = dataAdapter.getRenderedTreeNode(child); assert (renderedNode != null); // If this node is not open, then we open it. if (!renderedNode.isOpen()) { expandNode(renderedNode, false, dispatchNodeExpanded); } // Continue to expand the remainder of the path. previousParentNode = child; break; } } if (previousParentNode == null) { // The path was only partially expanded. return false; } } return true; } /** * Walks the tree rooted at the specified renderedNode and gathers a list of * paths that correspond to nodes that have been expanded below the specified * rendered node. All paths are expressed as root relative. * * These paths correspond to "expansion leaves". Which are effectively nodes * whose children are all leaves, or are all collapsed. That is, nodes whose * children all answer false to {@link TreeNodeElement#isOpen()}. */ private JsonArray<JsonArray<String>> gatherExpandedPaths(D rootData) { final JsonArray<JsonArray<String>> expandedPaths = JsonCollections.createArray(); // Can't gather the expansion state for a null parent. if (rootData == null) { return expandedPaths; } iterateDfs(rootData, getModel().dataAdapter, new Visitor<D>() { @Override public boolean shouldVisit(D node) { // If a child node is open, it means that it has been expanded and its // children should have rendered nodes. TreeNodeElement<D> renderedChild = getModel().dataAdapter.getRenderedTreeNode(node); return (renderedChild != null) && renderedChild.isOpen(); } @Override public void visit(D node, boolean willVisitChildren) { if (!willVisitChildren) { // This node is an expansion leaf. Accumulate the path. expandedPaths.add(getModel().dataAdapter.getNodePath(node)); } } }); return expandedPaths; } /** * Recursively iterates children of a given root node using DFS. * * @param rootData root node to start the iteration from * @param dataAdapter data adapter to get the children of a node * @param callback iteration callback */ public static <D> void iterateDfs(D rootData, NodeDataAdapter<D> dataAdapter, Visitor<D> callback) { JsonArray<D> nodes = JsonCollections.createArray(); nodes.add(rootData); // Iterative DFS. while (!nodes.isEmpty()) { D parentNodeData = nodes.pop(); boolean willVisitChildren = false; JsonArray<D> children = dataAdapter.getChildren(parentNodeData); for (int i = 0, n = children.size(); i < n; i++) { D child = children.get(i); if (callback.shouldVisit(child)) { // Add a filtered child to the stack of the nodes to visit. nodes.add(child); willVisitChildren = true; } } callback.visit(parentNodeData, willVisitChildren); } } private void maybeNotifyNodeActionExternal( TreeNodeElement<D> renderedNode, boolean dispatchNodeAction) { if (dispatchNodeAction && getModel().externalEventDelegate != null) { getModel().externalEventDelegate.onNodeAction(renderedNode); } } private void renderRecursive(Element parentContainer, D nodeData, int depth) { NodeDataAdapter<D> dataAdapter = getModel().dataAdapter; Tree.Css css = getResources().treeCss(); // Make the node. TreeNodeElement<D> newNode = createNode(nodeData); parentContainer.appendChild(newNode); // If we reach depth 0, we stop the recursion. if (depth == 0 || !newNode.hasChildrenContainer()) { if (dataAdapter.hasChildren(nodeData)) { newNode.closeNode(dataAdapter, css, getModel().animator, false); } return; } // Maybe continue the expansion. newNode.openNode(dataAdapter, css, getModel().animator, false); JsonArray<D> children = dataAdapter.getChildren(nodeData); for (int i = 0, n = children.size(); i < n; i++) { renderRecursive(newNode.getChildrenContainer(), children.get(i), depth - 1); } } /** * Returns the tree node whose element is or contains the given element, or * null if the given element cannot be matched to a tree node. */ public TreeNodeElement<D> getNodeFromElement(Element element) { Css css = getModel().resources.treeCss(); Element treeNodeBody = CssUtils.getAncestorOrSelfWithClassName(element, css.treeNodeBody()); return treeNodeBody != null ? getView().getTreeNodeFromTreeNodeBody(treeNodeBody, css) : null; } }