virtual.mjs 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. // Composables
  2. import { useDisplay } from "./display.mjs";
  3. import { useResizeObserver } from "./resizeObserver.mjs"; // Utilities
  4. import { computed, nextTick, onScopeDispose, ref, shallowRef, watch, watchEffect } from 'vue';
  5. import { clamp, debounce, IN_BROWSER, isObject, propsFactory } from "../util/index.mjs"; // Types
  6. const UP = -1;
  7. const DOWN = 1;
  8. /** Determines how large each batch of items should be */
  9. const BUFFER_PX = 100;
  10. export const makeVirtualProps = propsFactory({
  11. itemHeight: {
  12. type: [Number, String],
  13. default: null
  14. },
  15. height: [Number, String]
  16. }, 'virtual');
  17. export function useVirtual(props, items) {
  18. const display = useDisplay();
  19. const itemHeight = shallowRef(0);
  20. watchEffect(() => {
  21. itemHeight.value = parseFloat(props.itemHeight || 0);
  22. });
  23. const first = shallowRef(0);
  24. const last = shallowRef(Math.ceil(
  25. // Assume 16px items filling the entire screen height if
  26. // not provided. This is probably incorrect but it minimises
  27. // the chance of ending up with empty space at the bottom.
  28. // The default value is set here to avoid poisoning getSize()
  29. (parseInt(props.height) || display.height.value) / (itemHeight.value || 16)) || 1);
  30. const paddingTop = shallowRef(0);
  31. const paddingBottom = shallowRef(0);
  32. /** The scrollable element */
  33. const containerRef = ref();
  34. /** An element marking the top of the scrollable area,
  35. * used to add an offset if there's padding or other elements above the virtual list */
  36. const markerRef = ref();
  37. /** markerRef's offsetTop, lazily evaluated */
  38. let markerOffset = 0;
  39. const {
  40. resizeRef,
  41. contentRect
  42. } = useResizeObserver();
  43. watchEffect(() => {
  44. resizeRef.value = containerRef.value;
  45. });
  46. const viewportHeight = computed(() => {
  47. return containerRef.value === document.documentElement ? display.height.value : contentRect.value?.height || parseInt(props.height) || 0;
  48. });
  49. /** All static elements have been rendered and we have an assumed item height */
  50. const hasInitialRender = computed(() => {
  51. return !!(containerRef.value && markerRef.value && viewportHeight.value && itemHeight.value);
  52. });
  53. let sizes = Array.from({
  54. length: items.value.length
  55. });
  56. let offsets = Array.from({
  57. length: items.value.length
  58. });
  59. const updateTime = shallowRef(0);
  60. let targetScrollIndex = -1;
  61. function getSize(index) {
  62. return sizes[index] || itemHeight.value;
  63. }
  64. const updateOffsets = debounce(() => {
  65. const start = performance.now();
  66. offsets[0] = 0;
  67. const length = items.value.length;
  68. for (let i = 1; i <= length - 1; i++) {
  69. offsets[i] = (offsets[i - 1] || 0) + getSize(i - 1);
  70. }
  71. updateTime.value = Math.max(updateTime.value, performance.now() - start);
  72. }, updateTime);
  73. const unwatch = watch(hasInitialRender, v => {
  74. if (!v) return;
  75. // First render is complete, update offsets and visible
  76. // items in case our assumed item height was incorrect
  77. unwatch();
  78. markerOffset = markerRef.value.offsetTop;
  79. updateOffsets.immediate();
  80. calculateVisibleItems();
  81. if (!~targetScrollIndex) return;
  82. nextTick(() => {
  83. IN_BROWSER && window.requestAnimationFrame(() => {
  84. scrollToIndex(targetScrollIndex);
  85. targetScrollIndex = -1;
  86. });
  87. });
  88. });
  89. onScopeDispose(() => {
  90. updateOffsets.clear();
  91. });
  92. function handleItemResize(index, height) {
  93. const prevHeight = sizes[index];
  94. const prevMinHeight = itemHeight.value;
  95. itemHeight.value = prevMinHeight ? Math.min(itemHeight.value, height) : height;
  96. if (prevHeight !== height || prevMinHeight !== itemHeight.value) {
  97. sizes[index] = height;
  98. updateOffsets();
  99. }
  100. }
  101. function calculateOffset(index) {
  102. index = clamp(index, 0, items.value.length - 1);
  103. return offsets[index] || 0;
  104. }
  105. function calculateIndex(scrollTop) {
  106. return binaryClosest(offsets, scrollTop);
  107. }
  108. let lastScrollTop = 0;
  109. let scrollVelocity = 0;
  110. let lastScrollTime = 0;
  111. watch(viewportHeight, (val, oldVal) => {
  112. if (oldVal) {
  113. calculateVisibleItems();
  114. if (val < oldVal) {
  115. requestAnimationFrame(() => {
  116. scrollVelocity = 0;
  117. calculateVisibleItems();
  118. });
  119. }
  120. }
  121. });
  122. let scrollTimeout = -1;
  123. function handleScroll() {
  124. if (!containerRef.value || !markerRef.value) return;
  125. const scrollTop = containerRef.value.scrollTop;
  126. const scrollTime = performance.now();
  127. const scrollDeltaT = scrollTime - lastScrollTime;
  128. if (scrollDeltaT > 500) {
  129. scrollVelocity = Math.sign(scrollTop - lastScrollTop);
  130. // Not super important, only update at the
  131. // start of a scroll sequence to avoid reflows
  132. markerOffset = markerRef.value.offsetTop;
  133. } else {
  134. scrollVelocity = scrollTop - lastScrollTop;
  135. }
  136. lastScrollTop = scrollTop;
  137. lastScrollTime = scrollTime;
  138. window.clearTimeout(scrollTimeout);
  139. scrollTimeout = window.setTimeout(handleScrollend, 500);
  140. calculateVisibleItems();
  141. }
  142. function handleScrollend() {
  143. if (!containerRef.value || !markerRef.value) return;
  144. scrollVelocity = 0;
  145. lastScrollTime = 0;
  146. window.clearTimeout(scrollTimeout);
  147. calculateVisibleItems();
  148. }
  149. let raf = -1;
  150. function calculateVisibleItems() {
  151. cancelAnimationFrame(raf);
  152. raf = requestAnimationFrame(_calculateVisibleItems);
  153. }
  154. function _calculateVisibleItems() {
  155. if (!containerRef.value || !viewportHeight.value) return;
  156. const scrollTop = lastScrollTop - markerOffset;
  157. const direction = Math.sign(scrollVelocity);
  158. const startPx = Math.max(0, scrollTop - BUFFER_PX);
  159. const start = clamp(calculateIndex(startPx), 0, items.value.length);
  160. const endPx = scrollTop + viewportHeight.value + BUFFER_PX;
  161. const end = clamp(calculateIndex(endPx) + 1, start + 1, items.value.length);
  162. if (
  163. // Only update the side we're scrolling towards,
  164. // the other side will be updated incidentally
  165. (direction !== UP || start < first.value) && (direction !== DOWN || end > last.value)) {
  166. const topOverflow = calculateOffset(first.value) - calculateOffset(start);
  167. const bottomOverflow = calculateOffset(end) - calculateOffset(last.value);
  168. const bufferOverflow = Math.max(topOverflow, bottomOverflow);
  169. if (bufferOverflow > BUFFER_PX) {
  170. first.value = start;
  171. last.value = end;
  172. } else {
  173. // Only update the side that's reached its limit if there's still buffer left
  174. if (start <= 0) first.value = start;
  175. if (end >= items.value.length) last.value = end;
  176. }
  177. }
  178. paddingTop.value = calculateOffset(first.value);
  179. paddingBottom.value = calculateOffset(items.value.length) - calculateOffset(last.value);
  180. }
  181. function scrollToIndex(index) {
  182. const offset = calculateOffset(index);
  183. if (!containerRef.value || index && !offset) {
  184. targetScrollIndex = index;
  185. } else {
  186. containerRef.value.scrollTop = offset;
  187. }
  188. }
  189. const computedItems = computed(() => {
  190. return items.value.slice(first.value, last.value).map((item, index) => ({
  191. raw: item,
  192. index: index + first.value,
  193. key: isObject(item) && 'value' in item ? item.value : index + first.value
  194. }));
  195. });
  196. watch(items, () => {
  197. sizes = Array.from({
  198. length: items.value.length
  199. });
  200. offsets = Array.from({
  201. length: items.value.length
  202. });
  203. updateOffsets.immediate();
  204. calculateVisibleItems();
  205. }, {
  206. deep: true
  207. });
  208. return {
  209. calculateVisibleItems,
  210. containerRef,
  211. markerRef,
  212. computedItems,
  213. paddingTop,
  214. paddingBottom,
  215. scrollToIndex,
  216. handleScroll,
  217. handleScrollend,
  218. handleItemResize
  219. };
  220. }
  221. // https://gist.github.com/robertleeplummerjr/1cc657191d34ecd0a324
  222. function binaryClosest(arr, val) {
  223. let high = arr.length - 1;
  224. let low = 0;
  225. let mid = 0;
  226. let item = null;
  227. let target = -1;
  228. if (arr[high] < val) {
  229. return high;
  230. }
  231. while (low <= high) {
  232. mid = low + high >> 1;
  233. item = arr[mid];
  234. if (item > val) {
  235. high = mid - 1;
  236. } else if (item < val) {
  237. target = mid;
  238. low = mid + 1;
  239. } else if (item === val) {
  240. return mid;
  241. } else {
  242. return low;
  243. }
  244. }
  245. return target;
  246. }
  247. //# sourceMappingURL=virtual.mjs.map