quasar
Version:
Build high-performance VueJS user interfaces (SPA, PWA, SSR, Mobile and Desktop) in record time
928 lines (793 loc) • 23.7 kB
JavaScript
import {
h,
ref,
computed,
watch,
onActivated,
onDeactivated,
onBeforeMount,
onBeforeUnmount,
nextTick,
getCurrentInstance
} from 'vue'
import debounce from '../../utils/debounce/debounce.js'
import { noop } from '../../utils/event/event.js'
import { rtlHasScrollBug } from '../../utils/private.rtl/rtl.js'
const aggBucketSize = 1000
const scrollToEdges = [
'start',
'center',
'end',
'start-force',
'center-force',
'end-force'
]
const filterProto = Array.prototype.filter
const setOverflowAnchor =
__QUASAR_SSR__ ||
window.getComputedStyle(document.body).overflowAnchor === void 0
? noop
: function setOverflowAnchor(contentEl, index) {
if (contentEl === null) return
if (contentEl._qOverflowAnimationFrame !== void 0) {
cancelAnimationFrame(contentEl._qOverflowAnimationFrame)
}
contentEl._qOverflowAnimationFrame = requestAnimationFrame(() => {
if (contentEl === null) return
contentEl._qOverflowAnimationFrame = void 0
const children = contentEl.children || []
filterProto
.call(children, el => el.dataset && el.dataset.qVsAnchor !== void 0)
.forEach(el => {
delete el.dataset.qVsAnchor
})
const el = children[index]
if (el?.dataset) {
el.dataset.qVsAnchor = ''
}
})
}
function sumFn(acc, item) {
return acc + item
}
function getScrollDetails(
parent,
child,
beforeRef,
afterRef,
horizontal,
rtl,
stickyStart,
stickyEnd
) {
const parentCalc =
parent === window
? document.scrollingElement || document.documentElement
: parent,
propElSize = horizontal === true ? 'offsetWidth' : 'offsetHeight',
details = {
scrollStart: 0,
scrollViewSize: -stickyStart - stickyEnd,
scrollMaxSize: 0,
offsetStart: -stickyStart,
offsetEnd: -stickyEnd
}
if (horizontal === true) {
if (parent === window) {
details.scrollStart =
window.pageXOffset || window.scrollX || document.body.scrollLeft || 0
details.scrollViewSize += document.documentElement.clientWidth
} else {
details.scrollStart = parentCalc.scrollLeft
details.scrollViewSize += parentCalc.clientWidth
}
details.scrollMaxSize = parentCalc.scrollWidth
if (rtl === true) {
details.scrollStart =
(rtlHasScrollBug === true
? details.scrollMaxSize - details.scrollViewSize
: 0) - details.scrollStart
}
} else {
if (parent === window) {
details.scrollStart =
window.pageYOffset || window.scrollY || document.body.scrollTop || 0
details.scrollViewSize += document.documentElement.clientHeight
} else {
details.scrollStart = parentCalc.scrollTop
details.scrollViewSize += parentCalc.clientHeight
}
details.scrollMaxSize = parentCalc.scrollHeight
}
if (beforeRef !== null) {
for (
let el = beforeRef.previousElementSibling;
el !== null;
el = el.previousElementSibling
) {
if (el.classList.contains('q-virtual-scroll--skip') === false) {
details.offsetStart += el[propElSize]
}
}
}
if (afterRef !== null) {
for (
let el = afterRef.nextElementSibling;
el !== null;
el = el.nextElementSibling
) {
if (el.classList.contains('q-virtual-scroll--skip') === false) {
details.offsetEnd += el[propElSize]
}
}
}
if (child !== parent) {
const parentRect = parentCalc.getBoundingClientRect(),
childRect = child.getBoundingClientRect()
if (horizontal === true) {
details.offsetStart += childRect.left - parentRect.left
details.offsetEnd -= childRect.width
} else {
details.offsetStart += childRect.top - parentRect.top
details.offsetEnd -= childRect.height
}
if (parent !== window) {
details.offsetStart += details.scrollStart
}
details.offsetEnd += details.scrollMaxSize - details.offsetStart
}
return details
}
function setScroll(parent, scroll, horizontal, rtl) {
if (scroll === 'end') {
scroll = (parent === window ? document.body : parent)[
horizontal === true ? 'scrollWidth' : 'scrollHeight'
]
}
if (parent === window) {
if (horizontal === true) {
if (rtl === true) {
scroll =
(rtlHasScrollBug === true
? document.body.scrollWidth - document.documentElement.clientWidth
: 0) - scroll
}
window.scrollTo(
scroll,
window.pageYOffset || window.scrollY || document.body.scrollTop || 0
)
} else {
window.scrollTo(
window.pageXOffset || window.scrollX || document.body.scrollLeft || 0,
scroll
)
}
} else if (horizontal === true) {
if (rtl === true) {
scroll =
(rtlHasScrollBug === true
? parent.scrollWidth - parent.offsetWidth
: 0) - scroll
}
parent.scrollLeft = scroll
} else {
parent.scrollTop = scroll
}
}
function sumSize(sizeAgg, size, from, to) {
if (from >= to) {
return 0
}
const lastTo = size.length,
fromAgg = Math.floor(from / aggBucketSize),
toAgg = Math.floor((to - 1) / aggBucketSize) + 1
let total = sizeAgg.slice(fromAgg, toAgg).reduce(sumFn, 0)
if (from % aggBucketSize !== 0) {
total -= size.slice(fromAgg * aggBucketSize, from).reduce(sumFn, 0)
}
if (to % aggBucketSize !== 0 && to !== lastTo) {
total -= size.slice(to, toAgg * aggBucketSize).reduce(sumFn, 0)
}
return total
}
const commonVirtScrollProps = {
virtualScrollSliceSize: {
type: [Number, String],
default: 10
},
virtualScrollSliceRatioBefore: {
type: [Number, String],
default: 1
},
virtualScrollSliceRatioAfter: {
type: [Number, String],
default: 1
},
virtualScrollItemSize: {
type: [Number, String],
default: 24
},
virtualScrollStickySizeStart: {
type: [Number, String],
default: 0
},
virtualScrollStickySizeEnd: {
type: [Number, String],
default: 0
},
tableColspan: [Number, String]
}
export const commonVirtScrollPropsList = Object.keys(commonVirtScrollProps)
export const useVirtualScrollProps = {
virtualScrollHorizontal: Boolean,
onVirtualScroll: Function,
...commonVirtScrollProps
}
export function useVirtualScroll({
virtualScrollLength,
getVirtualScrollTarget,
getVirtualScrollEl,
virtualScrollItemSizeComputed // optional
}) {
const vm = getCurrentInstance()
const { props, emit, proxy } = vm
const { $q } = proxy
let prevScrollStart,
prevToIndex,
localScrollViewSize,
virtualScrollSizesAgg = [],
virtualScrollSizes
const virtualScrollPaddingBefore = ref(0)
const virtualScrollPaddingAfter = ref(0)
const virtualScrollSliceSizeComputed = ref({})
const beforeRef = ref(null)
const afterRef = ref(null)
const contentRef = ref(null)
const virtualScrollSliceRange = ref({ from: 0, to: 0 })
const colspanAttr = computed(() =>
props.tableColspan !== void 0 ? props.tableColspan : 100
)
if (virtualScrollItemSizeComputed === void 0) {
virtualScrollItemSizeComputed = computed(() => props.virtualScrollItemSize)
}
const needsReset = computed(
() =>
virtualScrollItemSizeComputed.value + ';' + props.virtualScrollHorizontal
)
const needsSliceRecalc = computed(
() =>
needsReset.value +
';' +
props.virtualScrollSliceRatioBefore +
';' +
props.virtualScrollSliceRatioAfter
)
watch(needsSliceRecalc, () => {
setVirtualScrollSize()
})
watch(needsReset, reset)
function reset() {
localResetVirtualScroll(prevToIndex, true)
}
function refresh(toIndex) {
localResetVirtualScroll(toIndex === void 0 ? prevToIndex : toIndex)
}
function scrollTo(toIndex, edge) {
const scrollEl = getVirtualScrollTarget()
if (scrollEl === void 0 || scrollEl === null || scrollEl.nodeType === 8) {
return
}
const scrollDetails = getScrollDetails(
scrollEl,
getVirtualScrollEl(),
beforeRef.value,
afterRef.value,
props.virtualScrollHorizontal,
$q.lang.rtl,
props.virtualScrollStickySizeStart,
props.virtualScrollStickySizeEnd
)
if (localScrollViewSize !== scrollDetails.scrollViewSize) {
setVirtualScrollSize(scrollDetails.scrollViewSize)
}
setVirtualScrollSliceRange(
scrollEl,
scrollDetails,
Math.min(
virtualScrollLength.value - 1,
Math.max(0, parseInt(toIndex, 10) || 0)
),
0,
scrollToEdges.indexOf(edge) !== -1
? edge
: prevToIndex !== -1 && toIndex > prevToIndex
? 'end'
: 'start'
)
}
function localOnVirtualScrollEvt() {
const scrollEl = getVirtualScrollTarget()
if (scrollEl === void 0 || scrollEl === null || scrollEl.nodeType === 8) {
return
}
const scrollDetails = getScrollDetails(
scrollEl,
getVirtualScrollEl(),
beforeRef.value,
afterRef.value,
props.virtualScrollHorizontal,
$q.lang.rtl,
props.virtualScrollStickySizeStart,
props.virtualScrollStickySizeEnd
),
listLastIndex = virtualScrollLength.value - 1,
listEndOffset =
scrollDetails.scrollMaxSize -
scrollDetails.offsetStart -
scrollDetails.offsetEnd -
virtualScrollPaddingAfter.value
if (prevScrollStart === scrollDetails.scrollStart) return
if (scrollDetails.scrollMaxSize <= 0) {
setVirtualScrollSliceRange(scrollEl, scrollDetails, 0, 0)
return
}
if (localScrollViewSize !== scrollDetails.scrollViewSize) {
setVirtualScrollSize(scrollDetails.scrollViewSize)
}
updateVirtualScrollSizes(virtualScrollSliceRange.value.from)
const scrollMaxStart = Math.floor(
scrollDetails.scrollMaxSize -
Math.max(scrollDetails.scrollViewSize, scrollDetails.offsetEnd) -
Math.min(
virtualScrollSizes[listLastIndex],
scrollDetails.scrollViewSize / 2
)
)
if (
scrollMaxStart > 0 &&
Math.ceil(scrollDetails.scrollStart) >= scrollMaxStart
) {
setVirtualScrollSliceRange(
scrollEl,
scrollDetails,
listLastIndex,
scrollDetails.scrollMaxSize -
scrollDetails.offsetEnd -
virtualScrollSizesAgg.reduce(sumFn, 0)
)
return
}
let toIndex = 0,
listOffset = scrollDetails.scrollStart - scrollDetails.offsetStart,
offset = listOffset
if (
listOffset <= listEndOffset &&
listOffset + scrollDetails.scrollViewSize >=
virtualScrollPaddingBefore.value
) {
listOffset -= virtualScrollPaddingBefore.value
toIndex = virtualScrollSliceRange.value.from
offset = listOffset
} else {
for (
let j = 0;
listOffset >= virtualScrollSizesAgg[j] && toIndex < listLastIndex;
j++
) {
listOffset -= virtualScrollSizesAgg[j]
toIndex += aggBucketSize
}
}
while (listOffset > 0 && toIndex < listLastIndex) {
listOffset -= virtualScrollSizes[toIndex]
if (listOffset > -scrollDetails.scrollViewSize) {
toIndex++
offset = listOffset
} else {
offset = virtualScrollSizes[toIndex] + listOffset
}
}
setVirtualScrollSliceRange(scrollEl, scrollDetails, toIndex, offset)
}
function setVirtualScrollSliceRange(
scrollEl,
scrollDetails,
toIndex,
offset,
align
) {
const alignForce =
typeof align === 'string' && align.indexOf('-force') !== -1
const alignEnd = alignForce === true ? align.replace('-force', '') : align
const alignRange = alignEnd !== void 0 ? alignEnd : 'start'
let from = Math.max(
0,
toIndex - virtualScrollSliceSizeComputed.value[alignRange]
),
to = from + virtualScrollSliceSizeComputed.value.total
if (to > virtualScrollLength.value) {
to = virtualScrollLength.value
from = Math.max(0, to - virtualScrollSliceSizeComputed.value.total)
}
prevScrollStart = scrollDetails.scrollStart
const rangeChanged =
from !== virtualScrollSliceRange.value.from ||
to !== virtualScrollSliceRange.value.to
if (rangeChanged === false && alignEnd === void 0) {
emitScroll(toIndex)
return
}
const { activeElement } = document
const contentEl = contentRef.value
if (
rangeChanged === true &&
contentEl !== null &&
contentEl !== activeElement &&
contentEl.contains(activeElement) === true
) {
contentEl.addEventListener('focusout', onBlurRefocusFn)
setTimeout(() => {
contentEl?.removeEventListener('focusout', onBlurRefocusFn)
})
}
setOverflowAnchor(contentEl, toIndex - from)
const sizeBefore =
alignEnd !== void 0
? virtualScrollSizes.slice(from, toIndex).reduce(sumFn, 0)
: 0
if (rangeChanged === true) {
// vue key matching algorithm works only if
// the array of VNodes changes on only one of the ends
// so we first change one end and then the other
const tempTo =
to >= virtualScrollSliceRange.value.from &&
from <= virtualScrollSliceRange.value.to
? virtualScrollSliceRange.value.to
: to
virtualScrollSliceRange.value = { from, to: tempTo }
virtualScrollPaddingBefore.value = sumSize(
virtualScrollSizesAgg,
virtualScrollSizes,
0,
from
)
virtualScrollPaddingAfter.value = sumSize(
virtualScrollSizesAgg,
virtualScrollSizes,
to,
virtualScrollLength.value
)
requestAnimationFrame(() => {
if (
virtualScrollSliceRange.value.to !== to &&
prevScrollStart === scrollDetails.scrollStart
) {
virtualScrollSliceRange.value = {
from: virtualScrollSliceRange.value.from,
to
}
virtualScrollPaddingAfter.value = sumSize(
virtualScrollSizesAgg,
virtualScrollSizes,
to,
virtualScrollLength.value
)
}
})
}
requestAnimationFrame(() => {
// if the scroll was changed give up
// (another call to setVirtualScrollSliceRange before animation frame)
if (prevScrollStart !== scrollDetails.scrollStart) return
if (rangeChanged === true) {
updateVirtualScrollSizes(from)
}
const sizeAfter = virtualScrollSizes
.slice(from, toIndex)
.reduce(sumFn, 0),
posStart =
sizeAfter +
scrollDetails.offsetStart +
virtualScrollPaddingBefore.value,
posEnd = posStart + virtualScrollSizes[toIndex]
let scrollPosition = posStart + offset
if (alignEnd !== void 0) {
const sizeDiff = sizeAfter - sizeBefore
const scrollStart = scrollDetails.scrollStart + sizeDiff
scrollPosition =
alignForce !== true &&
scrollStart < posStart &&
posEnd < scrollStart + scrollDetails.scrollViewSize
? scrollStart
: alignEnd === 'end'
? posEnd - scrollDetails.scrollViewSize
: posStart -
(alignEnd === 'start'
? 0
: Math.round(
(scrollDetails.scrollViewSize -
virtualScrollSizes[toIndex]) /
2
))
}
prevScrollStart = scrollPosition
setScroll(
scrollEl,
scrollPosition,
props.virtualScrollHorizontal,
$q.lang.rtl
)
emitScroll(toIndex)
})
}
function updateVirtualScrollSizes(from) {
const contentEl = contentRef.value
if (contentEl) {
const children = filterProto.call(
contentEl.children,
el =>
el.classList &&
el.classList.contains('q-virtual-scroll--skip') === false
),
childrenLength = children.length,
sizeFn =
props.virtualScrollHorizontal === true
? el => el.getBoundingClientRect().width
: el => el.offsetHeight
let index = from,
size,
diff
for (let i = 0; i < childrenLength; ) {
size = sizeFn(children[i])
i++
while (
i < childrenLength &&
children[i].classList.contains('q-virtual-scroll--with-prev') === true
) {
size += sizeFn(children[i])
i++
}
diff = size - virtualScrollSizes[index]
if (diff !== 0) {
virtualScrollSizes[index] += diff
virtualScrollSizesAgg[Math.floor(index / aggBucketSize)] += diff
}
index++
}
}
}
function onBlurRefocusFn() {
contentRef.value?.focus()
}
function localResetVirtualScroll(toIndex, fullReset) {
const defaultSize = Number(virtualScrollItemSizeComputed.value)
if (fullReset === true || Array.isArray(virtualScrollSizes) === false) {
virtualScrollSizes = []
}
const oldVirtualScrollSizesLength = virtualScrollSizes.length
virtualScrollSizes.length = virtualScrollLength.value
for (
let i = virtualScrollLength.value - 1;
i >= oldVirtualScrollSizesLength;
i--
) {
virtualScrollSizes[i] = defaultSize
}
const jMax = Math.floor((virtualScrollLength.value - 1) / aggBucketSize)
virtualScrollSizesAgg = []
for (let j = 0; j <= jMax; j++) {
let size = 0
const iMax = Math.min((j + 1) * aggBucketSize, virtualScrollLength.value)
for (let i = j * aggBucketSize; i < iMax; i++) {
size += virtualScrollSizes[i]
}
virtualScrollSizesAgg.push(size)
}
prevToIndex = -1
prevScrollStart = void 0
virtualScrollPaddingBefore.value = sumSize(
virtualScrollSizesAgg,
virtualScrollSizes,
0,
virtualScrollSliceRange.value.from
)
virtualScrollPaddingAfter.value = sumSize(
virtualScrollSizesAgg,
virtualScrollSizes,
virtualScrollSliceRange.value.to,
virtualScrollLength.value
)
if (toIndex >= 0) {
updateVirtualScrollSizes(virtualScrollSliceRange.value.from)
nextTick(() => {
scrollTo(toIndex)
})
} else {
onVirtualScrollEvt()
}
}
function setVirtualScrollSize(scrollViewSize) {
if (scrollViewSize === void 0 && typeof window !== 'undefined') {
const scrollEl = getVirtualScrollTarget()
if (scrollEl !== void 0 && scrollEl !== null && scrollEl.nodeType !== 8) {
scrollViewSize = getScrollDetails(
scrollEl,
getVirtualScrollEl(),
beforeRef.value,
afterRef.value,
props.virtualScrollHorizontal,
$q.lang.rtl,
props.virtualScrollStickySizeStart,
props.virtualScrollStickySizeEnd
).scrollViewSize
}
}
localScrollViewSize = scrollViewSize
const virtualScrollSliceRatioBefore =
parseFloat(props.virtualScrollSliceRatioBefore) || 0
const virtualScrollSliceRatioAfter =
parseFloat(props.virtualScrollSliceRatioAfter) || 0
const multiplier =
1 + virtualScrollSliceRatioBefore + virtualScrollSliceRatioAfter
const view =
scrollViewSize === void 0 || scrollViewSize <= 0
? 1
: Math.ceil(scrollViewSize / virtualScrollItemSizeComputed.value)
const baseSize = Math.max(
1,
view,
Math.ceil(
(props.virtualScrollSliceSize > 0 ? props.virtualScrollSliceSize : 10) /
multiplier
)
)
virtualScrollSliceSizeComputed.value = {
total: Math.ceil(baseSize * multiplier),
start: Math.ceil(baseSize * virtualScrollSliceRatioBefore),
center: Math.ceil(baseSize * (0.5 + virtualScrollSliceRatioBefore)),
end: Math.ceil(baseSize * (1 + virtualScrollSliceRatioBefore)),
view
}
}
function padVirtualScroll(tag, content) {
const paddingSize =
props.virtualScrollHorizontal === true ? 'width' : 'height'
const style = {
['--q-virtual-scroll-item-' + paddingSize]:
virtualScrollItemSizeComputed.value + 'px'
}
return [
tag === 'tbody'
? h(
tag,
{
class: 'q-virtual-scroll__padding',
key: 'before',
ref: beforeRef
},
[
h('tr', [
h('td', {
style: {
[paddingSize]: `${virtualScrollPaddingBefore.value}px`,
...style
},
colspan: colspanAttr.value
})
])
]
)
: h(tag, {
class: 'q-virtual-scroll__padding',
key: 'before',
ref: beforeRef,
style: {
[paddingSize]: `${virtualScrollPaddingBefore.value}px`,
...style
}
}),
h(
tag,
{
class: 'q-virtual-scroll__content',
key: 'content',
ref: contentRef,
tabindex: -1
},
content.flat()
),
tag === 'tbody'
? h(
tag,
{
class: 'q-virtual-scroll__padding',
key: 'after',
ref: afterRef
},
[
h('tr', [
h('td', {
style: {
[paddingSize]: `${virtualScrollPaddingAfter.value}px`,
...style
},
colspan: colspanAttr.value
})
])
]
)
: h(tag, {
class: 'q-virtual-scroll__padding',
key: 'after',
ref: afterRef,
style: {
[paddingSize]: `${virtualScrollPaddingAfter.value}px`,
...style
}
})
]
}
function emitScroll(index) {
if (prevToIndex !== index) {
if (props.onVirtualScroll !== void 0) {
emit('virtualScroll', {
index,
from: virtualScrollSliceRange.value.from,
to: virtualScrollSliceRange.value.to - 1,
direction: index < prevToIndex ? 'decrease' : 'increase',
ref: proxy
})
}
prevToIndex = index
}
}
setVirtualScrollSize()
const onVirtualScrollEvt = debounce(
localOnVirtualScrollEvt,
$q.platform.is.ios === true ? 120 : 35
)
onBeforeMount(() => {
setVirtualScrollSize()
})
let shouldActivate = false
onDeactivated(() => {
shouldActivate = true
})
onActivated(() => {
if (shouldActivate !== true) return
const scrollEl = getVirtualScrollTarget()
if (
prevScrollStart !== void 0 &&
scrollEl !== void 0 &&
scrollEl !== null &&
scrollEl.nodeType !== 8
) {
setScroll(
scrollEl,
prevScrollStart,
props.virtualScrollHorizontal,
$q.lang.rtl
)
} else {
scrollTo(prevToIndex)
}
})
if (!__QUASAR_SSR__) {
onBeforeUnmount(() => {
onVirtualScrollEvt.cancel()
})
}
// expose public methods
Object.assign(proxy, { scrollTo, reset, refresh })
return {
virtualScrollSliceRange,
virtualScrollSliceSizeComputed,
setVirtualScrollSize,
onVirtualScrollEvt,
localResetVirtualScroll,
padVirtualScroll,
scrollTo,
reset,
refresh
}
}