Source RapidContext_Widget_Tree.js

1/*
2 * RapidContext <https://www.rapidcontext.com/>
3 * Copyright (c) 2007-2023 Per Cederberg. All rights reserved.
4 *
5 * This program is free software: you can redistribute it and/or
6 * modify it under the terms of the BSD license.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
11 *
12 * See the RapidContext LICENSE for more details.
13 */
14
15// Namespace initialization
16if (typeof(RapidContext) == "undefined") {
17    RapidContext = {};
18}
19RapidContext.Widget = RapidContext.Widget || { Classes: {} };
20
21/**
22 * Creates a new tree widget.
23 *
24 * @constructor
25 * @param {Object} attrs the widget and node attributes
26 * @param {boolean} [attrs.hidden] the hidden widget flag, defaults to false
27 * @param {...TreeNode} [child] the child tree node widgets
28 *
29 * @return {Widget} the widget DOM node
30 *
31 * @class The tree widget class. Used to provide a dynamic tree with expandable
32 *     tree nodes, using a number of `<div>` HTML elements.
33 * @extends RapidContext.Widget
34 *
35 * @example <caption>JavaScript</caption>
36 * var tree = RapidContext.Widget.Tree({ style: { width: "200px", height: "400px" } });
37 * var root = RapidContext.Widget.TreeNode({ folder: true, name: "Root" });
38 * var child = RapidContext.Widget.TreeNode({ name: "Child" });
39 * root.addAll(child);
40 * tree.addAll(root);
41 *
42 * @example <caption>User Interface XML</caption>
43 * <Tree style="width: 200px; height: 400px;">
44 *   <TreeNode name="Root">
45 *     <TreeNode name="Child" />
46 *   </TreeNode>
47 * </Tree>
48 */
49RapidContext.Widget.Tree = function (attrs/*, ...*/) {
50    var o = MochiKit.DOM.DIV(attrs);
51    RapidContext.Widget._widgetMixin(o, RapidContext.Widget.Tree);
52    o.classList.add("widgetTree");
53    o.resizeContent = o._resizeContent;
54    o.selectedPath = null;
55    o.setAttrs(attrs);
56    o.addAll(Array.from(arguments).slice(1));
57    o.on("click", ".widgetTreeNodeLabel", o._handleNodeLabelClick);
58    return o;
59};
60
61// Register widget class
62RapidContext.Widget.Classes.Tree = RapidContext.Widget.Tree;
63
64/**
65 * Emitted when a tree node is expanded.
66 *
67 * @name RapidContext.Widget.Tree#onexpand
68 * @event
69 */
70
71/**
72 * Emitted when a tree node is collapsed.
73 *
74 * @name RapidContext.Widget.Tree#oncollapse
75 * @event
76 */
77
78/**
79 * Emitted when a tree node is selected. It will be emitted after
80 * "onunselect" if another node was previously selected.
81 *
82 * @name RapidContext.Widget.Tree#onselect
83 * @event
84 */
85
86/**
87 * Emitted when a tree node selection is removed. It will be emitted
88 * before "onselect" if another node was previously selected.
89 *
90 * @name RapidContext.Widget.Tree#onunselect
91 * @event
92 */
93
94/**
95 * Handles node label click events.
96 *
97 * @param {Event} evt the DOM Event object
98 */
99RapidContext.Widget.Tree.prototype._handleNodeLabelClick = function (evt) {
100    var node = evt.target.closest("div.widgetTreeNode");
101    if (RapidContext.Widget.isWidget(evt.target, "Icon")) {
102        node.toggle();
103    } else {
104        node.select();
105    }
106};
107
108/**
109 * Adds a single child tree node widget to this widget.
110 *
111 * @param {Widget} child the tree node widget to add
112 */
113RapidContext.Widget.Tree.prototype.addChildNode = function (child) {
114    if (!RapidContext.Widget.isWidget(child, "TreeNode")) {
115        throw new Error("Tree widget can only have TreeNode children");
116    }
117    this.append(child);
118};
119
120/**
121 * Removes all marked tree nodes. When adding or updating tree nodes, any
122 * node modified is automatically unmarked (e.g. by calling `setAttrs` on the
123 * tree nodes or `addPath` on the tree). This makes it easy to prune a tree
124 * after an update, by initially marking all tree nodes with `markAll()`,
125 * inserting or touching all nodes to keep, and finally calling this method to
126 * remove the remaining nodes.
127 *
128 * @example
129 * tree.markAll();
130 * tree.addPath(["Root", "Child"]);
131 * ...
132 * tree.removeAllMarked();
133 */
134RapidContext.Widget.Tree.prototype.removeAllMarked = function () {
135    var children = this.getChildNodes();
136    for (var i = 0; i < children.length; i++) {
137        if (children[i].marked === true) {
138            this.removeChildNode(children[i]);
139        } else {
140            children[i].removeAllMarked();
141        }
142    }
143};
144
145/**
146 * Marks this tree node and all child nodes recursively. When adding or
147 * updating tree nodes, any node modified is automatically unmarked (e.g. by
148 * calling `setAttrs` on the tree nodes or `addPath` on the tree). This makes
149 * it easy to prune a tree after an update, by initially marking all tree
150 * nodes, inserting or touching all nodes to keep, and finally calling
151 * `removeAllMarked()` to remove the remaining nodes.
152 *
153 * @example
154 * tree.markAll();
155 * tree.addPath(["Root", "Child"]);
156 * ...
157 * tree.removeAllMarked();
158 */
159RapidContext.Widget.Tree.prototype.markAll = function () {
160    var children = this.getChildNodes();
161    for (var i = 0; i < children.length; i++) {
162        children[i].markAll();
163    }
164};
165
166/**
167 * Finds a root tree node with the specified name.
168 *
169 * @param {string} name the root tree node name
170 *
171 * @return {Widget} the root tree node found, or
172 *         null if not found
173 */
174RapidContext.Widget.Tree.prototype.findRoot = function (name) {
175    var children = this.getChildNodes();
176    for (var i = 0; i < children.length; i++) {
177        if (children[i].name == name) {
178            return children[i];
179        }
180    }
181    return null;
182};
183
184/**
185 * Searches for a tree node from the specified path.
186 *
187 * @param {Array} path the tree node path (array of names)
188 *
189 * @return {Widget} the descendant tree node found, or
190 *         null if not found
191 */
192RapidContext.Widget.Tree.prototype.findByPath = function (path) {
193    if (path == null || path.length < 1) {
194        return null;
195    }
196    var root = this.findRoot(path[0]);
197    if (root != null) {
198        return root.findByPath(path.slice(1));
199    } else {
200        return null;
201    }
202};
203
204/**
205 * Returns the currently selected tree node.
206 *
207 * @return {Widget} the currently selected tree node, or
208 *         null if no node is selected
209 */
210RapidContext.Widget.Tree.prototype.selectedChild = function () {
211    if (this.selectedPath == null) {
212        return null;
213    } else {
214        return this.findByPath(this.selectedPath);
215    }
216};
217
218/**
219 * Sets the currently selected node in the tree. This method is only
220 * called from the tree node `select()` and `unselect()` methods.
221 *
222 * @param {Widget} node the new selected tree node, or null for none
223 */
224RapidContext.Widget.Tree.prototype._handleSelect = function (node) {
225    var prev = this.selectedChild();
226    if (node == null) {
227        this.selectedPath = null;
228        this.emit("unselect");
229    } else {
230        if (prev != null && prev !== node) {
231            prev.unselect();
232        }
233        this.selectedPath = node.path();
234        this.emit("select");
235    }
236};
237
238/**
239 * Recursively expands all nodes. If a depth is specified,
240 * expansions will not continue below that depth.
241 *
242 * @param {number} [depth] the optional maximum depth
243 */
244RapidContext.Widget.Tree.prototype.expandAll = function (depth) {
245    if (typeof(depth) !== "number") {
246        depth = 10;
247    }
248    var children = this.getChildNodes();
249    for (var i = 0; depth > 0 && i < children.length; i++) {
250        children[i].expandAll(depth - 1);
251    }
252};
253
254/**
255 * Recursively collapses all nodes. If a depth is specified, only
256 * nodes below that depth will be collapsed.
257 *
258 * @param {number} [depth] the optional minimum depth
259 */
260RapidContext.Widget.Tree.prototype.collapseAll = function (depth) {
261    if (typeof(depth) !== "number") {
262        depth = 0;
263    }
264    var children = this.getChildNodes();
265    for (var i = 0; i < children.length; i++) {
266        children[i].collapseAll(depth - 1);
267    }
268};
269
270/**
271 * Adds a path to the tree as a recursive list of child nodes. If
272 * nodes in the specified path already exists, they will be used
273 * instead of creating new nodes.
274 *
275 * @param {Array} path the tree node path (array of names)
276 *
277 * @return {Widget} the last node in the path
278 */
279RapidContext.Widget.Tree.prototype.addPath = function (path) {
280    if (path == null || path.length < 1) {
281        return null;
282    }
283    var node = this.findRoot(path[0]);
284    if (node == null) {
285        node = RapidContext.Widget.TreeNode({ name: path[0], folder: (path.length > 1) });
286        this.addChildNode(node);
287    }
288    node.marked = false;
289    for (var i = 1; i < path.length; i++) {
290        var child = node.findChild(path[i]);
291        if (child == null) {
292            var attrs = { name: path[i], folder: (path.length > i + 1) };
293            child = RapidContext.Widget.TreeNode(attrs);
294            node.addChildNode(child);
295        }
296        child.marked = false;
297        node = child;
298    }
299    return node;
300};
301
302/**
303 * Called when tree content should be resized. This method is also called when
304 * the widget is made visible in a container after being hidden.
305 */
306RapidContext.Widget.Tree.prototype._resizeContent = function () {
307    // Work-around to restore scrollTop for WebKit browsers
308    if (this.scrollTop == 0 && this.selectedPath) {
309        var node = this.selectedChild();
310        var h = this.clientHeight;
311        var y = node.offsetTop + node.offsetHeight;
312        this.scrollTop = Math.round(y - h / 2);
313    }
314};
315