| /* |
| * Licensed to the Apache Software Foundation (ASF) under one or more |
| * contributor license agreements. See the NOTICE file distributed with |
| * this work for additional information regarding copyright ownership. |
| * The ASF licenses this file to You under the Apache License, Version 2.0 |
| * (the "License"); you may not use this file except in compliance with |
| * the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| |
| // https://github.com/tangbc/vue-virtual-scroll-list |
| |
| const _debounce = (func, wait, immediate) => { |
| let timeout |
| return function () { |
| const context = this |
| const args = arguments |
| const later = function () { |
| timeout = null |
| if (!immediate) { |
| func.apply(context, args) |
| } |
| } |
| const callNow = immediate && !timeout |
| clearTimeout(timeout) |
| timeout = setTimeout(later, wait) |
| if (callNow) { |
| func.apply(context, args) |
| } |
| } |
| } |
| |
| export default { |
| props: { |
| size: { |
| type: Number, |
| required: true |
| }, |
| remain: { |
| type: Number, |
| required: true |
| }, |
| rtag: { |
| type: String, |
| default: 'div' |
| }, |
| wtag: { |
| type: String, |
| default: 'div' |
| }, |
| wclass: { |
| type: String, |
| default: '' |
| }, |
| wstyle: { |
| type: Object, |
| default: () => ({}) |
| }, |
| pagemode: { |
| type: Boolean, |
| default: false |
| }, |
| scrollelement: { |
| type: typeof window === 'undefined' ? Object : HTMLElement, |
| default: null |
| }, |
| start: { |
| type: Number, |
| default: 0 |
| }, |
| offset: { |
| type: Number, |
| default: 0 |
| }, |
| variable: { |
| type: [Function, Boolean], |
| default: false |
| }, |
| bench: { |
| type: Number, |
| default: 0 // also equal to remain |
| }, |
| debounce: { |
| type: Number, |
| default: 0 |
| }, |
| totop: { |
| type: [Function, Boolean], |
| default: false |
| }, |
| tobottom: { |
| type: [Function, Boolean], |
| default: false |
| }, |
| onscroll: { |
| type: [Function, Boolean], // Boolean disables default behavior |
| default: false |
| }, |
| istable: { |
| type: Boolean, |
| default: false |
| }, |
| item: { |
| type: [Function, Object], |
| default: null |
| }, |
| itemcount: { |
| type: Number, |
| default: 0 |
| }, |
| itemprops: { |
| type: Function, |
| /* istanbul ignore next */ |
| default () {} |
| } |
| }, |
| |
| // use changeProp to identify the prop change. |
| watch: { |
| size () { |
| this.changeProp = 'size' |
| }, |
| remain () { |
| this.changeProp = 'remain' |
| }, |
| bench () { |
| this.changeProp = 'bench' |
| this.itemModeForceRender() |
| }, |
| start () { |
| this.changeProp = 'start' |
| this.itemModeForceRender() |
| }, |
| offset () { |
| this.changeProp = 'offset' |
| this.itemModeForceRender() |
| }, |
| itemcount () { |
| this.changeProp = 'itemcount' |
| this.itemModeForceRender() |
| }, |
| scrollelement (newScrollelement, oldScrollelement) { |
| if (this.pagemode) { |
| return |
| } |
| if (oldScrollelement) { |
| this.removeScrollListener(oldScrollelement) |
| } |
| if (newScrollelement) { |
| this.addScrollListener(newScrollelement) |
| } |
| } |
| }, |
| |
| created () { |
| const start = this.start >= this.remain ? this.start : 0 |
| const keeps = this.remain + (this.bench || this.remain) |
| |
| const delta = Object.create(null) |
| |
| delta.direction = '' // current scroll direction, D: down, U: up. |
| delta.scrollTop = 0 // current scroll top, use to direction. |
| delta.start = start // start index. |
| delta.end = start + keeps - 1 // end index. |
| delta.keeps = keeps // nums keeping in real dom. |
| delta.total = 0 // all items count, update in filter. |
| delta.offsetAll = 0 // cache all the scrollable offset. |
| delta.paddingTop = 0 // container wrapper real padding-top. |
| delta.paddingBottom = 0 // container wrapper real padding-bottom. |
| delta.varCache = {} // object to cache variable index height and scroll offset. |
| delta.varAverSize = 0 // average/estimate item height before variable be calculated. |
| delta.varLastCalcIndex = 0 // last calculated variable height/offset index, always increase. |
| |
| this.delta = delta |
| }, |
| |
| mounted () { |
| if (this.pagemode) { |
| this.addScrollListener(window) |
| } else if (this.scrollelement) { |
| this.addScrollListener(this.scrollelement) |
| } |
| |
| if (this.start) { |
| const start = this.getZone(this.start).start |
| this.setScrollTop(this.variable ? this.getVarOffset(start) : start * this.size) |
| } else if (this.offset) { |
| this.setScrollTop(this.offset) |
| } |
| }, |
| |
| beforeDestroy () { |
| if (this.pagemode) { |
| this.removeScrollListener(window) |
| } else if (this.scrollelement) { |
| this.removeScrollListener(this.scrollelement) |
| } |
| }, |
| |
| // check if delta should update when props change. |
| beforeUpdate () { |
| const delta = this.delta |
| delta.keeps = this.remain + (this.bench || this.remain) |
| |
| const calcstart = this.changeProp === 'start' ? this.start : delta.start |
| const zone = this.getZone(calcstart) |
| |
| // if start, size or offset change, update scroll position. |
| if (this.changeProp && ['start', 'size', 'offset'].includes(this.changeProp)) { |
| const scrollTop = this.changeProp === 'offset' |
| ? this.offset : this.variable |
| ? this.getVarOffset(zone.isLast ? delta.total : zone.start) |
| : zone.isLast && (delta.total - calcstart <= this.remain) |
| ? delta.total * this.size : calcstart * this.size |
| |
| this.$nextTick(this.setScrollTop.bind(this, scrollTop)) |
| } |
| |
| // if points out difference, force update once again. |
| if ( |
| this.changeProp || |
| delta.end !== zone.end || |
| calcstart !== zone.start |
| ) { |
| this.changeProp = '' |
| delta.end = zone.end |
| delta.start = zone.start |
| this.forceRender() |
| } |
| }, |
| |
| methods: { |
| // add pagemode/scrollelement scroll event listener |
| addScrollListener (element) { |
| this.scrollHandler = this.debounce ? _debounce(this.onScroll.bind(this), this.debounce) : this.onScroll |
| element.addEventListener('scroll', this.scrollHandler, false) |
| }, |
| |
| // remove pagemode/scrollelement scroll event listener |
| removeScrollListener (element) { |
| element.removeEventListener('scroll', this.scrollHandler, false) |
| }, |
| |
| onScroll (event) { |
| const delta = this.delta |
| const vsl = this.$refs.vsl |
| let offset |
| if (this.pagemode) { |
| const elemRect = this.$el.getBoundingClientRect() |
| offset = -elemRect.top |
| } else if (this.scrollelement) { |
| const scrollelementRect = this.scrollelement.getBoundingClientRect() |
| const elemRect = this.$el.getBoundingClientRect() |
| offset = scrollelementRect.top - elemRect.top |
| } else { |
| offset = (vsl && (vsl.$el || vsl).scrollTop) || 0 |
| } |
| |
| delta.direction = offset > delta.scrollTop ? 'D' : 'U' |
| delta.scrollTop = offset |
| |
| if (delta.total > delta.keeps) { |
| this.updateZone(offset) |
| } else { |
| delta.end = delta.total - 1 |
| } |
| |
| const offsetAll = delta.offsetAll |
| if (this.onscroll) { |
| const param = Object.create(null) |
| param.offset = offset |
| param.offsetAll = offsetAll |
| param.start = delta.start |
| param.end = delta.end |
| this.onscroll(event, param) |
| } |
| |
| if (!offset && delta.total) { |
| this.fireEvent('totop') |
| } |
| |
| if (offset >= offsetAll) { |
| this.fireEvent('tobottom') |
| } |
| }, |
| |
| // update render zone by scroll offset. |
| updateZone (offset) { |
| const delta = this.delta |
| let overs = this.variable |
| ? this.getVarOvers(offset) |
| : Math.floor(offset / this.size) |
| |
| // if scroll up, we'd better decrease it's numbers. |
| if (delta.direction === 'U') { |
| overs = overs - this.remain + 1 |
| } |
| |
| const zone = this.getZone(overs) |
| const bench = this.bench || this.remain |
| |
| // for better performance, if scroll passes items within the bench, do not update. |
| // and if it's close to the last item, render next zone immediately. |
| const shouldRenderNextZone = Math.abs(overs - delta.start - bench) === 1 |
| if ( |
| !shouldRenderNextZone && |
| (overs - delta.start <= bench) && |
| !zone.isLast && (overs > delta.start) |
| ) { |
| return |
| } |
| |
| // make sure forceRender calls as less as possible. |
| if ( |
| shouldRenderNextZone || |
| zone.start !== delta.start || |
| zone.end !== delta.end |
| ) { |
| delta.end = zone.end |
| delta.start = zone.start |
| this.forceRender() |
| } |
| }, |
| |
| // return the right zone info based on `start/index`. |
| getZone (index) { |
| let start, end |
| const delta = this.delta |
| |
| index = parseInt(index, 10) |
| index = Math.max(0, index) |
| |
| const lastStart = delta.total - delta.keeps |
| const isLast = (index <= delta.total && index >= lastStart) || (index > delta.total) |
| |
| if (isLast) { |
| start = Math.max(0, lastStart) |
| } else { |
| start = index |
| } |
| end = start + delta.keeps - 1 |
| if (delta.total && end > delta.total) { |
| end = delta.total - 1 |
| } |
| |
| return { |
| end, |
| start, |
| isLast |
| } |
| }, |
| |
| // public method, force render ui list if needed. |
| // call this before the next rerender to get better performance. |
| forceRender () { |
| window.requestAnimationFrame(() => { |
| this.$forceUpdate() |
| }) |
| }, |
| |
| // force render ui if using item-mode. |
| itemModeForceRender () { |
| if (this.item) { |
| this.forceRender() |
| } |
| }, |
| |
| // return the scroll of passed items count in variable. |
| getVarOvers (offset) { |
| let low = 0 |
| let middle = 0 |
| let middleOffset = 0 |
| const delta = this.delta |
| let high = delta.total |
| |
| while (low <= high) { |
| middle = low + Math.floor((high - low) / 2) |
| middleOffset = this.getVarOffset(middle) |
| |
| // calculate the average variable height at first binary search. |
| if (!delta.varAverSize) { |
| delta.varAverSize = Math.floor(middleOffset / middle) |
| } |
| |
| if (middleOffset === offset) { |
| return middle |
| } else if (middleOffset < offset) { |
| low = middle + 1 |
| } else if (middleOffset > offset) { |
| high = middle - 1 |
| } |
| } |
| |
| return low > 0 ? --low : 0 |
| }, |
| |
| // return a variable scroll offset from given index. |
| getVarOffset (index, nocache) { |
| const delta = this.delta |
| const cache = delta.varCache[index] |
| |
| if (!nocache && cache) { |
| return cache.offset |
| } |
| |
| let offset = 0 |
| for (let i = 0; i < index; i++) { |
| const size = this.getVarSize(i, nocache) |
| delta.varCache[i] = { |
| size: size, |
| offset: offset |
| } |
| offset += size |
| } |
| |
| delta.varLastCalcIndex = Math.max(delta.varLastCalcIndex, index - 1) |
| delta.varLastCalcIndex = Math.min(delta.varLastCalcIndex, delta.total - 1) |
| |
| return offset |
| }, |
| |
| // return a variable size (height) from given index. |
| getVarSize (index, nocache) { |
| const cache = this.delta.varCache[index] |
| if (!nocache && cache) { |
| return cache.size |
| } |
| |
| if (typeof this.variable === 'function') { |
| return this.variable(index) || 0 |
| } else { |
| // when using item, it can only get current components height, |
| // need to be enhanced, or consider using variable-function instead |
| const slot = this.item |
| ? (this.$children[index] ? this.$children[index].$vnode : null) |
| : this.$slots.default[index] |
| |
| const style = slot && slot.data && slot.data.style |
| if (style && style.height) { |
| const shm = style.height.match(/^(.*)px$/) |
| return (shm && +shm[1]) || 0 |
| } |
| } |
| return 0 |
| }, |
| |
| // return the variable paddingTop based on current zone. |
| // @todo: if set a large `start` before variable was calculated, |
| // here will also case too much offset calculate when list is very large, |
| // consider use estimate paddingTop in this case just like `getVarPaddingBottom`. |
| getVarPaddingTop () { |
| return this.getVarOffset(this.delta.start) |
| }, |
| |
| // return the variable paddingBottom based on the current zone. |
| getVarPaddingBottom () { |
| const delta = this.delta |
| const last = delta.total - 1 |
| if (delta.total - delta.end <= delta.keeps || delta.varLastCalcIndex === last) { |
| return this.getVarOffset(last) - this.getVarOffset(delta.end) |
| } else { |
| // if unreached last zone or uncalculated real behind offset |
| // return the estimate paddingBottom and avoid too much calculations. |
| return (delta.total - delta.end) * (delta.varAverSize || this.size) |
| } |
| }, |
| |
| // return the variable all heights use to judge reach bottom. |
| getVarAllHeight () { |
| const delta = this.delta |
| if (delta.total - delta.end <= delta.keeps || delta.varLastCalcIndex === delta.total - 1) { |
| return this.getVarOffset(delta.total) |
| } else { |
| return this.getVarOffset(delta.start) + (delta.total - delta.end) * (delta.varAverSize || this.size) |
| } |
| }, |
| |
| // public method, allow the parent update variable by index. |
| updateVariable (index) { |
| // clear/update all the offsets and heights ahead of index. |
| this.getVarOffset(index, true) |
| }, |
| |
| // trigger a props event on parent. |
| fireEvent (event) { |
| if (this[event]) { |
| this[event]() |
| } |
| }, |
| |
| // set manual scroll top. |
| setScrollTop (scrollTop) { |
| if (this.pagemode) { |
| window.scrollTo(0, scrollTop) |
| } else if (this.scrollelement) { |
| this.scrollelement.scrollTo(0, scrollTop) |
| } else { |
| const vsl = this.$refs.vsl |
| if (vsl) { |
| (vsl.$el || vsl).scrollTop = scrollTop |
| } |
| } |
| }, |
| |
| // filter the shown items based on `start` and `end`. |
| filter (h) { |
| const delta = this.delta |
| const slots = this.$slots.default || [] |
| |
| // item-mode should be decided from items prop. |
| if (this.item || this.$scopedSlots.item) { |
| delta.total = this.itemcount |
| if (delta.keeps > delta.total) { |
| delta.end = delta.total - 1 |
| } |
| } else { |
| if (!slots.length) { |
| delta.start = 0 |
| } |
| delta.total = slots.length |
| } |
| |
| let paddingTop, paddingBottom, allHeight |
| const hasPadding = delta.total > delta.keeps |
| |
| if (this.variable) { |
| allHeight = this.getVarAllHeight() |
| paddingTop = hasPadding ? this.getVarPaddingTop() : 0 |
| paddingBottom = hasPadding ? this.getVarPaddingBottom() : 0 |
| } else { |
| allHeight = this.size * delta.total |
| paddingTop = this.size * (hasPadding ? delta.start : 0) |
| paddingBottom = this.size * (hasPadding ? delta.total - delta.keeps : 0) - paddingTop |
| } |
| |
| if (paddingBottom < this.size) { |
| paddingBottom = 0 |
| } |
| |
| delta.paddingTop = paddingTop |
| delta.paddingBottom = paddingBottom |
| delta.offsetAll = allHeight - this.size * this.remain |
| |
| const renders = [] |
| for (let i = delta.start; i < delta.total && i <= Math.ceil(delta.end); i++) { |
| let slot = null |
| if (this.$scopedSlots.item) { |
| slot = this.$scopedSlots.item(i) |
| } else if (this.item) { |
| slot = h(this.item, this.itemprops(i)) |
| } else { |
| slot = slots[i] |
| } |
| renders.push(slot) |
| } |
| |
| return renders |
| } |
| }, |
| |
| render (h) { |
| const dbc = this.debounce |
| let list = this.filter(h) |
| const { paddingTop, paddingBottom } = this.delta |
| |
| const istable = this.istable |
| const wtag = istable ? 'div' : this.wtag |
| const rtag = istable ? 'div' : this.rtag |
| if (istable) { |
| list = [h('table', [h('tbody', list)])] |
| } |
| const renderList = h(wtag, { |
| style: Object.assign({ |
| display: 'block', |
| 'padding-top': paddingTop + 'px', |
| 'padding-bottom': paddingBottom + 'px' |
| }, this.wstyle), |
| class: this.wclass, |
| attrs: { |
| role: 'group' |
| } |
| }, list) |
| |
| // page mode just render list, no wrapper. |
| if (this.pagemode || this.scrollelement) { |
| return renderList |
| } |
| |
| return h(rtag, { |
| ref: 'vsl', |
| style: { |
| display: 'block', |
| //'overflow-y': this.size >= this.remain ? 'auto' : 'initial', |
| 'overflow-y': 'auto', |
| height: this.size * this.remain + 'px' |
| }, |
| on: { |
| '&scroll': dbc ? _debounce(this.onScroll.bind(this), dbc) : this.onScroll |
| } |
| }, [ |
| renderList |
| ]) |
| } |
| } |
| |