@progress/kendo-angular-treeview
Version:
Kendo UI TreeView for Angular
271 lines (269 loc) • 11.8 kB
JavaScript
/**-----------------------------------------------------------------------------------------
* Copyright © 2025 Progress Software Corporation. All rights reserved.
* Licensed under commercial license. See LICENSE.md in the project root for more information
*-------------------------------------------------------------------------------------------*/
import { isDocumentAvailable } from '@progress/kendo-angular-common';
import { isPresent, closestNode, closestWithMatch, hasParent, isContent, nodeId, getContentElement } from '../utils';
import { DropPosition, DropAction, ScrollDirection } from './models';
/**
* Checks if the browser supports relative stacking context.
* https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Positioning/Understanding_z_index/The_stacking_context
*/
const hasRelativeStackingContext = memoize(() => {
if (!(isDocumentAvailable() && isPresent(document.body))) {
return false;
}
const top = 10;
const parent = document.createElement("div");
parent.style.transform = "matrix(10, 0, 0, 10, 0, 0)";
const innerDiv = document.createElement('div');
innerDiv.innerText = 'child';
innerDiv.style.position = 'fixed';
innerDiv.style.top = `${top}px`;
parent.appendChild(innerDiv);
document.body.appendChild(parent);
const isDifferent = parent.children[0].getBoundingClientRect().top !== top;
document.body.removeChild(parent);
return isDifferent;
});
/**
* Stores the result of the passed function's first invokation and returns it instead of invoking it again afterwards.
*/
function memoize(fn) {
let result;
let called = false;
return (...args) => {
if (called) {
return result;
}
result = fn(...args);
called = true;
return result;
};
}
/**
* @hidden
*
* Gets the offset of the parent element if the latter has the `transform` CSS prop applied.
* Transformed parents create new stacking context and the `fixed` children must be position based on the transformed parent.
* https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Positioning/Understanding_z_index/The_stacking_context
*
* If no parent container is `transform`-ed the function will return `{ left: 0, top: 0 }`;
*/
export const getContainerOffset = (element) => {
if (!(element && hasRelativeStackingContext())) {
return { left: 0, top: 0 };
}
let offsetParent = element.parentElement;
while (offsetParent) {
if (window.getComputedStyle(offsetParent).transform !== 'none') {
break;
}
offsetParent = offsetParent.parentElement;
}
if (offsetParent) {
const rect = offsetParent.getBoundingClientRect();
return {
left: rect.left - offsetParent.scrollLeft,
top: rect.top - offsetParent.scrollTop
};
}
return { left: 0, top: 0 };
};
/**
* @hidden
*/
export const getDropAction = (dropPosition, dropTarget) => {
if (!(isPresent(dropPosition) && isPresent(dropTarget))) {
return DropAction.Invalid;
}
switch (dropPosition) {
case DropPosition.Over:
return DropAction.Add;
case DropPosition.Before:
return isPresent(closestNode(dropTarget).previousElementSibling) ? DropAction.InsertMiddle : DropAction.InsertTop;
case DropPosition.After:
return isPresent(closestNode(dropTarget).nextElementSibling) ? DropAction.InsertMiddle : DropAction.InsertBottom;
default:
return DropAction.Invalid;
}
};
/**
* @hidden
*/
export const getDropPosition = (draggedItem, target, clientY, targetTreeView, containerOffset) => {
if (!(isPresent(draggedItem) && isPresent(target) && isPresent(targetTreeView) && isPresent(containerOffset))) {
return;
}
// the .k-treeview-mid element starts just after the checkbox/expand arrow and stretches till the end of the treeview on the right
const item = closestWithMatch(target, '.k-treeview-top, .k-treeview-mid, .k-treeview-bot');
if (!isPresent(item)) {
return;
}
// the content element (.k-treeview-leaf:not(.k-treeview-load-more-button)) holds just the treeview item text
const content = getContentElement(item);
const targetChildOfDraggedItem = hasParent(item, closestNode(draggedItem));
if (!isPresent(content) || (content === draggedItem) || targetChildOfDraggedItem) {
return;
}
const itemViewPortCoords = content.getBoundingClientRect();
/*
if the user is hovering a treeview item, split the item height into four parts:
- dropping into the top quarter should insert the dragged item before the drop target
- dropping into the bottom quarter should insert the dragged item after the drop target
- dropping into the second or third quarter should add the item as child node of the drop target
if the user is NOT hovering a treeview item (he's dragging somewhere on the right), split the item height to just two parts:
- dropping should insert before or after
*/
const itemDivisionHeight = itemViewPortCoords.height / (isContent(target) ? 4 : 2);
// clear any possible container offset created by parent elements with `transform` css property set
const pointerPosition = clientY - containerOffset.top;
const itemTop = itemViewPortCoords.top - containerOffset.top;
if (pointerPosition < itemTop + itemDivisionHeight) {
return DropPosition.Before;
}
if (pointerPosition >= itemTop + itemViewPortCoords.height - itemDivisionHeight) {
return DropPosition.After;
}
return DropPosition.Over;
};
/**
* @hidden
*/
export const treeItemFromEventTarget = (treeView, dropTarget) => {
if (!(isPresent(treeView) && isPresent(dropTarget))) {
return null;
}
const node = closestNode(dropTarget);
const index = nodeId(node);
const lookup = treeView.itemLookup(index);
if (!(isPresent(lookup) && isPresent(lookup.item.dataItem))) {
return null;
}
return lookup;
};
/**
* @hidden
*
* Emits `collapse` on the specified TreeView node if the latter is left empty after its last child node was dragged out.
*/
export const collapseEmptyParent = (parent, parentNodes, treeview) => {
if (isPresent(parent) && parentNodes.length === 0 && treeview.isExpanded(parent.item.dataItem, parent.item.index)) {
treeview.collapseNode(parent.item.dataItem, parent.item.index);
}
};
/**
* @hidden
*
* Expands the node if it's dropped into and it's not yet expanded.
*/
export const expandDropTarget = (dropTarget, treeView) => {
if (!treeView.isExpanded(dropTarget.item.dataItem, dropTarget.item.index)) {
treeView.expandNode(dropTarget.item.dataItem, dropTarget.item.index);
}
};
/**
* @hidden
*
* Extracts the event target from the viewport coords. Required for touch devices
* where the `event.target` of a `pointermove` event is always the initially dragged item.
*/
export const getDropTarget = (event) => {
if (!(isDocumentAvailable() && isPresent(document.elementFromPoint))) {
return event.target;
}
return document.elementFromPoint(event.clientX, event.clientY);
};
/**
* @hidden
*
* Checks if the original index is before the new one and corrects the new one by decrementing the index for the level, where the original item stood.
*/
export const updateMovedItemIndex = (newIndex, originalIndex) => {
const movedItemNewIndexParts = newIndex.split('_');
const originalItemIndexParts = originalIndex.split('_');
// if the original item was moved from a deeper level, there's no need for index correction
// e.g. 4_0_1 is moved to 5_0 => removing 4_0_1 will not cause 5_0 to be moved
if (movedItemNewIndexParts.length < originalItemIndexParts.length) {
return newIndex;
}
// check if the parent item paths are the same - index correction is not required when the original item path differs from the path of the moved item - they belong to different hierarchies
// e.g. 4_1 is moved to 5_1 - the parent item paths are differen (4 compared to 5) => removing 4_1 will not cause 5_1 to be moved
// e.g 4_1 is moved to 4_3 - the parent paths are the same (both 4) => removing 4_1 will cause 4_3 to be moved
const originalItemParentPathLength = originalItemIndexParts.length - 1;
const originalItemParentPath = originalItemIndexParts.slice(0, originalItemParentPathLength).join('_');
const movedItemParentPath = movedItemNewIndexParts.slice(0, originalItemParentPathLength).join('_');
// check if the the index of the level where the original item is taken from is greater than the one of the moved item
// e.g. 4_5 is moved to 4_1 (comapre 5 and 1) => removing 4_5 will not cause 4_1 to be moved
// e.g. 4_1 is moved to 4_5 (comapre 1 and 5) => removing 4_1 will cause 4_5 to be moved
const originalItemIndexLevel = originalItemIndexParts.length - 1;
const originalItemLevelIndex = Number(originalItemIndexParts[originalItemIndexLevel]);
const movedItemLevelIndex = Number(movedItemNewIndexParts[originalItemIndexLevel]);
if ((originalItemParentPath === movedItemParentPath) && (movedItemLevelIndex > originalItemLevelIndex)) {
// if the removed item causes the dropped item to be moved a position up - decrement the index at that level
movedItemNewIndexParts[originalItemIndexLevel] = String(movedItemLevelIndex - 1);
return movedItemNewIndexParts.join('_');
}
return newIndex;
};
/**
* @hidden
*/
const SCROLLBAR_REG_EXP = new RegExp('(auto|scroll)');
/**
* @hidden
*
* Retrives the first scrollable element starting the search from the provided one, traversing to the top of the DOM tree.
*/
export const getScrollableContainer = (node) => {
while (isPresent(node) && node.nodeName !== 'HTML') {
const hasOverflow = node.scrollHeight > node.clientHeight;
const hasScrollbar = SCROLLBAR_REG_EXP.test(getComputedStyle(node).overflowY);
if (hasOverflow && hasScrollbar) {
return node;
}
node = node.parentNode;
}
return node;
};
/**
* @hidden
*
* Checks if the top of the scrollable element is reached.
* Floors the scrollTop value.
*/
const isTopReached = (element) => Math.floor(element.scrollTop) <= 0;
/**
* @hidden
*
* Checks if the bottom of the scrollable element is reached.
* Ceils the scrollTop value.
*/
const isBottomReached = (element) => Math.ceil(element.scrollTop) >= element.scrollHeight - element.clientHeight;
/**
* @hidden
*
* Scrolls the element in the given direction by the provided step.
*
* If the targeted scroll incrementation doesn't yield any result due to device pixel ratio issues (https://github.com/dimitar-pechev/RenderingIndependentScrollOffsets#readme),
* increments the step with 1px and again attempts to change the scrollTop of the element, until the content is actually scrolled.
*
* Cuts the operation short after 20 unsuccessful attempts to prevent infinite loops in possible corner-case scenarios.
*/
export const scrollElementBy = (element, step, direction) => {
if (!(isPresent(element) && isDocumentAvailable())) {
return;
}
const initialScrollTop = element.scrollTop;
let currentStep = step;
let iterations = 0;
while (initialScrollTop === element.scrollTop &&
!(direction === ScrollDirection.Up && isTopReached(element)) &&
!(direction === ScrollDirection.Down && isBottomReached(element)) &&
iterations < 20 // as the bulgarian saying goes - to ties our underpants
) {
element.scrollTop += (currentStep * direction);
currentStep += 1;
iterations += 1;
}
};