UNPKG

@eclipse-scout/core

Version:
1,505 lines (1,333 loc) 132 kB
/* * Copyright (c) 2010, 2026 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 { AbstractTreeNavigationKeyStroke, 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, ScrollToAlignment, ScrollToOptions, strings, tooltips, TreeBreadcrumbFilter, TreeCheckKeyStroke, TreeCheckNodesResult, TreeCollapseAllKeyStroke, TreeCollapseOrDrillUpKeyStroke, TreeEventMap, TreeExpandOrDrillDownKeyStroke, TreeLayout, TreeModel, TreeNavigationDownKeyStroke, TreeNavigationEndKeyStroke, TreeNavigationHomeKeyStroke, TreeNavigationUpKeyStroke, TreeNode, TreeNodeModel, TreeSelectKeyStroke, 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; focusedNode: TreeNode; nodesFocusable = false; filters: Filter<TreeNode>[]; textFilterEnabled: boolean; filterSupport: FilterSupport<TreeNode>; filteredElementsDirty: boolean; filterAnimated: boolean; rebuildSuppressed: boolean; breadcrumbFilter: TreeBreadcrumbFilter; dragAndDropHandler: DragAndDropHandler; 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; 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.tabbable = true; 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.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); this._setFocusedNode(this.focusedNode); } /** * 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); this._updateChildrenChecked(nodes); 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(); this._setNodesFocusable(this.nodesFocusable); } protected _initTreeKeyStrokeContext() { let modifierBitMask = keyStrokeModifier.NONE; this.keyStrokeContext.registerKeyStrokes([ new TreeSelectKeyStroke(this), new TreeCheckKeyStroke(this), new TreeNavigationUpKeyStroke(this, modifierBitMask), new TreeNavigationDownKeyStroke(this, modifierBitMask), new TreeNavigationHomeKeyStroke(this, modifierBitMask), new TreeNavigationEndKeyStroke(this, modifierBitMask), new TreeCollapseAllKeyStroke(this), new TreeCollapseOrDrillUpKeyStroke(this, modifierBitMask, keys.LEFT, '←'), new TreeCollapseOrDrillUpKeyStroke(this, modifierBitMask, keys.SUBTRACT), new TreeExpandOrDrillDownKeyStroke(this, modifierBitMask, keys.RIGHT, '→'), new TreeExpandOrDrillDownKeyStroke(this, modifierBitMask, keys.ADD) ]); } setAutoCheckChildren(autoCheckChildren: boolean) { if (this.setProperty('autoCheckChildren', autoCheckChildren)) { this._updateChildrenChecked(this.nodes); } } /** @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; } this._initTreeNodeInternal(node, parentNode); 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, {checked: false, checkOnlyEnabled: false}); // deleted = unchecked delete this.nodesMap[node.id]; this._removeFromFlatList(node, false); // ensure node is no 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'); 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('focus', this._onFocus.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)); aria.role(this.$data, 'tree'); 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(); this._renderNodesFocusable(); } 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; } override get$Focusable(): JQuery { return this.$data; } protected _onFocus(event: JQuery.FocusEvent) { if (!this.focusedNode) { this.setFocusedNode(this.selectedNodes[0] || this.visibleNodesFlat[0]); } } /** @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); this._renderTabbable(); } 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(); this._updateAriaNodeIndices(); } 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); } protected override _renderTabbable() { if (this.tabbable === false) { this.get$Focusable().setTabbable(false); } else { this.get$Focusable().setTabbableOrFocusable(this.enabledComputed && this.visibleNodesFlat.length > 0); } } 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; } $node.toggleClass('expanded', expanded); aria.expanded($node, expanded); } 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.setSelected(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(); this._updateAriaActiveDescendant(); 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.setSelected(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 _updateChildrenChecked(nodes: TreeNode[]) { if (!this.checkable) { return; } let updatedNodes = new TreeCheckNodesResult(); let parentNodes = new Set<TreeNode>(); for (const node of nodes) { // It is not necessary to update destroyed nodes. Destroyed nodes are unchecked in _destroyTreeNode if (!node.destroyed) { updatedNodes.add(this._updateChildrenCheckedRecursive(node)); } if (node.parentNode) { parentNodes.add(node.parentNode); } } for (const parentNode of Array.from(parentNodes)) { updatedNodes.add(this._checkParentsRecursive(parentNode)); } this._processTreeCheckNodesResult(updatedNodes); } /** * 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.requireRenderTreeNodes.forEach(node => { if (!node.rendered) { return; } node._renderChecked(); node._renderChildrenChecked(); }); } // Trigger event let eventTriggerNodes = checkNodesResult.requireTriggerEventNodes; if (this.checkable && triggerEvent && eventTriggerNodes.size) { this.trigger('nodesChecked', { nodes: Array.from(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; if (node?.destroyed) { return; } 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 al