summaryrefslogtreecommitdiff
path: root/vendor/assets/javascripts/vue-virtual-scroller/src/components/RecycleScroller.vue
diff options
context:
space:
mode:
Diffstat (limited to 'vendor/assets/javascripts/vue-virtual-scroller/src/components/RecycleScroller.vue')
-rw-r--r--vendor/assets/javascripts/vue-virtual-scroller/src/components/RecycleScroller.vue657
1 files changed, 657 insertions, 0 deletions
diff --git a/vendor/assets/javascripts/vue-virtual-scroller/src/components/RecycleScroller.vue b/vendor/assets/javascripts/vue-virtual-scroller/src/components/RecycleScroller.vue
new file mode 100644
index 00000000000..5e9661a53c8
--- /dev/null
+++ b/vendor/assets/javascripts/vue-virtual-scroller/src/components/RecycleScroller.vue
@@ -0,0 +1,657 @@
+<template>
+ <div
+ v-observe-visibility="handleVisibilityChange"
+ class="vue-recycle-scroller"
+ :class="{
+ ready,
+ 'page-mode': pageMode,
+ [`direction-${direction}`]: true,
+ }"
+ @scroll.passive="handleScroll"
+ >
+ <div
+ v-if="$slots.before"
+ class="vue-recycle-scroller__slot"
+ >
+ <slot
+ name="before"
+ />
+ </div>
+
+ <div
+ ref="wrapper"
+ :style="{ [direction === 'vertical' ? 'minHeight' : 'minWidth']: totalSize + 'px' }"
+ class="vue-recycle-scroller__item-wrapper"
+ >
+ <div
+ v-for="view of pool"
+ :key="view.nr.id"
+ :style="ready ? {
+ transform: useTransform ? `translate${direction === 'vertical' ? 'Y' : 'X'}(${view.position}px)` : null,
+ top: !useTransform && direction === 'vertical' ? `${view.position}px` : null,
+ left: !useTransform && direction !== 'vertical' ? `${view.position}px` : null,
+ } : null"
+ class="vue-recycle-scroller__item-view"
+ :class="{ hover: hoverKey === view.nr.key }"
+ @mouseenter="hoverKey = view.nr.key"
+ @mouseleave="hoverKey = null"
+ >
+ <slot
+ :item="view.item"
+ :index="view.nr.index"
+ :active="view.nr.used"
+ />
+ </div>
+ </div>
+
+ <div
+ v-if="$slots.after"
+ class="vue-recycle-scroller__slot"
+ >
+ <slot
+ name="after"
+ />
+ </div>
+
+ <ResizeObserver @notify="handleResize" />
+ </div>
+</template>
+
+<script>
+import { ResizeObserver } from 'vue-resize'
+import { ObserveVisibility } from 'vue-observe-visibility'
+import ScrollParent from 'scrollparent'
+import config from '../config'
+import { props, simpleArray } from './common'
+import { supportsPassive } from '../utils'
+
+let uid = 0
+
+export default {
+ name: 'RecycleScroller',
+
+ components: {
+ ResizeObserver,
+ },
+
+ directives: {
+ ObserveVisibility,
+ },
+
+ props: {
+ ...props,
+
+ itemSize: {
+ type: Number,
+ default: null,
+ },
+
+ minItemSize: {
+ type: [Number, String],
+ default: null,
+ },
+
+ sizeField: {
+ type: String,
+ default: 'size',
+ },
+
+ typeField: {
+ type: String,
+ default: 'type',
+ },
+
+ buffer: {
+ type: Number,
+ default: 200,
+ },
+
+ pageMode: {
+ type: Boolean,
+ default: false,
+ },
+
+ prerender: {
+ type: Number,
+ default: 0,
+ },
+
+ emitUpdate: {
+ type: Boolean,
+ default: false,
+ },
+
+ useTransform: {
+ type: Boolean,
+ default: true,
+ }
+ },
+
+ data () {
+ return {
+ pool: [],
+ totalSize: 0,
+ ready: false,
+ hoverKey: null,
+ }
+ },
+
+ computed: {
+ sizes () {
+ if (this.itemSize === null) {
+ const sizes = {
+ '-1': { accumulator: 0 },
+ }
+ const items = this.items
+ const field = this.sizeField
+ const minItemSize = this.minItemSize
+ let computedMinSize = 10000
+ let accumulator = 0
+ let current
+ for (let i = 0, l = items.length; i < l; i++) {
+ current = items[i][field] || minItemSize
+ if (current < computedMinSize) {
+ computedMinSize = current
+ }
+ accumulator += current
+ sizes[i] = { accumulator, size: current }
+ }
+ // eslint-disable-next-line
+ this.$_computedMinItemSize = computedMinSize
+ return sizes
+ }
+ return []
+ },
+
+ simpleArray,
+ },
+
+ watch: {
+ items () {
+ this.updateVisibleItems(true)
+ },
+
+ pageMode () {
+ this.applyPageMode()
+ this.updateVisibleItems(false)
+ },
+
+ sizes: {
+ handler () {
+ this.updateVisibleItems(false)
+ },
+ deep: true,
+ },
+ },
+
+ created () {
+ this.$_startIndex = 0
+ this.$_endIndex = 0
+ this.$_views = new Map()
+ this.$_unusedViews = new Map()
+ this.$_scrollDirty = false
+ this.$_lastUpdateScrollPosition = 0
+
+ // In SSR mode, we also prerender the same number of item for the first render
+ // to avoir mismatch between server and client templates
+ if (this.prerender) {
+ this.$_prerender = true
+ this.updateVisibleItems(false)
+ }
+ },
+
+ mounted () {
+ this.applyPageMode()
+ this.$nextTick(() => {
+ // In SSR mode, render the real number of visible items
+ this.$_prerender = false
+ this.updateVisibleItems(true)
+ this.ready = true
+ })
+ },
+
+ beforeDestroy () {
+ this.removeListeners()
+ },
+
+ methods: {
+ addView (pool, index, item, key, type) {
+ const view = {
+ item,
+ position: 0,
+ }
+ const nonReactive = {
+ id: uid++,
+ index,
+ used: true,
+ key,
+ type,
+ }
+ Object.defineProperty(view, 'nr', {
+ configurable: false,
+ value: nonReactive,
+ })
+ pool.push(view)
+ return view
+ },
+
+ unuseView (view, fake = false) {
+ const unusedViews = this.$_unusedViews
+ const type = view.nr.type
+ let unusedPool = unusedViews.get(type)
+ if (!unusedPool) {
+ unusedPool = []
+ unusedViews.set(type, unusedPool)
+ }
+ unusedPool.push(view)
+ if (!fake) {
+ view.nr.used = false
+ view.position = -9999
+ this.$_views.delete(view.nr.key)
+ }
+ },
+
+ handleResize () {
+ this.$emit('resize')
+ if (this.ready) this.updateVisibleItems(false)
+ },
+
+ handleScroll (event) {
+ if (!this.$_scrollDirty) {
+ this.$_scrollDirty = true
+ requestAnimationFrame(() => {
+ this.$_scrollDirty = false
+ const { continuous } = this.updateVisibleItems(false, true)
+
+ // It seems sometimes chrome doesn't fire scroll event :/
+ // When non continous scrolling is ending, we force a refresh
+ if (!continuous) {
+ clearTimeout(this.$_refreshTimout)
+ this.$_refreshTimout = setTimeout(this.handleScroll, 100)
+ }
+ })
+ }
+ },
+
+ handleVisibilityChange (isVisible, entry) {
+ if (this.ready) {
+ if (isVisible || entry.boundingClientRect.width !== 0 || entry.boundingClientRect.height !== 0) {
+ this.$emit('visible')
+ requestAnimationFrame(() => {
+ this.updateVisibleItems(false)
+ })
+ } else {
+ this.$emit('hidden')
+ }
+ }
+ },
+
+ updateVisibleItems (checkItem, checkPositionDiff = false) {
+ const itemSize = this.itemSize
+ const minItemSize = this.$_computedMinItemSize
+ const typeField = this.typeField
+ const keyField = this.simpleArray ? null : this.keyField
+ const items = this.items
+ const count = items.length
+ const sizes = this.sizes
+ const views = this.$_views
+ const unusedViews = this.$_unusedViews
+ const pool = this.pool
+ let startIndex, endIndex
+ let totalSize
+
+ if (!count) {
+ startIndex = endIndex = totalSize = 0
+ } else if (this.$_prerender) {
+ startIndex = 0
+ endIndex = this.prerender
+ totalSize = null
+ } else {
+ const scroll = this.getScroll()
+
+ // Skip update if use hasn't scrolled enough
+ if (checkPositionDiff) {
+ let positionDiff = scroll.start - this.$_lastUpdateScrollPosition
+ if (positionDiff < 0) positionDiff = -positionDiff
+ if ((itemSize === null && positionDiff < minItemSize) || positionDiff < itemSize) {
+ return {
+ continuous: true,
+ }
+ }
+ }
+ this.$_lastUpdateScrollPosition = scroll.start
+
+ const buffer = this.buffer
+ scroll.start -= buffer
+ scroll.end += buffer
+
+ // Variable size mode
+ if (itemSize === null) {
+ let h
+ let a = 0
+ let b = count - 1
+ let i = ~~(count / 2)
+ let oldI
+
+ // Searching for startIndex
+ do {
+ oldI = i
+ h = sizes[i].accumulator
+ if (h < scroll.start) {
+ a = i
+ } else if (i < count - 1 && sizes[i + 1].accumulator > scroll.start) {
+ b = i
+ }
+ i = ~~((a + b) / 2)
+ } while (i !== oldI)
+ i < 0 && (i = 0)
+ startIndex = i
+
+ // For container style
+ totalSize = sizes[count - 1].accumulator
+
+ // Searching for endIndex
+ for (endIndex = i; endIndex < count && sizes[endIndex].accumulator < scroll.end; endIndex++);
+ if (endIndex === -1) {
+ endIndex = items.length - 1
+ } else {
+ endIndex++
+ // Bounds
+ endIndex > count && (endIndex = count)
+ }
+ } else {
+ // Fixed size mode
+ startIndex = ~~(scroll.start / itemSize)
+ endIndex = Math.ceil(scroll.end / itemSize)
+
+ // Bounds
+ startIndex < 0 && (startIndex = 0)
+ endIndex > count && (endIndex = count)
+
+ totalSize = count * itemSize
+ }
+ }
+
+ if (endIndex - startIndex > config.itemsLimit) {
+ this.itemsLimitError()
+ }
+
+ this.totalSize = totalSize
+
+ let view
+
+ const continuous = startIndex <= this.$_endIndex && endIndex >= this.$_startIndex
+
+ if (this.$_continuous !== continuous) {
+ if (continuous) {
+ views.clear()
+ unusedViews.clear()
+ for (let i = 0, l = pool.length; i < l; i++) {
+ view = pool[i]
+ this.unuseView(view)
+ }
+ }
+ this.$_continuous = continuous
+ } else if (continuous) {
+ for (let i = 0, l = pool.length; i < l; i++) {
+ view = pool[i]
+ if (view.nr.used) {
+ // Update view item index
+ if (checkItem) {
+ view.nr.index = items.findIndex(
+ item => keyField ? item[keyField] === view.item[keyField] : item === view.item,
+ )
+ }
+
+ // Check if index is still in visible range
+ if (
+ view.nr.index === -1 ||
+ view.nr.index < startIndex ||
+ view.nr.index >= endIndex
+ ) {
+ this.unuseView(view)
+ }
+ }
+ }
+ }
+
+ const unusedIndex = continuous ? null : new Map()
+
+ let item, type, unusedPool
+ let v
+ for (let i = startIndex; i < endIndex; i++) {
+ item = items[i]
+ const key = keyField ? item[keyField] : item
+ if (key == null) {
+ throw new Error(`Key is ${key} on item (keyField is '${keyField}')`)
+ }
+ view = views.get(key)
+
+ if (!itemSize && !sizes[i].size) {
+ if (view) this.unuseView(view)
+ continue
+ }
+
+ // No view assigned to item
+ if (!view) {
+ type = item[typeField]
+ unusedPool = unusedViews.get(type)
+
+ if (continuous) {
+ // Reuse existing view
+ if (unusedPool && unusedPool.length) {
+ view = unusedPool.pop()
+ view.item = item
+ view.nr.used = true
+ view.nr.index = i
+ view.nr.key = key
+ view.nr.type = type
+ } else {
+ view = this.addView(pool, i, item, key, type)
+ }
+ } else {
+ // Use existing view
+ // We don't care if they are already used
+ // because we are not in continous scrolling
+ v = unusedIndex.get(type) || 0
+
+ if (!unusedPool || v >= unusedPool.length) {
+ view = this.addView(pool, i, item, key, type)
+ this.unuseView(view, true)
+ unusedPool = unusedViews.get(type)
+ }
+
+ view = unusedPool[v]
+ view.item = item
+ view.nr.used = true
+ view.nr.index = i
+ view.nr.key = key
+ view.nr.type = type
+ unusedIndex.set(type, v + 1)
+ v++
+ }
+ views.set(key, view)
+ } else {
+ view.nr.used = true
+ view.item = item
+ }
+
+ // Update position
+ if (itemSize === null) {
+ view.position = sizes[i - 1].accumulator
+ } else {
+ view.position = i * itemSize
+ }
+ }
+
+ this.$_startIndex = startIndex
+ this.$_endIndex = endIndex
+
+ if (this.emitUpdate) this.$emit('update', startIndex, endIndex)
+
+ // After the user has finished scrolling
+ // Sort views so text selection is correct
+ clearTimeout(this.$_sortTimer)
+ this.$_sortTimer = setTimeout(this.sortViews, 300)
+
+ return {
+ continuous,
+ }
+ },
+
+ getListenerTarget () {
+ let target = ScrollParent(this.$el)
+ // Fix global scroll target for Chrome and Safari
+ if (window.document && (target === window.document.documentElement || target === window.document.body)) {
+ target = window
+ }
+ return target
+ },
+
+ getScroll () {
+ const { $el: el, direction } = this
+ const isVertical = direction === 'vertical'
+ let scrollState
+
+ if (this.pageMode) {
+ const bounds = el.getBoundingClientRect()
+ const boundsSize = isVertical ? bounds.height : bounds.width
+ let start = -(isVertical ? bounds.top : bounds.left)
+ let size = isVertical ? window.innerHeight : window.innerWidth
+ if (start < 0) {
+ size += start
+ start = 0
+ }
+ if (start + size > boundsSize) {
+ size = boundsSize - start
+ }
+ scrollState = {
+ start,
+ end: start + size,
+ }
+ } else if (isVertical) {
+ scrollState = {
+ start: el.scrollTop,
+ end: el.scrollTop + el.clientHeight,
+ }
+ } else {
+ scrollState = {
+ start: el.scrollLeft,
+ end: el.scrollLeft + el.clientWidth,
+ }
+ }
+
+ return scrollState
+ },
+
+ applyPageMode () {
+ if (this.pageMode) {
+ this.addListeners()
+ } else {
+ this.removeListeners()
+ }
+ },
+
+ addListeners () {
+ this.listenerTarget = this.getListenerTarget()
+ this.listenerTarget.addEventListener('scroll', this.handleScroll, supportsPassive ? {
+ passive: true,
+ } : false)
+ this.listenerTarget.addEventListener('resize', this.handleResize)
+ },
+
+ removeListeners () {
+ if (!this.listenerTarget) {
+ return
+ }
+
+ this.listenerTarget.removeEventListener('scroll', this.handleScroll)
+ this.listenerTarget.removeEventListener('resize', this.handleResize)
+
+ this.listenerTarget = null
+ },
+
+ scrollToItem (index) {
+ let scroll
+ if (this.itemSize === null) {
+ scroll = index > 0 ? this.sizes[index - 1].accumulator : 0
+ } else {
+ scroll = index * this.itemSize
+ }
+ this.scrollToPosition(scroll)
+ },
+
+ scrollToPosition (position) {
+ if (this.direction === 'vertical') {
+ this.$el.scrollTop = position
+ } else {
+ this.$el.scrollLeft = position
+ }
+ },
+
+ itemsLimitError () {
+ setTimeout(() => {
+ console.log('It seems the scroller element isn\'t scrolling, so it tries to render all the items at once.', 'Scroller:', this.$el)
+ console.log('Make sure the scroller has a fixed height (or width) and \'overflow-y\' (or \'overflow-x\') set to \'auto\' so it can scroll correctly and only render the items visible in the scroll viewport.')
+ })
+ throw new Error('Rendered items limit reached')
+ },
+
+ sortViews () {
+ this.pool.sort((viewA, viewB) => viewA.nr.index - viewB.nr.index)
+ },
+ },
+}
+</script>
+
+<style>
+.vue-recycle-scroller {
+ position: relative;
+}
+
+.vue-recycle-scroller.direction-vertical:not(.page-mode) {
+ overflow-y: auto;
+}
+
+.vue-recycle-scroller.direction-horizontal:not(.page-mode) {
+ overflow-x: auto;
+}
+
+.vue-recycle-scroller.direction-horizontal {
+ display: flex;
+}
+
+.vue-recycle-scroller__slot {
+ flex: auto 0 0;
+}
+
+.vue-recycle-scroller__item-wrapper {
+ flex: 1;
+ box-sizing: border-box;
+ overflow: hidden;
+ position: relative;
+}
+
+.vue-recycle-scroller.ready .vue-recycle-scroller__item-view {
+ position: absolute;
+ top: 0;
+ left: 0;
+ will-change: transform;
+}
+
+.vue-recycle-scroller.direction-vertical .vue-recycle-scroller__item-wrapper {
+ width: 100%;
+}
+
+.vue-recycle-scroller.direction-horizontal .vue-recycle-scroller__item-wrapper {
+ height: 100%;
+}
+
+.vue-recycle-scroller.ready.direction-vertical .vue-recycle-scroller__item-view {
+ width: 100%;
+}
+
+.vue-recycle-scroller.ready.direction-horizontal .vue-recycle-scroller__item-view {
+ height: 100%;
+}
+</style>