feat: implement virtual list with sliding window for TreeExplorer

This commit is contained in:
Yourz
2025-12-15 17:57:42 +08:00
parent 4a5e7c8bcb
commit d0005770aa
3 changed files with 651 additions and 34 deletions

View File

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

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

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