UNPKG

@opensig/opendesign

Version:

576 lines (575 loc) 21.1 kB
"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;