Skip to content

Commit f905211

Browse files
committed
Refactor virtual scrolling
Some Vue bugs are visible :(
1 parent d40009f commit f905211

File tree

8 files changed

+144
-93
lines changed

8 files changed

+144
-93
lines changed

.vscode/settings.json

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"typescript.tsdk": "node_modules\\typescript\\lib"
3+
}

src/datagrid.vue

+13-15
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<template>
22
<div class='dg-wrapper'>
3-
<div class='dg-scroller' v-virtual='virtual'>
3+
<virt-scroller ref='scroller' class='dg-scroller'>
44
<table class='dg' :class='selected && "dg-selectable"'>
55
<thead>
66
<tr>
@@ -19,39 +19,38 @@
1919
</tr>
2020
<tr v-if='loading' class='dg-loader'></tr>
2121
</thead>
22-
<tbody @click='toggle($event.target)'>
23-
<tr :style='{ height: virtual.topGap + "px" }'></tr>
24-
<tr v-for='d of virtual' :key='d.id'
25-
class='dg-row' :class='selected && selected.has(d) && "dg-selected"'
26-
:true-value='d'>
22+
<virt-body @click='toggle($event.target)' v-slot='{ items }'>
23+
<tr v-for='d of items()' :key='d.id' v-item='d'
24+
class='dg-row' :class='selected && selected.has(d) && "dg-selected"'>
2725
<td v-if='selected' class='dg-cell'>
2826
<input type='checkbox' :checked='selected.has(d)' />
2927
</td>
3028
<td v-for='c of columns' v-text='d[c.data]' class='dg-cell' :class='c.right && "dg-right"' />
3129
<td class='dg-cell dg-fill' />
3230
</tr>
33-
<tr :style='{ height: virtual.bottomGap + "px" }'></tr>
34-
</tbody>
31+
</virt-body>
3532
</table>
36-
</div>
33+
</virt-scroller>
3734
</div>
3835
</template>
3936

