Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,9 @@ plugins: [
| showDoubleQuotes | Show doublequotes on key | boolean | true |
| virtual | Use virtual scroll | boolean | false |
| height | The height of list when using virtual | number | 400 |
| itemHeight | The height of node when using virtual | number | 20 |
| itemHeight | Fixed row height when using virtual (ignored if `dynamicHeight` is true) | number | 20 |
| dynamicHeight | Enable dynamic row heights (measured per row) | boolean | false |
| estimatedItemHeight | Estimated row height used before measurement when `dynamicHeight` is true | number | 20 |
| selectedValue(v-model) | Selected data path | string, array | - |
| rootPath | Root data path | string | `root` |
| nodeSelectable | Defines whether a node supports selection | (node) => boolean | - |
Expand Down
16 changes: 15 additions & 1 deletion example/VirtualList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@
<label>itemHeight</label>
<input v-model="state.itemHeight" type="number" />
</div>
<div>
<label>dynamicHeight</label>
<input v-model="state.dynamicHeight" type="checkbox" />
</div>
<div v-if="state.dynamicHeight">
<label>estimatedItemHeight</label>
<input v-model="state.estimatedItemHeight" type="number" />
</div>
<div>
<label>showLine</label>
<input v-model="state.showLine" type="checkbox" />
Expand Down Expand Up @@ -49,6 +57,8 @@
:collapsed-node-length="state.collapsedNodeLength"
:virtual="true"
:item-height="+state.itemHeight"
:dynamic-height="state.dynamicHeight"
:estimated-item-height="+state.estimatedItemHeight"
:data="state.data"
:deep="state.deep"
:show-line="state.showLine"
Expand All @@ -74,7 +84,9 @@ const defaultData = {
for (let i = 0; i < 10000; i++) {
defaultData.data.push({
news_id: i,
title: 'iPhone X Review: Innovative future with real black technology',
title: `Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old. Richard McClintock, a Latin professor at Hampden-Sydney College in Virginia, looked up one of the more obscure Latin words, consectetur, from a Lorem Ipsum passage, and going through the cites of the word in classical literature, discovered the undoubtable source. Lorem Ipsum comes from sections 1.10.32 and 1.10.33 of "de Finibus Bonorum et Malorum" (The Extremes of Good and Evil) by Cicero, written in 45 BC. This book is a treatise on the theory of ethics, very popular during the Renaissance. The first line of Lorem Ipsum, "Lorem ipsum dolor sit amet..", comes from a line in section 1.10.32.

The standard chunk of Lorem Ipsum used since the 1500s is reproduced below for those interested. Sections 1.10.32 and 1.10.33 from "de Finibus Bonorum et Malorum" by Cicero are also reproduced in their exact original form, accompanied by English versions from the 1914 translation by H. Rackham.`,
source: 'Netease phone',
});
}
Expand All @@ -93,6 +105,8 @@ export default defineComponent({
deep: 3,
collapsedNodeLength: Infinity,
itemHeight: 20,
dynamicHeight: true,
estimatedItemHeight: 20,
});

const { localDarkMode, toggleLocalDarkMode, globalDarkModeState } = useDarkMode();
Expand Down
272 changes: 212 additions & 60 deletions src/components/Tree/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
ref,
PropType,
CSSProperties,
nextTick,
} from 'vue';
import TreeNode, { treeNodePropsPass, NodeDataType } from 'src/components/TreeNode';
import { emitError, jsonFlatten, cloneDeep } from 'src/utils';
Expand Down Expand Up @@ -40,11 +41,21 @@ export default defineComponent({
type: Number,
default: 400,
},
// When using virtual scroll, define the height of each row.
// When using virtual scroll without dynamicHeight, define the height of each row.
itemHeight: {
type: Number,
default: 20,
},
// Enable dynamic row heights for virtual scroll.
dynamicHeight: {
type: Boolean,
default: false,
},
// Estimated item height used before measurement in dynamic mode.
estimatedItemHeight: {
type: Number,
default: 20,
},
// When there is a selection function, define the selected path.
// For multiple selections, it is an array ['root.a','root.b'], for single selection, it is a string of 'root.a'.
selectedValue: {
Expand Down Expand Up @@ -104,8 +115,73 @@ export default defineComponent({
translateY: 0,
visibleData: null as NodeDataType[] | null,
hiddenPaths: initHiddenPaths(props.deep, props.collapsedNodeLength),
startIndex: 0,
endIndex: 0,
});

// Dynamic height bookkeeping
// heights[i] is the measured height of row i in the current flatData (or estimated if not measured yet)
// offsets[i] is the cumulative offset before row i (offsets[0] = 0, offsets[length] = totalHeight)
let heights: number[] = [];
let offsets: number[] = [];
let totalHeight = 0;
const rowRefs: Record<number, HTMLElement | null> = {};
const OVERSCAN_COUNT = 5;

const initDynamicHeights = (length: number) => {
heights = Array(length)
.fill(0)
.map(() => props.estimatedItemHeight || props.itemHeight || 20);
offsets = new Array(length + 1);
offsets[0] = 0;
for (let i = 0; i < length; i++) {
offsets[i + 1] = offsets[i] + heights[i];
}
totalHeight = offsets[length] || 0;
};

const recomputeOffsetsFrom = (start: number) => {
const length = heights.length;
if (start < 0) start = 0;
if (start > length) start = length;
for (let i = start; i < length; i++) {
offsets[i + 1] = offsets[i] + heights[i];
}
totalHeight = offsets[length] || 0;
};

const setRowRef = (index: number, el: HTMLElement | null) => {
if (el) {
rowRefs[index] = el;
} else {
delete rowRefs[index];
}
};

const lowerBound = (arr: number[], target: number) => {
// first index i where arr[i] >= target
let lo = 0;
let hi = arr.length - 1;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (arr[mid] < target) lo = mid + 1;
else hi = mid;
}
return lo;
};

const findStartIndexByScrollTop = (scrollTop: number) => {
// largest i such that offsets[i] <= scrollTop
const i = lowerBound(offsets, scrollTop + 0.0001); // epsilon to handle exact matches
return Math.max(0, Math.min(i - 1, heights.length - 1));
};

const findEndIndexByViewport = (scrollTop: number, viewportHeight: number) => {
const target = scrollTop + viewportHeight;
const i = lowerBound(offsets, target);
return Math.max(0, Math.min(i + 1, heights.length));
};

const flatData = computed(() => {
let startHiddenItem: null | NodeDataType = null;
const data = [];
Expand Down Expand Up @@ -154,31 +230,89 @@ export default defineComponent({
: '';
});

const listHeight = computed(() => {
if (props.dynamicHeight) {
return totalHeight || 0;
}
return flatData.value.length * props.itemHeight;
});

const updateVisibleData = () => {
const flatDataValue = flatData.value;
if (!flatDataValue) return;
if (props.virtual) {
const visibleCount = props.height / props.itemHeight;
const scrollTop = treeRef.value?.scrollTop || 0;
const scrollCount = Math.floor(scrollTop / props.itemHeight);
let start =
scrollCount < 0
? 0
: scrollCount + visibleCount > flatDataValue.length
? flatDataValue.length - visibleCount
: scrollCount;
if (start < 0) {
start = 0;

if (props.dynamicHeight) {
// Ensure dynamic arrays are initialized and consistent with data length
if (heights.length !== flatDataValue.length) {
initDynamicHeights(flatDataValue.length);
}

const start = findStartIndexByScrollTop(scrollTop);
const endNoOverscan = findEndIndexByViewport(scrollTop, props.height);
const startWithOverscan = Math.max(0, start - OVERSCAN_COUNT);
const endWithOverscan = Math.min(flatDataValue.length, endNoOverscan + OVERSCAN_COUNT);

state.startIndex = startWithOverscan;
state.endIndex = endWithOverscan;
state.translateY = offsets[startWithOverscan] || 0;
state.visibleData = flatDataValue.slice(startWithOverscan, endWithOverscan);

// Measure after render and update heights/offets if needed
nextTick().then(() => {
let changed = false;
for (let i = state.startIndex; i < state.endIndex; i++) {
const el = rowRefs[i];
if (!el) continue;
const h = el.offsetHeight;
if (h && heights[i] !== h) {
heights[i] = h;
// Update offsets from i forward
offsets[i + 1] = offsets[i] + heights[i];
recomputeOffsetsFrom(i + 1);
changed = true;
}
}
if (changed) {
// Recalculate slice based on new offsets
updateVisibleData();
}
});
} else {
const visibleCount = props.height / props.itemHeight;
const scrollCount = Math.floor(scrollTop / props.itemHeight);
let start =
scrollCount < 0
? 0
: scrollCount + visibleCount > flatDataValue.length
? flatDataValue.length - visibleCount
: scrollCount;
if (start < 0) {
start = 0;
}
const end = start + visibleCount;
state.translateY = start * props.itemHeight;
state.startIndex = start;
state.endIndex = end;
state.visibleData = flatDataValue.slice(start, end);
}
const end = start + visibleCount;
state.translateY = start * props.itemHeight;
state.visibleData = flatDataValue.filter((item, index) => index >= start && index < end);
} else {
state.translateY = 0;
state.startIndex = 0;
state.endIndex = flatDataValue.length;
state.visibleData = flatDataValue;
}
};

let rafId: number | null = null;
const handleTreeScroll = () => {
updateVisibleData();
if (rafId) {
cancelAnimationFrame(rafId);
}
rafId = requestAnimationFrame(() => {
updateVisibleData();
});
};

const handleSelectedChange = ({ path }: NodeDataType) => {
Expand Down Expand Up @@ -251,10 +385,26 @@ export default defineComponent({

watchEffect(() => {
if (flatData.value) {
if (props.virtual && props.dynamicHeight) {
if (heights.length !== flatData.value.length) {
initDynamicHeights(flatData.value.length);
}
}
updateVisibleData();
}
});

// Re-initialize dynamic height arrays when data shape changes significantly
watch(
() => [props.dynamicHeight, props.estimatedItemHeight, originFlatData.value.length],
() => {
if (props.virtual && props.dynamicHeight) {
initDynamicHeights(flatData.value.length);
nextTick(updateVisibleData);
}
},
);

watch(
() => props.deep,
val => {
Expand All @@ -274,47 +424,52 @@ export default defineComponent({
const renderNodeValue = props.renderNodeValue ?? slots.renderNodeValue;
const renderNodeActions = props.renderNodeActions ?? slots.renderNodeActions ?? false;

const nodeContent =
state.visibleData &&
state.visibleData.map(item => (
<TreeNode
key={item.id}
data={props.data}
rootPath={props.rootPath}
indent={props.indent}
node={item}
collapsed={!!state.hiddenPaths[item.path]}
theme={props.theme}
showDoubleQuotes={props.showDoubleQuotes}
showLength={props.showLength}
checked={selectedPaths.value.includes(item.path)}
selectableType={props.selectableType}
showLine={props.showLine}
showLineNumber={props.showLineNumber}
showSelectController={props.showSelectController}
selectOnClickNode={props.selectOnClickNode}
nodeSelectable={props.nodeSelectable}
highlightSelectedNode={props.highlightSelectedNode}
editable={props.editable}
editableTrigger={props.editableTrigger}
showIcon={props.showIcon}
showKeyValueSpace={props.showKeyValueSpace}
renderNodeKey={renderNodeKey}
renderNodeValue={renderNodeValue}
renderNodeActions={renderNodeActions}
onNodeClick={handleNodeClick}
onNodeMouseover={handleNodeMouseover}
onBracketsClick={handleBracketsClick}
onIconClick={handleIconClick}
onSelectedChange={handleSelectedChange}
onValueChange={handleValueChange}
style={
props.itemHeight && props.itemHeight !== 20
? { lineHeight: `${props.itemHeight}px` }
: {}
}
/>
));
const nodeContent = state.visibleData?.map((item, localIndex) => {
const globalIndex = state.startIndex + localIndex;
return (
<div key={item.id} ref={el => setRowRef(globalIndex, (el as HTMLElement) || null)}>
<TreeNode
data={props.data}
rootPath={props.rootPath}
indent={props.indent}
node={item}
collapsed={!!state.hiddenPaths[item.path]}
theme={props.theme}
showDoubleQuotes={props.showDoubleQuotes}
showLength={props.showLength}
checked={selectedPaths.value.includes(item.path)}
selectableType={props.selectableType}
showLine={props.showLine}
showLineNumber={props.showLineNumber}
showSelectController={props.showSelectController}
selectOnClickNode={props.selectOnClickNode}
nodeSelectable={props.nodeSelectable}
highlightSelectedNode={props.highlightSelectedNode}
editable={props.editable}
editableTrigger={props.editableTrigger}
showIcon={props.showIcon}
showKeyValueSpace={props.showKeyValueSpace}
renderNodeKey={renderNodeKey}
renderNodeValue={renderNodeValue}
renderNodeActions={renderNodeActions}
onNodeClick={handleNodeClick}
onNodeMouseover={handleNodeMouseover}
onBracketsClick={handleBracketsClick}
onIconClick={handleIconClick}
onSelectedChange={handleSelectedChange}
onValueChange={handleValueChange}
class={props.dynamicHeight ? 'dynamic-height' : undefined}
style={
props.dynamicHeight
? {}
: props.itemHeight && props.itemHeight !== 20
? { lineHeight: `${props.itemHeight}px` }
: {}
}
/>
</div>
);
});

return (
<div
Expand All @@ -336,10 +491,7 @@ export default defineComponent({
>
{props.virtual ? (
<div class="vjs-tree-list" style={{ height: `${props.height}px` }}>
<div
class="vjs-tree-list-holder"
style={{ height: `${flatData.value.length * props.itemHeight}px` }}
>
<div class="vjs-tree-list-holder" style={{ height: `${listHeight.value}px` }}>
<div
class="vjs-tree-list-holder-inner"
style={{ transform: `translateY(${state.translateY}px)` }}
Expand Down
Loading