/* eslint-disable no-void */ /* eslint-disable @typescript-eslint/restrict-plus-operands */ import { mergeProps, computed, defineComponent, ref, onMounted, h, onActivated, onDeactivated } from 'vue'; import { beforeNextFrameOnce, depx, pxfy } from 'seemly'; import { useMemo } from 'vooks'; import { useSsrAdapter } from '@css-render/vue3-ssr'; import VResizeObserver from '../../resize-observer/src/VResizeObserver'; import { c, cssrAnchorMetaName, FinweckTree } from '../../shared'; import { ensureMaybeTouch, ensureWheelScale } from './config'; const styles = c('.v-vl', { maxHeight: 'inherit', height: '100%', overflow: 'auto', minWidth: '1px' // a zero width container won't be scrollable }, [ c('&:not(.v-vl--show-scrollbar)', { scrollbarWidth: 'none' }, [ c('&::-webkit-scrollbar, &::-webkit-scrollbar-track-piece, &::-webkit-scrollbar-thumb', { width: 0, height: 0, display: 'none' }) ]) ]); export default defineComponent({ name: 'VirtualList', inheritAttrs: false, props: { showScrollbar: { type: Boolean, default: true }, items: { type: Array, default: () => [] }, // it is suppose to be the min height itemSize: { type: Number, required: true }, itemResizable: Boolean, itemsStyle: [String, Object], visibleItemsTag: { type: [String, Object], default: 'div' }, visibleItemsProps: Object, ignoreItemResize: Boolean, onScroll: Function, onWheel: Function, onResize: Function, defaultScrollKey: [Number, String], defaultScrollIndex: Number, keyField: { type: String, default: 'key' }, // Whether it is a good API? // ResizeObserver + footer & header is not enough. // Too complex for simple case paddingTop: { type: [Number, String], default: 0 }, paddingBottom: { type: [Number, String], default: 0 } }, setup(props) { const ssrAdapter = useSsrAdapter(); styles.mount({ id: 'vueuc/virtual-list', head: true, anchorMetaName: cssrAnchorMetaName, ssr: ssrAdapter }); onMounted(() => { const { defaultScrollIndex, defaultScrollKey } = props; if (defaultScrollIndex !== undefined && defaultScrollIndex !== null) { scrollTo({ index: defaultScrollIndex }); } else if (defaultScrollKey !== undefined && defaultScrollKey !== null) { scrollTo({ key: defaultScrollKey }); } }); let isDeactivated = false; let activateStateInitialized = false; onActivated(() => { isDeactivated = false; if (!activateStateInitialized) { activateStateInitialized = true; return; } // remount scrollTo({ top: scrollTopRef.value, left: scrollLeft }); }); onDeactivated(() => { isDeactivated = true; if (!activateStateInitialized) { activateStateInitialized = true; } }); const keyIndexMapRef = computed(() => { const map = new Map(); const { keyField } = props; props.items.forEach((item, index) => { map.set(item[keyField], index); }); return map; }); const listElRef = ref(null); const listHeightRef = ref(undefined); const keyToHeightOffset = new Map(); const finweckTreeRef = computed(() => { const { items, itemSize, keyField } = props; const ft = new FinweckTree(items.length, itemSize); items.forEach((item, index) => { const key = item[keyField]; const heightOffset = keyToHeightOffset.get(key); if (heightOffset !== undefined) { ft.add(index, heightOffset); } }); return ft; }); const finweckTreeUpdateTrigger = ref(0); let scrollLeft = 0; const scrollTopRef = ref(0); const startIndexRef = useMemo(() => { return Math.max(finweckTreeRef.value.getBound(scrollTopRef.value - depx(props.paddingTop)) - 1, 0); }); const viewportItemsRef = computed(() => { const { value: listHeight } = listHeightRef; if (listHeight === undefined) return []; const { items, itemSize } = props; const startIndex = startIndexRef.value; const endIndex = Math.min(startIndex + Math.ceil(listHeight / itemSize + 1), items.length - 1); const viewportItems = []; for (let i = startIndex; i <= endIndex; ++i) { viewportItems.push(items[i]); } return viewportItems; }); const scrollTo = (options, y) => { if (typeof options === 'number') { scrollToPosition(options, y, 'auto'); return; } const { left, top, index, key, position, behavior, debounce = true } = options; if (left !== undefined || top !== undefined) { scrollToPosition(left, top, behavior); } else if (index !== undefined) { scrollToIndex(index, behavior, debounce); } else if (key !== undefined) { const toIndex = keyIndexMapRef.value.get(key); if (toIndex !== undefined) scrollToIndex(toIndex, behavior, debounce); } else if (position === 'bottom') { scrollToPosition(0, Number.MAX_SAFE_INTEGER, behavior); } else if (position === 'top') { scrollToPosition(0, 0, behavior); } }; let anchorIndex; let anchorTimerId = null; function scrollToIndex(index, behavior, debounce) { const { value: ft } = finweckTreeRef; const targetTop = ft.sum(index) + depx(props.paddingTop); if (!debounce) { listElRef.value.scrollTo({ left: 0, top: targetTop, behavior }); } else { anchorIndex = index; if (anchorTimerId !== null) { window.clearTimeout(anchorTimerId); } anchorTimerId = window.setTimeout(() => { anchorIndex = undefined; anchorTimerId = null; }, 16); // use 0 ms may be ealier than resize callback... const { scrollTop, offsetHeight } = listElRef.value; if (targetTop > scrollTop) { const itemSize = ft.get(index); if (targetTop + itemSize <= scrollTop + offsetHeight) { // do nothing } else { listElRef.value.scrollTo({ left: 0, top: targetTop + itemSize - offsetHeight, behavior }); } } else { listElRef.value.scrollTo({ left: 0, top: targetTop, behavior }); } } } function scrollToPosition(left, top, behavior) { listElRef.value.scrollTo({ left, top, behavior }); } function handleItemResize(key, entry) { var _a, _b, _c; if (isDeactivated) return; if (props.ignoreItemResize) return; if (isHideByVShow(entry.target)) return; const { value: ft } = finweckTreeRef; const index = keyIndexMapRef.value.get(key); const previousHeight = ft.get(index); const height = (_c = (_b = (_a = entry.borderBoxSize) === null || _a === void 0 ? void 0 : _a[0]) === null || _b === void 0 ? void 0 : _b.blockSize) !== null && _c !== void 0 ? _c : entry.contentRect.height; if (height === previousHeight) return; // height offset based on itemSize // used when rebuild the finweck tree const offset = height - props.itemSize; if (offset === 0) { keyToHeightOffset.delete(key); } else { keyToHeightOffset.set(key, height - props.itemSize); } // delta height based on finweck tree data const delta = height - previousHeight; if (delta === 0) return; ft.add(index, delta); const listEl = listElRef.value; if (listEl != null) { if (anchorIndex === undefined) { const previousHeightSum = ft.sum(index); if (listEl.scrollTop > previousHeightSum) { listEl.scrollBy(0, delta); } } else { if (index < anchorIndex) { listEl.scrollBy(0, delta); } else if (index === anchorIndex) { const previousHeightSum = ft.sum(index); if (height + previousHeightSum > // Note, listEl shouldn't have border, nor offsetHeight won't be // correct listEl.scrollTop + listEl.offsetHeight) { listEl.scrollBy(0, delta); } } } syncViewport(); } finweckTreeUpdateTrigger.value++; } const mayUseWheel = !ensureMaybeTouch(); let wheelCatched = false; function handleListScroll(e) { var _a; (_a = props.onScroll) === null || _a === void 0 ? void 0 : _a.call(props, e); if (!mayUseWheel || !wheelCatched) { syncViewport(); } } function handleListWheel(e) { var _a; (_a = props.onWheel) === null || _a === void 0 ? void 0 : _a.call(props, e); if (mayUseWheel) { const listEl = listElRef.value; if (listEl != null) { if (e.deltaX === 0) { if (listEl.scrollTop === 0 && e.deltaY <= 0) { return; } if (listEl.scrollTop + listEl.offsetHeight >= listEl.scrollHeight && e.deltaY >= 0) { return; } } e.preventDefault(); listEl.scrollTop += e.deltaY / ensureWheelScale(); listEl.scrollLeft += e.deltaX / ensureWheelScale(); syncViewport(); wheelCatched = true; beforeNextFrameOnce(() => { wheelCatched = false; }); } } } function handleListResize(entry) { if (isDeactivated) return; // List is HTMLElement if (isHideByVShow(entry.target)) return; // If height is same, return if (entry.contentRect.height === listHeightRef.value) return; listHeightRef.value = entry.contentRect.height; const { onResize } = props; if (onResize !== undefined) onResize(entry); } function syncViewport() { const { value: listEl } = listElRef; // sometime ref el can be null // https://github.com/TuSimple/naive-ui/issues/811 if (listEl == null) return; scrollTopRef.value = listEl.scrollTop; scrollLeft = listEl.scrollLeft; } function isHideByVShow(el) { let cursor = el; while (cursor !== null) { if (cursor.style.display === 'none') return true; cursor = cursor.parentElement; } return false; } return { listHeight: listHeightRef, listStyle: { overflow: 'auto' }, keyToIndex: keyIndexMapRef, itemsStyle: computed(() => { const { itemResizable } = props; const height = pxfy(finweckTreeRef.value.sum()); // eslint-disable-next-line @typescript-eslint/no-unused-expressions finweckTreeUpdateTrigger.value; return [ props.itemsStyle, { boxSizing: 'content-box', height: itemResizable ? '' : height, minHeight: itemResizable ? height : '', paddingTop: pxfy(props.paddingTop), paddingBottom: pxfy(props.paddingBottom) } ]; }), visibleItemsStyle: computed(() => { // eslint-disable-next-line @typescript-eslint/no-unused-expressions finweckTreeUpdateTrigger.value; return { transform: `translateY(${pxfy(finweckTreeRef.value.sum(startIndexRef.value))})` }; }), viewportItems: viewportItemsRef, listElRef, itemsElRef: ref(null), scrollTo, handleListResize, handleListScroll, handleListWheel, handleItemResize }; }, render() { const { itemResizable, keyField, keyToIndex, visibleItemsTag } = this; return h(VResizeObserver, { onResize: this.handleListResize }, { default: () => { var _a, _b; return h('div', mergeProps(this.$attrs, { class: ['v-vl', this.showScrollbar && 'v-vl--show-scrollbar'], onScroll: this.handleListScroll, onWheel: this.handleListWheel, ref: 'listElRef' }), [ this.items.length !== 0 ? h('div', { ref: 'itemsElRef', class: 'v-vl-items', style: this.itemsStyle }, [ h(visibleItemsTag, Object.assign({ class: 'v-vl-visible-items', style: this.visibleItemsStyle }, this.visibleItemsProps), { default: () => this.viewportItems.map((item) => { const key = item[keyField]; const index = keyToIndex.get(key); const itemVNode = this.$slots.default({ item, index })[0]; if (itemResizable) { return h(VResizeObserver, { key, onResize: (entry) => this.handleItemResize(key, entry) }, { default: () => itemVNode }); } itemVNode.key = key; return itemVNode; }) }) ]) : (_b = (_a = this.$slots).empty) === null || _b === void 0 ? void 0 : _b.call(_a) ]); } }); } });