4037
<script lang="ts">
4138
import { shallowRef as sref, watch } from 'vue';
4239
import { Column } from "./column";
43-
import { useSelection } from "./selection";
40+
import { useSelection, ItemDirective } from "./selection";
4441
import SortIndicator from './sort-indicator';
4542
import { useSorting } from "./sorting";
46-
import { useVirtual, VirtualTable } from "./virtual";
43+
import { useVirtual, VirtualBody, VirtualScroller } from "./virtual/index";
4744
4845
export default {
4946
components: {
5047
'sort-indicator': SortIndicator,
48+
'virt-body': VirtualBody,
49+
'virt-scroller': VirtualScroller,
5150
},
5251
5352
directives: {
54-
'virtual': VirtualTable,
53+
'item': ItemDirective,
5554
},
5655
5756
props: {
@@ -65,13 +64,13 @@ export default {
6564
const data = sref([] as object[]);
6665
const selection = useSelection(data, props.selected);
6766
const sorting = useSorting(data);
68-
const virtual = useVirtual(sorting.data);
67+
const { scrollToTop } = useVirtual(sorting.data);
6968
7069
watch(async () => {
7170
loading.value = true;
7271
data.value = await props.data!;
7372
loading.value = false;
74-
virtual.scrollToTop();
73+
scrollToTop();
7574
});
7675
7776
return {
@@ -80,7 +79,6 @@ export default {
8079
selected: props.selected,
8180
...selection,
8281
...sorting,
83-
virtual,
8482
};
8583
}
8684
};

src/selection.ts

+9-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { computed, Ref, watch } from "vue";
1+
import { computed, Ref, watch, ObjectDirective } from "vue";
22

33
export function useSelection(data: Ref<object[]>, selected?: Set<object>) {
44
if (!selected) return null;
@@ -8,7 +8,7 @@ export function useSelection(data: Ref<object[]>, selected?: Set<object>) {
88

99
return {
1010
toggle(el: HTMLElement) {
11-
let item: object | undefined = el.closest('.dg-row')?.['_trueValue'];
11+
let item: object | undefined = el.closest('.dg-row')?.['_item'];
1212
if (!item) return;
1313
if (!selected.delete(item))
1414
selected.add(item);
@@ -27,4 +27,10 @@ export function useSelection(data: Ref<object[]>, selected?: Set<object>) {
2727
}
2828
})
2929
};
30-
}
30+
}
31+
32+
export const ItemDirective: ObjectDirective<HTMLElement> = {
33+
mounted(el, binding) {
34+
el['_item'] = binding.value;
35+
}
36+
};

src/virtual.ts

-75
This file was deleted.

src/virtual/body.vue

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<template>
2+
<tbody v-bind='$attrs'>
3+
<tr :style='{ height: topGap + "px" }' />
4+
<slot :items="items" />
5+
<tr :style='{ height: bottomGap + "px" }' />
6+
</tbody>
7+
</template>
8+
9+
<script lang="ts">
10+
import { getState } from "./state";
11+
12+
export default {
13+
setup() {
14+
return getState();
15+
}
16+
}
17+
</script>

src/virtual/index.ts

+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { Ref, provide, shallowReactive, unref, watch } from "vue";
2+
import { injectKey, VirtualState } from './state';
3+
4+
export { default as VirtualScroller } from './scroller.vue';
5+
export { default as VirtualBody } from './body.vue';
6+
7+
type Val<T> = T | Ref<T>;
8+
9+
export function useVirtual(data: Val<object[]>) {
10+
const state: VirtualState = shallowReactive({
11+
scroller: <any>null!, // initialized on mount // FIXME: TS error if declared as Element?
12+
height: 0,
13+
scrollTop: 0,
14+
rowHeight: 24,
15+
buffer: 4,
16+
topGap: 0,
17+
bottomGap: 0,
18+
index: 0,
19+
count: 0,
20+
21+
items: function*() {
22+
const all = unref(data);
23+
const to = state.index + state.count;
24+
for (let i = state.index; i < to; i++)
25+
yield all[i];
26+
},
27+
});
28+
29+
watch(() => {
30+
const length = unref(data).length;
31+
const { buffer, height, rowHeight, scrollTop } = state;
32+
const index = state.index = Math.max((scrollTop / rowHeight | 0) - buffer, 0);
33+
const count = state.count = Math.min((height / rowHeight | 0) + 1 + buffer + buffer, length - index);
34+
state.topGap = index * rowHeight;
35+
state.bottomGap = (length - count - index) * rowHeight;
36+
});
37+
38+
provide(injectKey, state);
39+
40+
return {
41+
scrollToTop() {
42+
state.scroller.scrollTop = 0;
43+
}
44+
}
45+
}

src/virtual/scroller.vue

+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<template>
2+
<div ref='el' @scroll.passive='onScroll' v-bind='$attrs'>
3+
<slot />
4+
</div>
5+
</template>
6+
7+
<script lang="ts">
8+
import { ref, onMounted } from 'vue';
9+
import { getState } from "./state";
10+
11+
export default {
12+
setup() {
13+
const state = getState();
14+
const el = ref<any>(); // FIXME: TS is giving me headaches inside onMounted
15+
16+
const resize = new ResizeObserver(entries => {
17+
for (let entry of entries) {
18+
let table = entry.target;
19+
state.height = entry.contentRect.height - table.querySelector('tHead')!.clientHeight;
20+
}
21+
});
22+
23+
function onScroll() {
24+
state.scrollTop = el.value.scrollTop;
25+
}
26+
27+
onMounted(() => {
28+
state.scroller = el.value;
29+
resize.observe(el.value);
30+
});
31+
32+
return {
33+
el,
34+
onScroll,
35+
};
36+
}
37+
};
38+
</script>

src/virtual/state.ts

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { InjectionKey, inject } from 'vue';
2+
3+
export interface VirtualState {
4+
scroller: Element;
5+
height: number;
6+
scrollTop: number;
7+
rowHeight: number;
8+
buffer: number;
9+
topGap: number;
10+
bottomGap: number;
11+
index: number;
12+
count: number;
13+
14+
items: () => Iterator<object>;
15+
}
16+
17+
export const injectKey : InjectionKey<VirtualState> = Symbol('virt-state');
18+
19+
export function getState() { return inject(injectKey)!; }

0 commit comments

Comments
 (0)