@opensig/opendesign
Version:
576 lines (575 loc) • 21.1 kB
JavaScript
"use strict";
const vue = require("vue");
const core = require("@vueuse/core");
const is = require("../_utils/is.js");
const helper = require("../_utils/helper.js");
const uniqueId = require("../_utils/unique-id.js");
require("../scrollbar/index.js");
const types = require("./types.js");
const VirtualListItem_vue_vue_type_script_setup_true_lang = require("./VirtualListItem.vue.js");
const binarySearch = require("./utils/binary-search.js");
const alignment = require("./utils/alignment.js");
const useScrollState = require("./composables/use-scroll-state.js");
const useWheel = require("./composables/use-wheel.js");
const vScrollbar = require("../scrollbar/vScrollbar.js");
const MAX_INITIAL_RESCROLL = 5;
const MAX_APPROACH = 10;
const _sfc_main = /* @__PURE__ */ vue.defineComponent({
__name: "OVirtualList",
props: types.virtualListProps,
emits: ["renderChange"],
setup(__props, { expose: __expose, emit: __emit }) {
const props = __props;
const emits = __emit;
const scrollbarProps = vue.computed(() => {
if (props.scrollbar === true) {
return {
showType: "always",
size: "medium"
};
}
return props.scrollbar;
});
const isHorizontal = vue.computed(() => props.layout === "horizontal");
const isFixedHeight = vue.computed(() => is.isNumber(props.itemSize));
const isFunctionHeight = vue.computed(() => is.isFunction(props.itemSize));
const isDynamicMode = vue.computed(() => !isFixedHeight.value && !isFunctionHeight.value);
const getItemHeight = (item, index) => {
if (isFixedHeight.value) {
return props.itemSize;
}
if (isFunctionHeight.value) {
return props.itemSize(item, index);
}
return props.defaultItemSize;
};
const { getScroll, setScroll: _setScroll, getAxisSize, getScrollSize, getClientSize, scrollToPos: _scrollToPos } = alignment.createAxisSelector(isHorizontal);
let isProgrammaticScroll = false;
const setScroll = (el, val) => {
isProgrammaticScroll = true;
_setScroll(el, val);
};
const scrollToPos = (el, pos, behavior) => {
isProgrammaticScroll = true;
_scrollToPos(el, pos, behavior);
};
const genFallbackId = uniqueId.useGetUniqueId();
const listData = vue.ref([]);
vue.watch(
() => props.list,
(value) => {
const hasId = value.length > 0 && !is.isUndefined(value[0].id);
if (!hasId && isDynamicMode.value && value.length > 0 && is.isClient) {
console.warn("[OVirtualList] 不定高模式下建议为每一项传入唯一 id 字段,否则动态追加数据时滚动位置可能跳变。已自动生成 fallback ID。");
}
listData.value = value.map((item, index) => ({
id: item.id ?? genFallbackId(),
data: item,
index
}));
},
{
immediate: true
}
);
const defaultStartIndex = vue.computed(() => {
if (is.isUndefined(props.defaultStartIndex)) {
return 0;
}
return Math.max(Math.min(props.defaultStartIndex, props.list.length - 1), 0);
});
const visibleStartIndex = vue.ref(defaultStartIndex.value ?? 0);
let visibleStartId;
const renderCount = vue.ref(1);
const startIndex = vue.computed(() => {
return Math.max(visibleStartIndex.value - props.buffer, 0);
});
const endIndex = vue.computed(() => {
return Math.min(visibleStartIndex.value + renderCount.value + props.buffer - 1, listData.value.length - 1);
});
let lastVisibleStartIndex = visibleStartIndex.value;
let lastRenderCount = renderCount.value;
const emitRenderChange = () => {
if (lastVisibleStartIndex !== visibleStartIndex.value || lastRenderCount !== renderCount.value) {
emits("renderChange", {
start: startIndex.value,
end: endIndex.value,
count: renderCount.value,
visible: visibleStartIndex.value
});
lastVisibleStartIndex = visibleStartIndex.value;
lastRenderCount = renderCount.value;
}
};
const wrapperRef = vue.ref();
const offset = vue.ref(0);
const { isScrolling, markScrolling, cleanup: cleanupScrollState } = useScrollState.useScrollState();
let initialScroll = isFixedHeight.value || isFunctionHeight.value;
let needsInitialReScroll = false;
let initialReScrollCount = 0;
let listMetaData = [];
let lastMeasuredIndex = -1;
let unmeasuredTotal = 0;
const ensureMeasured = (index) => {
if (index <= lastMeasuredIndex || listMetaData.length === 0) {
return;
}
const start = lastMeasuredIndex + 1;
let top = lastMeasuredIndex >= 0 ? listMetaData[lastMeasuredIndex].bottom : 0;
for (let i = start; i <= index; i++) {
const meta = listMetaData[i];
meta.top = top;
meta.bottom = top + meta.size;
top = meta.bottom;
unmeasuredTotal -= meta.size;
}
lastMeasuredIndex = index;
};
const safeMeta = (index) => {
if (listMetaData.length === 0) {
return void 0;
}
const i = Math.max(0, Math.min(index, listMetaData.length - 1));
return listMetaData[i];
};
const getMetaTop = (index) => {
const meta = safeMeta(index);
if (!meta) {
return 0;
}
if (index > lastMeasuredIndex) {
ensureMeasured(index);
}
return meta.top;
};
const getMetaBottom = (index) => {
const meta = safeMeta(index);
if (!meta) {
return 0;
}
if (index > lastMeasuredIndex) {
ensureMeasured(index);
}
return meta.bottom;
};
const getEstimatedTotalSize = () => {
if (listMetaData.length === 0) {
return 0;
}
if (lastMeasuredIndex < 0) {
return unmeasuredTotal;
}
return listMetaData[lastMeasuredIndex].bottom + unmeasuredTotal;
};
const initialSize = isFixedHeight.value ? props.itemSize * listData.value.length : props.defaultItemSize * listData.value.length;
const contentSize = vue.ref(initialSize);
const containerSize = vue.ref({
height: 0,
width: 0
});
const containerMainSize = vue.computed(() => isHorizontal.value ? containerSize.value.width : containerSize.value.height);
const isVirtualEnabled = vue.computed(() => {
if (props.threshold === null) {
return true;
}
return listData.value.length >= props.threshold && contentSize.value > containerMainSize.value;
});
const renderList = vue.computed(() => {
if (!isVirtualEnabled.value) {
return listData.value;
}
return listData.value.slice(startIndex.value, endIndex.value + 1);
});
const updateVisibleCount = (scrollOffset) => {
let scrollSize = scrollOffset;
if (is.isUndefined(scrollSize)) {
scrollSize = wrapperRef.value ? getScroll(wrapperRef.value) : 0;
}
const containerHeight = containerMainSize.value;
if (!wrapperRef.value || !containerHeight) {
return;
}
let render = 1;
for (let i = visibleStartIndex.value + 1; i < listMetaData.length; i++) {
if (getMetaTop(i) < scrollSize + containerHeight) {
render++;
}
}
renderCount.value = render;
emitRenderChange();
};
const debounceUpdateVisibleCount = helper.debounceRAF(updateVisibleCount);
const refreshStartIndex = (scrollTop) => {
for (let i = visibleStartIndex.value; i >= 0; i--) {
if (getMetaTop(i) <= scrollTop) {
visibleStartIndex.value = i;
break;
}
}
};
const refreshRenderCount = (scrollTop, mainSize) => {
let count = renderCount.value;
for (let i = endIndex.value; i < listMetaData.length; i++) {
if (getMetaTop(i) < scrollTop + mainSize) {
count++;
}
}
renderCount.value = count;
};
const onContainerResize = () => {
if (!wrapperRef.value) {
return;
}
containerSize.value.height = wrapperRef.value.offsetHeight;
containerSize.value.width = wrapperRef.value.offsetWidth;
const mainSize = containerMainSize.value;
if (mainSize === 0) {
offset.value = 0;
}
if (!initialScroll) {
if (contentSize.value < mainSize) {
visibleStartIndex.value = 0;
}
updateVisibleCount();
return;
}
const scrollTop = getScroll(wrapperRef.value);
refreshStartIndex(scrollTop);
refreshRenderCount(scrollTop, mainSize);
emitRenderChange();
};
core.useResizeObserver(wrapperRef, () => {
onContainerResize();
});
const contentStyle = vue.computed(() => ({
[isHorizontal.value ? "--_vl-content-width" : "--_vl-content-height"]: `${contentSize.value}px`
}));
const renderListStyle = vue.computed(() => {
return {
// 非虚拟模式(isVirtualEnabled=false)下全量渲染 DOM,不需要 transform 偏移;
// 若应用非 0 的 offset,会将项推出 o-virtual-body 的 overflow:hidden 范围,
// 导致末尾项永远无法滚入视口
[isHorizontal.value ? "--_vl-offset-x" : "--_vl-offset-y"]: `${isVirtualEnabled.value ? offset.value : 0}px`,
// 滚动中禁用子项交互,避免 hover/click 触发不必要的 re-render
pointerEvents: isScrolling.value ? "none" : void 0
};
});
let pendingScrollTo = null;
let approachCount = 0;
const setupPendingScroll = (toIndex, align, behavior) => {
pendingScrollTo = { index: toIndex, align, behavior };
approachCount = 0;
};
const resolveBehavior = (behavior) => isFixedHeight.value || isFunctionHeight.value ? behavior : "instant";
const scrollToView = (index, align = "start", behavior = "instant") => {
if (!wrapperRef.value) {
return;
}
const toIndex = Math.max(Math.min(listMetaData.length - 1, index), 0);
const item = safeMeta(toIndex);
if (!item) {
return;
}
const itemTop = getMetaTop(toIndex);
const cSize = getAxisSize(wrapperRef.value);
let _align = align;
if (_align === "nearest") {
const resolved = alignment.resolveNearestAlign({ currentScroll: getScroll(wrapperRef.value), itemTop, itemSize: item.size, containerSize: cSize });
if (resolved === null) {
return;
}
_align = resolved;
}
if (!item.measured && _align !== "start") {
setupPendingScroll(toIndex, _align, behavior);
scrollToPos(wrapperRef.value, itemTop, "instant");
return;
}
const scrollTarget = alignment.calculateScrollTarget(itemTop, _align, { containerSize: cSize, itemSize: item.size });
if (Math.abs(toIndex - visibleStartIndex.value) > renderCount.value && !item.measured) {
setupPendingScroll(toIndex, _align, behavior);
}
scrollToPos(wrapperRef.value, scrollTarget, resolveBehavior(behavior));
};
const debouncedReApproach = helper.debounceRAF(() => {
if (!pendingScrollTo || !wrapperRef.value) {
return;
}
approachCount++;
const { index: targetIndex, align: targetAlign, behavior: targetBehavior } = pendingScrollTo;
const targetMeta = safeMeta(targetIndex);
if (targetMeta && targetMeta.measured) {
pendingScrollTo = null;
scrollToView(targetIndex, targetAlign, targetBehavior);
return;
}
if (approachCount >= MAX_APPROACH) {
pendingScrollTo = null;
return;
}
const estimatedTop = getMetaTop(targetIndex);
const currentScroll = getScroll(wrapperRef.value);
if (Math.abs(estimatedTop - currentScroll) > 1) {
setScroll(wrapperRef.value, estimatedTop);
}
});
const buildMetaItem = (item, index, prevMetaMap) => {
if (isDynamicMode.value) {
const prev = prevMetaMap.get(item.id);
if (prev && prev.measured) {
return { id: item.id, index, size: prev.size, top: 0, bottom: 0, measured: true };
}
}
const isKnownHeight = isFixedHeight.value || isFunctionHeight.value;
return { id: item.id, index, size: getItemHeight(item.data, index), top: 0, bottom: 0, measured: isKnownHeight };
};
const repositionScroll = (dataList) => {
if (is.isUndefined(visibleStartId) || !wrapperRef.value) {
return;
}
const scrollOffset = getScroll(wrapperRef.value);
const delta = scrollOffset - getMetaTop(visibleStartIndex.value);
const newIndex = dataList.findIndex((item) => item.id === visibleStartId);
if (newIndex >= 0) {
visibleStartIndex.value = newIndex;
setScroll(wrapperRef.value, getMetaTop(newIndex) + delta);
}
};
vue.watch(
[() => props.itemSize, () => listData.value],
([, dataList]) => {
if (dataList.length === 0) {
listMetaData = [];
lastMeasuredIndex = -1;
unmeasuredTotal = 0;
contentSize.value = 0;
return;
}
const isKnownHeight = isFixedHeight.value || isFunctionHeight.value;
const prevMetaMap = isDynamicMode.value ? new Map(listMetaData.map((m) => [m.id, m])) : /* @__PURE__ */ new Map();
listMetaData = dataList.map((item, index) => buildMetaItem(item, index, prevMetaMap));
lastMeasuredIndex = -1;
unmeasuredTotal = 0;
for (const meta of listMetaData) {
unmeasuredTotal += meta.size;
}
if (isKnownHeight) {
ensureMeasured(listMetaData.length - 1);
}
contentSize.value = getEstimatedTotalSize();
repositionScroll(dataList);
},
{
immediate: true
}
);
const flushContentSize = helper.debounceRAF(() => {
contentSize.value = getEstimatedTotalSize();
});
const recalcRange = (start) => {
for (let i = start; i <= lastMeasuredIndex; i++) {
const meta = listMetaData[i];
meta.top = i > 0 ? listMetaData[i - 1].bottom : 0;
meta.bottom = meta.top + meta.size;
}
flushContentSize();
};
const metaAccessor = { getTop: getMetaTop, getBottom: getMetaBottom };
const getStartIndex = (scrollOffset) => binarySearch.findIndexByOffset(listMetaData.length, scrollOffset, metaAccessor);
const onScrollImpl = (scrollOffset) => {
if (isFixedHeight.value) {
visibleStartIndex.value = Math.floor(scrollOffset / props.itemSize);
} else {
visibleStartIndex.value = getStartIndex(scrollOffset);
}
offset.value = getMetaTop(startIndex.value);
const currentMeta = safeMeta(visibleStartIndex.value);
if (currentMeta) {
visibleStartId = currentMeta.id;
}
updateVisibleCount(scrollOffset);
};
const debounceOnScroll = helper.debounceRAF(onScrollImpl);
const onScroll = () => {
markScrolling();
if (isProgrammaticScroll) {
isProgrammaticScroll = false;
} else if (needsInitialReScroll) {
needsInitialReScroll = false;
}
const scrollOffset = wrapperRef.value ? getScroll(wrapperRef.value) : 0;
debounceOnScroll(scrollOffset);
};
const correctScrollForResize = (meta, itemTop, newSize) => {
if (wrapperRef.value && getScroll(wrapperRef.value) > itemTop) {
setScroll(wrapperRef.value, getScroll(wrapperRef.value) + newSize - meta.size);
}
};
const handleInitialScroll = (index) => {
if (index !== defaultStartIndex.value || initialScroll) {
return;
}
vue.nextTick(() => {
scrollToView(defaultStartIndex.value);
initialScroll = true;
if (isDynamicMode.value) {
needsInitialReScroll = true;
initialReScrollCount = 0;
}
});
};
const onItemResize = (en, index) => {
const el = en.target;
const meta = safeMeta(index);
if (!meta) {
return;
}
const newSize = getAxisSize(el);
if (meta.measured && meta.size === newSize) {
return;
}
const itemTop = getMetaTop(index);
correctScrollForResize(meta, itemTop, newSize);
meta.size = newSize;
meta.measured = true;
recalcRange(index);
if (pendingScrollTo) {
debouncedReApproach();
}
handleInitialScroll(index);
debounceUpdateVisibleCount();
};
const init = () => {
if (!wrapperRef.value) {
return;
}
if (isFixedHeight.value || isFunctionHeight.value) {
scrollToView(defaultStartIndex.value);
} else if (isDynamicMode.value && defaultStartIndex.value > 0) {
scrollToView(defaultStartIndex.value);
initialScroll = true;
needsInitialReScroll = true;
initialReScrollCount = 0;
}
};
vue.watch(contentSize, () => {
if (!needsInitialReScroll || !wrapperRef.value) {
return;
}
initialReScrollCount++;
if (initialReScrollCount > MAX_INITIAL_RESCROLL) {
needsInitialReScroll = false;
return;
}
vue.nextTick(() => {
if (!needsInitialReScroll || !wrapperRef.value) {
return;
}
const itemTop = getMetaTop(defaultStartIndex.value);
const currentScroll = getScroll(wrapperRef.value);
if (Math.abs(itemTop - currentScroll) > 1) {
scrollToView(defaultStartIndex.value);
} else {
needsInitialReScroll = false;
}
});
});
const scrollToOffset = (px) => {
if (!wrapperRef.value) {
return;
}
const max = getScrollSize(wrapperRef.value) - getClientSize(wrapperRef.value);
setScroll(wrapperRef.value, Math.max(0, Math.min(px, max)));
};
useWheel.useWheel({ wrapperRef, isHorizontal, axis: { getScroll, setScroll, getAxisSize, getScrollSize, getClientSize, scrollToPos } });
vue.onMounted(() => {
init();
});
vue.onUnmounted(() => {
cleanupScrollState();
debounceOnScroll.cancel();
debounceUpdateVisibleCount.cancel();
debouncedReApproach.cancel();
flushContentSize.cancel();
});
__expose({
scrollToView,
scrollToOffset
});
return (_ctx, _cache) => {
return vue.openBlock(), vue.createElementBlock(
"div",
{
class: vue.normalizeClass([{ "o-horizontal": isHorizontal.value }, "o-virtual-list"])
},
[
vue.withDirectives((vue.openBlock(), vue.createElementBlock(
"div",
{
ref_key: "wrapperRef",
ref: wrapperRef,
class: "o-virtual-list-wrapper",
onScrollPassive: onScroll
},
[
vue.createElementVNode(
"div",
{
style: vue.normalizeStyle(contentStyle.value),
class: "o-virtual-body"
},
[
vue.createElementVNode(
"div",
{
style: vue.normalizeStyle(renderListStyle.value),
class: "o-virtual-render-list"
},
[
(vue.openBlock(true), vue.createElementBlock(
vue.Fragment,
null,
vue.renderList(renderList.value, (item) => {
return vue.openBlock(), vue.createBlock(VirtualListItem_vue_vue_type_script_setup_true_lang, {
key: item.index,
index: item.index,
layout: props.layout,
"main-size": isFixedHeight.value || isFunctionHeight.value ? getItemHeight(item.data, item.index) : void 0,
"observe-resize": isDynamicMode.value,
onResize: onItemResize
}, {
default: vue.withCtx(() => [
vue.renderSlot(_ctx.$slots, "default", {
index: item.index,
item: item.data
})
]),
_: 2
/* DYNAMIC */
}, 1032, ["index", "layout", "main-size", "observe-resize"]);
}),
128
/* KEYED_FRAGMENT */
))
],
4
/* STYLE */
)
],
4
/* STYLE */
)
],
32
/* NEED_HYDRATION */
)), [
[vue.unref(vScrollbar.vScrollbar), scrollbarProps.value]
])
],
2
/* CLASS */
);
};
}
});
module.exports = _sfc_main;