@eclipse-scout/core
Version:
Eclipse Scout runtime
1,498 lines (1,327 loc) • 126 kB
text/typescript
/*
* Copyright (c) 2010, 2025 BSI Business Systems Integration AG
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
import {
Action, aria, arrays, ContextMenuPopup, DesktopPopupOpenEvent, Device, DoubleClickSupport, dragAndDrop, DragAndDropHandler, DropType, EnumObject, EventHandler, Filter, Filterable, FilterOrFunction, FilterResult, FilterSupport,
FullModelOf, graphics, HtmlComponent, InitModelOf, keys, KeyStrokeContext, keyStrokeModifier, LazyNodeFilter, Menu, MenuBar, MenuDestinations, MenuFilter, MenuItemsOrder, menus as menuUtil, ObjectOrChildModel, ObjectOrModel, objects,
Range, Rectangle, scout, scrollbars, ScrollDirection, ScrollToOptions, strings, tooltips, TreeBreadcrumbFilter, TreeCheckNodesResult, TreeCollapseAllKeyStroke, TreeCollapseOrDrillUpKeyStroke, TreeEventMap, TreeExpandOrDrillDownKeyStroke,
TreeLayout, TreeModel, TreeNavigationDownKeyStroke, TreeNavigationEndKeyStroke, TreeNavigationUpKeyStroke, TreeNode, TreeNodeModel, TreeSpaceKeyStroke, UpdateFilteredElementsOptions, Widget
} from '../index';
import $ from 'jquery';
export class Tree extends Widget implements TreeModel, Filterable<TreeNode> {
declare model: TreeModel;
declare eventMap: TreeEventMap;
declare self: Tree;
toggleBreadcrumbStyleEnabled: boolean;
breadcrumbTogglingThreshold: number;
autoCheckChildren: boolean;
checkable: boolean;
checkableStyle: TreeCheckableStyle;
displayStyle: TreeDisplayStyle;
dropType: DropType;
dropMaximumSize: number;
lazyExpandingEnabled: boolean;
menus: Menu[];
contextMenu: ContextMenuPopup;
menuBar: MenuBar;
keyStrokes: Action[];
multiCheck: boolean;
nodes: TreeNode[];
/** all nodes by id */
nodesMap: Record<string, TreeNode>;
nodePaddingLevelCheckable: number;
nodePaddingLevelNotCheckable: number;
nodePaddingLevelDiffParentHasIcon: number;
nodePaddingLeft: number;
/** is read from CSS */
nodeCheckBoxPaddingLeft: number;
nodeControlPaddingLeft: number;
/** is read from CSS */
nodePaddingLevel: number;
scrollToSelection: boolean;
/** Only necessary for breadcrumb mode */
scrollTopHistory: number[];
selectedNodes: TreeNode[];
/** The previously selected node, relevant for breadcrumb in compact mode */
prevSelectedNode: TreeNode;
filters: Filter<TreeNode>[];
textFilterEnabled: boolean;
filterSupport: FilterSupport<TreeNode>;
filteredElementsDirty: boolean;
filterAnimated: boolean;
rebuildSuppressed: boolean;
breadcrumbFilter: TreeBreadcrumbFilter;
dragAndDropHandler: DragAndDropHandler;
/**
* performance optimization: E.g. rather than iterating over the whole tree when unchecking all nodes,
* we explicitly keep track of nodes to uncheck (useful e.g. for single-check mode in very large trees).
*/
checkedNodes: TreeNode[];
groupedNodes: Record<string, boolean>;
visibleNodesFlat: TreeNode[];
visibleNodesMap: Record<string, boolean>;
viewRangeRendered: Range;
viewRangeDirty: boolean;
viewRangeSize: number;
startAnimationFunc: () => void;
runningAnimations: number;
runningAnimationsFinishFunc: () => void;
nodeHeight: number;
nodeWidth: number;
maxNodeWidth: number;
nodeWidthDirty: boolean;
requestFocusOnNodeControlMouseDown: boolean;
initialTraversing: boolean;
defaultMenuTypes: string[];
$data: JQuery;
$fillBefore: JQuery;
$fillAfter: JQuery;
/** may be used by subclasses to set additional CSS classes */
protected _additionalContainerClasses: string;
protected _renderViewportBlocked: boolean;
protected _doubleClickSupport: DoubleClickSupport;
/** used by _renderExpansion() */
protected _$animationWrapper: JQuery;
protected _$expandAnimationWrappers: JQuery[];
protected _filterMenusHandler: MenuFilter;
protected _popupOpenHandler: EventHandler<DesktopPopupOpenEvent>;
/** contains all parents of a selected node, the selected node and the first level children */
protected _inSelectionPathList: Record<string, boolean>;
protected _scrollDirections: ScrollDirection;
protected _changeNodeTaskScheduled: boolean;
protected _$mouseDownNode: JQuery;
constructor() {
super();
this.toggleBreadcrumbStyleEnabled = false;
this.breadcrumbTogglingThreshold = null;
this.autoCheckChildren = false;
this.checkable = false;
this.checkableStyle = Tree.CheckableStyle.CHECKBOX_TREE_NODE;
this.displayStyle = Tree.DisplayStyle.DEFAULT;
this.dropType = DropType.NONE;
this.dropMaximumSize = dragAndDrop.DEFAULT_DROP_MAXIMUM_SIZE;
this.lazyExpandingEnabled = true;
this.menus = [];
this.contextMenu = null;
this.menuBar = null;
this.keyStrokes = [];
this.multiCheck = true;
this.nodes = [];
this.nodesMap = {};
this.nodePaddingLevelCheckable = 23;
this.nodePaddingLevelNotCheckable = 18;
this.nodePaddingLevelDiffParentHasIcon = null; /* is read from CSS */
this.nodePaddingLeft = null;
this.nodeCheckBoxPaddingLeft = 29;
this.nodeControlPaddingLeft = null;
this.nodePaddingLevel = this.nodePaddingLevelNotCheckable;
this.scrollToSelection = false;
this.scrollTop = 0;
this.scrollTopHistory = [];
this.selectedNodes = [];
this.prevSelectedNode = null;
this.filters = [];
this.textFilterEnabled = true;
this.filterSupport = this._createFilterSupport();
this.filteredElementsDirty = false;
this.filterAnimated = true;
this.checkedNodes = [];
this.groupedNodes = {};
this.visibleNodesFlat = [];
this.visibleNodesMap = {};
this._addWidgetProperties(['menus', 'keyStrokes']);
this._additionalContainerClasses = '';
this._doubleClickSupport = new DoubleClickSupport();
this._$animationWrapper = null;
this._$expandAnimationWrappers = [];
this._filterMenusHandler = this._filterMenus.bind(this);
this._popupOpenHandler = this._onDesktopPopupOpen.bind(this);
this._inSelectionPathList = {};
this._changeNodeTaskScheduled = false;
this.viewRangeRendered = new Range(0, 0);
this.viewRangeSize = 20;
this.startAnimationFunc = function() {
this.runningAnimations++;
}.bind(this);
this.runningAnimations = 0;
this.runningAnimationsFinishFunc = function() {
this.runningAnimations--;
if (this.runningAnimations <= 0) {
this.runningAnimations = 0;
this._renderViewportBlocked = false;
this.invalidateLayoutTree();
}
}.bind(this);
this.nodeHeight = 0;
this.nodeWidth = 0;
this.maxNodeWidth = 0;
this.nodeWidthDirty = false;
this.$data = null;
this._scrollDirections = 'both';
this.requestFocusOnNodeControlMouseDown = true;
this.defaultMenuTypes = [Tree.MenuType.EmptySpace];
this._$mouseDownNode = null;
}
static DisplayStyle = {
DEFAULT: 'default',
BREADCRUMB: 'breadcrumb'
} as const;
static CheckableStyle = {
/**
* Node check is only possible by checking the checkbox.
*/
CHECKBOX: 'checkbox',
/**
* Node check is possible by clicking anywhere on the node.
*/
CHECKBOX_TREE_NODE: 'checkbox_tree_node'
} as const;
static MenuType = {
EmptySpace: 'Tree.EmptySpace',
SingleSelection: 'Tree.SingleSelection',
MultiSelection: 'Tree.MultiSelection',
Header: 'Tree.Header'
} as const;
/**
* Used to calculate the view range size. See {@link calculateViewRangeSize}.
*/
static VIEW_RANGE_DIVISOR = 4;
protected override _init(model: InitModelOf<this>) {
super._init(model);
this.setFilters(this.filters, false);
this.addFilter(new LazyNodeFilter(this), false);
this.breadcrumbFilter = new TreeBreadcrumbFilter(this);
if (this.displayStyle === Tree.DisplayStyle.BREADCRUMB) {
this.addFilter(this.breadcrumbFilter, false);
}
this.initialTraversing = true;
this._setCheckable(this.checkable);
this.ensureTreeNodes(this.nodes);
this._initNodes(this.nodes);
this.initialTraversing = false;
this.menuBar = scout.create(MenuBar, {
parent: this,
position: MenuBar.Position.BOTTOM,
menuOrder: new MenuItemsOrder(this.session, 'Tree', this.defaultMenuTypes),
menuFilter: this._filterMenusHandler,
cssClass: 'bounded'
});
this._updateItemPath(true);
this._setDisplayStyle(this.displayStyle);
this._setKeyStrokes(this.keyStrokes);
this._setMenus(this.menus);
}
/**
* Initialize nodes, applies filters and updates flat list
*/
protected _initNodes(nodes: TreeNode[], parentNode?: TreeNode) {
if (!nodes) {
nodes = this.nodes;
}
Tree.visitNodes(this._initTreeNode.bind(this), nodes, parentNode);
if (typeof this.selectedNodes[0] === 'string') {
this.selectedNodes = this.nodesByIds(this.selectedNodes as unknown as string[]);
}
this._updateSelectionPath();
nodes.forEach(node => this.applyFiltersForNode(node));
Tree.visitNodes((node: TreeNode, parentNode: TreeNode) => this._addToVisibleFlatList(node, false), nodes, parentNode);
}
/**
* Iterates through the given array and converts node-models to instances of {@link TreeNode} (or a subclass).
* If the array element is already a {@link TreeNode} the function leaves the element untouched. This function also
* ensures that the attribute {@link TreeNode.childNodeIndex} is set.
*/
ensureTreeNodes(nodes: ObjectOrModel<TreeNode>[], parentNode?: TreeNode) {
if (nodes.length === 0) {
return;
}
let nextChildNodeIndex = 0;
if (this.initialized) {
let previousNodes = parentNode ? parentNode.childNodes : this.nodes;
if (previousNodes.length > 0) {
nextChildNodeIndex = scout.nvl(previousNodes[previousNodes.length - 1].childNodeIndex, -1) + 1;
}
}
for (let i = 0; i < nodes.length; i++) {
let node = nodes[i];
node.childNodeIndex = scout.nvl(node.childNodeIndex, nextChildNodeIndex + i);
if (node instanceof TreeNode) {
continue;
}
nodes[i] = this._createTreeNode(node);
}
}
protected _createTreeNode(nodeModel?: TreeNodeModel): TreeNode {
nodeModel = nodeModel || {};
nodeModel.objectType = scout.nvl(nodeModel.objectType, TreeNode);
nodeModel.parent = this;
return scout.create(nodeModel as FullModelOf<TreeNode>);
}
protected override _createKeyStrokeContext(): KeyStrokeContext {
return new KeyStrokeContext();
}
protected override _initKeyStrokeContext() {
super._initKeyStrokeContext();
this._initTreeKeyStrokeContext();
}
protected _initTreeKeyStrokeContext() {
let modifierBitMask = keyStrokeModifier.NONE;
this.keyStrokeContext.registerKeyStrokes([
new TreeSpaceKeyStroke(this),
new TreeNavigationUpKeyStroke(this, modifierBitMask),
new TreeNavigationDownKeyStroke(this, modifierBitMask),
new TreeCollapseAllKeyStroke(this, modifierBitMask),
new TreeCollapseOrDrillUpKeyStroke(this, modifierBitMask, keys.LEFT, '←'),
new TreeCollapseOrDrillUpKeyStroke(this, modifierBitMask, keys.SUBTRACT, '-'),
new TreeNavigationEndKeyStroke(this, modifierBitMask),
new TreeExpandOrDrillDownKeyStroke(this, modifierBitMask, keys.RIGHT, '→'),
new TreeExpandOrDrillDownKeyStroke(this, modifierBitMask, keys.ADD, '+')
]);
}
setAutoCheckChildren(autoCheckChildren: boolean) {
this.setProperty('autoCheckChildren', autoCheckChildren);
}
protected _setAutoCheckChildren(autoCheckChildren: boolean) {
this._setProperty('autoCheckChildren', autoCheckChildren);
}
/** @see TreeModel.menus */
setMenus(menus: ObjectOrChildModel<Menu>[]) {
this.setProperty('menus', menus);
}
protected _setMenus(argMenus: Menu[]) {
this.updateKeyStrokes(argMenus, this.menus);
this._setProperty('menus', argMenus);
this._updateMenuBar();
}
protected _updateMenuBar() {
let menuItems = this._filterMenus(this.menus, MenuDestinations.MENU_BAR, false, true);
this.menuBar.setMenuItems(menuItems);
let contextMenuItems = this._filterMenus(this.menus, MenuDestinations.CONTEXT_MENU, true);
if (this.contextMenu) {
this.contextMenu.updateMenuItems(contextMenuItems);
}
}
protected _setKeyStrokes(keyStrokes: Action[]) {
this.updateKeyStrokes(keyStrokes, this.keyStrokes);
this._setProperty('keyStrokes', keyStrokes);
}
protected _resetTreeNode(node: TreeNode, parentNode: TreeNode) {
node.reset();
}
isSelectedNode(node: TreeNode): boolean {
return this.selectedNodes.indexOf(node) > -1;
}
protected _updateSelectionPath() {
let selectedNode = this.selectedNodes[0];
if (!selectedNode) {
return;
}
this._inSelectionPathList[selectedNode.id] = true;
selectedNode.childNodes.forEach(child => {
this._inSelectionPathList[child.id] = true;
});
let parentNode = selectedNode.parentNode;
while (parentNode) {
this._inSelectionPathList[parentNode.id] = true;
parentNode = parentNode.parentNode;
}
}
protected _initTreeNode(node: TreeNode, parentNode: TreeNode) {
this.nodesMap[node.id] = node;
if (parentNode) {
node.parentNode = parentNode;
node.level = node.parentNode.level + 1;
}
if (node.checked) {
this.checkedNodes.push(node);
}
this._initTreeNodeInternal(node, parentNode);
this._updateMarkChildrenChecked(node);
node.initialized = true;
}
/**
* Override this function if you want a custom node init before filtering.
* The default implementation does nothing.
*/
protected _initTreeNodeInternal(node: TreeNode, parentNode: TreeNode) {
// nop
}
protected override _destroy() {
super._destroy();
this.visitNodes(this._destroyTreeNode.bind(this));
this.nodes = []; // finally, clear array with root tree-nodes
}
protected _destroyTreeNode(node: TreeNode) {
this._checkNode(node, false, false); // deleted = unchecked
delete this.nodesMap[node.id];
this._removeFromFlatList(node, false); // ensure node is not longer in visible nodes list.
node.destroy();
if (this._onNodeDeleted) { // Necessary for subclasses
this._onNodeDeleted(node);
}
}
protected _onNodeDeleted(node: TreeNode) {
// nop
}
/**
* pre-order (top-down) traversal of the tree-nodes of this tree.
*
* If func returns true the children of the visited node are not visited.
*/
visitNodes(func: (node: TreeNode, parentNode?: TreeNode) => boolean | void, parentNode?: TreeNode) {
return Tree.visitNodes(func, this.nodes, parentNode);
}
protected override _render() {
this.$container = this.$parent.appendDiv('tree');
aria.role(this.$container, 'tree');
if (this._additionalContainerClasses) {
this.$container.addClass(this._additionalContainerClasses);
}
let layout = new TreeLayout(this);
this.htmlComp = HtmlComponent.install(this.$container, this.session);
this.htmlComp.setLayout(layout);
this._renderData();
this.menuBar.render();
this.session.desktop.on('popupOpen', this._popupOpenHandler);
this._renderCheckableStyle();
}
protected _renderData() {
this.$data = this.$container.appendDiv('tree-data')
.on('contextmenu', this._onContextMenu.bind(this))
.on('mousedown', '.tree-node', this._onNodeMouseDown.bind(this))
.on('mouseup', '.tree-node', this._onNodeMouseUp.bind(this))
.on('dblclick', '.tree-node', this._onNodeDoubleClick.bind(this))
.on('mousedown', '.tree-node-control', this._onNodeControlMouseDown.bind(this))
.on('mouseup', '.tree-node-control', this._onNodeControlMouseUp.bind(this))
.on('dblclick', '.tree-node-control', this._onNodeControlDoubleClick.bind(this));
HtmlComponent.install(this.$data, this.session);
if (this.isHorizontalScrollingEnabled()) {
this.$data.toggleClass('scrollable-tree', true);
}
this._installScrollbars({
axis: this._scrollDirections
});
this._installNodeTooltipSupport();
this._updateNodeDimensions();
// render display style before viewport (not in renderProperties) to have a correct style from the beginning
this._renderDisplayStyle();
this._renderViewport();
}
protected override _renderProperties() {
super._renderProperties();
this._renderTextFilterEnabled();
this._renderMultiCheck();
}
protected override _postRender() {
super._postRender();
this._renderSelection();
}
protected override _remove() {
this.session.desktop.off('popupOpen', this._popupOpenHandler);
this.filterSupport.remove();
// stop all animations
if (this._$animationWrapper) {
this._$animationWrapper.stop(false, true);
}
// Detach nodes from jQuery objects (because those will be removed)
this.visitNodes(this._resetTreeNode.bind(this));
dragAndDrop.uninstallDragAndDropHandler(this);
this._uninstallNodeTooltipSupport();
this.$fillBefore = null;
this.$fillAfter = null;
this.$data = null;
// reset rendered view range because no range is rendered
this.viewRangeRendered = new Range(0, 0);
super._remove();
}
isHorizontalScrollingEnabled(): boolean {
return this._scrollDirections === 'both' || this._scrollDirections === 'x';
}
isTreeNodeCheckEnabled(): boolean {
return this.checkableStyle === Tree.CheckableStyle.CHECKBOX_TREE_NODE;
}
protected override _onScroll(event: JQuery.ScrollEvent) {
let scrollToSelectionBackup = this.scrollToSelection;
this.scrollToSelection = false;
let scrollTop = this.$data[0].scrollTop;
let scrollLeft = this.$data[0].scrollLeft;
if (this.scrollTop !== scrollTop && this.rendered) {
this._renderViewport();
}
this.scrollTop = scrollTop;
this.scrollLeft = scrollLeft;
this.scrollToSelection = scrollToSelectionBackup;
}
override setScrollTop(scrollTop: number) {
this.setProperty('scrollTop', scrollTop);
// call _renderViewport to make sure nodes are rendered immediately. The browser fires the scroll event handled by onDataScroll delayed
if (this.rendered) {
this._renderViewport();
// Render scroll top again to make sure the data really is at the expected position
// This seems only to be necessary for Chrome and the tree, it seems to work for IE and table.
// It is not optimal, because actually it should be possible to modify the $data[0].scrollTop without using this function
// Some debugging showed that after reducing the height of the afterFiller in _renderFiller the scrollTop will be wrong.
// Updating the scrollTop in renderFiller or other view range relevant function is bad because it corrupts smooth scrolling (see also commit c14ce92e0a7bff568d4f2d715e3061a782e728c2)
this._renderScrollTop();
}
}
/** @internal */
override _renderScrollTop() {
if (this.rendering) {
// Not necessary to do it while rendering since it will be done by the layout
return;
}
scrollbars.scrollTop(this.$data, this.scrollTop);
}
override get$Scrollable(): JQuery {
return this.$data;
}
/** @internal */
_renderViewport() {
if (this.runningAnimations > 0 || this._renderViewportBlocked) {
// animation pending do not render view port because finishing should rerenderViewport
return;
}
if (!this.$container.isEveryParentVisible()) {
// If the tree is invisible, the width and height of the nodes cannot be determined
// In that case, the tree won't be layouted either -> as soon as it will be layouted, renderViewport will be called again
return;
}
let viewRange = this._calculateCurrentViewRange();
this._renderViewRange(viewRange);
}
protected _calculateCurrentViewRange(): Range {
let node,
scrollTop = this.$data[0].scrollTop,
maxScrollTop = this.$data[0].scrollHeight - this.$data[0].clientHeight;
if (maxScrollTop === 0 && this.visibleNodesFlat.length > 0) {
// no scrollbars visible
node = this.visibleNodesFlat[0];
} else {
node = this._nodeAtScrollTop(scrollTop);
}
return this._calculateViewRangeForNode(node);
}
protected _rerenderViewport() {
if (this._renderViewportBlocked) {
return;
}
this._removeRenderedNodes();
this._renderFiller();
this._updateDomNodeWidth();
this._renderViewport();
}
protected _removeRenderedNodes() {
let $nodes = this.$data.find('.tree-node');
$nodes.each((i, elem) => {
let $node = $(elem),
node = $node.data('node');
if ($node.hasClass('hiding')) {
// Do not remove nodes which are removed using an animation
return;
}
this._removeNode(node);
});
this.viewRangeRendered = new Range(0, 0);
}
protected _renderViewRangeForNode(node: TreeNode) {
let viewRange = this._calculateViewRangeForNode(node);
this._renderViewRange(viewRange);
}
protected _renderNodesInRange(range: Range) {
let prepend = false;
let nodes = this.visibleNodesFlat;
if (nodes.length === 0) {
return;
}
let maxRange = new Range(0, nodes.length);
range = maxRange.intersect(range);
if (this.viewRangeRendered.size() > 0 && !range.intersect(this.viewRangeRendered).equals(new Range(0, 0))) {
throw new Error('New range must not intersect with existing.');
}
if (range.to <= this.viewRangeRendered.from) {
prepend = true;
}
let newRange = this.viewRangeRendered.union(range);
if (newRange.length === 2) {
throw new Error('Can only prepend or append rows to the existing range. Existing: ' + this.viewRangeRendered + '. New: ' + newRange);
}
this.viewRangeRendered = newRange[0];
let numNodesRendered = this.ensureRangeVisible(range);
$.log.isTraceEnabled() && $.log.trace(numNodesRendered + ' new nodes rendered from ' + range);
}
ensureRangeVisible(range: Range): number {
let nodes = this.visibleNodesFlat;
let nodesToInsert = [];
for (let r = range.from; r < range.to; r++) {
let node = nodes[r];
if (!node.attached) {
nodesToInsert.push(node);
}
}
this._insertNodesInDOM(nodesToInsert);
return nodesToInsert.length;
}
/** @internal */
_renderFiller() {
if (!this.$fillBefore) {
this.$fillBefore = this.$data.prependDiv('tree-data-fill');
}
let fillBeforeDimensions = this._calculateFillerDimension(new Range(0, this.viewRangeRendered.from));
this.$fillBefore.cssHeight(fillBeforeDimensions.height);
if (this.isHorizontalScrollingEnabled()) {
this.$fillBefore.cssWidth(fillBeforeDimensions.width);
this.maxNodeWidth = Math.max(fillBeforeDimensions.width, this.maxNodeWidth);
}
$.log.isTraceEnabled() && $.log.trace('FillBefore height: ' + fillBeforeDimensions.height);
if (!this.$fillAfter) {
this.$fillAfter = this.$data.appendDiv('tree-data-fill');
}
let fillAfterDimensions = {
height: 0,
width: 0
};
fillAfterDimensions = this._calculateFillerDimension(new Range(this.viewRangeRendered.to, this.visibleNodesFlat.length));
this.$fillAfter.cssHeight(fillAfterDimensions.height);
if (this.isHorizontalScrollingEnabled()) {
this.$fillAfter.cssWidth(fillAfterDimensions.width);
this.maxNodeWidth = Math.max(fillAfterDimensions.width, this.maxNodeWidth);
}
$.log.isTraceEnabled() && $.log.trace('FillAfter height: ' + fillAfterDimensions.height);
}
protected _calculateFillerDimension(range: Range): { width: number; height: number } {
let dataWidth = 0;
if (this.rendered) {
// the outer-width is only correct if this tree is already rendered. otherwise wrong values are returned.
dataWidth = this.$data.width();
}
let dimension = {
height: 0,
width: Math.max(dataWidth, this.maxNodeWidth)
};
for (let i = range.from; i < range.to; i++) {
let node = this.visibleNodesFlat[i];
dimension.height += this._heightForNode(node);
dimension.width = Math.max(dimension.width, this._widthForNode(node));
}
return dimension;
}
protected _removeNodesInRange(range: Range) {
let node: TreeNode,
numNodesRemoved = 0,
nodes = this.visibleNodesFlat;
let maxRange = new Range(0, nodes.length);
range = maxRange.intersect(range);
let newRange = this.viewRangeRendered.subtract(range);
if (newRange.length === 2) {
throw new Error('Can only remove nodes at the beginning or end of the existing range. ' + this.viewRangeRendered + '. New: ' + newRange);
}
this.viewRangeRendered = newRange[0];
for (let i = range.from; i < range.to; i++) {
node = nodes[i];
this._removeNode(node);
numNodesRemoved++;
}
$.log.isTraceEnabled() && $.log.trace(numNodesRemoved + ' nodes removed from ' + range + '.');
}
/**
* Just removes the node, does NOT adjust this.viewRangeRendered
*/
protected _removeNode(node: TreeNode) {
let $node = node.$node;
if (!$node) {
return;
}
if ($node.hasClass('hiding')) {
// Do not remove nodes which are removed using an animation
return;
}
// only remove node
$node.detach();
node.attached = false;
}
/**
* Renders the rows visible in the viewport and removes the other rows
*/
protected _renderViewRange(viewRange: Range) {
if (viewRange.from === this.viewRangeRendered.from && viewRange.to === this.viewRangeRendered.to && !this.viewRangeDirty) {
// When node with has changed (because of changes in layout) we must at least
// update the internal node width even though the view-range has not changed.
if (this.nodeWidthDirty) {
this._renderFiller();
this._updateDomNodeWidth();
}
// Range already rendered -> do nothing
return;
}
if (!this.viewRangeDirty) {
let rangesToRender = viewRange.subtract(this.viewRangeRendered);
let rangesToRemove = this.viewRangeRendered.subtract(viewRange);
let maxRange = new Range(0, this.visibleNodesFlat.length);
rangesToRemove.forEach(range => {
this._removeNodesInRange(range);
if (maxRange.to < range.to) {
this.viewRangeRendered = viewRange;
}
});
rangesToRender.forEach(range => {
this._renderNodesInRange(range);
});
} else {
// expansion changed
this.viewRangeRendered = viewRange;
this.ensureRangeVisible(viewRange);
}
// check if at least last and first row in range got correctly rendered
if (this.viewRangeRendered.size() > 0) {
let nodes = this.visibleNodesFlat;
let firstNode = nodes[this.viewRangeRendered.from];
let lastNode = nodes[this.viewRangeRendered.to - 1];
if (this.viewRangeDirty) {
// cleanup nodes before range and after
let $nodesBeforeFirstNode = firstNode.$node.prevAll('.tree-node');
let $nodesAfterLastNode = lastNode.$node.nextAll('.tree-node');
this._cleanupNodes($nodesBeforeFirstNode);
this._cleanupNodes($nodesAfterLastNode);
}
if (!firstNode.attached || !lastNode.attached) {
throw new Error('Nodes not rendered as expected. ' + this.viewRangeRendered +
'. First: ' + graphics.debugOutput(firstNode.$node) +
'. Last: ' + graphics.debugOutput(lastNode.$node) +
'. Length: visibleNodesFlat=' + this.visibleNodesFlat.length + ' nodes=' + this.nodes.length + ' nodesMap=' + Object.keys(this.nodesMap).length);
}
}
this._postRenderViewRange();
this.viewRangeDirty = false;
}
protected _postRenderViewRange() {
this._renderFiller();
this._updateDomNodeWidth();
this._renderSelection();
}
protected _visibleNodesInViewRange(): TreeNode[] {
return this.visibleNodesFlat.slice(this.viewRangeRendered.from, this.viewRangeRendered.to);
}
protected _updateDomNodeWidth() {
if (!this.isHorizontalScrollingEnabled()) {
return;
}
if (!this.rendered || !this.nodeWidthDirty) {
return;
}
let nodes = this._visibleNodesInViewRange();
let maxNodeWidth = this.maxNodeWidth;
// find max-width
maxNodeWidth = nodes.reduce((aggr: number, node) => Math.max(node.width, aggr), scout.nvl(maxNodeWidth, 0));
// set max width on all nodes
nodes.forEach(node => node.$node.cssWidth(maxNodeWidth));
this.nodeWidthDirty = false;
}
protected _cleanupNodes($nodes: JQuery) {
for (let i = 0; i < $nodes.length; i++) {
this._removeNode($nodes.eq(i).data('node'));
}
}
/**
* Returns the TreeNode which is at position scrollTop.
*/
protected _nodeAtScrollTop(scrollTop: number): TreeNode {
let height = 0,
nodeTop;
this.visibleNodesFlat.some((node, i) => {
height += this._heightForNode(node);
if (scrollTop < height) {
nodeTop = node;
return true;
}
return false;
});
let visibleNodesLength = this.visibleNodesFlat.length;
if (!nodeTop && visibleNodesLength > 0) {
nodeTop = this.visibleNodesFlat[visibleNodesLength - 1];
}
return nodeTop;
}
protected _heightForNode(node: TreeNode): number {
let height = 0;
if (node.height) {
height = node.height;
} else {
height = this.nodeHeight;
}
return height;
}
protected _widthForNode(node: TreeNode): number {
let width = 0;
if (node.width) {
width = node.width;
} else {
width = this.nodeWidth;
}
return width;
}
/**
* Returns a range of size this.viewRangeSize. Start of range is nodeIndex - viewRangeSize / 4.
* -> 1/4 of the nodes are before the viewport 2/4 in the viewport 1/4 after the viewport,
* assuming viewRangeSize is 2*number of possible nodes in the viewport (see calculateViewRangeSize).
*/
protected _calculateViewRangeForNode(node: TreeNode): Range {
let viewRange = new Range(),
quarterRange = Math.floor(this.viewRangeSize / Tree.VIEW_RANGE_DIVISOR),
diff;
let nodeIndex = this.visibleNodesFlat.indexOf(node);
viewRange.from = Math.max(nodeIndex - quarterRange, 0);
viewRange.to = Math.min(viewRange.from + this.viewRangeSize, this.visibleNodesFlat.length);
if (!node || nodeIndex === -1) {
return viewRange;
}
// Try to use the whole viewRangeSize (extend from if necessary)
diff = this.viewRangeSize - viewRange.size();
if (diff > 0) {
viewRange.from = Math.max(viewRange.to - this.viewRangeSize, 0);
}
return viewRange;
}
/**
* Calculates the optimal view range size (number of nodes to be rendered).
* It uses the default node height to estimate how many nodes fit in the view port.
* The view range size is this value * 2.
* <p>
* Note: the value calculated by this function is important for calculating the
* 'insertBatch'. When the value becomes smaller than 4 ({@link Tree.VIEW_RANGE_DIVISOR}) this
* will cause errors on inserting nodes at the right position. See #262890.
*/
calculateViewRangeSize(): number {
// Make sure row height is up-to-date (row height may be different after zooming)
this._updateNodeDimensions();
if (this.nodeHeight === 0) {
throw new Error('Cannot calculate view range with nodeHeight = 0');
}
let viewRangeMultiplier = Tree.VIEW_RANGE_DIVISOR / 2; // See _calculateViewRangeForNode
let viewRange = Math.ceil(this.$data.outerHeight() / this.nodeHeight) * viewRangeMultiplier;
return Math.max(Tree.VIEW_RANGE_DIVISOR, viewRange);
}
setViewRangeSize(viewRangeSize: number) {
if (this.viewRangeSize === viewRangeSize) {
return;
}
this._setProperty('viewRangeSize', viewRangeSize);
if (this.rendered) {
this._renderViewport();
}
}
protected _updateNodeDimensions() {
let emptyNode = this._createTreeNode();
let $node = this._renderNode(emptyNode).appendTo(this.$data);
this.nodeHeight = $node.outerHeight(true);
if (this.isHorizontalScrollingEnabled()) {
let oldNodeWidth = this.nodeWidth;
this.nodeWidth = $node.outerWidth(true);
if (oldNodeWidth !== this.nodeWidth) {
this.viewRangeDirty = true;
}
}
emptyNode.reset();
}
/**
* Updates the node heights for every visible node and clears the height of the others
*/
updateNodeHeights() {
this.visibleNodesFlat.forEach(node => {
if (!node.attached) {
node.height = null;
} else {
node.height = node.$node.outerHeight(true);
}
});
}
removeAllNodes() {
this._removeNodes(this.nodes);
}
/**
* @param parentNode
* Optional. If provided, this node's state will be updated (e.g. it will be collapsed
* if it does no longer have child nodes). Can also be an array, in which case all of
* those nodes are updated.
*/
protected _removeNodes(nodes: TreeNode[], parentNode?: TreeNode | TreeNode[]) {
if (nodes.length === 0) {
return;
}
nodes.forEach(node => {
this._removeFromFlatList(node, true);
if (node.childNodes.length > 0) {
this._removeNodes(node.childNodes, node);
}
if (node.$node) {
if (this._$animationWrapper && this._$animationWrapper.find(node.$node).length > 0) {
this._$animationWrapper.stop(false, true);
}
node.reset();
}
});
// If every child node was deleted mark node as collapsed (independent of the model state)
// --> makes it consistent with addNodes and expand (expansion is not allowed if there are no child nodes)
arrays.ensure(parentNode).forEach(p => {
if (p && p.$node && p.childNodes.length === 0) {
p.$node.removeClass('expanded lazy');
aria.expanded(p.$node, null);
}
});
if (this.rendered) {
this.viewRangeDirty = true;
this.invalidateLayoutTree();
}
}
protected _renderNode(node: TreeNode): JQuery {
let paddingLeft = this._computeNodePaddingLeft(node);
node.render(this.$container, paddingLeft);
return node.$node;
}
protected _removeMenus() {
// menubar takes care of removal
}
protected _filterMenus(argMenus: Menu[], destination: MenuDestinations, onlyVisible?: boolean, enableDisableKeyStrokes?: boolean): Menu[] {
return menuUtil.filterAccordingToSelection('Tree', this.selectedNodes.length, argMenus, destination, {onlyVisible, enableDisableKeyStrokes, defaultMenuTypes: this.defaultMenuTypes});
}
protected override _renderEnabled() {
super._renderEnabled();
this._installOrUninstallDragAndDropHandler();
let enabled = this.enabledComputed;
this.$data.setEnabled(enabled);
this.$container.setTabbableOrFocusable(enabled);
}
protected override _renderDisabledStyle() {
super._renderDisabledStyle();
this._renderDisabledStyleInternal(this.$data);
}
setCheckable(checkable: boolean) {
this.setProperty('checkable', checkable);
}
protected _setCheckable(checkable: boolean) {
this._setProperty('checkable', checkable);
this._updateNodePaddingLevel();
}
protected _updateNodePaddingLevel() {
if (this.isBreadcrumbStyleActive()) {
this.nodePaddingLevel = 0;
} else if (this.checkable) {
this.nodePaddingLevel = this.nodePaddingLevelCheckable;
} else {
this.nodePaddingLevel = this.nodePaddingLevelNotCheckable;
}
}
setCheckableStyle(checkableStyle: TreeCheckableStyle) {
this.setProperty('checkableStyle', checkableStyle);
}
protected _renderCheckable() {
// Define helper functions
let isNodeRendered = (node: TreeNode) => Boolean(node.$node);
let updateCheckableStateRec = (node: TreeNode) => {
let $node = node.$node;
let $control = $node.children('.tree-node-control');
let $checkbox = $node.children('.tree-node-checkbox');
node._updateControl($control);
if (this.checkable) {
if ($checkbox.length === 0) {
node._renderCheckbox();
}
} else {
$checkbox.remove();
}
$node.cssPaddingLeft(this._computeNodePaddingLeft(node));
// Recursion
if (node.childNodes) {
node.childNodes.filter(isNodeRendered).forEach(updateCheckableStateRec);
}
};
// Start recursion
this.nodes.filter(isNodeRendered).forEach(updateCheckableStateRec);
}
protected _renderDisplayStyle() {
this.$container.toggleClass('breadcrumb', this.isBreadcrumbStyleActive());
this.nodePaddingLeft = null;
this.nodeControlPaddingLeft = null;
let nodeElements = objects.values(this.nodesMap)
.filter(n => !!n.$node)
.map(n => n.$node.get(0));
this._updateNodePaddingsLeft($(nodeElements));
// update scrollbar if mode has changed (from tree to bc or vice versa)
this.invalidateLayoutTree();
}
protected _renderExpansion(node: TreeNode, options?: TreeRenderExpansionOptions) {
let opts: TreeRenderExpansionOptions = {
expandLazyChanged: false,
expansionChanged: false
};
$.extend(opts, options);
let $node = node.$node,
expanded = node.expanded;
// Only render if node is rendered to make it possible to expand/collapse currently hidden nodes (used by collapseAll).
if (!$node || $node.length === 0) {
return;
}
// Only expand / collapse if there are child nodes
if (node.childNodes.length === 0) {
return;
}
$node.toggleClass('lazy', expanded && node.expandedLazy);
if (!opts.expansionChanged && !opts.expandLazyChanged) {
// Expansion state has not changed -> return
return;
}
if (expanded) {
$node.addClass('expanded');
aria.expanded($node, true);
} else {
$node.removeClass('expanded');
aria.expanded($node, false);
}
}
protected _renderSelection() {
// Add children class to root nodes if no nodes are selected
if (this.selectedNodes.length === 0) {
this.nodes.forEach(childNode => {
if (childNode.rendered) {
childNode.$node.addClass('child-of-selected');
}
});
}
this.$container.toggleClass('no-nodes-selected', this.selectedNodes.length === 0);
this.selectedNodes.forEach(node => {
if (!this.visibleNodesMap[node.id]) {
return;
}
// Mark all ancestor nodes, especially necessary for bread crumb mode
let parentNode = node.parentNode;
if (parentNode && parentNode.rendered) {
parentNode.$node.addClass('parent-of-selected');
}
while (parentNode) {
if (parentNode.rendered) {
parentNode.$node.addClass('ancestor-of-selected');
}
parentNode = parentNode.parentNode;
}
// Mark all child nodes
if (node.expanded) {
node.childNodes.forEach(childNode => {
if (childNode.rendered) {
childNode.$node.addClass('child-of-selected');
}
});
}
if (node.rendered) {
node.$node.select(true);
aria.selected(node.$node, true);
}
});
// Update 'group' markers for all rendered nodes
for (let i = this.viewRangeRendered.from; i < this.viewRangeRendered.to; i++) {
if (i >= this.visibleNodesFlat.length) {
break;
}
let node = this.visibleNodesFlat[i];
if (node && node.rendered) {
node.$node.toggleClass('group', Boolean(this.groupedNodes[node.id]));
}
}
this._updateNodePaddingsLeft();
this._highlightPrevSelectedNode();
if (this.scrollToSelection) {
this.revealSelection();
}
}
protected _renderCheckableStyle() {
this.$data.toggleClass('checkable', this.isTreeNodeCheckEnabled());
}
protected _highlightPrevSelectedNode() {
if (!this.isBreadcrumbStyleActive()) {
return;
}
if (!this.prevSelectedNode || !this.prevSelectedNode.rendered || this.prevSelectedNode.prevSelectionAnimationDone) {
return;
}
// Highlight previously selected node, but do it only once
if (this.prevSelectedNode.$node.hasClass('animate-prev-selected')) {
return;
}
this.prevSelectedNode.$node.addClassForAnimation('animate-prev-selected').oneAnimationEnd(() => {
this.prevSelectedNode.prevSelectionAnimationDone = true;
});
}
protected _removeSelection() {
// Remove children class on root nodes if no nodes were selected
if (this.selectedNodes.length === 0) {
this.nodes.forEach(childNode => {
if (childNode.rendered) {
childNode.$node.removeClass('child-of-selected');
}
});
}
// Ensure animate-prev-selected class is removed (in case animation did not start)
if (this.prevSelectedNode && this.prevSelectedNode.rendered) {
this.prevSelectedNode.$node.removeClass('animate-prev-selected');
}
this.selectedNodes.forEach(this._removeNodeSelection, this);
}
protected _removeNodeSelection(node: TreeNode) {
if (node.rendered) {
node.$node.select(false);
aria.selected(node.$node, null);
}
// remove ancestor and child classes
let parentNode = node.parentNode;
if (parentNode && parentNode.rendered) {
parentNode.$node.removeClass('parent-of-selected');
}
while (parentNode && parentNode.rendered) {
parentNode.$node.removeClass('ancestor-of-selected');
parentNode = parentNode.parentNode;
}
if (node.expanded) {
node.childNodes.forEach(childNode => {
if (childNode.rendered) {
childNode.$node.removeClass('child-of-selected');
}
});
}
}
setDropType(dropType: DropType) {
this.setProperty('dropType', dropType);
}
protected _renderDropType() {
this._installOrUninstallDragAndDropHandler();
}
setDropMaximumSize(dropMaximumSize: number) {
this.setProperty('dropMaximumSize', dropMaximumSize);
}
protected _installOrUninstallDragAndDropHandler() {
dragAndDrop.installOrUninstallDragAndDropHandler(
{
target: this,
doInstall: () => this.dropType && this.enabledComputed,
selector: '.tree-data,.tree-node',
onDrop: event => this.trigger('drop', event),
dropType: () => this.dropType,
additionalDropProperties: event => {
let $target = $(event.currentTarget);
let properties = {
nodeId: ''
};
if ($target.hasClass('tree-node')) {
let node = $target.data('node');
properties.nodeId = node.id;
}
return properties;
}
});
}
protected _updateMarkChildrenChecked(node: TreeNode) {
let checkResult = this._checkParentsRecursive(node);
if (!node.initialized) {
// No rendering of nodes which have not been initialized yet
checkResult.removeNode(node);
}
// Trigger events and render
this._processTreeCheckNodesResult(checkResult);
}
/**
* Processes a {@link TreeCheckNodesResult} object. It renders all updated nodes and triggers
* a `nodesChecked` event.
*
* @param checkNodesResult object with tree node check update
* @param triggerEvent indicates whether events should be triggered or not. Default is true.
*/
protected _processTreeCheckNodesResult(checkNodesResult: TreeCheckNodesResult, triggerEvent = true) {
// Render
if (this.rendered) {
checkNodesResult.getNodesForRendering().forEach(node => {
node._renderChecked();
node._renderChildrenChecked();
});
}
// Trigger event
let eventTriggerNodes = checkNodesResult.getNodesForEventTrigger();
if (this.checkable && triggerEvent && eventTriggerNodes.length) {
this.trigger('nodesChecked', {
nodes: eventTriggerNodes
});
}
}
protected _installNodeTooltipSupport() {
tooltips.install(this.$data, {
parent: this,
selector: '.tree-node',
text: this._nodeTooltipText.bind(this),
arrowPosition: 50,
arrowPositionUnit: '%',
nativeTooltip: !Device.get().isCustomEllipsisTooltipPossible(),
originProducer: this._treeNodeTooltipOrigin.bind(this)
});
}
protected _treeNodeTooltipOrigin($node: JQuery): Rectangle {
// Measure entire node
let origin = graphics.offsetBounds($node);
// Measure content
let $text = $node.children('.text');
let textOffset = graphics.offset($text);
let textSize = graphics.prefSize($text);
if (strings.hasText($text.text())) {
// Text (with or without icon)
origin.x = textOffset.x;
origin.width = textSize.width;
} else {
let $icon = $node.children('.icon');
if ($icon.length) {
// Icon only
let iconOffset = graphics.offset($icon);
let iconSize = graphics.size($icon);
let iconInsets = graphics.insets($icon);
origin.x = iconOffset.x + iconInsets.left;
origin.width = iconSize.width - iconInsets.horizontal();
} else {
// Neither text nor icon
origin.x = textOffset.x;
origin.width = 10;
}
}
// Intersect with scroll parents
origin = scrollbars.intersectViewport(origin, $node.scrollParents());
return origin;
}
protected _uninstallNodeTooltipSupport() {
tooltips.uninstall(this.$data);
}
protected _nodeTooltipText($node: JQuery): string {
let node = $node.data('node') as TreeNode;
if (node.tooltipText) {
return node.tooltipText;
}
if (this._isTruncatedNodeTooltipEnabled() && node.$text.isContentTruncated()) {
return node.$text.text();
}
}
protected _isTruncatedNodeTooltipEnabled(): boolean {
return true;
}
setDisplayStyle(displayStyle: TreeDisplayStyle) {
if (this.displayStyle === displayStyle) {
return;
}
this._renderViewportBlocked = true;
this._setDisplayStyle(displayStyle);
if (this.rendered) {
this._renderDisplayStyle();
}
this._renderViewportBlocked = false;
}
protected _setDisplayStyle(displayStyle: TreeDisplayStyle) {
this._setProperty('displayStyle', displayStyle);
if (this.displayStyle === Tree.DisplayStyle.BREADCRUMB) {
if (this.selectedNodes.length > 0) {
let selectedNode = this.selectedNodes[0];
if (!selectedNode.expanded) {
this.expandNode(selectedNode);
}
}
this.filterAnimated = false;
this.addFilter(this.breadcrumbFilter, false);
this.filterVisibleNodes();
} else {
this.removeFilter(this.breadcrumbFilter);
this.filterAnimated = true;
}
this._updateNodePaddingLevel();
}
protected _updateNodePaddingsLeft($nodesToUpdate?: JQuery) {
let $nodes = $nodesToUpdate || this.$nodes();
$nodes.each((index: number, element: HTMLElement) => {
let $node = $(element);
let node = $node.data('node') as TreeNode;
let paddingLeft = this._computeNodePaddingLeft(node);
$node.cssPaddingLeft(objects.isNullOrUndefined(paddingLeft) ? '' : paddingLeft);
node._updateControl($node.children('.tree-node-control'));
});
}
setBreadcrumbStyleActive(active: boolean) {
if (active) {
this.setDisplayStyle(Tree.DisplayStyle.BREADCRUMB);
} else {
this.setDisplayStyle(Tree.DisplayStyle.DEFAULT);
}
}
isNodeInBreadcrumbVisible(node: TreeNode): boolean {
return this._inSelectionPathList[node.id] === undefined ? false : this._inSelectionPathList[node.id];
}
isBreadcrumbStyleActive(): boolean {
return this.displayStyle === Tree.DisplayStyle.BREADCRUMB;
}
setToggleBreadcrumbStyleEnabled(enabled: boolean) {
this.setProperty('toggleBreadcrumbStyleEnabled', enabled);
}
setBreadcrumbTogglingThreshold(width: number) {
this.setProperty('breadcrumbTogglingThreshold', width);
}
expandNode(node: TreeNode, opts?: TreeNodeExpandOptions) {
this.setNodeExpanded(node, true, opts);
}
collapseNode(node: TreeNode, opts?: TreeNodeExpandOptions) {
this.setNodeExpanded(node, false, opts);
}
collapseAll() {
this.rebuildSuppressed = true;
// Collapse all expanded child nodes (only model)
this.visitNodes(node => this.collapseNode(node));
if (this.rendered) {
// ensure correct rendering
this._rerenderViewport();
}
this.rebuildSuppressed = false;
}
setNodeExpanded(node: TreeNode, expanded: boolean, opts?: TreeNodeExpandOptions) {
opts = opts || {};
let lazy = opts.lazy;
if (objects.isNullOrUndefined(lazy)) {
if (node.expanded === expanded) {
// no state change: Keep the current "expandedLazy" state
lazy = node.expandedLazy;
} else if (expanded) {
// collapsed -> expanded: Set the "expandedLazy" state to the node's "lazyExpandingEnabled" flag
lazy = node.lazyExpandingEnabled;
} else {
// expanded -> collapsed: Set the "expandedLazy" state to false
lazy = false;
}
}
let renderAnimated: boolean = scout.nvl(opts.renderAnimated, true);
// Never do lazy expansion if it is disabled on the tree
if (!this.lazyExpandingEnabled) {
lazy = false;
}
if (this.isBreadcrumbStyleActive()) {
// Do not allow to collapse a selected node
if (!expanded && this.selectedNodes.indexOf(node) > -1) {
this.setNodeExpanded(node, true, opts);
return;
}
}
// Optionally collapse all children (recursively)
if (opts.collapseChildNodes) {
// Suppress render expansion
let childOpts = objects.valueCopy(opts);
childOpts.renderExpansion = false;
node.childNodes.forEach(childNode => {
if (childNode.expanded) {
this.collapseNode(childNode, childOpts);
}
});
}
let renderExpansionOpts: TreeRenderExpansionOptions = {
expa