Source RapidContext_Widget_Tree.js

1/*
2 * RapidContext <https://www.rapidcontext.com/>
3 * Copyright (c) 2007-2025 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    const 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    const 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    const children = this.getChildNodes();
135    for (let 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    const children = this.getChildNodes();
160    for (let 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    const children = this.getChildNodes();
175    for (let 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    const 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    return this.findByPath(this.selectedPath);
211};
212
213/**
214 * Sets the currently selected node in the tree. This method is only
215 * called from the tree node `select()` and `unselect()` methods.
216 *
217 * @param {Widget} node the new selected tree node, or null for none
218 */
219RapidContext.Widget.Tree.prototype._handleSelect = function (node) {
220    const prev = this.selectedChild();
221    if (node == null) {
222        this.selectedPath = null;
223        this.emit("unselect");
224    } else {
225        if (prev != null && prev !== node) {
226            prev.unselect();
227        }
228        this.selectedPath = node.path();
229        this.emit("select");
230    }
231};
232
233/**
234 * Recursively expands all nodes. If a depth is specified,
235 * expansions will not continue below that depth.
236 *
237 * @param {number} [depth] the optional maximum depth
238 */
239RapidContext.Widget.Tree.prototype.expandAll = function (depth) {
240    if (typeof(depth) !== "number") {
241        depth = 10;
242    }
243    if (depth > 0) {
244        const children = this.getChildNodes();
245        for (let i = 0; i < children.length; i++) {
246            children[i].expandAll(depth - 1);
247        }
248    }
249};
250
251/**
252 * Recursively collapses all nodes. If a depth is specified, only
253 * nodes below that depth will be collapsed.
254 *
255 * @param {number} [depth] the optional minimum depth
256 */
257RapidContext.Widget.Tree.prototype.collapseAll = function (depth) {
258    if (typeof(depth) !== "number") {
259        depth = 0;
260    }
261    const children = this.getChildNodes();
262    for (let i = 0; i < children.length; i++) {
263        children[i].collapseAll(depth - 1);
264    }
265};
266
267/**
268 * Adds a path to the tree as a recursive list of child nodes. If
269 * nodes in the specified path already exists, they will be used
270 * instead of creating new nodes.
271 *
272 * @param {Array} path the tree node path (array of names)
273 *
274 * @return {Widget} the last node in the path
275 */
276RapidContext.Widget.Tree.prototype.addPath = function (path) {
277    if (path == null || path.length < 1) {
278        return null;
279    }
280    let node = this.findRoot(path[0]);
281    if (node == null) {
282        node = RapidContext.Widget.TreeNode({ name: path[0], folder: (path.length > 1) });
283        this.addChildNode(node);
284    }
285    node.marked = false;
286    for (let i = 1; i < path.length; i++) {
287        let child = node.findChild(path[i]);
288        if (child == null) {
289            const attrs = { name: path[i], folder: (path.length > i + 1) };
290            child = RapidContext.Widget.TreeNode(attrs);
291            node.addChildNode(child);
292        }
293        child.marked = false;
294        node = child;
295    }
296    return node;
297};
298