UNPKG

@progress/kendo-angular-treeview

Version:
271 lines (269 loc) 11.8 kB
/**----------------------------------------------------------------------------------------- * 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; } };