Source RapidContext_Widget_TreeNode.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 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    let toggle = RapidContext.Widget.Icon("fa fa-fw");
55    let icon = RapidContext.Widget.Icon("fa fa-fw fa-dot-circle-o");
56    let label = MochiKit.DOM.SPAN({ "class": "widgetTreeNodeText" });
57    let cls = "widgetTreeNodeLabel overflow-ellipsis text-nowrap";
58    let div = MochiKit.DOM.DIV({ "class": cls }, toggle, icon, label);
59    let o = MochiKit.DOM.DIV({}, div);
60    RapidContext.Widget._widgetMixin(o, RapidContext.Widget.TreeNode);
61    o.classList.add("widgetTreeNode");
62    let isFolder = (arguments.length > 1);
63    attrs = Object.assign({ 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 = MochiKit.DOM.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 = Object.assign({}, 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        let 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 all marked tree nodes. When adding or updating tree nodes, any
144 * node modified is automatically unmarked (e.g. by calling `setAttrs`). This
145 * makes it easy to prune a tree after an update, by initially marking all
146 * tree nodes with `markAll()`, inserting or touching all nodes to keep, and
147 * finally calling this method to remove the remaining nodes.
148 *
149 * @example
150 * parent.markAll();
151 * parent.setAttrs();
152 * child.setAttrs();
153 * ...
154 * parent.removeAllMarked();
155 */
156RapidContext.Widget.TreeNode.prototype.removeAllMarked = function () {
157    let children = this.getChildNodes();
158    for (let i = 0; i < children.length; i++) {
159        if (children[i].marked === true) {
160            this.removeChildNode(children[i]);
161        } else {
162            children[i].removeAllMarked();
163        }
164    }
165};
166
167/**
168 * Marks this tree node and all child nodes recursively. When adding or
169 * updating tree nodes, any node modified is automatically unmarked (e.g. by
170 * calling `setAttrs`). This makes it easy to prune a tree after an update, by
171 * initially marking all tree nodes, inserting or touching all nodes to keep,
172 * and finally calling `removeAllMarked()` to remove the remaining nodes.
173 *
174 * @example
175 * parent.markAll();
176 * parent.setAttrs();
177 * child.setAttrs();
178 * ...
179 * parent.removeAllMarked();
180 */
181RapidContext.Widget.TreeNode.prototype.markAll = function () {
182    this.marked = true;
183    let children = this.getChildNodes();
184    for (let i = 0; i < children.length; i++) {
185        children[i].markAll();
186    }
187};
188
189/**
190 * Checks if this node is a folder.
191 *
192 * @return {boolean} `true` if this node is a folder, or
193 *         `false` otherwise
194 */
195RapidContext.Widget.TreeNode.prototype.isFolder = function () {
196    return this._containerNode() != null;
197};
198
199/**
200 * Checks if this folder node is expanded.
201 *
202 * @return {boolean} `true` if this node is expanded, or
203 *         `false` otherwise
204 */
205RapidContext.Widget.TreeNode.prototype.isExpanded = function () {
206    let container = this._containerNode();
207    return !!container && !container.classList.contains("widgetHidden");
208};
209
210/**
211 * Checks if this node is selected.
212 *
213 * @return {boolean} `true` if the node is selected, or
214 *         `false` otherwise
215 */
216RapidContext.Widget.TreeNode.prototype.isSelected = function () {
217    return this.firstChild.classList.contains("selected");
218};
219
220/**
221 * Returns the ancestor tree widget.
222 *
223 * @return {Widget} the ancestor tree widget, or
224 *         null if none was found
225 */
226RapidContext.Widget.TreeNode.prototype.tree = function () {
227    let parent = this.parent();
228    if (parent != null) {
229        return parent.tree();
230    }
231    if (RapidContext.Widget.isWidget(this.parentNode, "Tree")) {
232        return this.parentNode;
233    } else {
234        return null;
235    }
236};
237
238/**
239 * Returns the parent tree node widget.
240 *
241 * @return {Widget} the parent tree node widget, or
242 *         null if this is a root node
243 */
244RapidContext.Widget.TreeNode.prototype.parent = function () {
245    let node = this.parentNode;
246    if (node && node.classList.contains("widgetTreeNodeContainer")) {
247        return node.parentNode;
248    } else {
249        return null;
250    }
251};
252
253/**
254 * Returns the path to this tree node.
255 *
256 * @return {Array} the tree node path, i.e an array of node names
257 */
258RapidContext.Widget.TreeNode.prototype.path = function () {
259    let parent = this.parent();
260    if (parent == null) {
261        return [this.name];
262    } else {
263        let path = parent.path();
264        path.push(this.name);
265        return path;
266    }
267};
268
269/**
270 * Finds a child tree node with the specified name.
271 *
272 * @param {string} name the child tree node name
273 *
274 * @return {Widget} the child tree node found, or
275 *         null if not found
276 */
277RapidContext.Widget.TreeNode.prototype.findChild = function (name) {
278    let children = this.getChildNodes();
279    for (let i = 0; i < children.length; i++) {
280        if (children[i].name == name) {
281            return children[i];
282        }
283    }
284    return null;
285};
286
287/**
288 * Searches for a descendant tree node from the specified path.
289 *
290 * @param {Array} path the tree node path (array of node names)
291 *
292 * @return {Widget} the descendant tree node found, or
293 *         null if not found
294 */
295RapidContext.Widget.TreeNode.prototype.findByPath = function (path) {
296    let node = this;
297    for (let i = 0; node != null && path != null && i < path.length; i++) {
298        node = node.findChild(path[i]);
299    }
300    return node;
301};
302
303/**
304 * Selects this tree node.
305 */
306RapidContext.Widget.TreeNode.prototype.select = function () {
307    this.firstChild.classList.add("selected");
308    let tree = this.tree();
309    if (tree != null) {
310        tree._handleSelect(this);
311    }
312    this.expand();
313};
314
315/**
316 * Unselects this tree node.
317 */
318RapidContext.Widget.TreeNode.prototype.unselect = function () {
319    if (this.isSelected()) {
320        this.firstChild.classList.remove("selected");
321        let tree = this.tree();
322        if (tree != null) {
323            tree._handleSelect(null);
324        }
325    }
326};
327
328/**
329 * Expands this node to display any child nodes. If the parent node
330 * is not expanded, it will be expanded as well.
331 */
332RapidContext.Widget.TreeNode.prototype.expand = function () {
333    let parent = this.parent();
334    if (parent != null && !parent.isExpanded()) {
335        parent.expand();
336    }
337    let container = this._containerNode();
338    if (container != null && !this.isExpanded()) {
339        this.firstChild.childNodes[0].setAttrs({ "class": "fa fa-fw fa-minus-square-o" });
340        if (!this.icon) {
341            this.firstChild.childNodes[1].setAttrs({ "class": "fa fa-fw fa-folder-open" });
342        }
343        container.classList.remove("widgetHidden");
344        let tree = this.tree();
345        if (tree != null) {
346            let detail = { tree: tree, node: this };
347            tree.emit("expand", { detail: detail });
348        }
349    }
350};
351
352/**
353 * Recursively expands this node and all its children. If a depth is
354 * specified, expansions will not continue below that depth.
355 *
356 * @param {number} [depth] the optional maximum depth
357 */
358RapidContext.Widget.TreeNode.prototype.expandAll = function (depth) {
359    if (typeof(depth) !== "number") {
360        depth = 10;
361    }
362    this.expand();
363    let children = this.getChildNodes();
364    for (let i = 0; depth > 0 && i < children.length; i++) {
365        children[i].expandAll(depth - 1);
366    }
367};
368
369/**
370 * Collapses this node to hide any child nodes.
371 */
372RapidContext.Widget.TreeNode.prototype.collapse = function () {
373    let container = this._containerNode();
374    if (container != null && this.isExpanded()) {
375        this.firstChild.childNodes[0].setAttrs({ "class": "fa fa-fw fa-plus-square-o" });
376        if (!this.icon) {
377            this.firstChild.childNodes[1].setAttrs({ "class": "fa fa-fw fa-folder" });
378        }
379        container.classList.add("widgetHidden");
380        let tree = this.tree();
381        if (tree != null) {
382            let detail = { tree: tree, node: this };
383            tree.emit("collapse", { detail: detail });
384        }
385    }
386};
387
388/**
389 * Recursively collapses this node and all its children. If a depth
390 * is specified, only children below that depth will be collapsed.
391 *
392 * @param {number} [depth] the optional minimum depth
393 */
394RapidContext.Widget.TreeNode.prototype.collapseAll = function (depth) {
395    if (typeof(depth) !== "number") {
396        depth = 0;
397    }
398    if (depth <= 0) {
399        this.collapse();
400    }
401    let children = this.getChildNodes();
402    for (let i = 0; i < children.length; i++) {
403        children[i].collapseAll(depth - 1);
404    }
405};
406
407/**
408 * Toggles expand and collapse for this node.
409 */
410RapidContext.Widget.TreeNode.prototype.toggle = function () {
411    if (this.isExpanded()) {
412        this.collapse();
413    } else {
414        this.expand();
415    }
416};
417