Source RapidContext_Widget_TreeNode.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 node widget.
23 *
24 * @constructor
25 * @param {Object} attrs the widget and node attributes
26 * @param {string} attrs.name the tree node name
27 * @param {boolean} [attrs.folder] the folder flag, defaults to `false` if no
28 *            child nodes are provided in constructor call
29 * @param {string} [attrs.icon] the icon reference to use, defaults
30 *            to "FOLDER" for folders and "DOCUMENT" otherwise
31 * @param {string} [attrs.tooltip] the tooltip text when hovering
32 * @param {boolean} [attrs.hidden] the hidden widget flag, defaults to `false`
33 * @param {...TreeNode} [child] the child tree node widgets
34 *
35 * @return {Widget} the widget DOM node
36 *
37 * @class The tree node widget class. Used to provide a tree node in a tree,
38 *     using a number of `<div>` HTML elements. Note that events should
39 *     normally not be listened for on individual tree nodes, but rather on
40 *     the tree as a whole.
41 * @extends RapidContext.Widget
42 *
43 * @example <caption>JavaScript</caption>
44 * let parent = RapidContext.Widget.TreeNode({ folder: true, name: "Parent" });
45 * let child = RapidContext.Widget.TreeNode({ name: "Child" });
46 * parent.addAll(child);
47 *
48 * @example <caption>User Interface XML</caption>
49 * <TreeNode name="Parent">
50 *   <TreeNode name="Child" />
51 * </TreeNode>
52 */
53RapidContext.Widget.TreeNode = function (attrs/*, ...*/) {
54    const toggle = RapidContext.Widget.Icon("fa fa-fw");
55    const icon = RapidContext.Widget.Icon("fa fa-fw fa-dot-circle-o");
56    const label = RapidContext.UI.SPAN({ "class": "widgetTreeNodeText" });
57    const cls = "widgetTreeNodeLabel overflow-ellipsis text-nowrap";
58    const div = RapidContext.UI.DIV({ "class": cls }, toggle, icon, label);
59    const o = RapidContext.UI.DIV({}, div);
60    RapidContext.Widget._widgetMixin(o, RapidContext.Widget.TreeNode);
61    o.classList.add("widgetTreeNode");
62    const isFolder = (arguments.length > 1);
63    attrs = { name: "Tree Node", folder: isFolder, ...attrs };
64    o.setAttrs(attrs);
65    o.addAll(Array.from(arguments).slice(1));
66    return o;
67};
68
69// Register widget class
70RapidContext.Widget.Classes.TreeNode = RapidContext.Widget.TreeNode;
71
72/**
73 * Returns and optionally creates the widget container DOM node. If a
74 * child container is created, it will be hidden by default.
75 *
76 * @param {boolean} [create] the create flag, defaults to `false`
77 *
78 * @return {Node} the container DOM node, or
79 *         null if this widget has no container (yet)
80 */
81RapidContext.Widget.TreeNode.prototype._containerNode = function (create) {
82    let container = this.lastChild;
83    if (container.classList.contains("widgetTreeNodeContainer")) {
84        return container;
85    } else if (create) {
86        container = RapidContext.UI.DIV({ "class": "widgetTreeNodeContainer widgetHidden" });
87        this.append(container);
88        this.firstChild.childNodes[0].setAttrs({ "class": "fa fa-fw fa-plus-square-o" });
89        if (!this.icon) {
90            this.firstChild.childNodes[1].setAttrs({ "class": "fa fa-fw fa-folder" });
91        }
92        return container;
93    } else {
94        return null;
95    }
96};
97
98/**
99 * Updates the widget or HTML DOM node attributes.
100 *
101 * @param {Object} attrs the widget and node attributes to set
102 * @param {string} [attrs.name] the tree node name
103 * @param {boolean} [attrs.folder] the folder flag, cannot be
104 *            reverted to `false` once set (implicitly or explicitly)
105 * @param {Icon|Object|string} [attrs.icon] icon the icon to set, or
106 *            null to remove
107 * @param {string} [attrs.tooltip] the tooltip text when hovering
108 * @param {boolean} [attrs.hidden] the hidden widget flag
109 */
110RapidContext.Widget.TreeNode.prototype.setAttrs = function (attrs) {
111    this.marked = false;
112    attrs = { ...attrs };
113    if ("name" in attrs) {
114        this.querySelector(".widgetTreeNodeText").innerText = attrs.name;
115    }
116    if ("folder" in attrs) {
117        this._containerNode(RapidContext.Data.bool(attrs.folder));
118        delete attrs.folder;
119    }
120    if ("icon" in attrs) {
121        const icon = RapidContext.Widget.Icon(attrs.icon);
122        this.firstChild.childNodes[1].replaceWith(icon);
123    }
124    if ("tooltip" in attrs) {
125        this.firstChild.title = attrs.tooltip;
126    }
127    this.__setAttrs(attrs);
128};
129
130/**
131 * Adds a single child tree node widget to this widget.
132 *
133 * @param {Widget} child the tree node widget to add
134 */
135RapidContext.Widget.TreeNode.prototype.addChildNode = function (child) {
136    if (!RapidContext.Widget.isWidget(child, "TreeNode")) {
137        throw new Error("TreeNode widget can only have TreeNode children");
138    }
139    this._containerNode(true).append(child);
140};
141
142/**
143 * Removes a single child tree node widget from this widget.
144 *
145 * @param {Widget} child the tree node widget to remove
146 */
147RapidContext.Widget.TreeNode.prototype.removeChildNode = function (child) {
148    const elem = this._containerNode();
149    if (elem) {
150        child && child.unselect();
151        elem.removeChild(child);
152    }
153};
154
155/**
156 * Removes all marked tree nodes. When adding or updating tree nodes, any
157 * node modified is automatically unmarked (e.g. by calling `setAttrs`). This
158 * makes it easy to prune a tree after an update, by initially marking all
159 * tree nodes with `markAll()`, inserting or touching all nodes to keep, and
160 * finally calling this method to remove the remaining nodes.
161 *
162 * @example
163 * parent.markAll();
164 * parent.setAttrs();
165 * child.setAttrs();
166 * ...
167 * parent.removeAllMarked();
168 */
169RapidContext.Widget.TreeNode.prototype.removeAllMarked = function () {
170    const children = this.getChildNodes();
171    for (let i = 0; i < children.length; i++) {
172        if (children[i].marked === true) {
173            this.removeChildNode(children[i]);
174        } else {
175            children[i].removeAllMarked();
176        }
177    }
178};
179
180/**
181 * Marks this tree node and all child nodes recursively. When adding or
182 * updating tree nodes, any node modified is automatically unmarked (e.g. by
183 * calling `setAttrs`). This makes it easy to prune a tree after an update, by
184 * initially marking all tree nodes, inserting or touching all nodes to keep,
185 * and finally calling `removeAllMarked()` to remove the remaining nodes.
186 *
187 * @example
188 * parent.markAll();
189 * parent.setAttrs();
190 * child.setAttrs();
191 * ...
192 * parent.removeAllMarked();
193 */
194RapidContext.Widget.TreeNode.prototype.markAll = function () {
195    this.marked = true;
196    const children = this.getChildNodes();
197    for (let i = 0; i < children.length; i++) {
198        children[i].markAll();
199    }
200};
201
202/**
203 * Checks if this node is a folder.
204 *
205 * @return {boolean} `true` if this node is a folder, or
206 *         `false` otherwise
207 */
208RapidContext.Widget.TreeNode.prototype.isFolder = function () {
209    return this._containerNode() != null;
210};
211
212/**
213 * Checks if this folder node is expanded.
214 *
215 * @return {boolean} `true` if this node is expanded, or
216 *         `false` otherwise
217 */
218RapidContext.Widget.TreeNode.prototype.isExpanded = function () {
219    const container = this._containerNode();
220    return !!container && !container.classList.contains("widgetHidden");
221};
222
223/**
224 * Checks if this node is selected.
225 *
226 * @return {boolean} `true` if the node is selected, or
227 *         `false` otherwise
228 */
229RapidContext.Widget.TreeNode.prototype.isSelected = function () {
230    return this.firstChild.classList.contains("selected");
231};
232
233/**
234 * Returns the ancestor tree widget.
235 *
236 * @return {Widget} the ancestor tree widget, or
237 *         null if none was found
238 */
239RapidContext.Widget.TreeNode.prototype.tree = function () {
240    const parent = this.parent();
241    if (parent != null) {
242        return parent.tree();
243    }
244    if (RapidContext.Widget.isWidget(this.parentNode, "Tree")) {
245        return this.parentNode;
246    } else {
247        return null;
248    }
249};
250
251/**
252 * Returns the parent tree node widget.
253 *
254 * @return {Widget} the parent tree node widget, or
255 *         null if this is a root node
256 */
257RapidContext.Widget.TreeNode.prototype.parent = function () {
258    const node = this.parentNode;
259    if (node && node.classList.contains("widgetTreeNodeContainer")) {
260        return node.parentNode;
261    } else {
262        return null;
263    }
264};
265
266/**
267 * Returns the path to this tree node.
268 *
269 * @return {Array} the tree node path, i.e an array of node names
270 */
271RapidContext.Widget.TreeNode.prototype.path = function () {
272    const parent = this.parent();
273    if (parent == null) {
274        return [this.name];
275    } else {
276        const path = parent.path();
277        path.push(this.name);
278        return path;
279    }
280};
281
282/**
283 * Finds a child tree node with the specified name.
284 *
285 * @param {string} name the child tree node name
286 *
287 * @return {Widget} the child tree node found, or
288 *         null if not found
289 */
290RapidContext.Widget.TreeNode.prototype.findChild = function (name) {
291    const children = this.getChildNodes();
292    for (let i = 0; i < children.length; i++) {
293        if (children[i].name == name) {
294            return children[i];
295        }
296    }
297    return null;
298};
299
300/**
301 * Searches for a descendant tree node from the specified path.
302 *
303 * @param {Array} path the tree node path (array of node names)
304 *
305 * @return {Widget} the descendant tree node found, or
306 *         null if not found
307 */
308RapidContext.Widget.TreeNode.prototype.findByPath = function (path) {
309    let node = this;
310    if (path != null) {
311        for (let i = 0; node != null && i < path.length; i++) {
312            node = node.findChild(path[i]);
313        }
314    }
315    return node;
316};
317
318/**
319 * Selects this tree node.
320 */
321RapidContext.Widget.TreeNode.prototype.select = function () {
322    this.firstChild.classList.add("selected");
323    const tree = this.tree();
324    if (tree != null) {
325        tree._handleSelect(this);
326    }
327    this.expand();
328};
329
330/**
331 * Unselects this tree node.
332 */
333RapidContext.Widget.TreeNode.prototype.unselect = function () {
334    if (this.isSelected()) {
335        this.firstChild.classList.remove("selected");
336        const tree = this.tree();
337        if (tree != null) {
338            tree._handleSelect(null);
339        }
340    }
341};
342
343/**
344 * Expands this node to display any child nodes. If the parent node
345 * is not expanded, it will be expanded as well.
346 */
347RapidContext.Widget.TreeNode.prototype.expand = function () {
348    const parent = this.parent();
349    if (parent != null && !parent.isExpanded()) {
350        parent.expand();
351    }
352    const container = this._containerNode();
353    if (container != null && !this.isExpanded()) {
354        this.firstChild.childNodes[0].setAttrs({ "class": "fa fa-fw fa-minus-square-o" });
355        if (!this.icon) {
356            this.firstChild.childNodes[1].setAttrs({ "class": "fa fa-fw fa-folder-open" });
357        }
358        container.classList.remove("widgetHidden");
359        const tree = this.tree();
360        if (tree != null) {
361            const detail = { tree: tree, node: this };
362            tree.emit("expand", { detail: detail });
363        }
364    }
365};
366
367/**
368 * Recursively expands this node and all its children. If a depth is
369 * specified, expansions will not continue below that depth.
370 *
371 * @param {number} [depth] the optional maximum depth
372 */
373RapidContext.Widget.TreeNode.prototype.expandAll = function (depth) {
374    if (typeof(depth) !== "number") {
375        depth = 10;
376    }
377    this.expand();
378    if (depth > 0) {
379        const children = this.getChildNodes();
380        for (let i = 0; i < children.length; i++) {
381            children[i].expandAll(depth - 1);
382        }
383    }
384};
385
386/**
387 * Collapses this node to hide any child nodes.
388 */
389RapidContext.Widget.TreeNode.prototype.collapse = function () {
390    const container = this._containerNode();
391    if (container != null && this.isExpanded()) {
392        this.firstChild.childNodes[0].setAttrs({ "class": "fa fa-fw fa-plus-square-o" });
393        if (!this.icon) {
394            this.firstChild.childNodes[1].setAttrs({ "class": "fa fa-fw fa-folder" });
395        }
396        container.classList.add("widgetHidden");
397        const tree = this.tree();
398        if (tree != null) {
399            const detail = { tree: tree, node: this };
400            tree.emit("collapse", { detail: detail });
401        }
402    }
403};
404
405/**
406 * Recursively collapses this node and all its children. If a depth
407 * is specified, only children below that depth will be collapsed.
408 *
409 * @param {number} [depth] the optional minimum depth
410 */
411RapidContext.Widget.TreeNode.prototype.collapseAll = function (depth) {
412    if (typeof(depth) !== "number") {
413        depth = 0;
414    }
415    if (depth <= 0) {
416        this.collapse();
417    }
418    const children = this.getChildNodes();
419    for (let i = 0; i < children.length; i++) {
420        children[i].collapseAll(depth - 1);
421    }
422};
423
424/**
425 * Toggles expand and collapse for this node.
426 */
427RapidContext.Widget.TreeNode.prototype.toggle = function () {
428    if (this.isExpanded()) {
429        this.collapse();
430    } else {
431        this.expand();
432    }
433};
434