1import Vue from 'vue'
2import Virtual from './virtual'
3import { Item, Slot } from './Item'
4import { VirtualProps } from './props'
5
6const EVENT_TYPE = {
7  ITEM: 'item_resize',
8  SLOT: 'slot_resize'
9}
10const SLOT_TYPE = {
11  HEADER: 'header', // string value also use for aria role attribute
12  FOOTER: 'footer'
13}
14
15const VirtualList = Vue.component('virtual-list', {
16  props: VirtualProps,
17
18  data() {
19    return {
20      range: null
21    }
22  },
23
24  watch: {
25    'dataSources.length'() {
26      this.virtual.updateParam('uniqueIds', this.getUniqueIdFromDataSources())
27      this.virtual.handleDataSourcesChange()
28    },
29
30    start(newValue) {
31      this.scrollToIndex(newValue)
32    },
33
34    offset(newValue) {
35      this.scrollToOffset(newValue)
36    }
37  },
38
39  created() {
40    this.isHorizontal = this.direction === 'horizontal'
41    this.directionKey = this.isHorizontal ? 'scrollLeft' : 'scrollTop'
42
43    this.installVirtual()
44
45    // listen item size change
46    this.$on(EVENT_TYPE.ITEM, this.onItemResized)
47
48    // listen slot size change
49    if (this.$slots.header || this.$slots.footer) {
50      this.$on(EVENT_TYPE.SLOT, this.onSlotResized)
51    }
52  },
53
54  // set back offset when awake from keep-alive
55  activated() {
56    this.scrollToOffset(this.virtual.offset)
57  },
58
59  mounted() {
60    // set position
61    if (this.start) {
62      this.scrollToIndex(this.start)
63    } else if (this.offset) {
64      this.scrollToOffset(this.offset)
65    }
66
67    // in page mode we bind scroll event to document
68    if (this.pageMode) {
69      this.updatePageModeFront()
70
71      document.addEventListener('scroll', this.onScroll, {
72        passive: false
73      })
74    }
75  },
76
77  beforeDestroy() {
78    this.virtual.destroy()
79    if (this.pageMode) {
80      document.removeEventListener('scroll', this.onScroll)
81    }
82  },
83
84  methods: {
85    // get item size by id
86    getSize(id) {
87      return this.virtual.sizes.get(id)
88    },
89
90    // get the total number of stored (rendered) items
91    getSizes() {
92      return this.virtual.sizes.size
93    },
94
95    // return current scroll offset
96    getOffset() {
97      if (this.pageMode) {
98        return document.documentElement[this.directionKey] || document.body[this.directionKey]
99      } else {
100        const { root } = this.$refs
101        return root ? Math.ceil(root[this.directionKey]) : 0
102      }
103    },
104
105    // return client viewport size
106    getClientSize() {
107      const key = this.isHorizontal ? 'clientWidth' : 'clientHeight'
108      if (this.pageMode) {
109        return document.documentElement[key] || document.body[key]
110      } else {
111        const { root } = this.$refs
112        return root ? Math.ceil(root[key]) : 0
113      }
114    },
115
116    // return all scroll size
117    getScrollSize() {
118      const key = this.isHorizontal ? 'scrollWidth' : 'scrollHeight'
119      if (this.pageMode) {
120        return document.documentElement[key] || document.body[key]
121      } else {
122        const { root } = this.$refs
123        return root ? Math.ceil(root[key]) : 0
124      }
125    },
126
127    // set current scroll position to a expectant offset
128    scrollToOffset(offset) {
129      if (this.pageMode) {
130        document.body[this.directionKey] = offset
131        document.documentElement[this.directionKey] = offset
132      } else {
133        const { root } = this.$refs
134        if (root) {
135          root[this.directionKey] = offset
136        }
137      }
138    },
139
140    // set current scroll position to a expectant index
141    scrollToIndex(index) {
142      // scroll to bottom
143      if (index >= this.dataSources.length - 1) {
144        this.scrollToBottom()
145      } else {
146        const offset = this.virtual.getOffset(index)
147        this.scrollToOffset(offset)
148      }
149    },
150
151    // set current scroll position to bottom
152    scrollToBottom() {
153      const { shepherd } = this.$refs
154      if (shepherd) {
155        const offset = shepherd[this.isHorizontal ? 'offsetLeft' : 'offsetTop']
156        this.scrollToOffset(offset)
157
158        // check if it's really scrolled to the bottom
159        // maybe list doesn't render and calculate to last range
160        // so we need retry in next event loop until it really at bottom
161        setTimeout(() => {
162          if (this.getOffset() + this.getClientSize() < this.getScrollSize()) {
163            this.scrollToBottom()
164          }
165        }, 3)
166      }
167    },
168
169    // when using page mode we need update slot header size manually
170    // taking root offset relative to the browser as slot header size
171    updatePageModeFront() {
172      const { root } = this.$refs
173      if (root) {
174        const rect = root.getBoundingClientRect()
175        const { defaultView } = root.ownerDocument
176        const offsetFront = this.isHorizontal ? (rect.left + defaultView.pageXOffset) : (rect.top + defaultView.pageYOffset)
177        this.virtual.updateParam('slotHeaderSize', offsetFront)
178      }
179    },
180
181    // reset all state back to initial
182    reset() {
183      this.virtual.destroy()
184      this.scrollToOffset(0)
185      this.installVirtual()
186    },
187
188    // ----------- public method end -----------
189
190    installVirtual() {
191      this.virtual = new Virtual({
192        slotHeaderSize: 0,
193        slotFooterSize: 0,
194        keeps: this.keeps,
195        estimateSize: this.estimateSize,
196        buffer: Math.round(this.keeps / 3), // recommend for a third of keeps
197        uniqueIds: this.getUniqueIdFromDataSources()
198      }, this.onRangeChanged)
199
200      // sync initial range
201      this.range = this.virtual.getRange()
202    },
203
204    getUniqueIdFromDataSources() {
205      const { dataKey } = this
206      return this.dataSources.map((dataSource) => typeof dataKey === 'function' ? dataKey(dataSource) : dataSource[dataKey])
207    },
208
209    // event called when each item mounted or size changed
210    onItemResized(id, size) {
211      this.virtual.saveSize(id, size)
212      this.$emit('resized', id, size)
213    },
214
215    // event called when slot mounted or size changed
216    onSlotResized(type, size, hasInit) {
217      if (type === SLOT_TYPE.HEADER) {
218        this.virtual.updateParam('slotHeaderSize', size)
219      } else if (type === SLOT_TYPE.FOOTER) {
220        this.virtual.updateParam('slotFooterSize', size)
221      }
222
223      if (hasInit) {
224        this.virtual.handleSlotSizeChange()
225      }
226    },
227
228    // here is the re-rendering entry
229    onRangeChanged(range) {
230      this.range = range
231    },
232
233    onScroll(evt) {
234      const offset = this.getOffset()
235      const clientSize = this.getClientSize()
236      const scrollSize = this.getScrollSize()
237
238      // iOS scroll-spring-back behavior will make direction mistake
239      if (offset < 0 || (offset + clientSize > scrollSize + 1) || !scrollSize) {
240        return
241      }
242
243      this.virtual.handleScroll(offset)
244      this.emitEvent(offset, clientSize, scrollSize, evt)
245    },
246
247    // emit event in special position
248    emitEvent(offset, clientSize, scrollSize, evt) {
249      this.$emit('scroll', evt, this.virtual.getRange())
250
251      if (this.virtual.isFront() && !!this.dataSources.length && (offset - this.topThreshold <= 0)) {
252        this.$emit('totop')
253      } else if (this.virtual.isBehind() && (offset + clientSize + this.bottomThreshold >= scrollSize)) {
254        this.$emit('tobottom')
255      }
256    },
257
258    // get the real render slots based on range data
259    // in-place patch strategy will try to reuse components as possible
260    // so those components that are reused will not trigger lifecycle mounted
261    getRenderSlots(h) {
262      const slots = []
263      const { start, end } = this.range
264      const { dataSources, dataKey, itemClass, itemTag, itemStyle, isHorizontal, extraProps, dataComponent, itemScopedSlots } = this
265      for (let index = start; index <= end; index++) {
266        const dataSource = dataSources[index]
267        if (dataSource) {
268          const uniqueKey = typeof dataKey === 'function' ? dataKey(dataSource) : dataSource[dataKey]
269          if (typeof uniqueKey === 'string' || typeof uniqueKey === 'number') {
270            slots.push(h(Item, {
271              props: {
272                index,
273                tag: itemTag,
274                event: EVENT_TYPE.ITEM,
275                horizontal: isHorizontal,
276                uniqueKey: uniqueKey,
277                source: dataSource,
278                extraProps: extraProps,
279                component: dataComponent,
280                scopedSlots: itemScopedSlots
281              },
282              style: itemStyle,
283              class: `${itemClass}${this.itemClassAdd ? ' ' + this.itemClassAdd(index) : ''}`
284            }))
285          } else {
286            console.warn(`Cannot get the data-key '${dataKey}' from data-sources.`)
287          }
288        } else {
289          console.warn(`Cannot get the index '${index}' from data-sources.`)
290        }
291      }
292      return slots
293    }
294  },
295
296  // render function, a closer-to-the-compiler alternative to templates
297  // https://vuejs.org/v2/guide/render-function.html#The-Data-Object-In-Depth
298  render(h) {
299    const { header, footer } = this.$slots
300    const { padFront, padBehind } = this.range
301    const { isHorizontal, pageMode, rootTag, wrapTag, wrapClass, wrapStyle, headerTag, headerClass, headerStyle, footerTag, footerClass, footerStyle } = this
302    const paddingStyle = { padding: isHorizontal ? `0px ${padBehind}px 0px ${padFront}px` : `${padFront}px 0px ${padBehind}px` }
303    const wrapperStyle = wrapStyle ? Object.assign({}, wrapStyle, paddingStyle) : paddingStyle
304
305    return h(rootTag, {
306      ref: 'root',
307      on: {
308        '&scroll': !pageMode && this.onScroll
309      }
310    }, [
311      // header slot
312      header ? h(Slot, {
313        class: headerClass,
314        style: headerStyle,
315        props: {
316          tag: headerTag,
317          event: EVENT_TYPE.SLOT,
318          uniqueKey: SLOT_TYPE.HEADER
319        }
320      }, header) : null,
321
322      // main list
323      h(wrapTag, {
324        class: wrapClass,
325        attrs: {
326          role: 'group'
327        },
328        style: wrapperStyle
329      }, this.getRenderSlots(h)),
330
331      // footer slot
332      footer ? h(Slot, {
333        class: footerClass,
334        style: footerStyle,
335        props: {
336          tag: footerTag,
337          event: EVENT_TYPE.SLOT,
338          uniqueKey: SLOT_TYPE.FOOTER
339        }
340      }, footer) : null,
341
342      // an empty element use to scroll to bottom
343      h('div', {
344        ref: 'shepherd',
345        style: {
346          width: isHorizontal ? '0px' : '100%',
347          height: isHorizontal ? '100%' : '0px'
348        }
349      })
350    ])
351  }
352})
353
354export default VirtualList