Compare commits
No commits in common. "very-old" and "master" have entirely different histories.
|
@ -0,0 +1,42 @@
|
||||||
|
module.exports = {
|
||||||
|
"parser": "babel-eslint",
|
||||||
|
"env": {
|
||||||
|
"es6": true,
|
||||||
|
"browser": true
|
||||||
|
},
|
||||||
|
"extends": [
|
||||||
|
"eslint:recommended",
|
||||||
|
"plugin:react/recommended"
|
||||||
|
],
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaVersion": 6,
|
||||||
|
"sourceType": "module",
|
||||||
|
"ecmaFeatures": {
|
||||||
|
"experimentalObjectRestSpread": true,
|
||||||
|
"jsx": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"react"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"indent": [
|
||||||
|
"error",
|
||||||
|
4
|
||||||
|
],
|
||||||
|
"linebreak-style": [
|
||||||
|
"error",
|
||||||
|
"unix"
|
||||||
|
],
|
||||||
|
"semi": [
|
||||||
|
"error",
|
||||||
|
"always"
|
||||||
|
],
|
||||||
|
"no-control-regex": [
|
||||||
|
"off"
|
||||||
|
],
|
||||||
|
"no-empty": [
|
||||||
|
"off"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
|
@ -0,0 +1,151 @@
|
||||||
|
/**
|
||||||
|
* Virtual scroll driver for dynamic row heights
|
||||||
|
*
|
||||||
|
* License: GNU LGPLv3.0+
|
||||||
|
* (c) Vitaliy Filippov 2018+
|
||||||
|
*
|
||||||
|
* @param props { totalItems, minRowHeight, viewportHeight, scrollTop }
|
||||||
|
* @param oldState - previous state object
|
||||||
|
* @param getRenderedItemHeight = (itemIndex) => height
|
||||||
|
* this function MUST return the height of currently rendered item or 0 if it's not currently rendered
|
||||||
|
* the returned height MUST be >= props.minRowHeight
|
||||||
|
* the function MAY cache heights of rendered items if you want your list to be more responsive
|
||||||
|
* @returns new state object
|
||||||
|
* you MUST re-render your list when any state values change
|
||||||
|
* you MUST preserve all keys in the state object and pass it back via `oldState` on the next run
|
||||||
|
* you MUST use the following keys for rendering:
|
||||||
|
* newState.targetHeight - height of the 1px wide invisible div you should render in the scroll container
|
||||||
|
* newState.topPlaceholderHeight - height of the first (top) placeholder. omit placeholder if it is 0
|
||||||
|
* newState.firstMiddleItem - first item to be rendered after top placeholder
|
||||||
|
* newState.middleItemCount - item count to be renderer after top placeholder. omit items if it is 0
|
||||||
|
* newState.middlePlaceholderHeight - height of the second (middle) placeholder. omit placeholder if it is 0
|
||||||
|
* newState.lastItemCount - item count to be rendered in the end of the list
|
||||||
|
*/
|
||||||
|
export function virtualScrollDriver(props, oldState, getRenderedItemHeight)
|
||||||
|
{
|
||||||
|
const viewportHeight = props.viewportHeight;
|
||||||
|
const viewportItemCount = Math.ceil(viewportHeight/props.minRowHeight); // +border?
|
||||||
|
const newState = {
|
||||||
|
viewportHeight,
|
||||||
|
viewportItemCount,
|
||||||
|
totalItems: props.totalItems,
|
||||||
|
scrollHeightInItems: oldState.scrollHeightInItems,
|
||||||
|
avgRowHeight: oldState.avgRowHeight,
|
||||||
|
targetHeight: 0,
|
||||||
|
topPlaceholderHeight: 0,
|
||||||
|
firstMiddleItem: 0,
|
||||||
|
middleItemCount: 0,
|
||||||
|
middlePlaceholderHeight: 0,
|
||||||
|
lastItemCount: props.totalItems,
|
||||||
|
lastItemsTotalHeight: oldState.lastItemsTotalHeight,
|
||||||
|
};
|
||||||
|
if (!oldState.viewportHeight)
|
||||||
|
{
|
||||||
|
oldState = { ...oldState };
|
||||||
|
for (let k in newState)
|
||||||
|
{
|
||||||
|
oldState[k] = oldState[k] || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (2*newState.viewportItemCount >= props.totalItems)
|
||||||
|
{
|
||||||
|
// We need at least 2*viewportItemCount to perform virtual scrolling
|
||||||
|
return newState;
|
||||||
|
}
|
||||||
|
newState.lastItemCount = newState.viewportItemCount;
|
||||||
|
{
|
||||||
|
let lastItemsHeight = 0, lastVisibleItems = 0;
|
||||||
|
let lastItemSize;
|
||||||
|
while (lastItemsHeight < viewportHeight)
|
||||||
|
{
|
||||||
|
lastItemSize = getRenderedItemHeight(props.totalItems - 1 - lastVisibleItems);
|
||||||
|
if (!lastItemSize)
|
||||||
|
{
|
||||||
|
// Some required items in the end are missing
|
||||||
|
lastItemSize = 0;
|
||||||
|
}
|
||||||
|
lastItemsHeight += lastItemSize < props.minRowHeight ? props.minRowHeight : lastItemSize;
|
||||||
|
lastVisibleItems++;
|
||||||
|
}
|
||||||
|
newState.scrollHeightInItems = props.totalItems - lastVisibleItems + (lastItemsHeight-viewportHeight) / lastItemSize;
|
||||||
|
// Calculate heights of the rest of items
|
||||||
|
while (lastVisibleItems < newState.viewportItemCount)
|
||||||
|
{
|
||||||
|
lastItemsHeight += getRenderedItemHeight(props.totalItems - 1 - lastVisibleItems);
|
||||||
|
lastVisibleItems++;
|
||||||
|
}
|
||||||
|
newState.lastItemsTotalHeight = lastItemsHeight;
|
||||||
|
newState.avgRowHeight = lastItemsHeight / lastVisibleItems;
|
||||||
|
newState.avgRowHeight = !oldState.avgRowHeight || newState.avgRowHeight > oldState.avgRowHeight
|
||||||
|
? newState.avgRowHeight
|
||||||
|
: oldState.avgRowHeight;
|
||||||
|
}
|
||||||
|
newState.targetHeight = newState.avgRowHeight * newState.scrollHeightInItems + newState.viewportHeight;
|
||||||
|
const scrollTop = props.scrollTop;
|
||||||
|
let scrollPos = scrollTop / (newState.targetHeight - newState.viewportHeight);
|
||||||
|
if (scrollPos > 1)
|
||||||
|
{
|
||||||
|
// Rare case - avgRowHeight isn't enough and we need more
|
||||||
|
// avgRowHeight will be corrected after rendering all items
|
||||||
|
scrollPos = 1;
|
||||||
|
}
|
||||||
|
let firstVisibleItem = scrollPos * newState.scrollHeightInItems;
|
||||||
|
const firstVisibleItemOffset = firstVisibleItem - Math.floor(firstVisibleItem);
|
||||||
|
// FIXME: Render some items before current for smoothness
|
||||||
|
firstVisibleItem = Math.floor(firstVisibleItem);
|
||||||
|
let firstVisibleItemHeight = getRenderedItemHeight(firstVisibleItem) || newState.avgRowHeight;
|
||||||
|
newState.topPlaceholderHeight = scrollTop - firstVisibleItemHeight*firstVisibleItemOffset;
|
||||||
|
if (newState.topPlaceholderHeight < 0)
|
||||||
|
{
|
||||||
|
newState.topPlaceholderHeight = 0;
|
||||||
|
}
|
||||||
|
if (firstVisibleItem + newState.viewportItemCount >= props.totalItems - newState.viewportItemCount)
|
||||||
|
{
|
||||||
|
// Only one placeholder is required
|
||||||
|
newState.lastItemCount = props.totalItems - firstVisibleItem;
|
||||||
|
let sum = 0, count = props.totalItems - newState.viewportItemCount - firstVisibleItem;
|
||||||
|
count = count > 0 ? count : 0;
|
||||||
|
for (let i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
const itemSize = getRenderedItemHeight(i+newState.firstMiddleItem);
|
||||||
|
if (!itemSize)
|
||||||
|
{
|
||||||
|
// Some required items in the middle are missing
|
||||||
|
return newState;
|
||||||
|
}
|
||||||
|
sum += itemSize;
|
||||||
|
}
|
||||||
|
const correctedAvg = (sum + newState.lastItemsTotalHeight) / (count + newState.viewportItemCount);
|
||||||
|
if (correctedAvg > newState.avgRowHeight)
|
||||||
|
{
|
||||||
|
newState.avgRowHeight = correctedAvg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
newState.firstMiddleItem = firstVisibleItem;
|
||||||
|
newState.middleItemCount = newState.viewportItemCount;
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < newState.middleItemCount; i++)
|
||||||
|
{
|
||||||
|
const itemSize = getRenderedItemHeight(i+newState.firstMiddleItem);
|
||||||
|
if (!itemSize)
|
||||||
|
{
|
||||||
|
// Some required items in the middle are missing
|
||||||
|
return newState;
|
||||||
|
}
|
||||||
|
sum += itemSize;
|
||||||
|
}
|
||||||
|
newState.middlePlaceholderHeight = newState.targetHeight - sum - newState.lastItemsTotalHeight - newState.topPlaceholderHeight;
|
||||||
|
if (newState.middlePlaceholderHeight < 0)
|
||||||
|
{
|
||||||
|
newState.middlePlaceholderHeight = 0;
|
||||||
|
}
|
||||||
|
const correctedAvg = (sum + newState.lastItemsTotalHeight) / (newState.middleItemCount + newState.viewportItemCount);
|
||||||
|
if (correctedAvg > newState.avgRowHeight)
|
||||||
|
{
|
||||||
|
newState.avgRowHeight = correctedAvg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newState;
|
||||||
|
}
|
|
@ -0,0 +1,132 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { virtualScrollDriver } from './DynamicVirtualScroll.js';
|
||||||
|
|
||||||
|
export class DynamicVirtualScrollExample extends React.PureComponent
|
||||||
|
{
|
||||||
|
useFixedHeader = true
|
||||||
|
|
||||||
|
constructor()
|
||||||
|
{
|
||||||
|
super();
|
||||||
|
const items = [];
|
||||||
|
for (let i = 0; i < 1000; i++)
|
||||||
|
{
|
||||||
|
items[i] = 30 + Math.round(Math.random()*50);
|
||||||
|
}
|
||||||
|
this.state = { items };
|
||||||
|
}
|
||||||
|
|
||||||
|
getRenderedItemHeight_MemoryExample = (index) =>
|
||||||
|
{
|
||||||
|
// Just for example: imitating renderer not knowing about off-screen items
|
||||||
|
if (index >= this.state.firstMiddleItem && index < this.state.firstMiddleItem+this.state.middleItemCount ||
|
||||||
|
index >= this.state.items.length - this.state.lastItemCount)
|
||||||
|
{
|
||||||
|
return this.state.items[index];
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
getRenderedItemHeight_DOMExample = (index) =>
|
||||||
|
{
|
||||||
|
// DOM example. As smooth as the previous one (memory example), even without caching
|
||||||
|
if (this.itemElements[index])
|
||||||
|
{
|
||||||
|
return this.itemElements[index].getBoundingClientRect().height;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
getRenderedItemHeight = this.getRenderedItemHeight_DOMExample
|
||||||
|
|
||||||
|
renderItems(start, count)
|
||||||
|
{
|
||||||
|
return this.state.items.slice(start, start+count).map((item, index) => (<div
|
||||||
|
key={'i'+(index+start)}
|
||||||
|
ref={e => this.itemElements[index+start] = e}
|
||||||
|
style={{height: item+'px', color: 'white', textAlign: 'center', lineHeight: item+'px', background: 'rgb('+Math.round(item*255/80)+',0,0)'}}>
|
||||||
|
№ {index+start}: {item}px
|
||||||
|
</div>));
|
||||||
|
}
|
||||||
|
|
||||||
|
render()
|
||||||
|
{
|
||||||
|
this.itemElements = [];
|
||||||
|
return (<div style={{position: 'relative', width: '400px'}}>
|
||||||
|
<div style={{overflowY: 'scroll', height: '400px', width: '400px', overflowAnchor: 'none', outline: 'none'}}
|
||||||
|
tabIndex="1"
|
||||||
|
ref={e => this.viewport = e}
|
||||||
|
onScroll={this.driver}>
|
||||||
|
<div style={{height: this.state.targetHeight+'px'}}>
|
||||||
|
{this.useFixedHeader
|
||||||
|
? <div style={{height: '30px'}}></div>
|
||||||
|
: null}
|
||||||
|
{this.state.topPlaceholderHeight
|
||||||
|
? <div style={{height: this.state.topPlaceholderHeight+'px'}}></div>
|
||||||
|
: null}
|
||||||
|
{this.state.middleItemCount
|
||||||
|
? this.renderItems(this.state.firstMiddleItem, this.state.middleItemCount)
|
||||||
|
: null}
|
||||||
|
{this.state.middlePlaceholderHeight
|
||||||
|
? <div style={{height: this.state.middlePlaceholderHeight+'px'}}></div>
|
||||||
|
: null}
|
||||||
|
{this.state.lastItemCount
|
||||||
|
? this.renderItems(this.state.items.length-this.state.lastItemCount, this.state.lastItemCount)
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{this.useFixedHeader ? <div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: this.state.scrollbarWidth+'px',
|
||||||
|
height: '30px',
|
||||||
|
background: '#0080c0',
|
||||||
|
color: 'white',
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: '30px'}}>
|
||||||
|
fixed header
|
||||||
|
</div> : null}
|
||||||
|
</div>);
|
||||||
|
}
|
||||||
|
|
||||||
|
driver = () =>
|
||||||
|
{
|
||||||
|
const newState = virtualScrollDriver(
|
||||||
|
{
|
||||||
|
totalItems: this.state.items.length,
|
||||||
|
minRowHeight: 30,
|
||||||
|
viewportHeight: this.viewport.clientHeight - (this.useFixedHeader ? 30 : 0),
|
||||||
|
scrollTop: this.viewport.scrollTop,
|
||||||
|
},
|
||||||
|
this.state,
|
||||||
|
this.getRenderedItemHeight
|
||||||
|
);
|
||||||
|
newState.scrollbarWidth = this.viewport ? this.viewport.offsetWidth-this.viewport.clientWidth : 12;
|
||||||
|
this.setStateIfDiffers(newState);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate = () =>
|
||||||
|
{
|
||||||
|
this.driver();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount()
|
||||||
|
{
|
||||||
|
this.componentDidUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
setStateIfDiffers(state, cb)
|
||||||
|
{
|
||||||
|
for (const k in state)
|
||||||
|
{
|
||||||
|
if (this.state[k] != state[k])
|
||||||
|
{
|
||||||
|
this.setState(state, cb);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,161 @@
|
||||||
|
# Dynamic Virtual Scroll Driver
|
||||||
|
|
||||||
|
Virtual scrolling is a technique for displaying long lists or tables when you render only a small number
|
||||||
|
of visible items and skip items that are offscreen. You may also have heard about it like
|
||||||
|
"buffered render" or "windowed render" - it's the same.
|
||||||
|
|
||||||
|
There are plenty of virtual scroll implementations for JS.
|
||||||
|
Some of them are part of a larger UI library (ag-grid, ExtJS and so on), some of them are more
|
||||||
|
standalone (react-virtualized, react-window, ngx-virtual-scroller, ngx-ui-scroll, react-dynamic-virtual-list).
|
||||||
|
|
||||||
|
However, there is a thing that they all miss: dynamic (and unknown apriori) row heights.
|
||||||
|
Some implementations allow to set different row heights for items, but you must calculate
|
||||||
|
all heights before rendering; some allow dynamic row heights, but have bugs and act weird or don't really work;
|
||||||
|
others just force you to use fixed row height. Most implementations are also tied to some specific
|
||||||
|
UI component or framework and are unusable with other ones.
|
||||||
|
|
||||||
|
Good news, everyone: we have a solution!
|
||||||
|
|
||||||
|
It is render-agnostic and implemented in this library. Basically, this library only does the maths for you
|
||||||
|
while letting you render your component yourself. You can use it with React, Angular, pure JS or any other
|
||||||
|
framework you want to. You can implement lists and grids (tables) with it. It works smoothly, does not break
|
||||||
|
built-in browser scrolling and even works on mobile devices.
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
|
||||||
|
The library exports a single function:
|
||||||
|
|
||||||
|
```
|
||||||
|
import { virtualScrollDriver } from 'dynamic-virtual-scroll';
|
||||||
|
|
||||||
|
const newState = virtualScrollDriver(
|
||||||
|
{ totalItems, minRowHeight, viewportHeight, scrollTop },
|
||||||
|
oldState,
|
||||||
|
function getRenderedItemHeight(itemIndex) { ... }
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
You must call it after each render and also when the viewport, scroll position or items change.
|
||||||
|
|
||||||
|
Description of parameters:
|
||||||
|
|
||||||
|
* `totalItems` - total number of items in your list
|
||||||
|
* `minRowHeight` - minimum item height
|
||||||
|
* `viewportHeight` - current viewport height (take from DOM)
|
||||||
|
* `scrollTop` - current scroll position (take from DOM)
|
||||||
|
* `oldState` - previous state object as returned from previous `virtualScrollDriver()` call
|
||||||
|
* `getRenderedItemHeight = (itemIndex) => height`:
|
||||||
|
* this function MUST return the height of currently rendered item or 0 if it's not currently rendered
|
||||||
|
* the returned height MUST be >= props.minRowHeight
|
||||||
|
* the function MAY cache heights of rendered items if you want your list to be more responsive
|
||||||
|
* WARNING: you SHOULD NOT use `element.offsetHeight` for measuring. Either use `element.getBoundingClientRect().height`
|
||||||
|
or use some pre-computed heights, because `offsetHeight` may truncate the height to -1px when
|
||||||
|
browser scale is not 100%. Also it gives incorrect results with CSS transforms.
|
||||||
|
|
||||||
|
Returned object is `newState`. It contains the render parameters for you and also some internal state variables.
|
||||||
|
What to do with it:
|
||||||
|
|
||||||
|
* you MUST re-render your list when any state values change
|
||||||
|
* you MUST preserve all keys in the state object and pass it back via `oldState` on the next run
|
||||||
|
* you MUST base your rendering on the following keys:
|
||||||
|
* `newState.targetHeight` - height of the 1px wide invisible div you should render in the scroll container
|
||||||
|
* `newState.topPlaceholderHeight` - height of the first (top) placeholder. omit placeholder if it is 0
|
||||||
|
* `newState.firstMiddleItem` - first item to be rendered after top placeholder
|
||||||
|
* `newState.middleItemCount` - item count to be renderer after top placeholder. omit items if it is 0
|
||||||
|
* `newState.middlePlaceholderHeight` - height of the second (middle) placeholder. omit placeholder if it is 0
|
||||||
|
* `newState.lastItemCount` - item count to be rendered in the end of the list
|
||||||
|
* also note that you MUST set `overflow-anchor: none` on your scroll container. You'll end up with
|
||||||
|
`virtualScrollDriver()` not able to finish updating in Chrome if you don't.
|
||||||
|
|
||||||
|
# Usage with React
|
||||||
|
|
||||||
|
There is a reference virtual list implementation for React.
|
||||||
|
|
||||||
|
It is usually sufficient for almost everything, including grids. Sad thing about grids (virtualized tables)
|
||||||
|
in HTML is that automatic table-like layout is slow in browsers, so in fact the best way to implement
|
||||||
|
them is via simple virtualized lists of \<div>'s with absolutely positioned cells inside.
|
||||||
|
|
||||||
|
```
|
||||||
|
import React from 'react';
|
||||||
|
import { VirtualScrollList } from 'dynamic-virtual-scroll/VirtualScrollList.es5.js';
|
||||||
|
|
||||||
|
class MyList extends React.Component
|
||||||
|
{
|
||||||
|
renderItem = (i) =>
|
||||||
|
{
|
||||||
|
if (!this.items[i])
|
||||||
|
return null;
|
||||||
|
return <div style={{minHeight: '20px'}}>{this.items[i].title}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
render()
|
||||||
|
{
|
||||||
|
return <VirtualScrollList
|
||||||
|
totalItems={this.items.length}
|
||||||
|
renderItem={this.renderItem}
|
||||||
|
minRowHeight={20}
|
||||||
|
/>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Description of VirtualScrollList parameters:
|
||||||
|
|
||||||
|
- totalItems: required, total number of items in the list.
|
||||||
|
- minRowHeight: required, minimal possible item height in the list.
|
||||||
|
- renderItem: required, function that renders item by index as React element(s).
|
||||||
|
- viewportHeight: optional, viewport height to use for virtual scrolling.
|
||||||
|
May be used in case when it can't be determined automatically by VirtualScroll,
|
||||||
|
for example inside an animated element with css maxHeight.
|
||||||
|
- header: optional, React element(s) to unconditionally render in the beginning of
|
||||||
|
the list. The intended usage is to render fixed header row with CSS position: sticky
|
||||||
|
over the scrolled content.
|
||||||
|
- headerHeight: optional. In case there is a fixed header, this must be its height
|
||||||
|
in pixels.
|
||||||
|
- All other parameters (className, style, onClick, etc) are passed as-is to the
|
||||||
|
underlying root \<div> of the list.
|
||||||
|
|
||||||
|
VirtualScrollList contains some extra shenanigans to make sure the scroll position
|
||||||
|
preserves when the total number of items changes. Also it has two extra methods:
|
||||||
|
|
||||||
|
- `list.scrollToItem(itemIndex)` - positions the list at `itemIndex`. The index may
|
||||||
|
contain fractional part, in that case the list will be positioned at the corresponding
|
||||||
|
% of height of the item.
|
||||||
|
- `list.getItemScrollPos()` - returns current scroll position in items. The returned
|
||||||
|
index may contain fractional part and may be used as input to `list.scrollToItem()`.
|
||||||
|
|
||||||
|
# Simpler React example
|
||||||
|
|
||||||
|
See `DynamicVirtualScrollExample.js`.
|
||||||
|
|
||||||
|
How to test it:
|
||||||
|
|
||||||
|
* Clone this repository
|
||||||
|
* `npm install`
|
||||||
|
* `npm run build`
|
||||||
|
* Open `index.html` in your browser
|
||||||
|
|
||||||
|
# Demo
|
||||||
|
|
||||||
|
http://yourcmc.ru/dynamic-virtual-scroll/
|
||||||
|
|
||||||
|
# Algorithm
|
||||||
|
|
||||||
|
* Use reasonable fixed minimum row height
|
||||||
|
* Always render `screen/minHeight` last rows
|
||||||
|
* Find maximum possible viewport start in units of (item number + % of item)
|
||||||
|
* Measure average height of last rows
|
||||||
|
* `avgHeight = max(minHeight, lastRowAvgHeight)`
|
||||||
|
* `targetHeight = avgHeight*rowCount`
|
||||||
|
* Total scroll view height will be `targetHeight`
|
||||||
|
* `scrollPos = targetHeight > offsetHeight ? min(1, scrollTop / (targetHeight - offsetHeight)) : 0`
|
||||||
|
* First visible item will be `Math.floor(scrollPos*maxPossibleViewportStart)`
|
||||||
|
* Additional scroll offset will be `itemOffset = (scrollPos*maxPossibleViewportStart - Math.floor(scrollPos*maxPossibleViewportStart))*firstVisibleItemHeight`
|
||||||
|
* First (top) placeholder height will be `scrollTop-itemOffset`
|
||||||
|
* Second (middle) placeholder height will be `avgHeight*nodeCount - sum(heights of all rendered rows) - (first placeholder height)`
|
||||||
|
|
||||||
|
# License and author
|
||||||
|
|
||||||
|
Author: Vitaliy Filippov, 2018+
|
||||||
|
|
||||||
|
License: GNU LGPLv3.0 or newer
|
|
@ -0,0 +1,239 @@
|
||||||
|
/**
|
||||||
|
* A simple React list with virtual scrolling based on dynamic-virtual-scroll driver
|
||||||
|
* USUALLY sufficient for everything including grids (using absolute sizing of cells).
|
||||||
|
* Just because browsers can't do virtualized grid or table layouts efficiently.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
import { virtualScrollDriver } from 'dynamic-virtual-scroll';
|
||||||
|
|
||||||
|
export class VirtualScrollList extends React.Component
|
||||||
|
{
|
||||||
|
static propTypes = {
|
||||||
|
className: PropTypes.string,
|
||||||
|
style: PropTypes.object,
|
||||||
|
totalItems: PropTypes.number.isRequired,
|
||||||
|
minRowHeight: PropTypes.number.isRequired,
|
||||||
|
viewportHeight: PropTypes.number,
|
||||||
|
header: PropTypes.any,
|
||||||
|
headerHeight: PropTypes.number,
|
||||||
|
renderItem: PropTypes.func.isRequired,
|
||||||
|
}
|
||||||
|
|
||||||
|
state = {
|
||||||
|
targetHeight: 0,
|
||||||
|
topPlaceholderHeight: 0,
|
||||||
|
firstMiddleItem: 0,
|
||||||
|
middleItemCount: 0,
|
||||||
|
middlePlaceholderHeight: 0,
|
||||||
|
lastItemCount: 0,
|
||||||
|
scrollTo: 0,
|
||||||
|
scrollTimes: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
setItemRef = []
|
||||||
|
itemRefs = []
|
||||||
|
itemRefCount = []
|
||||||
|
|
||||||
|
makeRef(i)
|
||||||
|
{
|
||||||
|
this.setItemRef[i] = (e) =>
|
||||||
|
{
|
||||||
|
// If the new row instance is mounted before unmouting the old one,
|
||||||
|
// we get called 2 times in wrong order: first with the new instance,
|
||||||
|
// then with null telling us that the old one is unmounted.
|
||||||
|
// We track reference count to workaround it.
|
||||||
|
this.itemRefCount[i] = (this.itemRefCount[i]||0) + (e ? 1 : -1);
|
||||||
|
if (e || !this.itemRefCount[i])
|
||||||
|
{
|
||||||
|
this.itemRefs[i] = e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
renderItems(start, count, is_end)
|
||||||
|
{
|
||||||
|
let r = [];
|
||||||
|
for (let i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
let item = this.props.renderItem(i+start);
|
||||||
|
if (item)
|
||||||
|
{
|
||||||
|
if (!this.setItemRef[i+start])
|
||||||
|
{
|
||||||
|
this.makeRef(i+start);
|
||||||
|
}
|
||||||
|
r.push(<item.type {...item.props} key={i+start} ref={this.setItemRef[i+start]} />);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
render()
|
||||||
|
{
|
||||||
|
if (this.state.totalItems && this.props.totalItems != this.state.totalItems &&
|
||||||
|
this.state.scrollTimes <= 0 && this.viewport && this.viewport.offsetParent)
|
||||||
|
{
|
||||||
|
// Automatically preserve scroll position when item count changes...
|
||||||
|
// But only when the list is on-screen! We'll end up with an infinite update loop if it's off-screen.
|
||||||
|
this.state.scrollTo = this.getItemScrollPos();
|
||||||
|
this.state.scrollTimes = 2;
|
||||||
|
}
|
||||||
|
const props = { ...this.props };
|
||||||
|
for (const k in VirtualScrollList.propTypes)
|
||||||
|
{
|
||||||
|
delete props[k];
|
||||||
|
}
|
||||||
|
return (<div
|
||||||
|
{...props}
|
||||||
|
className={this.props.className}
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
...(this.props.style||{}),
|
||||||
|
overflowAnchor: 'none',
|
||||||
|
}}
|
||||||
|
ref={this.setViewport}
|
||||||
|
onScroll={this.onScroll}>
|
||||||
|
{this.props.header}
|
||||||
|
{this.state.targetHeight > 0
|
||||||
|
? <div key="target" style={{position: 'absolute', top: 0, left: '-5px', width: '1px', height: this.state.targetHeight+'px'}}></div>
|
||||||
|
: null}
|
||||||
|
{this.state.topPlaceholderHeight
|
||||||
|
? <div style={{height: this.state.topPlaceholderHeight+'px'}} key="top"></div>
|
||||||
|
: null}
|
||||||
|
{this.renderItems(this.state.firstMiddleItem, this.state.middleItemCount)}
|
||||||
|
{this.state.middlePlaceholderHeight
|
||||||
|
? <div style={{height: this.state.middlePlaceholderHeight+'px'}} key="mid"></div>
|
||||||
|
: null}
|
||||||
|
{this.renderItems(this.props.totalItems-this.state.lastItemCount, this.state.lastItemCount, true)}
|
||||||
|
</div>);
|
||||||
|
}
|
||||||
|
|
||||||
|
setViewport = (e) =>
|
||||||
|
{
|
||||||
|
this.viewport = e;
|
||||||
|
}
|
||||||
|
|
||||||
|
getRenderedItemHeight = (index) =>
|
||||||
|
{
|
||||||
|
if (this.itemRefs[index])
|
||||||
|
{
|
||||||
|
const e = ReactDOM.findDOMNode(this.itemRefs[index]);
|
||||||
|
if (e)
|
||||||
|
{
|
||||||
|
// MSIE sometimes manages to report non-integer element heights for elements of an integer height...
|
||||||
|
// Non-integer element sizes are allowed in getBoundingClientRect, one notable example of them
|
||||||
|
// are collapsed table borders. But we still ignore less than 1/100 of a pixel difference.
|
||||||
|
return Math.round(e.getBoundingClientRect().height*100)/100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
onScroll = () =>
|
||||||
|
{
|
||||||
|
this.driver();
|
||||||
|
if (this.props.onScroll)
|
||||||
|
{
|
||||||
|
this.props.onScroll(this.viewport);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate = () =>
|
||||||
|
{
|
||||||
|
let changed = this.driver();
|
||||||
|
if (!changed && this.state.scrollTimes > 0 && this.props.totalItems > 0 &&
|
||||||
|
this.viewport && this.viewport.offsetParent)
|
||||||
|
{
|
||||||
|
// FIXME: It would be better to find a way to put this logic back into virtual-scroll-driver
|
||||||
|
let pos = this.state.scrollTo;
|
||||||
|
if (pos > this.state.scrollHeightInItems)
|
||||||
|
{
|
||||||
|
pos = this.state.scrollHeightInItems;
|
||||||
|
}
|
||||||
|
if (this.state.targetHeight)
|
||||||
|
{
|
||||||
|
this.viewport.scrollTop = Math.round((this.state.targetHeight - this.state.viewportHeight)*pos/this.state.scrollHeightInItems);
|
||||||
|
this.setState({ scrollTimes: this.state.scrollTimes - 1 });
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
const el = ReactDOM.findDOMNode(this.itemRefs[Math.floor(pos)]);
|
||||||
|
if (el)
|
||||||
|
{
|
||||||
|
this.viewport.scrollTop = el.offsetTop - (this.props.headerHeight||0) + el.offsetHeight*(pos-Math.floor(pos));
|
||||||
|
}
|
||||||
|
this.setState({ scrollTimes: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount()
|
||||||
|
{
|
||||||
|
this.driver();
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollToItem = (pos) =>
|
||||||
|
{
|
||||||
|
// Scroll position must be recalculated twice, because first render
|
||||||
|
// may change the average row height. In fact, it must be repeated
|
||||||
|
// until average row height stops changing, but twice is usually sufficient
|
||||||
|
this.setState({ scrollTo: pos, scrollTimes: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
getItemScrollPos = () =>
|
||||||
|
{
|
||||||
|
if (this.state.targetHeight)
|
||||||
|
{
|
||||||
|
// Virtual scroll is active
|
||||||
|
let pos = this.viewport.scrollTop / (this.state.targetHeight - this.state.viewportHeight);
|
||||||
|
return pos * this.state.scrollHeightInItems;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Virtual scroll is inactive
|
||||||
|
let avgr = this.viewport.scrollHeight / this.state.totalItems;
|
||||||
|
return this.viewport.scrollTop / avgr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
driver = () =>
|
||||||
|
{
|
||||||
|
if (!this.viewport || !this.viewport.offsetParent)
|
||||||
|
{
|
||||||
|
// Fool tolerance - do nothing if we are hidden
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const newState = virtualScrollDriver(
|
||||||
|
{
|
||||||
|
totalItems: this.props.totalItems,
|
||||||
|
minRowHeight: this.props.minRowHeight,
|
||||||
|
viewportHeight: this.props.viewportHeight || (this.viewport.clientHeight-(this.props.headerHeight||0)),
|
||||||
|
scrollTop: this.viewport.scrollTop,
|
||||||
|
},
|
||||||
|
this.state,
|
||||||
|
this.getRenderedItemHeight
|
||||||
|
);
|
||||||
|
if (newState.viewportHeight || this.state.viewportHeight)
|
||||||
|
{
|
||||||
|
return this.setStateIfDiffers(newState);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStateIfDiffers(state, cb)
|
||||||
|
{
|
||||||
|
for (const k in state)
|
||||||
|
{
|
||||||
|
if (this.state[k] != state[k])
|
||||||
|
{
|
||||||
|
this.setState(state, cb);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,12 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Dynamic Virtual Scroll Driver Demo</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
</div>
|
||||||
|
<script type="text/javascript" src="dist/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -0,0 +1,8 @@
|
||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom';
|
||||||
|
|
||||||
|
import { DynamicVirtualScrollExample } from './DynamicVirtualScrollExample.js';
|
||||||
|
|
||||||
|
ReactDOM.render(
|
||||||
|
<DynamicVirtualScrollExample />, document.getElementById('app')
|
||||||
|
);
|
|
@ -0,0 +1,69 @@
|
||||||
|
{
|
||||||
|
"name": "dynamic-virtual-scroll",
|
||||||
|
"version": "1.0.15",
|
||||||
|
"author": {
|
||||||
|
"name": "Vitaliy Filippov",
|
||||||
|
"email": "vitalif@yourcmc.ru",
|
||||||
|
"url": "http://yourcmc.ru/wiki/"
|
||||||
|
},
|
||||||
|
"description": "Render-agnostic Dynamic Virtual Scroll Driver",
|
||||||
|
"keywords": [
|
||||||
|
"virtual",
|
||||||
|
"scrolling",
|
||||||
|
"list",
|
||||||
|
"grid",
|
||||||
|
"windowed",
|
||||||
|
"dynamic",
|
||||||
|
"virtual-list",
|
||||||
|
"virtual-scroll",
|
||||||
|
"lazy-rendering",
|
||||||
|
"react"
|
||||||
|
],
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/vitalif/dynamic-virtual-scroll"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/vitalif/dynamic-virtual-scroll",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/vitalif/dynamic-virtual-scroll/issues"
|
||||||
|
},
|
||||||
|
"license": "LGPL",
|
||||||
|
"maintainers": [
|
||||||
|
{
|
||||||
|
"name": "vitalif",
|
||||||
|
"email": "vitalif@yourcmc.ru"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"files": [
|
||||||
|
"DynamicVirtualScroll.es5.js",
|
||||||
|
"DynamicVirtualScroll.js",
|
||||||
|
"VirtualScrollList.es5.js",
|
||||||
|
"VirtualScrollList.js",
|
||||||
|
"DynamicVirtualScrollExample.js",
|
||||||
|
"README.md"
|
||||||
|
],
|
||||||
|
"main": "./DynamicVirtualScroll.es5.js",
|
||||||
|
"module": "./DynamicVirtualScroll.js",
|
||||||
|
"dependencies": {},
|
||||||
|
"devDependencies": {
|
||||||
|
"babel-cli": "^6.26.0",
|
||||||
|
"babel-core": "^6.26.3",
|
||||||
|
"babel-eslint": "^10.0.1",
|
||||||
|
"babel-loader": "^7.1.5",
|
||||||
|
"babel-polyfill": "^6.26.0",
|
||||||
|
"babel-preset-env": "^1.7.0",
|
||||||
|
"babel-preset-react": "^6.24.1",
|
||||||
|
"babel-preset-stage-1": "^6.24.1",
|
||||||
|
"eslint": "^5.1.0",
|
||||||
|
"eslint-plugin-react": "^7.10.0",
|
||||||
|
"react": "^16.4.1",
|
||||||
|
"react-dom": "^16.4.1",
|
||||||
|
"webpack": "^4.20.2",
|
||||||
|
"webpack-cli": "^3.1.2"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "eslint DynamicVirtualScroll.js && webpack --mode=production --optimize-minimize && (babel DynamicVirtualScroll.js > DynamicVirtualScroll.es5.js) && (babel VirtualScrollList.js > VirtualScrollList.es5.js)",
|
||||||
|
"watch-dev": "NODE_ENV=development webpack --mode=development -w",
|
||||||
|
"watch": "webpack --mode=production -w --optimize-minimize"
|
||||||
|
}
|
||||||
|
}
|
230
test.htm
230
test.htm
|
@ -1,230 +0,0 @@
|
||||||
<div tabindex="1" id="scroll" style="overflow-y: scroll; width: 300px; position: relative; height: 300px; border: 1px solid gray;" onscroll="enqueue(scrollother)">
|
|
||||||
<div style="height: 1500px" id="content"></div>
|
|
||||||
<div id="realcontent" style="z-index: 1; width: 100%; position: absolute; overflow: hidden"></div>
|
|
||||||
</div>
|
|
||||||
<style>
|
|
||||||
.item { border: 1px solid gray; box-sizing: border-box; text-align: center; }
|
|
||||||
</style>
|
|
||||||
<script>
|
|
||||||
/*<!--*/
|
|
||||||
//http://stackoverflow.com/questions/11730596/non-linear-scrolling
|
|
||||||
var s = document.getElementById('scroll');
|
|
||||||
var c = document.getElementById('content');
|
|
||||||
var rc = document.getElementById('realcontent');
|
|
||||||
|
|
||||||
var minheight = 100;
|
|
||||||
|
|
||||||
var api = (function() {
|
|
||||||
var items = [];
|
|
||||||
for (var i = 0; i < 100; i++)
|
|
||||||
items.push(30+Math.round(Math.random()*120));
|
|
||||||
items = [31,109,107,97,140,133,49,69,123,43,137,128,31,101,120,148,119,30,31,37,43,119,105,106,91,148,36,85,76,97,148,31,53,126,138,51,85,65,53,78,39,49,133,49,103,146,143,148,115,49,55,98,44,84,57,127,98,98,104,116,150,82,123,62,134,109,89,71,36,50,146,146,53,35,86,143,86,138,39,81,72,106,88,117,127,97,127,147,102,42,124,99,32,51,84,45,106,139,140,69];
|
|
||||||
// Аналог API получения элементов
|
|
||||||
return [ items.length, function(start, count, callback)
|
|
||||||
{
|
|
||||||
setTimeout(function() { callback(items.slice(start, start+count)); }, 200);
|
|
||||||
} ]
|
|
||||||
})();
|
|
||||||
var totalItems = api[0];
|
|
||||||
var getItems = api[1];
|
|
||||||
|
|
||||||
c.style.height = (totalItems*minheight)+'px';
|
|
||||||
|
|
||||||
var lastFirst = 0;
|
|
||||||
var curFirst = 0, curOffset = 0, curCount = 0, curPos = null;
|
|
||||||
var pagesize = Math.floor(s.offsetHeight/30);
|
|
||||||
|
|
||||||
rc.parentNode.addEventListener('keydown', handlekey);
|
|
||||||
c.addEventListener('keydown', handlekey, true);
|
|
||||||
rc.addEventListener('keydown', handlekey, true);
|
|
||||||
//rc.parentNode.addEventListener('DOMMouseScroll', mouse_wheel);
|
|
||||||
//rc.parentNode.onmousewheel = mouse_wheel;
|
|
||||||
rc.parentNode.focus();
|
|
||||||
|
|
||||||
function mouse_wheel(event)
|
|
||||||
{
|
|
||||||
event = event||window.event;
|
|
||||||
var direction = ((event.wheelDelta) ? event.wheelDelta/120 : event.detail/-3) || false;
|
|
||||||
if (direction)
|
|
||||||
{
|
|
||||||
if (event.preventDefault)
|
|
||||||
event.preventDefault();
|
|
||||||
event.returnValue = false;
|
|
||||||
enqueue(scrollbypx, -direction * 30);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlekey(ev)
|
|
||||||
{
|
|
||||||
if (ev.keyCode == 38 || ev.keyCode == 40 || ev.keyCode == 33 || ev.keyCode == 34) // up, down, pgup, pgdown
|
|
||||||
{
|
|
||||||
var px;
|
|
||||||
if (ev.keyCode == 38) px = -30;
|
|
||||||
else if (ev.keyCode == 40) px = 30;
|
|
||||||
else if (ev.keyCode == 33) px = -270;
|
|
||||||
else if (ev.keyCode == 34) px = 270;
|
|
||||||
enqueue(scrollbypx, px);
|
|
||||||
}
|
|
||||||
else if (ev.keyCode == 36)
|
|
||||||
s.scrollTop = 0;
|
|
||||||
else if (ev.keyCode == 35)
|
|
||||||
s.scrollTop = s.scrollHeight-s.offsetHeight+1;
|
|
||||||
else
|
|
||||||
return;
|
|
||||||
ev.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
var rq = [];
|
|
||||||
var loadedItems = [];
|
|
||||||
|
|
||||||
function enqueue(fn, arg)
|
|
||||||
{
|
|
||||||
if (!rq.length)
|
|
||||||
fn(arg);
|
|
||||||
else if (arg)
|
|
||||||
rq.push(function() { fn(arg) });
|
|
||||||
else if (rq[rq.length-1] != fn)
|
|
||||||
rq.push(fn);
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadfrom(start, count)
|
|
||||||
{
|
|
||||||
console.log('load from '+start+': '+count+' items');
|
|
||||||
getItems(start, count, function(partItems)
|
|
||||||
{
|
|
||||||
var h, i;
|
|
||||||
for (i = 0; i < partItems.length; i++)
|
|
||||||
loadedItems[start+i] = partItems[i];
|
|
||||||
rq.length && (rq.shift())();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// скроллинг в пикселях (в пределах видимости - можно использовать для up/down, pgup/pgdown, колеса мыши)
|
|
||||||
function scrollbypx(px)
|
|
||||||
{
|
|
||||||
if (px < 0)
|
|
||||||
{
|
|
||||||
var cpx = px;
|
|
||||||
cpx += curOffset*loadedItems[curFirst];
|
|
||||||
var nf = curFirst;
|
|
||||||
while (nf > 0 && cpx < 0)
|
|
||||||
{
|
|
||||||
nf--;
|
|
||||||
if (!loadedItems[nf])
|
|
||||||
{
|
|
||||||
rq.unshift(function() { scrollbypx(px) });
|
|
||||||
loadfrom(nf+1 > pagesize ? nf+1-pagesize : 0, pagesize);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
cpx += loadedItems[nf];
|
|
||||||
}
|
|
||||||
newFirst = nf+(cpx/loadedItems[nf]);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
px -= loadedItems[curFirst]*(1-curOffset);
|
|
||||||
var nf = curFirst;
|
|
||||||
while (nf < totalItems-1 && px > 0)
|
|
||||||
{
|
|
||||||
nf++;
|
|
||||||
px -= loadedItems[nf];
|
|
||||||
}
|
|
||||||
nfo = (px >= 0 ? loadedItems[nf] : px+loadedItems[nf]);
|
|
||||||
newFirst = nf+(nfo/loadedItems[nf]);
|
|
||||||
}
|
|
||||||
s.scrollTop = newFirst/lastFirst*(s.scrollHeight-s.offsetHeight+1);
|
|
||||||
rq.length && (rq.shift())();
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadnext()
|
|
||||||
{
|
|
||||||
var h, i;
|
|
||||||
for (h = 0, i = curFirst; i < totalItems && h < s.offsetHeight; i++)
|
|
||||||
{
|
|
||||||
if (!loadedItems[i])
|
|
||||||
{
|
|
||||||
var start = i;
|
|
||||||
while (start < totalItems && !loadedItems[start] && start < i+pagesize)
|
|
||||||
start++;
|
|
||||||
start = start-pagesize;
|
|
||||||
start = start < 0 ? 0 : start;
|
|
||||||
rq.unshift(loadnext);
|
|
||||||
loadfrom(start, pagesize);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
h += (i == curFirst ? loadedItems[i]*(1-curOffset) : loadedItems[i]);
|
|
||||||
}
|
|
||||||
curCount = i-curFirst;
|
|
||||||
rq.length && (rq.shift())();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderitems()
|
|
||||||
{
|
|
||||||
var ih = '', h = 0;
|
|
||||||
for (var i = 0; i < curCount; i++)
|
|
||||||
{
|
|
||||||
ih += '<div class="item" style="height: '+loadedItems[curFirst+i]+'px">'+(curFirst+i)+'</div>';
|
|
||||||
h += loadedItems[curFirst+i];
|
|
||||||
}
|
|
||||||
// curPos, чтобы при плавном скролле вниз загруженное в середину не было видно
|
|
||||||
var t = Math.round(-loadedItems[curFirst]*curOffset+curPos);
|
|
||||||
rc.style.height = '1px'; // на случай, если браузеру вздумается где-то посередине перерисовать
|
|
||||||
rc.style.top = t+'px';
|
|
||||||
rc.style.height = (t+h < s.scrollHeight-1 ? h : s.scrollHeight-1-t)+'px';
|
|
||||||
rc.innerHTML = ih;
|
|
||||||
// Тут можно убить лишние элементы
|
|
||||||
var q0 = curFirst-pagesize;
|
|
||||||
q0 = q0 - q0%pagesize;
|
|
||||||
var q1 = curFirst+curCount+pagesize;
|
|
||||||
q1 = q1 + (pagesize-q1%pagesize);
|
|
||||||
var destroyed = 0;
|
|
||||||
for (var i in loadedItems)
|
|
||||||
{
|
|
||||||
if (i < q0 || i >= q1)
|
|
||||||
{
|
|
||||||
destroyed++;
|
|
||||||
delete loadedItems[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (destroyed > 0)
|
|
||||||
console.log('destroy '+destroyed+' items');
|
|
||||||
rq.length && (rq.shift())();
|
|
||||||
}
|
|
||||||
|
|
||||||
// нелинейный скроллинг (в % от элементов)
|
|
||||||
function scrollother()
|
|
||||||
{
|
|
||||||
var i, h;
|
|
||||||
if (s.scrollTop == curPos)
|
|
||||||
return;
|
|
||||||
var newFirst = s.scrollTop / (s.scrollHeight-s.offsetHeight+1) * lastFirst;
|
|
||||||
if (newFirst > lastFirst)
|
|
||||||
newFirst = lastFirst;
|
|
||||||
curFirst = Math.floor(newFirst);
|
|
||||||
curOffset = newFirst-curFirst;
|
|
||||||
curPos = s.scrollTop;
|
|
||||||
rq.unshift(renderitems);
|
|
||||||
loadnext();
|
|
||||||
}
|
|
||||||
|
|
||||||
function initscroll()
|
|
||||||
{
|
|
||||||
var h = 0, i;
|
|
||||||
for (i = totalItems-1; i >= 0 && h < s.offsetHeight; i--)
|
|
||||||
{
|
|
||||||
if (!loadedItems[i])
|
|
||||||
{
|
|
||||||
rq.unshift(initscroll);
|
|
||||||
loadfrom(i+1-pagesize, pagesize);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
h += loadedItems[i];
|
|
||||||
}
|
|
||||||
lastFirst = i+1+(h-s.offsetHeight)/loadedItems[i+1];
|
|
||||||
rq.length && (rq.shift())();
|
|
||||||
}
|
|
||||||
|
|
||||||
rq.push(scrollother);
|
|
||||||
initscroll();
|
|
||||||
//-->
|
|
||||||
</script>
|
|
|
@ -0,0 +1,49 @@
|
||||||
|
const webpack = require('webpack');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
entry: {
|
||||||
|
main: [ "babel-polyfill", './main.js' ]
|
||||||
|
},
|
||||||
|
context: __dirname,
|
||||||
|
output: {
|
||||||
|
path: __dirname,
|
||||||
|
filename: 'dist/[name].js'
|
||||||
|
},
|
||||||
|
module: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
test: /.jsx?$/,
|
||||||
|
loader: 'babel-loader',
|
||||||
|
exclude: /node_modules(?!\/react-toolbox\/components)/
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.css$/,
|
||||||
|
use: [
|
||||||
|
"style-loader",
|
||||||
|
{
|
||||||
|
loader: "css-loader",
|
||||||
|
options: {
|
||||||
|
modules: true, // default is false
|
||||||
|
sourceMap: true,
|
||||||
|
importLoaders: 1,
|
||||||
|
localIdentName: "[name]--[local]--[hash:base64:8]"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"postcss-loader"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
new webpack.DefinePlugin({
|
||||||
|
"process.env": {
|
||||||
|
NODE_ENV: JSON.stringify(process.env.NODE_ENV || "production")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
],
|
||||||
|
performance: {
|
||||||
|
maxEntrypointSize: 3000000,
|
||||||
|
maxAssetSize: 3000000
|
||||||
|
}
|
||||||
|
};
|
Loading…
Reference in New Issue