mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-06 16:10:09 +00:00
feat: implement virtual list with sliding window for TreeExplorer
This commit is contained in:
@@ -1,45 +1,54 @@
|
||||
<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-[600px]"
|
||||
@scroll="handleTreeScroll"
|
||||
>
|
||||
<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 { useThrottleFn } 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'
|
||||
@@ -54,6 +63,14 @@ import type {
|
||||
TreeExplorerNode
|
||||
} from '@/types/treeExplorerTypes'
|
||||
import { combineTrees, findNodeByKey } from '@/utils/treeUtil'
|
||||
import type { WindowRange } from '@/utils/virtualListUtils'
|
||||
import {
|
||||
applyWindow as applyWindowUtil,
|
||||
calculateSpacerHeights,
|
||||
createInitialWindowRange,
|
||||
shiftWindowBackward as shiftWindowBackwardUtil,
|
||||
shiftWindowForward as shiftWindowForwardUtil
|
||||
} from '@/utils/virtualListUtils'
|
||||
|
||||
const expandedKeys = defineModel<Record<string, boolean>>('expandedKeys', {
|
||||
required: true
|
||||
@@ -84,12 +101,241 @@ const {
|
||||
}
|
||||
)
|
||||
|
||||
// Sliding window configuration
|
||||
const WINDOW_SIZE = 60
|
||||
const BUFFER_SIZE = 20
|
||||
const NODE_HEIGHT = 28 // Approximate height per tree node in pixels
|
||||
|
||||
// For each parent node, track the sliding window range [start, end)
|
||||
const parentWindowRanges = ref<Record<string, WindowRange>>({})
|
||||
|
||||
// Track previous expanded keys to detect collapse events
|
||||
const prevExpandedKeys = ref<Record<string, boolean>>({})
|
||||
|
||||
// Reset window ranges when nodes are collapsed
|
||||
watch(
|
||||
expandedKeys,
|
||||
(newKeys) => {
|
||||
for (const key in prevExpandedKeys.value) {
|
||||
if (prevExpandedKeys.value[key] && !newKeys[key]) {
|
||||
delete parentWindowRanges.value[key]
|
||||
}
|
||||
}
|
||||
prevExpandedKeys.value = { ...newKeys }
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// Ref to access the tree container for scroll detection
|
||||
const treeContainerRef = ref<HTMLDivElement | null>(null)
|
||||
|
||||
// Calculate total top and bottom spacer heights from all expanded nodes
|
||||
const getTotalSpacerHeights = () => {
|
||||
let topTotal = 0
|
||||
let bottomTotal = 0
|
||||
|
||||
const calculateForNode = (node: RenderedTreeExplorerNode) => {
|
||||
if (!node.children || node.leaf) return
|
||||
const isExpanded = expandedKeys.value?.[node.key] ?? false
|
||||
if (!isExpanded) return
|
||||
|
||||
const totalChildren = node.children.length
|
||||
const range = parentWindowRanges.value[node.key] ?? {
|
||||
start: 0,
|
||||
end: Math.min(WINDOW_SIZE, totalChildren)
|
||||
}
|
||||
|
||||
topTotal += range.start * NODE_HEIGHT
|
||||
bottomTotal += (totalChildren - range.end) * NODE_HEIGHT
|
||||
|
||||
// Recursively check children in the window
|
||||
for (let i = range.start; i < range.end && i < node.children.length; i++) {
|
||||
calculateForNode(node.children[i])
|
||||
}
|
||||
}
|
||||
|
||||
for (const child of renderedRoot.value.children || []) {
|
||||
calculateForNode(child)
|
||||
}
|
||||
|
||||
return { topTotal, bottomTotal }
|
||||
}
|
||||
|
||||
// Reset window to the beginning for a single node (recursive)
|
||||
const resetNodeWindowToTop = (node: RenderedTreeExplorerNode) => {
|
||||
if (!node.children || node.leaf) return
|
||||
const isExpanded = expandedKeys.value?.[node.key] ?? false
|
||||
if (!isExpanded) return
|
||||
|
||||
parentWindowRanges.value[node.key] = createInitialWindowRange(
|
||||
node.children.length,
|
||||
WINDOW_SIZE
|
||||
)
|
||||
|
||||
// Recursively reset children
|
||||
for (const child of node.children) {
|
||||
resetNodeWindowToTop(child)
|
||||
}
|
||||
}
|
||||
|
||||
// Reset all windows to the beginning
|
||||
const resetWindowsToTop = () => {
|
||||
for (const parent of renderedRoot.value.children || []) {
|
||||
resetNodeWindowToTop(parent)
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll handler with throttling
|
||||
const handleTreeScroll = useThrottleFn(() => {
|
||||
if (!treeContainerRef.value) return
|
||||
|
||||
const container = treeContainerRef.value
|
||||
const scrollTop = container.scrollTop
|
||||
const scrollHeight = container.scrollHeight
|
||||
const clientHeight = container.clientHeight
|
||||
|
||||
// Special case: when scrolled to top, reset all windows to start
|
||||
if (scrollTop === 0) {
|
||||
resetWindowsToTop()
|
||||
return
|
||||
}
|
||||
|
||||
const { topTotal, bottomTotal } = getTotalSpacerHeights()
|
||||
const realContentHeight = scrollHeight - topTotal - bottomTotal
|
||||
const adjustedScrollTop = Math.max(0, scrollTop - topTotal)
|
||||
const scrollPercentage =
|
||||
realContentHeight > 0
|
||||
? (adjustedScrollTop + clientHeight) / realContentHeight
|
||||
: 1
|
||||
|
||||
// When scrolling near bottom (70%), shift window forward
|
||||
if (scrollPercentage > 0.7) {
|
||||
shiftWindowsForward()
|
||||
}
|
||||
// When scrolling near top (30%), shift window backward
|
||||
if (scrollPercentage < 0.3) {
|
||||
shiftWindowsBackward()
|
||||
}
|
||||
}, 100)
|
||||
|
||||
// Shift window forward for a single node (recursive)
|
||||
const shiftNodeWindowForward = (node: RenderedTreeExplorerNode) => {
|
||||
if (!node.children || node.leaf) return
|
||||
const isExpanded = expandedKeys.value?.[node.key] ?? false
|
||||
if (!isExpanded) return
|
||||
|
||||
const totalChildren = node.children.length
|
||||
const range =
|
||||
parentWindowRanges.value[node.key] ??
|
||||
createInitialWindowRange(totalChildren, WINDOW_SIZE)
|
||||
const newRange = shiftWindowForwardUtil(
|
||||
range,
|
||||
totalChildren,
|
||||
BUFFER_SIZE,
|
||||
WINDOW_SIZE
|
||||
)
|
||||
if (newRange) {
|
||||
parentWindowRanges.value[node.key] = newRange
|
||||
}
|
||||
|
||||
// Recursively process children in current window
|
||||
for (let i = range.start; i < range.end && i < node.children.length; i++) {
|
||||
shiftNodeWindowForward(node.children[i])
|
||||
}
|
||||
}
|
||||
|
||||
// Shift all windows forward (load more at end)
|
||||
const shiftWindowsForward = () => {
|
||||
for (const parent of renderedRoot.value.children || []) {
|
||||
shiftNodeWindowForward(parent)
|
||||
}
|
||||
}
|
||||
|
||||
// Shift window backward for a single node (recursive)
|
||||
const shiftNodeWindowBackward = (node: RenderedTreeExplorerNode) => {
|
||||
if (!node.children || node.leaf) return
|
||||
const isExpanded = expandedKeys.value?.[node.key] ?? false
|
||||
if (!isExpanded) return
|
||||
|
||||
const totalChildren = node.children.length
|
||||
const range =
|
||||
parentWindowRanges.value[node.key] ??
|
||||
createInitialWindowRange(totalChildren, WINDOW_SIZE)
|
||||
const newRange = shiftWindowBackwardUtil(
|
||||
range,
|
||||
totalChildren,
|
||||
BUFFER_SIZE,
|
||||
WINDOW_SIZE
|
||||
)
|
||||
if (newRange) {
|
||||
parentWindowRanges.value[node.key] = newRange
|
||||
}
|
||||
|
||||
// Recursively process children in current window
|
||||
for (let i = range.start; i < range.end && i < node.children.length; i++) {
|
||||
shiftNodeWindowBackward(node.children[i])
|
||||
}
|
||||
}
|
||||
|
||||
// Shift all windows backward (load more at start)
|
||||
const shiftWindowsBackward = () => {
|
||||
for (const parent of renderedRoot.value.children || []) {
|
||||
shiftNodeWindowBackward(parent)
|
||||
}
|
||||
}
|
||||
|
||||
const renderedRoot = computed<RenderedTreeExplorerNode>(() => {
|
||||
const renderedRoot = fillNodeInfo(props.root)
|
||||
return newFolderNode.value
|
||||
? combineTrees(renderedRoot, newFolderNode.value)
|
||||
: renderedRoot
|
||||
})
|
||||
|
||||
// Apply sliding window to limit visible children
|
||||
const applyWindow = (
|
||||
node: RenderedTreeExplorerNode
|
||||
): RenderedTreeExplorerNode =>
|
||||
applyWindowUtil(node, parentWindowRanges.value, WINDOW_SIZE)
|
||||
|
||||
// Final tree to display with sliding window applied
|
||||
const displayRoot = computed<RenderedTreeExplorerNode>(() => {
|
||||
return {
|
||||
...renderedRoot.value,
|
||||
children: (renderedRoot.value.children || []).map(applyWindow)
|
||||
}
|
||||
})
|
||||
|
||||
// Get spacer heights for a node's children container
|
||||
const getNodeChildrenStyle = (node: RenderedTreeExplorerNode | undefined) => {
|
||||
if (!node?.children || node.leaf) {
|
||||
return { class: 'virtual-node-children' }
|
||||
}
|
||||
|
||||
// Get the original node from renderedRoot to access full children count
|
||||
const originalNode = findNodeByKey(renderedRoot.value, node.key)
|
||||
if (!originalNode?.children) {
|
||||
return { class: 'virtual-node-children' }
|
||||
}
|
||||
|
||||
const totalChildren = originalNode.children.length
|
||||
const range =
|
||||
parentWindowRanges.value[node.key] ??
|
||||
createInitialWindowRange(totalChildren, WINDOW_SIZE)
|
||||
|
||||
const { topSpacer, bottomSpacer } = calculateSpacerHeights(
|
||||
totalChildren,
|
||||
range,
|
||||
NODE_HEIGHT
|
||||
)
|
||||
|
||||
return {
|
||||
class: 'virtual-node-children',
|
||||
style: {
|
||||
'--top-spacer': `${topSpacer}px`,
|
||||
'--bottom-spacer': `${bottomSpacer}px`
|
||||
}
|
||||
}
|
||||
}
|
||||
const getTreeNodeIcon = (node: TreeExplorerNode) => {
|
||||
if (node.getIcon) {
|
||||
const icon = node.getIcon()
|
||||
@@ -106,6 +352,7 @@ const getTreeNodeIcon = (node: TreeExplorerNode) => {
|
||||
const isExpanded = expandedKeys.value?.[node.key] ?? false
|
||||
return isExpanded ? 'pi pi-folder-open' : 'pi pi-folder'
|
||||
}
|
||||
|
||||
const fillNodeInfo = (node: TreeExplorerNode): RenderedTreeExplorerNode => {
|
||||
const children = node.children?.map(fillNodeInfo) ?? []
|
||||
const totalLeaves = node.leaf
|
||||
@@ -253,6 +500,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,
|
||||
|
||||
221
src/utils/__tests__/virtualListUtils.test.ts
Normal file
221
src/utils/__tests__/virtualListUtils.test.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
|
||||
import type { WindowRange } from '../virtualListUtils'
|
||||
import {
|
||||
applyWindow,
|
||||
calculateScrollPercentage,
|
||||
calculateSpacerHeights,
|
||||
createInitialWindowRange,
|
||||
shiftWindowBackward,
|
||||
shiftWindowForward
|
||||
} 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('shiftWindowForward', () => {
|
||||
it('shifts window forward by buffer size', () => {
|
||||
const currentRange: WindowRange = { start: 0, end: 60 }
|
||||
const result = shiftWindowForward(currentRange, 100, 20, 60)
|
||||
expect(result).toEqual({ start: 20, end: 80 })
|
||||
})
|
||||
|
||||
it('returns null when already at end', () => {
|
||||
const currentRange: WindowRange = { start: 40, end: 100 }
|
||||
const result = shiftWindowForward(currentRange, 100, 20, 60)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('caps end at totalChildren', () => {
|
||||
const currentRange: WindowRange = { start: 30, end: 90 }
|
||||
const result = shiftWindowForward(currentRange, 100, 20, 60)
|
||||
expect(result).toEqual({ start: 40, end: 100 })
|
||||
})
|
||||
|
||||
it('adjusts start to maintain window size when near end', () => {
|
||||
const currentRange: WindowRange = { start: 20, end: 80 }
|
||||
const result = shiftWindowForward(currentRange, 95, 20, 60)
|
||||
expect(result).toEqual({ start: 35, end: 95 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('shiftWindowBackward', () => {
|
||||
it('shifts window backward by buffer size', () => {
|
||||
const currentRange: WindowRange = { start: 40, end: 100 }
|
||||
const result = shiftWindowBackward(currentRange, 100, 20, 60)
|
||||
expect(result).toEqual({ start: 20, end: 80 })
|
||||
})
|
||||
|
||||
it('returns null when already at start', () => {
|
||||
const currentRange: WindowRange = { start: 0, end: 60 }
|
||||
const result = shiftWindowBackward(currentRange, 100, 20, 60)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('caps start at 0', () => {
|
||||
const currentRange: WindowRange = { start: 10, end: 70 }
|
||||
const result = shiftWindowBackward(currentRange, 100, 20, 60)
|
||||
expect(result).toEqual({ start: 0, end: 60 })
|
||||
})
|
||||
|
||||
it('caps end at totalChildren when window would exceed', () => {
|
||||
const currentRange: WindowRange = { start: 20, end: 50 }
|
||||
const result = shiftWindowBackward(currentRange, 50, 20, 60)
|
||||
expect(result).toEqual({ start: 0, end: 50 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateSpacerHeights', () => {
|
||||
it('calculates correct spacer heights', () => {
|
||||
const range: WindowRange = { start: 20, end: 80 }
|
||||
const result = calculateSpacerHeights(100, range, 28)
|
||||
expect(result).toEqual({
|
||||
topSpacer: 20 * 28,
|
||||
bottomSpacer: 20 * 28
|
||||
})
|
||||
})
|
||||
|
||||
it('returns zero spacers when window covers all children', () => {
|
||||
const range: WindowRange = { start: 0, end: 50 }
|
||||
const result = calculateSpacerHeights(50, range, 28)
|
||||
expect(result).toEqual({
|
||||
topSpacer: 0,
|
||||
bottomSpacer: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('handles window at start', () => {
|
||||
const range: WindowRange = { start: 0, end: 60 }
|
||||
const result = calculateSpacerHeights(100, range, 28)
|
||||
expect(result).toEqual({
|
||||
topSpacer: 0,
|
||||
bottomSpacer: 40 * 28
|
||||
})
|
||||
})
|
||||
|
||||
it('handles window at end', () => {
|
||||
const range: WindowRange = { start: 40, end: 100 }
|
||||
const result = calculateSpacerHeights(100, range, 28)
|
||||
expect(result).toEqual({
|
||||
topSpacer: 40 * 28,
|
||||
bottomSpacer: 0
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateScrollPercentage', () => {
|
||||
it('calculates percentage correctly', () => {
|
||||
const result = calculateScrollPercentage(500, 2000, 400, 0, 0)
|
||||
expect(result).toBeCloseTo(0.45)
|
||||
})
|
||||
|
||||
it('adjusts for top spacer height', () => {
|
||||
const result = calculateScrollPercentage(600, 2000, 400, 200, 200)
|
||||
// realContentHeight = 2000 - 200 - 200 = 1600
|
||||
// adjustedScrollTop = max(0, 600 - 200) = 400
|
||||
// percentage = (400 + 400) / 1600 = 0.5
|
||||
expect(result).toBeCloseTo(0.5)
|
||||
})
|
||||
|
||||
it('returns 1 when realContentHeight is 0', () => {
|
||||
const result = calculateScrollPercentage(0, 100, 50, 50, 50)
|
||||
expect(result).toBe(1)
|
||||
})
|
||||
|
||||
it('handles scrollTop less than topSpacerHeight', () => {
|
||||
const result = calculateScrollPercentage(100, 2000, 400, 200, 200)
|
||||
// adjustedScrollTop = max(0, 100 - 200) = 0
|
||||
// realContentHeight = 1600
|
||||
// percentage = (0 + 400) / 1600 = 0.25
|
||||
expect(result).toBeCloseTo(0.25)
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
136
src/utils/virtualListUtils.ts
Normal file
136
src/utils/virtualListUtils.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
|
||||
export interface WindowRange {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 const applyWindow = (
|
||||
node: RenderedTreeExplorerNode,
|
||||
windowRanges: Record<string, WindowRange>,
|
||||
windowSize: number
|
||||
): RenderedTreeExplorerNode => {
|
||||
if (!node.children || node.leaf || node.children.length === 0) {
|
||||
return node
|
||||
}
|
||||
|
||||
const totalChildren = node.children.length
|
||||
const range = windowRanges[node.key] ?? {
|
||||
start: 0,
|
||||
end: Math.min(windowSize, totalChildren)
|
||||
}
|
||||
|
||||
// Recursively apply window to children
|
||||
const windowedChildren = node.children
|
||||
.slice(range.start, range.end)
|
||||
.map((child) => applyWindow(child, windowRanges, windowSize))
|
||||
|
||||
return {
|
||||
...node,
|
||||
children: windowedChildren
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shift window forward (load more at end)
|
||||
* @param currentRange - Current window range
|
||||
* @param totalChildren - Total number of children
|
||||
* @param bufferSize - Number of items to shift
|
||||
* @param windowSize - Maximum window size
|
||||
* @returns New window range, or null if no shift needed
|
||||
*/
|
||||
export const shiftWindowForward = (
|
||||
currentRange: WindowRange,
|
||||
totalChildren: number,
|
||||
bufferSize: number,
|
||||
windowSize: number
|
||||
): WindowRange | null => {
|
||||
if (currentRange.end >= totalChildren) {
|
||||
return null
|
||||
}
|
||||
const newEnd = Math.min(currentRange.end + bufferSize, totalChildren)
|
||||
const newStart = Math.max(0, newEnd - windowSize)
|
||||
return { start: newStart, end: newEnd }
|
||||
}
|
||||
|
||||
/**
|
||||
* Shift window backward (load more at start)
|
||||
* @param currentRange - Current window range
|
||||
* @param totalChildren - Total number of children
|
||||
* @param bufferSize - Number of items to shift
|
||||
* @param windowSize - Maximum window size
|
||||
* @returns New window range, or null if no shift needed
|
||||
*/
|
||||
export const shiftWindowBackward = (
|
||||
currentRange: WindowRange,
|
||||
totalChildren: number,
|
||||
bufferSize: number,
|
||||
windowSize: number
|
||||
): WindowRange | null => {
|
||||
if (currentRange.start <= 0) {
|
||||
return null
|
||||
}
|
||||
const newStart = Math.max(0, currentRange.start - bufferSize)
|
||||
const newEnd = Math.min(newStart + windowSize, totalChildren)
|
||||
return { start: newStart, end: newEnd }
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate spacer heights for a node's children
|
||||
* @param totalChildren - Total number of children
|
||||
* @param range - Current window range
|
||||
* @param nodeHeight - Height of each node in pixels
|
||||
* @returns Top and bottom spacer heights
|
||||
*/
|
||||
export const calculateSpacerHeights = (
|
||||
totalChildren: number,
|
||||
range: WindowRange,
|
||||
nodeHeight: number
|
||||
): { topSpacer: number; bottomSpacer: number } => {
|
||||
const topSpacer = range.start * nodeHeight
|
||||
const bottomSpacer = Math.max(0, totalChildren - range.end) * nodeHeight
|
||||
return { topSpacer, bottomSpacer }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 const createInitialWindowRange = (
|
||||
totalChildren: number,
|
||||
windowSize: number
|
||||
): WindowRange => ({
|
||||
start: 0,
|
||||
end: Math.min(windowSize, totalChildren)
|
||||
})
|
||||
|
||||
/**
|
||||
* Calculate scroll percentage adjusted for spacer heights
|
||||
* @param scrollTop - Current scroll position
|
||||
* @param scrollHeight - Total scrollable height
|
||||
* @param clientHeight - Visible height
|
||||
* @param topSpacerHeight - Combined top spacer height
|
||||
* @param bottomSpacerHeight - Combined bottom spacer height
|
||||
* @returns Scroll percentage between 0 and 1
|
||||
*/
|
||||
export const calculateScrollPercentage = (
|
||||
scrollTop: number,
|
||||
scrollHeight: number,
|
||||
clientHeight: number,
|
||||
topSpacerHeight: number,
|
||||
bottomSpacerHeight: number
|
||||
): number => {
|
||||
const realContentHeight = scrollHeight - topSpacerHeight - bottomSpacerHeight
|
||||
if (realContentHeight <= 0) return 1
|
||||
const adjustedScrollTop = Math.max(0, scrollTop - topSpacerHeight)
|
||||
return (adjustedScrollTop + clientHeight) / realContentHeight
|
||||
}
|
||||
Reference in New Issue
Block a user