UNPKG

@eclipse-scout/core

Version:
1,498 lines (1,327 loc) 126 kB
/* * 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