Source RapidContext_Widget_Tree.js

1/*
2 * RapidContext <https://www.rapidcontext.com/>
3 * Copyright (c) 2007-2024 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 = RapidContext.UI.DIV(attrs);
51    RapidContext.Widget._widgetMixin(o, RapidContext.Widget.Tree);
52    o.classList.add("widgetTree");
53    o.selectedPath = null;
54    o.setAttrs(attrs);
55    o.addAll(Array.from(arguments).slice(1));
56    o.on("click", ".widgetTreeNodeLabel", o._handleNodeLabelClick);
57    return o;
58};
59
60// Register widget class
61RapidContext.Widget.Classes.Tree = RapidContext.Widget.Tree;
62
63/**
64 * Emitted when a tree node is expanded.
65 *
66 * @name RapidContext.Widget.Tree#onexpand
67 * @event
68 */
69
70/**
71 * Emitted when a tree node is collapsed.
72 *
73 * @name RapidContext.Widget.Tree#oncollapse
74 * @event
75 */
76
77/**
78 * Emitted when a tree node is selected. It will be emitted after
79 * "onunselect" if another node was previously selected.
80 *
81 * @name RapidContext.Widget.Tree#onselect
82 * @event
83 */
84
85/**
86 * Emitted when a tree node selection is removed. It will be emitted
87 * before "onselect" if another node was previously selected.
88 *
89 * @name RapidContext.Widget.Tree#onunselect
90 * @event
91 */
92
93/**
94 * Handles node label click events.
95 *
96 * @param {Event} evt the DOM Event object
97 */
98RapidContext.Widget.Tree.prototype._handleNodeLabelClick = function (evt) {
99    var node = evt.target.closest("div.widgetTreeNode");
100    if (RapidContext.Widget.isWidget(evt.target, "Icon")) {
101        node.toggle();
102    } else {
103        node.select();
104    }
105};
106
107/**
108 * Adds a single child tree node widget to this widget.
109 *
110 * @param {Widget} child the tree node widget to add
111 */
112RapidContext.Widget.Tree.prototype.addChildNode = function (child) {
113    if (!RapidContext.Widget.isWidget(child, "TreeNode")) {
114        throw new Error("Tree widget can only have TreeNode children");
115    }
116    this.append(child);
117};
118
119/**
120 * Removes all marked tree nodes. When adding or updating tree nodes, any
121 * node modified is automatically unmarked (e.g. by calling `setAttrs` on the
122 * tree nodes or `addPath` on the tree). This makes it easy to prune a tree
123 * after an update, by initially marking all tree nodes with `markAll()`,
124 * inserting or touching all nodes to keep, and finally calling this method to
125 * remove the remaining nodes.
126 *
127 * @example
128 * tree.markAll();
129 * tree.addPath(["Root", "Child"]);
130 * ...
131 * tree.removeAllMarked();
132 */
133RapidContext.Widget.Tree.prototype.removeAllMarked = function () {
134    var children = this.getChildNodes();
135    for (var i = 0; i < children.length; i++) {
136        if (children[i].marked === true) {
137            this.removeChildNode(children[i]);
138        } else {
139            children[i].removeAllMarked();
140        }
141    }
142};
143
144/**
145 * Marks this tree node and all child nodes recursively. When adding or
146 * updating tree nodes, any node modified is automatically unmarked (e.g. by
147 * calling `setAttrs` on the tree nodes or `addPath` on the tree). This makes
148 * it easy to prune a tree after an update, by initially marking all tree
149 * nodes, inserting or touching all nodes to keep, and finally calling
150 * `removeAllMarked()` to remove the remaining nodes.
151 *
152 * @example
153 * tree.markAll();
154 * tree.addPath(["Root", "Child"]);
155 * ...
156 * tree.removeAllMarked();
157 */
158RapidContext.Widget.Tree.prototype.markAll = function () {
159    var children = this.getChildNodes();
160    for (var i = 0; i < children.length; i++) {
161        children[i].markAll();
162    }
163};
164
165/**
166 * Finds a root tree node with the specified name.
167 *
168 * @param {string} name the root tree node name
169 *
170 * @return {Widget} the root tree node found, or
171 *         null if not found
172 */
173RapidContext.Widget.Tree.prototype.findRoot = function (name) {
174    var children = this.getChildNodes();
175    for (var i = 0; i < children.length; i++) {
176        if (children[i].name == name) {
177            return children[i];
178        }
179    }
180    return null;
181};
182
183/**
184 * Searches for a tree node from the specified path.
185 *
186 * @param {Array} path the tree node path (array of names)
187 *
188 * @return {Widget} the descendant tree node found, or
189 *         null if not found
190 */
191RapidContext.Widget.Tree.prototype.findByPath = function (path) {
192    if (path == null || path.length < 1) {
193        return null;
194    }
195    var root = this.findRoot(path[0]);
196    if (root != null) {
197        return root.findByPath(path.slice(1));
198    } else {
199        return null;
200    }
201};
202
203/**
204 * Returns the currently selected tree node.
205 *
206 * @return {Widget} the currently selected tree node, or
207 *         null if no node is selected
208 */
209RapidContext.Widget.Tree.prototype.selectedChild = function () {
210    if (this.selectedPath == null) {
211        return null;
212    } else {
213        return this.findByPath(this.selectedPath);
214    }
215};
216
217/**
218 * Sets the currently selected node in the tree. This method is only
219 * called from the tree node `select()` and `unselect()` methods.
220 *
221 * @param {Widget} node the new selected tree node, or null for none
222 */
223RapidContext.Widget.Tree.prototype._handleSelect = function (node) {
224    var prev = this.selectedChild();
225    if (node == null) {
226        this.selectedPath = null;
227        this.emit("unselect");
228    } else {
229        if (prev != null && prev !== node) {
230            prev.unselect();
231        }
232        this.selectedPath = node.path();
233        this.emit("select");
234    }
235};
236
237/**
238 * Recursively expands all nodes. If a depth is specified,
239 * expansions will not continue below that depth.
240 *
241 * @param {number} [depth] the optional maximum depth
242 */
243RapidContext.Widget.Tree.prototype.expandAll = function (depth) {
244    if (typeof(depth) !== "number") {
245        depth = 10;
246    }
247    var children = this.getChildNodes();
248    for (var i = 0; depth > 0 && i < children.length; i++) {
249        children[i].expandAll(depth - 1);
250    }
251};
252
253/**
254 * Recursively collapses all nodes. If a depth is specified, only
255 * nodes below that depth will be collapsed.
256 *
257 * @param {number} [depth] the optional minimum depth
258 */
259RapidContext.Widget.Tree.prototype.collapseAll = function (depth) {
260    if (typeof(depth) !== "number") {
261        depth = 0;
262    }
263    var children = this.getChildNodes();
264    for (var i = 0; i < children.length; i++) {
265        children[i].collapseAll(depth - 1);
266    }
267};
268
269/**
270 * Adds a path to the tree as a recursive list of child nodes. If
271 * nodes in the specified path already exists, they will be used
272 * instead of creating new nodes.
273 *
274 * @param {Array} path the tree node path (array of names)
275 *
276 * @return {Widget} the last node in the path
277 */
278RapidContext.Widget.Tree.prototype.addPath = function (path) {
279    if (path == null || path.length < 1) {
280        return null;
281    }
282    var node = this.findRoot(path[0]);
283    if (node == null) {
284        node = RapidContext.Widget.TreeNode({ name: path[0], folder: (path.length > 1) });
285        this.addChildNode(node);
286    }
287    node.marked = false;
288    for (var i = 1; i < path.length; i++) {
289        var child = node.findChild(path[i]);
290        if (child == null) {
291            var attrs = { name: path[i], folder: (path.length > i + 1) };
292            child = RapidContext.Widget.TreeNode(attrs);
293            node.addChildNode(child);
294        }
295        child.marked = false;
296        node = child;
297    }
298    return node;
299};
300