Compare commits

...

26 Commits

Author SHA1 Message Date
Yourz
0a5baa6256 fix: update for coderabbit 2026-01-22 18:51:16 +08:00
Yourz
d87a2fe2f9 fix: unit tests 2026-01-22 17:53:09 +08:00
Yourz
25d34c6163 fix: refactoring 2026-01-22 17:20:07 +08:00
Yourz
31c3dc643b fix: refactoring 2026-01-22 17:02:54 +08:00
Yourz
3e86090427 fix: virtual list 2026-01-22 16:16:46 +08:00
Yourz
cf54456763 temp commit 2026-01-22 16:16:46 +08:00
Yourz
b7c5aaa99d fix: re-arrange the code 2026-01-22 16:16:46 +08:00
Yourz
e539439d8f fix: 3rd level nodes scroll stuck 2026-01-22 16:16:46 +08:00
Yourz
2e7fda6a9c fix: multiple sub nodes 2026-01-22 16:16:46 +08:00
Yourz
1cd97cb7e7 fix: multiple sub nodes 2026-01-22 16:16:46 +08:00
GitHub Action
77ab1a58a8 [automated] Apply ESLint and Prettier fixes 2026-01-22 16:16:45 +08:00
Yourz
2810184d12 fix: unit tests 2026-01-22 16:16:45 +08:00
Yourz
cc3509430b fix: update buffer rows 2026-01-22 16:16:45 +08:00
Yourz
f06649ae42 fix: restore tree container ref 2026-01-22 16:16:45 +08:00
Yourz
22f2ef1898 fix: update virtual list of tree by virtual grid component 2026-01-22 16:16:45 +08:00
Yourz
89d0554746 fix: remove throttle 2026-01-22 16:16:45 +08:00
Yourz
bd47fb376c fix: solve blank sidebar 2026-01-22 16:16:45 +08:00
Yourz
7042c5dbb4 refactor: extract nested ternary into early return pattern 2026-01-22 16:16:45 +08:00
Yourz
489c82fb3b fix: add error handling for async node.handleClick
Wrap node.handleClick() with errorHandling.wrapWithErrorHandlingAsync
to prevent unhandled promise rejections
2026-01-22 16:16:45 +08:00
Yourz
ed186f9033 refactor: extract SCROLL_THROTTLE_MS constant and inline applyWindow
Apply coderabbitai suggestions
2026-01-22 16:16:45 +08:00
Yourz
e094e42c07 refactor: apply coderabbitai suggestions
- Use createInitialWindowRange and calculateSpacerHeights in getTotalSpacerHeights
- Use active range after shift for recursive window processing
- Remove unused export from findNodeByKey
2026-01-22 16:16:44 +08:00
Yourz
0738773b15 refactor: extract common shiftNodeWindow helper function
Apply DRY principle suggestion from coderabbitai
2026-01-22 16:16:44 +08:00
Yourz
8656cc8468 refactor: optimize TreeExplorer based on coderabbitai suggestions 2026-01-22 16:16:44 +08:00
Yourz
6a6301012f refactor: use calculateScrollPercentage utility function
Apply suggestion from coderabbitai bot
2026-01-22 16:16:44 +08:00
Yourz
19d43ea455 fix: set tree container max height to calc(100vh - 144px) 2026-01-22 16:16:44 +08:00
Yourz
d0005770aa feat: implement virtual list with sliding window for TreeExplorer 2026-01-22 16:16:44 +08:00
4 changed files with 1133 additions and 110 deletions

View File

@@ -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,

View File

@@ -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
}

View 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)
})
})
})

View 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 }
}