mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-15 01:48:06 +00:00
Compare commits
26 Commits
pr5-list-v
...
feat/tree-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a5baa6256 | ||
|
|
d87a2fe2f9 | ||
|
|
25d34c6163 | ||
|
|
31c3dc643b | ||
|
|
3e86090427 | ||
|
|
cf54456763 | ||
|
|
b7c5aaa99d | ||
|
|
e539439d8f | ||
|
|
2e7fda6a9c | ||
|
|
1cd97cb7e7 | ||
|
|
77ab1a58a8 | ||
|
|
2810184d12 | ||
|
|
cc3509430b | ||
|
|
f06649ae42 | ||
|
|
22f2ef1898 | ||
|
|
89d0554746 | ||
|
|
bd47fb376c | ||
|
|
7042c5dbb4 | ||
|
|
489c82fb3b | ||
|
|
ed186f9033 | ||
|
|
e094e42c07 | ||
|
|
0738773b15 | ||
|
|
8656cc8468 | ||
|
|
6a6301012f | ||
|
|
19d43ea455 | ||
|
|
d0005770aa |
@@ -1,45 +1,53 @@
|
||||
<template>
|
||||
<Tree
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
v-model:selection-keys="selectionKeys"
|
||||
class="tree-explorer px-2 py-0 2xl:px-4 bg-transparent"
|
||||
:class="props.class"
|
||||
:value="renderedRoot.children"
|
||||
selection-mode="single"
|
||||
:pt="{
|
||||
nodeLabel: 'tree-explorer-node-label',
|
||||
nodeContent: ({ context }) => ({
|
||||
class: 'group/tree-node',
|
||||
onClick: (e: MouseEvent) =>
|
||||
onNodeContentClick(e, context.node as RenderedTreeExplorerNode),
|
||||
onContextmenu: (e: MouseEvent) =>
|
||||
handleContextMenu(e, context.node as RenderedTreeExplorerNode)
|
||||
}),
|
||||
nodeToggleButton: () => ({
|
||||
onClick: (e: MouseEvent) => {
|
||||
e.stopImmediatePropagation()
|
||||
}
|
||||
})
|
||||
}"
|
||||
<div
|
||||
ref="treeContainerRef"
|
||||
class="tree-container overflow-y-auto max-h-[calc(100vh-144px)]"
|
||||
>
|
||||
<template #folder="{ node }">
|
||||
<slot name="folder" :node="node">
|
||||
<TreeExplorerTreeNode :node="node" />
|
||||
</slot>
|
||||
</template>
|
||||
<template #node="{ node }">
|
||||
<slot name="node" :node="node">
|
||||
<TreeExplorerTreeNode :node="node" />
|
||||
</slot>
|
||||
</template>
|
||||
</Tree>
|
||||
<Tree
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
v-model:selection-keys="selectionKeys"
|
||||
class="tree-explorer px-2 py-0 2xl:px-4 bg-transparent"
|
||||
:class="props.class"
|
||||
:value="displayRoot.children"
|
||||
selection-mode="single"
|
||||
:pt="{
|
||||
nodeLabel: 'tree-explorer-node-label',
|
||||
nodeContent: ({ context }) => ({
|
||||
class: 'group/tree-node',
|
||||
onClick: (e: MouseEvent) =>
|
||||
onNodeContentClick(e, context.node as RenderedTreeExplorerNode),
|
||||
onContextmenu: (e: MouseEvent) =>
|
||||
handleContextMenu(e, context.node as RenderedTreeExplorerNode)
|
||||
}),
|
||||
nodeToggleButton: () => ({
|
||||
onClick: (e: MouseEvent) => {
|
||||
e.stopImmediatePropagation()
|
||||
}
|
||||
}),
|
||||
nodeChildren: ({ instance }) =>
|
||||
getNodeChildrenStyle(instance.node as RenderedTreeExplorerNode)
|
||||
}"
|
||||
>
|
||||
<template #folder="{ node }">
|
||||
<slot name="folder" :node="node">
|
||||
<TreeExplorerTreeNode :node="node" />
|
||||
</slot>
|
||||
</template>
|
||||
<template #node="{ node }">
|
||||
<slot name="node" :node="node">
|
||||
<TreeExplorerTreeNode :node="node" />
|
||||
</slot>
|
||||
</template>
|
||||
</Tree>
|
||||
</div>
|
||||
<ContextMenu ref="menu" :model="menuItems" />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useElementSize, useScroll } from '@vueuse/core'
|
||||
import ContextMenu from 'primevue/contextmenu'
|
||||
import type { MenuItem, MenuItemCommandEvent } from 'primevue/menuitem'
|
||||
import Tree from 'primevue/tree'
|
||||
import { computed, provide, ref } from 'vue'
|
||||
import { computed, provide, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
|
||||
@@ -53,14 +61,23 @@ import type {
|
||||
RenderedTreeExplorerNode,
|
||||
TreeExplorerNode
|
||||
} from '@/types/treeExplorerTypes'
|
||||
import { combineTrees, findNodeByKey } from '@/utils/treeUtil'
|
||||
import { combineTrees } from '@/utils/treeUtil'
|
||||
import type { WindowRange } from '@/utils/virtualListUtils'
|
||||
import {
|
||||
applyWindow,
|
||||
calculateChildrenListBounds,
|
||||
calculateSpacerHeightsVariable,
|
||||
calculateTreePositionsAndHeights,
|
||||
calculateWindowRangeByHeights,
|
||||
createInitialWindowRange,
|
||||
mergeWindowRange
|
||||
} from '@/utils/virtualListUtils'
|
||||
|
||||
const expandedKeys = defineModel<Record<string, boolean>>('expandedKeys', {
|
||||
required: true
|
||||
})
|
||||
provide(InjectKeyExpandedKeys, expandedKeys)
|
||||
const selectionKeys = defineModel<Record<string, boolean>>('selectionKeys')
|
||||
// Tracks whether the caller has set the selectionKeys model.
|
||||
const storeSelectionKeys = selectionKeys.value !== undefined
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -84,28 +101,91 @@ const {
|
||||
}
|
||||
)
|
||||
|
||||
const renderedRoot = computed<RenderedTreeExplorerNode>(() => {
|
||||
const renderedRoot = fillNodeInfo(props.root)
|
||||
return newFolderNode.value
|
||||
? combineTrees(renderedRoot, newFolderNode.value)
|
||||
: renderedRoot
|
||||
const DEFAULT_NODE_HEIGHT = 32
|
||||
const SCROLL_THROTTLE = 64
|
||||
|
||||
const treeContainerRef = ref<HTMLDivElement | null>(null)
|
||||
const menu = ref<InstanceType<typeof ContextMenu> | null>(null)
|
||||
const menuTargetNode = ref<RenderedTreeExplorerNode | null>(null)
|
||||
const renameEditingNode = ref<RenderedTreeExplorerNode | null>(null)
|
||||
const parentNodeWindowRanges = ref<Record<string, WindowRange>>({})
|
||||
const nodeHeightsCache = ref<Map<string, number>>(new Map())
|
||||
|
||||
const { height: containerHeight } = useElementSize(treeContainerRef)
|
||||
const { y: scrollY } = useScroll(treeContainerRef, {
|
||||
throttle: SCROLL_THROTTLE,
|
||||
eventListenerOptions: { passive: true }
|
||||
})
|
||||
const getTreeNodeIcon = (node: TreeExplorerNode) => {
|
||||
if (node.getIcon) {
|
||||
const icon = node.getIcon()
|
||||
if (icon) {
|
||||
return icon
|
||||
}
|
||||
} else if (node.icon) {
|
||||
return node.icon
|
||||
}
|
||||
// node.icon is undefined
|
||||
if (node.leaf) {
|
||||
return 'pi pi-file'
|
||||
}
|
||||
const isExpanded = expandedKeys.value?.[node.key] ?? false
|
||||
return isExpanded ? 'pi pi-folder-open' : 'pi pi-folder'
|
||||
|
||||
const viewRows = computed(() =>
|
||||
containerHeight.value
|
||||
? Math.ceil(containerHeight.value / DEFAULT_NODE_HEIGHT)
|
||||
: 0
|
||||
)
|
||||
const bufferRows = computed(() => Math.max(1, Math.floor(viewRows.value / 3)))
|
||||
const windowSize = computed(() => viewRows.value + bufferRows.value * 2)
|
||||
|
||||
const isNodeExpanded = (node: RenderedTreeExplorerNode): boolean =>
|
||||
!!(node.children && !node.leaf && expandedKeys.value?.[node.key])
|
||||
|
||||
const calculateNodePositionsAndHeights = (
|
||||
root: RenderedTreeExplorerNode
|
||||
): { positions: Map<string, number>; heights: Map<string, number> } => {
|
||||
return calculateTreePositionsAndHeights({
|
||||
root,
|
||||
itemHeight: DEFAULT_NODE_HEIGHT,
|
||||
getChildren: (node) => node.children,
|
||||
isExpanded: isNodeExpanded
|
||||
})
|
||||
}
|
||||
|
||||
const calculateNodeWindowRange = (
|
||||
node: RenderedTreeExplorerNode,
|
||||
nodePositions: Map<string, number>,
|
||||
nodeHeights: Map<string, number>,
|
||||
scrollTop: number,
|
||||
scrollBottom: number,
|
||||
bufferHeight: number
|
||||
): WindowRange | null => {
|
||||
if (!isNodeExpanded(node)) return null
|
||||
|
||||
const children = node.children!
|
||||
if (!children.length) return null
|
||||
|
||||
const { listStart, listEnd } = calculateChildrenListBounds({
|
||||
node,
|
||||
children,
|
||||
nodePositions,
|
||||
nodeHeights,
|
||||
itemHeight: DEFAULT_NODE_HEIGHT
|
||||
})
|
||||
|
||||
const getItemStart = (child: RenderedTreeExplorerNode) =>
|
||||
nodePositions.get(child.key) ?? 0
|
||||
const getItemHeight = (child: RenderedTreeExplorerNode) =>
|
||||
nodeHeights.get(child.key) ?? DEFAULT_NODE_HEIGHT
|
||||
|
||||
return calculateWindowRangeByHeights({
|
||||
items: children,
|
||||
listStart,
|
||||
listEnd,
|
||||
scrollTop,
|
||||
scrollBottom,
|
||||
bufferHeight,
|
||||
bufferRows: bufferRows.value,
|
||||
windowSize: windowSize.value,
|
||||
getItemStart,
|
||||
getItemHeight
|
||||
})
|
||||
}
|
||||
|
||||
const getTreeNodeIcon = (node: TreeExplorerNode): string => {
|
||||
const customIcon = node.getIcon?.() ?? node.icon
|
||||
if (customIcon) return customIcon
|
||||
if (node.leaf) return 'pi pi-file'
|
||||
return expandedKeys.value?.[node.key] ? 'pi pi-folder-open' : 'pi pi-folder'
|
||||
}
|
||||
|
||||
const fillNodeInfo = (node: TreeExplorerNode): RenderedTreeExplorerNode => {
|
||||
const children = node.children?.map(fillNodeInfo) ?? []
|
||||
const totalLeaves = node.leaf
|
||||
@@ -117,37 +197,182 @@ const fillNodeInfo = (node: TreeExplorerNode): RenderedTreeExplorerNode => {
|
||||
children,
|
||||
type: node.leaf ? 'node' : 'folder',
|
||||
totalLeaves,
|
||||
badgeText: node.getBadgeText ? node.getBadgeText() : undefined,
|
||||
badgeText: node.getBadgeText?.() ?? undefined,
|
||||
isEditingLabel: node.key === renameEditingNode.value?.key
|
||||
}
|
||||
}
|
||||
|
||||
const renderedRoot = computed<RenderedTreeExplorerNode>(() => {
|
||||
const root = fillNodeInfo(props.root)
|
||||
return newFolderNode.value ? combineTrees(root, newFolderNode.value) : root
|
||||
})
|
||||
|
||||
// Build a lookup map for O(1) node access instead of O(n) tree traversal
|
||||
const nodeKeyMap = computed<Record<string, RenderedTreeExplorerNode>>(() => {
|
||||
const map: Record<string, RenderedTreeExplorerNode> = {}
|
||||
const buildMap = (node: RenderedTreeExplorerNode) => {
|
||||
map[node.key] = node
|
||||
node.children?.forEach(buildMap)
|
||||
}
|
||||
buildMap(renderedRoot.value)
|
||||
return map
|
||||
})
|
||||
|
||||
const updateVisibleParentRanges = () => {
|
||||
if (!containerHeight.value || !renderedRoot.value.children) {
|
||||
return
|
||||
}
|
||||
|
||||
const scrollTop = scrollY.value
|
||||
const scrollBottom = scrollTop + containerHeight.value
|
||||
const bufferHeight = bufferRows.value * DEFAULT_NODE_HEIGHT
|
||||
const { positions: nodePositions, heights: nodeHeights } =
|
||||
calculateNodePositionsAndHeights(renderedRoot.value)
|
||||
nodeHeightsCache.value = nodeHeights
|
||||
|
||||
const currentRanges = parentNodeWindowRanges.value
|
||||
const newRanges: Record<string, WindowRange> = {}
|
||||
let hasChanges = false
|
||||
const mergeOptions = {
|
||||
bufferRows: bufferRows.value,
|
||||
windowSize: windowSize.value
|
||||
}
|
||||
|
||||
const processNode = (node: RenderedTreeExplorerNode) => {
|
||||
if (!isNodeExpanded(node)) return
|
||||
|
||||
const children = node.children!
|
||||
const calculated = calculateNodeWindowRange(
|
||||
node,
|
||||
nodePositions,
|
||||
nodeHeights,
|
||||
scrollTop,
|
||||
scrollBottom,
|
||||
bufferHeight
|
||||
)
|
||||
|
||||
if (!calculated) return
|
||||
|
||||
const { range, changed } = mergeWindowRange(
|
||||
currentRanges[node.key],
|
||||
calculated,
|
||||
{ ...mergeOptions, totalChildren: children.length }
|
||||
)
|
||||
newRanges[node.key] = range
|
||||
if (changed) hasChanges = true
|
||||
|
||||
children.forEach(processNode)
|
||||
}
|
||||
|
||||
renderedRoot.value.children.forEach(processNode)
|
||||
|
||||
const keysChanged =
|
||||
Object.keys(newRanges).length !== Object.keys(currentRanges).length
|
||||
if (hasChanges || keysChanged) {
|
||||
parentNodeWindowRanges.value = newRanges
|
||||
}
|
||||
}
|
||||
|
||||
watch([scrollY, containerHeight], updateVisibleParentRanges, {
|
||||
immediate: true
|
||||
})
|
||||
watch(renderedRoot, updateVisibleParentRanges)
|
||||
watch(expandedKeys, updateVisibleParentRanges, { deep: true })
|
||||
|
||||
const displayRoot = computed<RenderedTreeExplorerNode>(() => {
|
||||
const root = renderedRoot.value
|
||||
if (!root.children) return root
|
||||
return {
|
||||
...root,
|
||||
children: root.children.map((node) =>
|
||||
applyWindow(node, parentNodeWindowRanges.value, windowSize.value)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const getNodeChildrenStyle = (node: RenderedTreeExplorerNode) => {
|
||||
const baseStyle = { class: 'virtual-node-children' }
|
||||
|
||||
if (!node || !isNodeExpanded(node)) {
|
||||
return baseStyle
|
||||
}
|
||||
|
||||
const originalNode = nodeKeyMap.value[node.key]
|
||||
const children = originalNode?.children
|
||||
if (!children?.length) {
|
||||
return baseStyle
|
||||
}
|
||||
|
||||
const range =
|
||||
parentNodeWindowRanges.value[node.key] ??
|
||||
createInitialWindowRange(children.length, windowSize.value)
|
||||
|
||||
const getHeight = (child: RenderedTreeExplorerNode) =>
|
||||
nodeHeightsCache.value.get(child.key) ?? DEFAULT_NODE_HEIGHT
|
||||
const { topSpacer, bottomSpacer } = calculateSpacerHeightsVariable(
|
||||
children,
|
||||
range,
|
||||
getHeight
|
||||
)
|
||||
|
||||
return {
|
||||
...baseStyle,
|
||||
style: {
|
||||
'--top-spacer': `${topSpacer}px`,
|
||||
'--bottom-spacer': `${bottomSpacer}px`
|
||||
}
|
||||
}
|
||||
}
|
||||
const errorHandling = useErrorHandling()
|
||||
|
||||
const onNodeContentClick = async (
|
||||
e: MouseEvent,
|
||||
node: RenderedTreeExplorerNode
|
||||
) => {
|
||||
): Promise<void> => {
|
||||
if (!storeSelectionKeys) {
|
||||
selectionKeys.value = {}
|
||||
}
|
||||
if (node.handleClick) {
|
||||
await node.handleClick(e)
|
||||
await errorHandling.wrapWithErrorHandlingAsync(
|
||||
() => node.handleClick?.(e),
|
||||
node.handleError
|
||||
)()
|
||||
}
|
||||
|
||||
emit('nodeClick', node, e)
|
||||
}
|
||||
const menu = ref<InstanceType<typeof ContextMenu> | null>(null)
|
||||
const menuTargetNode = ref<RenderedTreeExplorerNode | null>(null)
|
||||
|
||||
const extraMenuItems = computed(() => {
|
||||
return menuTargetNode.value?.contextMenuItems
|
||||
? typeof menuTargetNode.value.contextMenuItems === 'function'
|
||||
? menuTargetNode.value.contextMenuItems(menuTargetNode.value)
|
||||
: menuTargetNode.value.contextMenuItems
|
||||
: []
|
||||
const targetNode = menuTargetNode.value
|
||||
if (!targetNode) return []
|
||||
const contextMenuItems = targetNode.contextMenuItems
|
||||
if (!contextMenuItems) return []
|
||||
return typeof contextMenuItems === 'function'
|
||||
? contextMenuItems(targetNode)
|
||||
: contextMenuItems
|
||||
})
|
||||
const renameEditingNode = ref<RenderedTreeExplorerNode | null>(null)
|
||||
const errorHandling = useErrorHandling()
|
||||
|
||||
const wrapCommandWithErrorHandler = (
|
||||
command: (event: MenuItemCommandEvent) => void,
|
||||
{ isAsync = false }: { isAsync: boolean }
|
||||
):
|
||||
| ((event: MenuItemCommandEvent) => void)
|
||||
| ((event: MenuItemCommandEvent) => Promise<void>) => {
|
||||
return isAsync
|
||||
? errorHandling.wrapWithErrorHandlingAsync(
|
||||
command as (event: MenuItemCommandEvent) => Promise<void>,
|
||||
menuTargetNode.value?.handleError
|
||||
)
|
||||
: errorHandling.wrapWithErrorHandling(
|
||||
command,
|
||||
menuTargetNode.value?.handleError
|
||||
)
|
||||
}
|
||||
|
||||
const handleNodeLabelEdit = async (
|
||||
node: RenderedTreeExplorerNode,
|
||||
newName: string
|
||||
) => {
|
||||
): Promise<void> => {
|
||||
await errorHandling.wrapWithErrorHandlingAsync(
|
||||
async () => {
|
||||
if (node.key === newFolderNode.value?.key) {
|
||||
@@ -162,39 +387,33 @@ const handleNodeLabelEdit = async (
|
||||
}
|
||||
)()
|
||||
}
|
||||
|
||||
provide(InjectKeyHandleEditLabelFunction, handleNodeLabelEdit)
|
||||
|
||||
const { t } = useI18n()
|
||||
const renameCommand = (node: RenderedTreeExplorerNode) => {
|
||||
renameEditingNode.value = node
|
||||
}
|
||||
const deleteCommand = async (node: RenderedTreeExplorerNode) => {
|
||||
await node.handleDelete?.()
|
||||
emit('nodeDelete', node)
|
||||
}
|
||||
const menuItems = computed<MenuItem[]>(() =>
|
||||
[
|
||||
getAddFolderMenuItem(menuTargetNode.value),
|
||||
const menuItems = computed<MenuItem[]>(() => {
|
||||
const targetNode = menuTargetNode.value
|
||||
if (!targetNode) return []
|
||||
|
||||
return [
|
||||
getAddFolderMenuItem(targetNode),
|
||||
{
|
||||
label: t('g.rename'),
|
||||
icon: 'pi pi-file-edit',
|
||||
command: () => {
|
||||
if (menuTargetNode.value) {
|
||||
renameCommand(menuTargetNode.value)
|
||||
}
|
||||
renameEditingNode.value = targetNode
|
||||
},
|
||||
visible: menuTargetNode.value?.handleRename !== undefined
|
||||
visible: targetNode.handleRename !== undefined
|
||||
},
|
||||
{
|
||||
label: t('g.delete'),
|
||||
icon: 'pi pi-trash',
|
||||
command: async () => {
|
||||
if (menuTargetNode.value) {
|
||||
await deleteCommand(menuTargetNode.value)
|
||||
}
|
||||
await targetNode.handleDelete?.()
|
||||
emit('nodeDelete', targetNode)
|
||||
},
|
||||
visible: menuTargetNode.value?.handleDelete !== undefined,
|
||||
isAsync: true // The delete command can be async
|
||||
visible: targetNode.handleDelete !== undefined,
|
||||
isAsync: true
|
||||
},
|
||||
...extraMenuItems.value
|
||||
].map((menuItem: MenuItem) => ({
|
||||
@@ -205,38 +424,23 @@ const menuItems = computed<MenuItem[]>(() =>
|
||||
})
|
||||
: undefined
|
||||
}))
|
||||
)
|
||||
})
|
||||
|
||||
const handleContextMenu = (e: MouseEvent, node: RenderedTreeExplorerNode) => {
|
||||
menuTargetNode.value = node
|
||||
emit('contextMenu', node, e)
|
||||
if (menuItems.value.filter((item) => item.visible).length > 0) {
|
||||
if (menuItems.value.some((item) => item.visible)) {
|
||||
menu.value?.show(e)
|
||||
}
|
||||
}
|
||||
|
||||
const wrapCommandWithErrorHandler = (
|
||||
command: (event: MenuItemCommandEvent) => void,
|
||||
{ isAsync = false }: { isAsync: boolean }
|
||||
) => {
|
||||
return isAsync
|
||||
? errorHandling.wrapWithErrorHandlingAsync(
|
||||
command as (...args: any[]) => Promise<any>,
|
||||
menuTargetNode.value?.handleError
|
||||
)
|
||||
: errorHandling.wrapWithErrorHandling(
|
||||
command,
|
||||
menuTargetNode.value?.handleError
|
||||
)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
/**
|
||||
* The command to add a folder to a node via the context menu
|
||||
* @param targetNodeKey - The key of the node where the folder will be added under
|
||||
*/
|
||||
addFolderCommand: (targetNodeKey: string) => {
|
||||
const targetNode = findNodeByKey(renderedRoot.value, targetNodeKey)
|
||||
const targetNode = nodeKeyMap.value[targetNodeKey]
|
||||
if (targetNode) {
|
||||
addFolderCommand(targetNode)
|
||||
}
|
||||
@@ -253,6 +457,19 @@ defineExpose({
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
/* Virtual scrolling spacers using CSS pseudo-elements (only for ul) */
|
||||
:deep(ul.virtual-node-children)::before {
|
||||
content: '';
|
||||
display: block;
|
||||
height: var(--top-spacer, 0);
|
||||
}
|
||||
|
||||
:deep(ul.virtual-node-children)::after {
|
||||
content: '';
|
||||
display: block;
|
||||
height: var(--bottom-spacer, 0);
|
||||
}
|
||||
|
||||
/*
|
||||
* The following styles are necessary to avoid layout shift when dragging nodes over folders.
|
||||
* By setting the position to relative on the parent and using an absolutely positioned pseudo-element,
|
||||
|
||||
@@ -105,10 +105,7 @@ export function sortedTree(
|
||||
return newNode
|
||||
}
|
||||
|
||||
export const findNodeByKey = <T extends TreeNode>(
|
||||
root: T,
|
||||
key: string
|
||||
): T | null => {
|
||||
const findNodeByKey = <T extends TreeNode>(root: T, key: string): T | null => {
|
||||
if (root.key === key) {
|
||||
return root
|
||||
}
|
||||
|
||||
561
src/utils/virtualListUtils.test.ts
Normal file
561
src/utils/virtualListUtils.test.ts
Normal file
@@ -0,0 +1,561 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
|
||||
import type { WindowRange } from './virtualListUtils'
|
||||
import {
|
||||
applyWindow,
|
||||
calculateChildrenListBounds,
|
||||
calculateSpacerHeightsVariable,
|
||||
calculateTreePositionsAndHeights,
|
||||
calculateWindowRangeByHeights,
|
||||
createInitialWindowRange,
|
||||
mergeWindowRange
|
||||
} from './virtualListUtils'
|
||||
|
||||
describe('virtualListUtils', () => {
|
||||
describe('createInitialWindowRange', () => {
|
||||
it('creates range starting from 0', () => {
|
||||
const range = createInitialWindowRange(100, 60)
|
||||
expect(range).toEqual({ start: 0, end: 60 })
|
||||
})
|
||||
|
||||
it('caps end at totalChildren when less than windowSize', () => {
|
||||
const range = createInitialWindowRange(30, 60)
|
||||
expect(range).toEqual({ start: 0, end: 30 })
|
||||
})
|
||||
|
||||
it('handles zero children', () => {
|
||||
const range = createInitialWindowRange(0, 60)
|
||||
expect(range).toEqual({ start: 0, end: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyWindow', () => {
|
||||
const createMockNode = (
|
||||
key: string,
|
||||
children?: RenderedTreeExplorerNode[]
|
||||
): RenderedTreeExplorerNode => ({
|
||||
key,
|
||||
label: `Node ${key}`,
|
||||
leaf: !children,
|
||||
children,
|
||||
totalLeaves: children ? children.length : 1,
|
||||
icon: 'pi pi-file',
|
||||
type: children ? 'folder' : 'node'
|
||||
})
|
||||
|
||||
it('returns leaf node unchanged', () => {
|
||||
const leafNode = createMockNode('leaf')
|
||||
const result = applyWindow(leafNode, {}, 60)
|
||||
expect(result).toEqual(leafNode)
|
||||
})
|
||||
|
||||
it('returns node with empty children unchanged', () => {
|
||||
const emptyFolderNode = createMockNode('folder', [])
|
||||
const result = applyWindow(emptyFolderNode, {}, 60)
|
||||
expect(result).toEqual(emptyFolderNode)
|
||||
})
|
||||
|
||||
it('applies default window when no range specified', () => {
|
||||
const children = Array.from({ length: 100 }, (_, i) =>
|
||||
createMockNode(`child-${i}`)
|
||||
)
|
||||
const parentNode = createMockNode('parent', children)
|
||||
const result = applyWindow(parentNode, {}, 60)
|
||||
expect(result.children).toHaveLength(60)
|
||||
expect(result.children?.[0].key).toBe('child-0')
|
||||
expect(result.children?.[59].key).toBe('child-59')
|
||||
})
|
||||
|
||||
it('applies specified window range', () => {
|
||||
const children = Array.from({ length: 100 }, (_, i) =>
|
||||
createMockNode(`child-${i}`)
|
||||
)
|
||||
const parentNode = createMockNode('parent', children)
|
||||
const windowRanges = { parent: { start: 20, end: 80 } }
|
||||
const result = applyWindow(parentNode, windowRanges, 60)
|
||||
expect(result.children).toHaveLength(60)
|
||||
expect(result.children?.[0].key).toBe('child-20')
|
||||
expect(result.children?.[59].key).toBe('child-79')
|
||||
})
|
||||
|
||||
it('applies window recursively to nested children', () => {
|
||||
const grandchildren = Array.from({ length: 100 }, (_, i) =>
|
||||
createMockNode(`grandchild-${i}`)
|
||||
)
|
||||
const child = createMockNode('child', grandchildren)
|
||||
const parentNode = createMockNode('parent', [child])
|
||||
const windowRanges = { child: { start: 10, end: 30 } }
|
||||
const result = applyWindow(parentNode, windowRanges, 60)
|
||||
expect(result.children?.[0].children).toHaveLength(20)
|
||||
expect(result.children?.[0].children?.[0].key).toBe('grandchild-10')
|
||||
})
|
||||
|
||||
it('handles window larger than children count', () => {
|
||||
const children = Array.from({ length: 30 }, (_, i) =>
|
||||
createMockNode(`child-${i}`)
|
||||
)
|
||||
const parentNode = createMockNode('parent', children)
|
||||
const result = applyWindow(parentNode, {}, 60)
|
||||
expect(result.children).toHaveLength(30)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeWindowRange', () => {
|
||||
it('returns calculated when existing is undefined', () => {
|
||||
const result = mergeWindowRange(
|
||||
undefined,
|
||||
{ start: 10, end: 20 },
|
||||
{
|
||||
bufferRows: 2,
|
||||
windowSize: 10,
|
||||
totalChildren: 100
|
||||
}
|
||||
)
|
||||
expect(result).toEqual({ range: { start: 10, end: 20 }, changed: true })
|
||||
})
|
||||
|
||||
it('keeps existing when calculated is within buffer', () => {
|
||||
const result = mergeWindowRange(
|
||||
{ start: 10, end: 30 },
|
||||
{ start: 11, end: 29 },
|
||||
{ bufferRows: 5, windowSize: 10, totalChildren: 100 }
|
||||
)
|
||||
expect(result.changed).toBe(false)
|
||||
expect(result.range).toEqual({ start: 10, end: 30 })
|
||||
})
|
||||
|
||||
it('expands when calculated exceeds existing', () => {
|
||||
const result = mergeWindowRange(
|
||||
{ start: 10, end: 30 },
|
||||
{ start: 5, end: 40 },
|
||||
{ bufferRows: 2, windowSize: 10, totalChildren: 100 }
|
||||
)
|
||||
expect(result.changed).toBe(true)
|
||||
// maxWindowSize defaults to windowSize * 2 = 20, so end is capped at start + 20 = 25
|
||||
expect(result.range).toEqual({ start: 5, end: 25 })
|
||||
})
|
||||
|
||||
it('respects maxWindowSize when provided', () => {
|
||||
const result = mergeWindowRange(
|
||||
{ start: 10, end: 30 },
|
||||
{ start: 5, end: 40 },
|
||||
{
|
||||
bufferRows: 2,
|
||||
windowSize: 10,
|
||||
totalChildren: 100,
|
||||
maxWindowSize: 50
|
||||
}
|
||||
)
|
||||
expect(result.changed).toBe(true)
|
||||
expect(result.range).toEqual({ start: 5, end: 40 })
|
||||
})
|
||||
|
||||
it('shrinks when calculated is smaller and outside buffer', () => {
|
||||
const result = mergeWindowRange(
|
||||
{ start: 10, end: 30 },
|
||||
{ start: 15, end: 25 },
|
||||
{ bufferRows: 2, windowSize: 10, totalChildren: 100 }
|
||||
)
|
||||
expect(result.changed).toBe(true)
|
||||
expect(result.range).toEqual({ start: 15, end: 25 })
|
||||
})
|
||||
|
||||
it('caps window size when exceeding maxWindowSize on end update', () => {
|
||||
const result = mergeWindowRange(
|
||||
{ start: 10, end: 30 },
|
||||
{ start: 10, end: 50 },
|
||||
{ bufferRows: 2, windowSize: 10, totalChildren: 100 }
|
||||
)
|
||||
expect(result.changed).toBe(true)
|
||||
// maxWindowSize = 20, so start is adjusted: end (30) - 20 = 10, but we keep start at 10
|
||||
// Actually, since updateStart is false, we adjust start: end (50) - 20 = 30
|
||||
expect(result.range.start).toBe(30)
|
||||
expect(result.range.end).toBe(50)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateSpacerHeightsVariable', () => {
|
||||
it('calculates spacers using variable item heights', () => {
|
||||
const items = [10, 20, 30, 40, 50]
|
||||
const range: WindowRange = { start: 1, end: 4 } // visible: 20,30,40
|
||||
const result = calculateSpacerHeightsVariable(items, range, (n) => n)
|
||||
expect(result).toEqual({ topSpacer: 10, bottomSpacer: 50 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateWindowRangeByHeights', () => {
|
||||
it('returns start window when list is below viewport', () => {
|
||||
const items = Array.from({ length: 100 }, (_, i) => i)
|
||||
const range = calculateWindowRangeByHeights({
|
||||
items,
|
||||
listStart: 1000,
|
||||
listEnd: 2000,
|
||||
scrollTop: 0,
|
||||
scrollBottom: 300,
|
||||
bufferHeight: 50,
|
||||
bufferRows: 2,
|
||||
windowSize: 10,
|
||||
getItemStart: (n) => n * 10,
|
||||
getItemHeight: () => 10
|
||||
})
|
||||
expect(range).toEqual({ start: 0, end: 10 })
|
||||
})
|
||||
|
||||
it('returns end window when list is above viewport', () => {
|
||||
const items = Array.from({ length: 100 }, (_, i) => i)
|
||||
const range = calculateWindowRangeByHeights({
|
||||
items,
|
||||
listStart: 0,
|
||||
listEnd: 500,
|
||||
scrollTop: 2000,
|
||||
scrollBottom: 2300,
|
||||
bufferHeight: 50,
|
||||
bufferRows: 2,
|
||||
windowSize: 10,
|
||||
getItemStart: (n) => n * 10,
|
||||
getItemHeight: () => 10
|
||||
})
|
||||
expect(range).toEqual({ start: 90, end: 100 })
|
||||
})
|
||||
|
||||
it('computes window around viewport with buffer and min size', () => {
|
||||
const items = Array.from({ length: 100 }, (_, i) => i)
|
||||
const range = calculateWindowRangeByHeights({
|
||||
items,
|
||||
listStart: 0,
|
||||
listEnd: 2000,
|
||||
scrollTop: 200,
|
||||
scrollBottom: 260,
|
||||
bufferHeight: 0,
|
||||
bufferRows: 2,
|
||||
windowSize: 10,
|
||||
getItemStart: (n) => n * 10,
|
||||
getItemHeight: () => 10
|
||||
})
|
||||
expect(range.end - range.start).toBeGreaterThanOrEqual(10)
|
||||
expect(range.start).toBeLessThanOrEqual(20)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateTreePositionsAndHeights', () => {
|
||||
type TestNode = { key: string; children?: TestNode[] }
|
||||
|
||||
it('calculates positions and heights for flat tree', () => {
|
||||
const root: { children?: TestNode[] } = {
|
||||
children: [{ key: 'a' }, { key: 'b' }, { key: 'c' }]
|
||||
}
|
||||
|
||||
const { positions, heights } = calculateTreePositionsAndHeights({
|
||||
root,
|
||||
itemHeight: 32,
|
||||
getChildren: (node) => node.children,
|
||||
isExpanded: () => false
|
||||
})
|
||||
|
||||
expect(positions.get('a')).toBe(0)
|
||||
expect(positions.get('b')).toBe(32)
|
||||
expect(positions.get('c')).toBe(64)
|
||||
|
||||
expect(heights.get('a')).toBe(32)
|
||||
expect(heights.get('b')).toBe(32)
|
||||
expect(heights.get('c')).toBe(32)
|
||||
})
|
||||
|
||||
it('calculates positions and heights for nested tree with expanded nodes', () => {
|
||||
const root: { children?: TestNode[] } = {
|
||||
children: [
|
||||
{
|
||||
key: 'parent',
|
||||
children: [{ key: 'child1' }, { key: 'child2' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const { positions, heights } = calculateTreePositionsAndHeights({
|
||||
root,
|
||||
itemHeight: 32,
|
||||
getChildren: (node) => node.children,
|
||||
isExpanded: (node) => node.key === 'parent'
|
||||
})
|
||||
|
||||
expect(positions.get('parent')).toBe(0)
|
||||
expect(positions.get('child1')).toBe(32)
|
||||
expect(positions.get('child2')).toBe(64)
|
||||
|
||||
expect(heights.get('parent')).toBe(96) // 32 + 32 + 32
|
||||
expect(heights.get('child1')).toBe(32)
|
||||
expect(heights.get('child2')).toBe(32)
|
||||
})
|
||||
|
||||
it('calculates positions and heights for nested tree with collapsed nodes', () => {
|
||||
const root: { children?: TestNode[] } = {
|
||||
children: [
|
||||
{
|
||||
key: 'parent',
|
||||
children: [{ key: 'child1' }, { key: 'child2' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const { positions, heights } = calculateTreePositionsAndHeights({
|
||||
root,
|
||||
itemHeight: 32,
|
||||
getChildren: (node) => node.children,
|
||||
isExpanded: () => false
|
||||
})
|
||||
|
||||
expect(positions.get('parent')).toBe(0)
|
||||
expect(heights.get('parent')).toBe(32) // Only parent height, children not included
|
||||
})
|
||||
|
||||
it('handles deeply nested tree', () => {
|
||||
const root: { children?: TestNode[] } = {
|
||||
children: [
|
||||
{
|
||||
key: 'level1',
|
||||
children: [
|
||||
{
|
||||
key: 'level2',
|
||||
children: [{ key: 'level3' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const { positions, heights } = calculateTreePositionsAndHeights({
|
||||
root,
|
||||
itemHeight: 32,
|
||||
getChildren: (node) => node.children,
|
||||
isExpanded: () => true
|
||||
})
|
||||
|
||||
expect(positions.get('level1')).toBe(0)
|
||||
expect(positions.get('level2')).toBe(32)
|
||||
expect(positions.get('level3')).toBe(64)
|
||||
|
||||
expect(heights.get('level1')).toBe(96) // 32 + 32 + 32
|
||||
expect(heights.get('level2')).toBe(64) // 32 + 32
|
||||
expect(heights.get('level3')).toBe(32)
|
||||
})
|
||||
|
||||
it('handles empty tree', () => {
|
||||
const root: { children?: TestNode[] } = { children: [] }
|
||||
|
||||
const { positions, heights } = calculateTreePositionsAndHeights({
|
||||
root,
|
||||
itemHeight: 32,
|
||||
getChildren: (node) => node.children,
|
||||
isExpanded: () => false
|
||||
})
|
||||
|
||||
expect(positions.size).toBe(0)
|
||||
expect(heights.size).toBe(0)
|
||||
})
|
||||
|
||||
it('handles root without children', () => {
|
||||
const root: { children?: TestNode[] } = {}
|
||||
|
||||
const { positions, heights } = calculateTreePositionsAndHeights({
|
||||
root,
|
||||
itemHeight: 32,
|
||||
getChildren: (node) => node.children,
|
||||
isExpanded: () => false
|
||||
})
|
||||
|
||||
expect(positions.size).toBe(0)
|
||||
expect(heights.size).toBe(0)
|
||||
})
|
||||
|
||||
it('handles mixed expanded and collapsed nodes', () => {
|
||||
const root: { children?: TestNode[] } = {
|
||||
children: [
|
||||
{
|
||||
key: 'expanded',
|
||||
children: [{ key: 'child1' }, { key: 'child2' }]
|
||||
},
|
||||
{
|
||||
key: 'collapsed',
|
||||
children: [{ key: 'child3' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const { positions, heights } = calculateTreePositionsAndHeights({
|
||||
root,
|
||||
itemHeight: 32,
|
||||
getChildren: (node) => node.children,
|
||||
isExpanded: (node) => node.key === 'expanded'
|
||||
})
|
||||
|
||||
expect(positions.get('expanded')).toBe(0)
|
||||
expect(positions.get('child1')).toBe(32)
|
||||
expect(positions.get('child2')).toBe(64)
|
||||
expect(positions.get('collapsed')).toBe(96)
|
||||
|
||||
expect(heights.get('expanded')).toBe(96) // 32 + 32 + 32
|
||||
expect(heights.get('collapsed')).toBe(32) // Only collapsed node height
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateChildrenListBounds', () => {
|
||||
type TestNode = { key: string }
|
||||
|
||||
it('calculates bounds for node with children', () => {
|
||||
const node: TestNode = { key: 'parent' }
|
||||
const children: TestNode[] = [
|
||||
{ key: 'child1' },
|
||||
{ key: 'child2' },
|
||||
{ key: 'child3' }
|
||||
]
|
||||
|
||||
const nodePositions = new Map<string, number>([
|
||||
['parent', 0],
|
||||
['child1', 32],
|
||||
['child2', 64],
|
||||
['child3', 96]
|
||||
])
|
||||
|
||||
const nodeHeights = new Map<string, number>([
|
||||
['parent', 128],
|
||||
['child1', 32],
|
||||
['child2', 32],
|
||||
['child3', 32]
|
||||
])
|
||||
|
||||
const result = calculateChildrenListBounds({
|
||||
node,
|
||||
children,
|
||||
nodePositions,
|
||||
nodeHeights,
|
||||
itemHeight: 32
|
||||
})
|
||||
|
||||
expect(result.listStart).toBe(32) // parent position (0) + itemHeight (32)
|
||||
expect(result.listEnd).toBe(128) // child3 position (96) + child3 height (32)
|
||||
})
|
||||
|
||||
it('handles empty children array', () => {
|
||||
const node: TestNode = { key: 'parent' }
|
||||
const children: TestNode[] = []
|
||||
|
||||
const nodePositions = new Map<string, number>([['parent', 100]])
|
||||
const nodeHeights = new Map<string, number>([['parent', 32]])
|
||||
|
||||
const result = calculateChildrenListBounds({
|
||||
node,
|
||||
children,
|
||||
nodePositions,
|
||||
nodeHeights,
|
||||
itemHeight: 32
|
||||
})
|
||||
|
||||
expect(result.listStart).toBe(132) // parent position (100) + itemHeight (32)
|
||||
expect(result.listEnd).toBe(132) // Same as listStart when no children
|
||||
})
|
||||
|
||||
it('uses default values when node position is missing', () => {
|
||||
const node: TestNode = { key: 'parent' }
|
||||
const children: TestNode[] = [{ key: 'child1' }]
|
||||
|
||||
const nodePositions = new Map<string, number>()
|
||||
const nodeHeights = new Map<string, number>([['child1', 32]])
|
||||
|
||||
const result = calculateChildrenListBounds({
|
||||
node,
|
||||
children,
|
||||
nodePositions,
|
||||
nodeHeights,
|
||||
itemHeight: 32
|
||||
})
|
||||
|
||||
expect(result.listStart).toBe(32) // default node position (0) + itemHeight (32)
|
||||
expect(result.listEnd).toBe(64) // default child position (32) + child height (32)
|
||||
})
|
||||
|
||||
it('uses default values when child position or height is missing', () => {
|
||||
const node: TestNode = { key: 'parent' }
|
||||
const children: TestNode[] = [{ key: 'child1' }]
|
||||
|
||||
const nodePositions = new Map<string, number>([
|
||||
['parent', 0]
|
||||
// child1 position missing
|
||||
])
|
||||
const nodeHeights = new Map<string, number>()
|
||||
// child1 height missing
|
||||
|
||||
const result = calculateChildrenListBounds({
|
||||
node,
|
||||
children,
|
||||
nodePositions,
|
||||
nodeHeights,
|
||||
itemHeight: 32
|
||||
})
|
||||
|
||||
expect(result.listStart).toBe(32) // parent position (0) + itemHeight (32)
|
||||
expect(result.listEnd).toBe(64) // default child position (32) + default height (32)
|
||||
})
|
||||
|
||||
it('handles single child', () => {
|
||||
const node: TestNode = { key: 'parent' }
|
||||
const children: TestNode[] = [{ key: 'child1' }]
|
||||
|
||||
const nodePositions = new Map<string, number>([
|
||||
['parent', 0],
|
||||
['child1', 32]
|
||||
])
|
||||
|
||||
const nodeHeights = new Map<string, number>([
|
||||
['parent', 64],
|
||||
['child1', 32]
|
||||
])
|
||||
|
||||
const result = calculateChildrenListBounds({
|
||||
node,
|
||||
children,
|
||||
nodePositions,
|
||||
nodeHeights,
|
||||
itemHeight: 32
|
||||
})
|
||||
|
||||
expect(result.listStart).toBe(32)
|
||||
expect(result.listEnd).toBe(64)
|
||||
})
|
||||
|
||||
it('handles children with variable heights', () => {
|
||||
const node: TestNode = { key: 'parent' }
|
||||
const children: TestNode[] = [
|
||||
{ key: 'child1' },
|
||||
{ key: 'child2' },
|
||||
{ key: 'child3' }
|
||||
]
|
||||
|
||||
const nodePositions = new Map<string, number>([
|
||||
['parent', 0],
|
||||
['child1', 32],
|
||||
['child2', 64],
|
||||
['child3', 96]
|
||||
])
|
||||
|
||||
const nodeHeights = new Map<string, number>([
|
||||
['parent', 160],
|
||||
['child1', 32],
|
||||
['child2', 64], // Larger height
|
||||
['child3', 32]
|
||||
])
|
||||
|
||||
const result = calculateChildrenListBounds({
|
||||
node,
|
||||
children,
|
||||
nodePositions,
|
||||
nodeHeights,
|
||||
itemHeight: 32
|
||||
})
|
||||
|
||||
expect(result.listStart).toBe(32)
|
||||
expect(result.listEnd).toBe(128) // child3 position (96) + child3 height (32)
|
||||
})
|
||||
})
|
||||
})
|
||||
248
src/utils/virtualListUtils.ts
Normal file
248
src/utils/virtualListUtils.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
|
||||
export interface WindowRange {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export function mergeWindowRange(
|
||||
existing: WindowRange | undefined,
|
||||
calculated: WindowRange,
|
||||
{
|
||||
bufferRows,
|
||||
windowSize,
|
||||
totalChildren,
|
||||
maxWindowSize = windowSize * 2
|
||||
}: {
|
||||
bufferRows: number
|
||||
windowSize: number
|
||||
totalChildren: number
|
||||
maxWindowSize?: number
|
||||
}
|
||||
): { range: WindowRange; changed: boolean } {
|
||||
if (!existing) {
|
||||
return { range: calculated, changed: true }
|
||||
}
|
||||
|
||||
const updateStart =
|
||||
calculated.start < existing.start ||
|
||||
(existing.start > 0 && calculated.start > existing.start + bufferRows)
|
||||
const updateEnd =
|
||||
calculated.end > existing.end ||
|
||||
(existing.end < totalChildren && calculated.end < existing.end - bufferRows)
|
||||
|
||||
let start = updateStart ? calculated.start : existing.start
|
||||
let end = updateEnd ? calculated.end : existing.end
|
||||
|
||||
if (end - start > maxWindowSize) {
|
||||
if (updateStart) {
|
||||
end = Math.min(totalChildren, start + maxWindowSize)
|
||||
} else {
|
||||
start = Math.max(0, end - maxWindowSize)
|
||||
}
|
||||
}
|
||||
|
||||
const changed =
|
||||
updateStart || updateEnd || start !== existing.start || end !== existing.end
|
||||
|
||||
return { range: { start, end }, changed }
|
||||
}
|
||||
|
||||
export function calculateSpacerHeightsVariable<T>(
|
||||
items: T[],
|
||||
range: WindowRange,
|
||||
getHeight: (item: T) => number
|
||||
): { topSpacer: number; bottomSpacer: number } {
|
||||
const topSpacer = items
|
||||
.slice(0, range.start)
|
||||
.reduce((sum, item) => sum + getHeight(item), 0)
|
||||
|
||||
const bottomSpacer = items
|
||||
.slice(range.end)
|
||||
.reduce((sum, item) => sum + getHeight(item), 0)
|
||||
|
||||
return { topSpacer, bottomSpacer }
|
||||
}
|
||||
|
||||
export function calculateWindowRangeByHeights<T>({
|
||||
items,
|
||||
listStart,
|
||||
listEnd,
|
||||
scrollTop,
|
||||
scrollBottom,
|
||||
bufferHeight,
|
||||
bufferRows,
|
||||
windowSize,
|
||||
getItemStart,
|
||||
getItemHeight
|
||||
}: {
|
||||
items: T[]
|
||||
listStart: number
|
||||
listEnd: number
|
||||
scrollTop: number
|
||||
scrollBottom: number
|
||||
bufferHeight: number
|
||||
bufferRows: number
|
||||
windowSize: number
|
||||
getItemStart: (item: T) => number
|
||||
getItemHeight: (item: T) => number
|
||||
}): WindowRange {
|
||||
const total = items.length
|
||||
if (total === 0) return { start: 0, end: 0 }
|
||||
|
||||
const scrollTopWithBuffer = scrollTop - bufferHeight
|
||||
const scrollBottomWithBuffer = scrollBottom + bufferHeight
|
||||
|
||||
// Quick checks for lists outside viewport
|
||||
if (listEnd < scrollTopWithBuffer) {
|
||||
return { start: Math.max(0, total - windowSize), end: total }
|
||||
}
|
||||
|
||||
if (listStart > scrollBottomWithBuffer) {
|
||||
return { start: 0, end: Math.min(windowSize, total) }
|
||||
}
|
||||
|
||||
// Find visible range by iterating items
|
||||
let start = 0
|
||||
let end = total
|
||||
|
||||
for (let i = 0; i < total; i++) {
|
||||
const item = items[i]
|
||||
const itemStart = getItemStart(item)
|
||||
const itemEnd = itemStart + getItemHeight(item)
|
||||
|
||||
if (itemEnd < scrollTopWithBuffer) start = i + 1
|
||||
if (itemStart <= scrollBottomWithBuffer) end = i + 1
|
||||
}
|
||||
|
||||
// Apply buffer and ensure minimum window size
|
||||
start = Math.max(0, start - bufferRows)
|
||||
end = Math.min(total, Math.max(end + bufferRows, start + windowSize))
|
||||
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a sliding window to limit visible children of a node
|
||||
* @param node - The node to apply the window to
|
||||
* @param windowRanges - Map of node keys to their window ranges
|
||||
* @param windowSize - Maximum number of items in the window
|
||||
* @returns The node with windowed children
|
||||
*/
|
||||
export function applyWindow(
|
||||
node: RenderedTreeExplorerNode,
|
||||
windowRanges: Record<string, WindowRange>,
|
||||
windowSize: number
|
||||
): RenderedTreeExplorerNode {
|
||||
if (!node.children || node.leaf || node.children.length === 0) {
|
||||
return node
|
||||
}
|
||||
|
||||
const range =
|
||||
windowRanges[node.key] ??
|
||||
createInitialWindowRange(node.children.length, windowSize)
|
||||
|
||||
// Recursively apply window to children
|
||||
const windowedChildren = node.children
|
||||
.slice(range.start, range.end)
|
||||
.map((child) => applyWindow(child, windowRanges, windowSize))
|
||||
|
||||
return {
|
||||
...node,
|
||||
children: windowedChildren
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create initial window range for a node
|
||||
* @param totalChildren - Total number of children
|
||||
* @param windowSize - Maximum window size
|
||||
* @returns Initial window range starting from 0
|
||||
*/
|
||||
export function createInitialWindowRange(
|
||||
totalChildren: number,
|
||||
windowSize: number
|
||||
): WindowRange {
|
||||
return {
|
||||
start: 0,
|
||||
end: Math.min(windowSize, totalChildren)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate positions and heights for tree nodes in a single traversal
|
||||
* @param root - Root node with children
|
||||
* @param options - Configuration options
|
||||
* @returns Maps of node positions and heights
|
||||
*/
|
||||
export function calculateTreePositionsAndHeights<T extends { key: string }>({
|
||||
root,
|
||||
itemHeight,
|
||||
getChildren,
|
||||
isExpanded
|
||||
}: {
|
||||
root: { children?: T[] }
|
||||
itemHeight: number
|
||||
getChildren: (node: T) => T[] | undefined
|
||||
isExpanded: (node: T) => boolean
|
||||
}): { positions: Map<string, number>; heights: Map<string, number> } {
|
||||
const nodePositions = new Map<string, number>()
|
||||
const nodeHeights = new Map<string, number>()
|
||||
let currentPos = 0
|
||||
|
||||
const traverse = (node: T): number => {
|
||||
nodePositions.set(node.key, currentPos)
|
||||
currentPos += itemHeight
|
||||
|
||||
let nodeHeight = itemHeight
|
||||
const children = getChildren(node)
|
||||
if (isExpanded(node) && children) {
|
||||
for (const child of children) {
|
||||
nodeHeight += traverse(child)
|
||||
}
|
||||
}
|
||||
|
||||
nodeHeights.set(node.key, nodeHeight)
|
||||
return nodeHeight
|
||||
}
|
||||
|
||||
root.children?.forEach(traverse)
|
||||
return { positions: nodePositions, heights: nodeHeights }
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate list start and end positions for a parent node's children
|
||||
* @param node - Parent node
|
||||
* @param children - Children array
|
||||
* @param nodePositions - Map of node positions
|
||||
* @param nodeHeights - Map of node heights
|
||||
* @param itemHeight - Height of a single item
|
||||
* @returns Object with listStart and listEnd positions
|
||||
*/
|
||||
export function calculateChildrenListBounds<T extends { key: string }>({
|
||||
node,
|
||||
children,
|
||||
nodePositions,
|
||||
nodeHeights,
|
||||
itemHeight
|
||||
}: {
|
||||
node: T
|
||||
children: T[]
|
||||
nodePositions: Map<string, number>
|
||||
nodeHeights: Map<string, number>
|
||||
itemHeight: number
|
||||
}): { listStart: number; listEnd: number } {
|
||||
const nodeStart = nodePositions.get(node.key) ?? 0
|
||||
const listStart = nodeStart + itemHeight
|
||||
|
||||
if (children.length === 0) {
|
||||
return { listStart, listEnd: listStart }
|
||||
}
|
||||
|
||||
const lastChild = children.at(-1)!
|
||||
const lastChildStart = nodePositions.get(lastChild.key) ?? listStart
|
||||
const lastChildHeight = nodeHeights.get(lastChild.key) ?? itemHeight
|
||||
const listEnd = lastChildStart + lastChildHeight
|
||||
|
||||
return { listStart, listEnd }
|
||||
}
|
||||
Reference in New Issue
Block a user