mirror of
https://github.com/ilyakooo0/urbit.git
synced 2024-12-15 10:02:47 +03:00
VirtualScroller: change virtualisation method
We now virtualize 'treadmill' style i.e. by rendering a subset of the list into a window. When the scroll position gets close to an edge, we save our scroll position, adjust the subset and then restore our scroll position
This commit is contained in:
parent
390a17b706
commit
56337c953b
@ -38,7 +38,7 @@ export function ChatResource(props: ChatResourceProps) {
|
|||||||
const canWrite = isWriter(group, station);
|
const canWrite = isWriter(group, station);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const count = Math.min(50, unreadCount + 15);
|
const count = 100;
|
||||||
props.api.graph.getNewest(owner, name, count);
|
props.api.graph.getNewest(owner, name, count);
|
||||||
}, [station]);
|
}, [station]);
|
||||||
|
|
||||||
|
@ -78,7 +78,6 @@ export const UnreadMarker = React.forwardRef(({ dayBreak, when }, ref) => (
|
|||||||
));
|
));
|
||||||
|
|
||||||
interface ChatMessageProps {
|
interface ChatMessageProps {
|
||||||
measure(element): void;
|
|
||||||
msg: Post;
|
msg: Post;
|
||||||
previousMsg?: Post;
|
previousMsg?: Post;
|
||||||
nextMsg?: Post;
|
nextMsg?: Post;
|
||||||
@ -96,9 +95,14 @@ interface ChatMessageProps {
|
|||||||
api: GlobalApi;
|
api: GlobalApi;
|
||||||
highlighted?: boolean;
|
highlighted?: boolean;
|
||||||
renderSigil?: boolean;
|
renderSigil?: boolean;
|
||||||
|
innerRef: (el: HTMLDivElement | null) => void;
|
||||||
|
shiftLayout: {
|
||||||
|
save: () => void;
|
||||||
|
restore: () => void;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class ChatMessage extends Component<ChatMessageProps> {
|
class ChatMessage extends Component<ChatMessageProps> {
|
||||||
private divRef: React.RefObject<HTMLDivElement>;
|
private divRef: React.RefObject<HTMLDivElement>;
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
@ -107,9 +111,6 @@ export default class ChatMessage extends Component<ChatMessageProps> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
if (this.divRef.current) {
|
|
||||||
this.props.measure(this.divRef.current);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
@ -124,7 +125,6 @@ export default class ChatMessage extends Component<ChatMessageProps> {
|
|||||||
className = '',
|
className = '',
|
||||||
isPending,
|
isPending,
|
||||||
style,
|
style,
|
||||||
measure,
|
|
||||||
scrollWindow,
|
scrollWindow,
|
||||||
isLastMessage,
|
isLastMessage,
|
||||||
unreadMarkerRef,
|
unreadMarkerRef,
|
||||||
@ -132,6 +132,7 @@ export default class ChatMessage extends Component<ChatMessageProps> {
|
|||||||
api,
|
api,
|
||||||
highlighted,
|
highlighted,
|
||||||
fontSize,
|
fontSize,
|
||||||
|
shiftLayout,
|
||||||
groups,
|
groups,
|
||||||
associations
|
associations
|
||||||
} = this.props;
|
} = this.props;
|
||||||
@ -157,9 +158,6 @@ export default class ChatMessage extends Component<ChatMessageProps> {
|
|||||||
.unix(msg['time-sent'] / 1000)
|
.unix(msg['time-sent'] / 1000)
|
||||||
.format(renderSigil ? 'h:mm A' : 'h:mm');
|
.format(renderSigil ? 'h:mm A' : 'h:mm');
|
||||||
|
|
||||||
const reboundMeasure = (event) => {
|
|
||||||
return measure(this.divRef.current);
|
|
||||||
};
|
|
||||||
|
|
||||||
const messageProps = {
|
const messageProps = {
|
||||||
msg,
|
msg,
|
||||||
@ -167,7 +165,6 @@ export default class ChatMessage extends Component<ChatMessageProps> {
|
|||||||
contacts,
|
contacts,
|
||||||
association,
|
association,
|
||||||
group,
|
group,
|
||||||
measure: reboundMeasure.bind(this),
|
|
||||||
style,
|
style,
|
||||||
containerClass,
|
containerClass,
|
||||||
isPending,
|
isPending,
|
||||||
@ -177,7 +174,8 @@ export default class ChatMessage extends Component<ChatMessageProps> {
|
|||||||
highlighted,
|
highlighted,
|
||||||
fontSize,
|
fontSize,
|
||||||
associations,
|
associations,
|
||||||
groups
|
groups,
|
||||||
|
shiftLayout
|
||||||
};
|
};
|
||||||
|
|
||||||
const unreadContainerStyle = {
|
const unreadContainerStyle = {
|
||||||
@ -186,7 +184,7 @@ export default class ChatMessage extends Component<ChatMessageProps> {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
ref={this.divRef}
|
ref={this.props.innerRef}
|
||||||
pt={renderSigil ? 2 : 0}
|
pt={renderSigil ? 2 : 0}
|
||||||
pb={isLastMessage ? 4 : 2}
|
pb={isLastMessage ? 4 : 2}
|
||||||
pr={5}
|
pr={5}
|
||||||
@ -218,11 +216,12 @@ export default class ChatMessage extends Component<ChatMessageProps> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default React.forwardRef((props, ref) => <ChatMessage {...props} innerRef={ref} />);
|
||||||
|
|
||||||
export const MessageAuthor = ({
|
export const MessageAuthor = ({
|
||||||
timestamp,
|
timestamp,
|
||||||
contacts,
|
contacts,
|
||||||
msg,
|
msg,
|
||||||
measure,
|
|
||||||
group,
|
group,
|
||||||
api,
|
api,
|
||||||
associations,
|
associations,
|
||||||
@ -367,8 +366,8 @@ export const Message = ({
|
|||||||
timestamp,
|
timestamp,
|
||||||
contacts,
|
contacts,
|
||||||
msg,
|
msg,
|
||||||
measure,
|
|
||||||
group,
|
group,
|
||||||
|
shiftLayout,
|
||||||
api,
|
api,
|
||||||
associations,
|
associations,
|
||||||
groups,
|
groups,
|
||||||
@ -401,7 +400,7 @@ export const Message = ({
|
|||||||
<TextContent
|
<TextContent
|
||||||
associations={associations}
|
associations={associations}
|
||||||
groups={groups}
|
groups={groups}
|
||||||
measure={measure}
|
shiftLayout={shiftLayout}
|
||||||
api={api}
|
api={api}
|
||||||
fontSize={1}
|
fontSize={1}
|
||||||
lineHeight={'20px'}
|
lineHeight={'20px'}
|
||||||
@ -420,7 +419,7 @@ export const Message = ({
|
|||||||
>
|
>
|
||||||
<RemoteContent
|
<RemoteContent
|
||||||
url={content.url}
|
url={content.url}
|
||||||
onLoad={measure}
|
shiftLayout={shiftLayout}
|
||||||
imageProps={{
|
imageProps={{
|
||||||
style: {
|
style: {
|
||||||
maxWidth: 'min(100%,18rem)',
|
maxWidth: 'min(100%,18rem)',
|
||||||
|
@ -86,7 +86,6 @@ export default class ChatWindow extends Component<
|
|||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this.calculateUnreadIndex();
|
this.calculateUnreadIndex();
|
||||||
this.virtualList?.calculateVisibleItems();
|
|
||||||
window.addEventListener('blur', this.handleWindowBlur);
|
window.addEventListener('blur', this.handleWindowBlur);
|
||||||
window.addEventListener('focus', this.handleWindowFocus);
|
window.addEventListener('focus', this.handleWindowFocus);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@ -145,7 +144,7 @@ export default class ChatWindow extends Component<
|
|||||||
this.scrollToUnread();
|
this.scrollToUnread();
|
||||||
}
|
}
|
||||||
this.prevSize = graph.size;
|
this.prevSize = graph.size;
|
||||||
this.virtualList?.calculateVisibleItems();
|
//this.virtualList?.calculateVisibleItems();
|
||||||
this.stayLockedIfActive();
|
this.stayLockedIfActive();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -168,7 +167,7 @@ export default class ChatWindow extends Component<
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.virtualList?.scrollToData(unreadIndex);
|
//this.virtualList?.scrollToData(unreadIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
dismissUnread() {
|
dismissUnread() {
|
||||||
@ -179,18 +178,18 @@ export default class ChatWindow extends Component<
|
|||||||
this.props.api.hark.markCountAsRead(association, '/', 'mention');
|
this.props.api.hark.markCountAsRead(association, '/', 'mention');
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchMessages(newer: boolean, force = false): Promise<void> {
|
async fetchMessages(newer: boolean): Promise<boolean> {
|
||||||
const { api, station, graph } = this.props;
|
const { api, station, graph } = this.props;
|
||||||
|
if(this.state.fetchPending) {
|
||||||
if (this.state.fetchPending && !force) {
|
return false;
|
||||||
return new Promise((resolve, reject) => {});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
this.setState({ fetchPending: true });
|
this.setState({ fetchPending: true });
|
||||||
|
|
||||||
const [, , ship, name] = station.split('/');
|
const [, , ship, name] = station.split('/');
|
||||||
const currSize = graph.size;
|
const currSize = graph.size;
|
||||||
if (newer && !this.loadedNewest) {
|
if (newer) {
|
||||||
const [index] = graph.peekLargest()!;
|
const [index] = graph.peekLargest()!;
|
||||||
await api.graph.getYoungerSiblings(
|
await api.graph.getYoungerSiblings(
|
||||||
ship,
|
ship,
|
||||||
@ -198,20 +197,14 @@ export default class ChatWindow extends Component<
|
|||||||
20,
|
20,
|
||||||
`/${index.toString()}`
|
`/${index.toString()}`
|
||||||
);
|
);
|
||||||
if (currSize === graph.size) {
|
} else {
|
||||||
console.log('loaded all newest');
|
|
||||||
this.loadedNewest = true;
|
|
||||||
}
|
|
||||||
} else if (!newer && !this.loadedOldest) {
|
|
||||||
const [index] = graph.peekSmallest()!;
|
const [index] = graph.peekSmallest()!;
|
||||||
await api.graph.getOlderSiblings(ship, name, 20, `/${index.toString()}`);
|
await api.graph.getOlderSiblings(ship, name, 20, `/${index.toString()}`);
|
||||||
this.calculateUnreadIndex();
|
this.calculateUnreadIndex();
|
||||||
if (currSize === graph.size) {
|
|
||||||
console.log('loaded all oldest');
|
|
||||||
this.loadedOldest = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
this.setState({ fetchPending: false });
|
this.setState({ fetchPending: false });
|
||||||
|
console.log(currSize, graph.size);
|
||||||
|
return currSize === graph.size;
|
||||||
}
|
}
|
||||||
|
|
||||||
onScroll({ scrollTop, scrollHeight, windowHeight }) {
|
onScroll({ scrollTop, scrollHeight, windowHeight }) {
|
||||||
@ -293,7 +286,8 @@ export default class ChatWindow extends Component<
|
|||||||
onScroll={this.onScroll.bind(this)}
|
onScroll={this.onScroll.bind(this)}
|
||||||
data={graph}
|
data={graph}
|
||||||
size={graph.size}
|
size={graph.size}
|
||||||
renderer={({ index, measure, scrollWindow }) => {
|
id={association.resource}
|
||||||
|
renderer={({ index, shiftLayout, measure, scrollWindow, ref }) => {
|
||||||
const msg = graph.get(index)?.post;
|
const msg = graph.get(index)?.post;
|
||||||
if (!msg) return null;
|
if (!msg) return null;
|
||||||
if (!this.state.initialized) {
|
if (!this.state.initialized) {
|
||||||
@ -316,12 +310,14 @@ export default class ChatWindow extends Component<
|
|||||||
const isLastRead: boolean = this.state.unreadIndex.eq(index);
|
const isLastRead: boolean = this.state.unreadIndex.eq(index);
|
||||||
const props = {
|
const props = {
|
||||||
measure,
|
measure,
|
||||||
|
ref,
|
||||||
highlighted,
|
highlighted,
|
||||||
scrollWindow,
|
scrollWindow,
|
||||||
isPending,
|
isPending,
|
||||||
isLastRead,
|
isLastRead,
|
||||||
isLastMessage,
|
isLastMessage,
|
||||||
msg,
|
msg,
|
||||||
|
shiftLayout,
|
||||||
...messageProps
|
...messageProps
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
@ -333,9 +329,7 @@ export default class ChatWindow extends Component<
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
loadRows={(newer) => {
|
loadRows={this.fetchMessages.bind(this)}
|
||||||
this.fetchMessages(newer);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
);
|
);
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, { PureComponent, Fragment } from 'react';
|
import React, { Component, Fragment } from 'react';
|
||||||
import { BaseAnchor, BaseImage, Box, Button, Text } from '@tlon/indigo-react';
|
import { BaseAnchor, BaseImage, Box, Button, Text } from '@tlon/indigo-react';
|
||||||
import { hasProvider } from 'oembed-parser';
|
import { hasProvider } from 'oembed-parser';
|
||||||
import EmbedContainer from 'react-oembed-container';
|
import EmbedContainer from 'react-oembed-container';
|
||||||
@ -18,6 +18,10 @@ interface RemoteContentProps {
|
|||||||
textProps?: any;
|
textProps?: any;
|
||||||
style?: any;
|
style?: any;
|
||||||
onLoad?(): void;
|
onLoad?(): void;
|
||||||
|
shiftLayout: {
|
||||||
|
save: () => void;
|
||||||
|
restore: () => void;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RemoteContentState {
|
interface RemoteContentState {
|
||||||
@ -30,9 +34,10 @@ const IMAGE_REGEX = new RegExp(/(jpg|img|png|gif|tiff|jpeg|webp|webm|svg)$/i);
|
|||||||
const AUDIO_REGEX = new RegExp(/(mp3|wav|ogg)$/i);
|
const AUDIO_REGEX = new RegExp(/(mp3|wav|ogg)$/i);
|
||||||
const VIDEO_REGEX = new RegExp(/(mov|mp4|ogv)$/i);
|
const VIDEO_REGEX = new RegExp(/(mov|mp4|ogv)$/i);
|
||||||
|
|
||||||
class RemoteContent extends PureComponent<RemoteContentProps, RemoteContentState> {
|
class RemoteContent extends Component<RemoteContentProps, RemoteContentState> {
|
||||||
private fetchController: AbortController | undefined;
|
private fetchController: AbortController | undefined;
|
||||||
containerRef: HTMLDivElement | null = null;
|
containerRef: HTMLDivElement | null = null;
|
||||||
|
private saving = true;
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
@ -56,8 +61,23 @@ class RemoteContent extends PureComponent<RemoteContentProps, RemoteContentState
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
let unfoldState = this.state.unfold;
|
let unfoldState = this.state.unfold;
|
||||||
unfoldState = !unfoldState;
|
unfoldState = !unfoldState;
|
||||||
|
this.props.shiftLayout.save();
|
||||||
this.setState({ unfold: unfoldState });
|
this.setState({ unfold: unfoldState });
|
||||||
setTimeout(this.props.onLoad, 500);
|
}
|
||||||
|
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps, prevState) {
|
||||||
|
if(prevState.embed !== this.state.embed) {
|
||||||
|
console.log('remotecontent: restoring');
|
||||||
|
prevProps.shiftLayout.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoad = () => {
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
this.props.shiftLayout.restore();
|
||||||
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
loadOembed() {
|
loadOembed() {
|
||||||
@ -107,9 +127,9 @@ return;
|
|||||||
oembedProps = {},
|
oembedProps = {},
|
||||||
textProps = {},
|
textProps = {},
|
||||||
style = {},
|
style = {},
|
||||||
onLoad = () => {},
|
|
||||||
...props
|
...props
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
const { onLoad } = this;
|
||||||
const { noCors } = this.state;
|
const { noCors } = this.state;
|
||||||
const isImage = IMAGE_REGEX.test(url);
|
const isImage = IMAGE_REGEX.test(url);
|
||||||
const isAudio = AUDIO_REGEX.test(url);
|
const isAudio = AUDIO_REGEX.test(url);
|
||||||
@ -193,13 +213,14 @@ return;
|
|||||||
className='embed-container'
|
className='embed-container'
|
||||||
style={style}
|
style={style}
|
||||||
flexShrink={0}
|
flexShrink={0}
|
||||||
onLoad={onLoad}
|
onLoad={this.onLoad}
|
||||||
{...oembedProps}
|
{...oembedProps}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{this.state.embed && this.state.embed.html && this.state.unfold
|
{this.state.embed && this.state.embed.html && this.state.unfold
|
||||||
? <EmbedContainer markup={this.state.embed.html}>
|
? <EmbedContainer markup={this.state.embed.html}>
|
||||||
<div className="embed-container" ref={(el) => {
|
<div className="embed-container" ref={(el) => {
|
||||||
|
this.onLoad();
|
||||||
this.containerRef = el;
|
this.containerRef = el;
|
||||||
}}
|
}}
|
||||||
dangerouslySetInnerHTML={{ __html: this.state.embed.html }}
|
dangerouslySetInnerHTML={{ __html: this.state.embed.html }}
|
||||||
|
@ -8,47 +8,83 @@ import BigIntOrderedMap from '@urbit/api/lib/BigIntOrderedMap';
|
|||||||
|
|
||||||
interface RendererProps {
|
interface RendererProps {
|
||||||
index: BigInteger;
|
index: BigInteger;
|
||||||
measure: (el: any) => void;
|
shiftLayout: {
|
||||||
scrollWindow: any
|
save: () => void;
|
||||||
|
restore: () => void;
|
||||||
|
}
|
||||||
|
scrollWindow: any;
|
||||||
|
ref: (el: HTMLElement | null) => void;
|
||||||
}
|
}
|
||||||
|
const PAGE_DELTA = 20;
|
||||||
|
const PAGE_SIZE = 60;
|
||||||
|
|
||||||
interface VirtualScrollerProps {
|
interface VirtualScrollerProps<T> {
|
||||||
origin: 'top' | 'bottom';
|
origin: 'top' | 'bottom';
|
||||||
loadRows(newer: boolean): void;
|
loadRows(newer: boolean): Promise<boolean>;
|
||||||
data: BigIntOrderedMap<BigInteger, any>;
|
data: BigIntOrderedMap<T>;
|
||||||
|
id: string;
|
||||||
renderer: (props: RendererProps) => JSX.Element | null;
|
renderer: (props: RendererProps) => JSX.Element | null;
|
||||||
onStartReached?(): void;
|
onStartReached?(): void;
|
||||||
onEndReached?(): void;
|
onEndReached?(): void;
|
||||||
size: number;
|
size: number;
|
||||||
onCalculateVisibleItems?(visibleItems: BigIntOrderedMap<BigInteger, JSX.Element>): void;
|
totalSize: number;
|
||||||
|
|
||||||
|
onCalculateVisibleItems?(visibleItems: BigIntOrderedMap<T>): void;
|
||||||
onScroll?({ scrollTop, scrollHeight, windowHeight }): void;
|
onScroll?({ scrollTop, scrollHeight, windowHeight }): void;
|
||||||
style?: any;
|
style?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VirtualScrollerState {
|
interface VirtualScrollerState<T> {
|
||||||
startgap: number | undefined;
|
startgap: number | undefined;
|
||||||
visibleItems: BigIntOrderedMap<BigInteger, Element>;
|
visibleItems: BigIntOrderedMap<T>;
|
||||||
endgap: number | undefined;
|
endgap: number | undefined;
|
||||||
totalHeight: number;
|
totalHeight: number;
|
||||||
averageHeight: number;
|
averageHeight: number;
|
||||||
scrollTop: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class VirtualScroller extends Component<VirtualScrollerProps, VirtualScrollerState> {
|
// nb: in this file, an index refers to a BigInteger and an offset refers to a
|
||||||
private scrollContainer: React.RefObject<HTMLDivElement>;
|
// number used to index a listified BigIntOrderedMap
|
||||||
public window: HTMLDivElement | null;
|
|
||||||
private cache: BigIntOrderedMap<any>;
|
|
||||||
private pendingLoad: {
|
|
||||||
start: BigInteger;
|
|
||||||
end: BigInteger
|
|
||||||
timeout: ReturnType<typeof setTimeout>;
|
|
||||||
} | undefined;
|
|
||||||
|
|
||||||
overscan = 150;
|
export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T>, VirtualScrollerState<T>> {
|
||||||
|
/**
|
||||||
|
* A reference to our scroll container
|
||||||
|
*/
|
||||||
|
private window: HTMLDivElement | null = null;
|
||||||
|
/**
|
||||||
|
* A map of child refs, used to calculate scroll position
|
||||||
|
*/
|
||||||
|
private childRefs = new BigIntOrderedMap<HTMLElement>();
|
||||||
|
/**
|
||||||
|
* If saving, the bottommost visible element that we pin our scroll to
|
||||||
|
*/
|
||||||
|
private savedIndex: BigInteger | null = null;
|
||||||
|
/**
|
||||||
|
* If saving, the distance between the top of `this.savedEl` and the bottom
|
||||||
|
* of the screen
|
||||||
|
*/
|
||||||
|
private savedDistance = 0;
|
||||||
|
|
||||||
OVERSCAN_SIZE = 100; // Minimum number of messages on either side before loadRows is called
|
/**
|
||||||
|
* If saving, the number of requested saves. If several images are loading
|
||||||
|
* at once, we save the scroll pos the first time we see it and restore
|
||||||
|
* once the number of requested saves is zero
|
||||||
|
*/
|
||||||
|
private saveDepth = 0;
|
||||||
|
|
||||||
constructor(props: VirtualScrollerProps) {
|
private isUpdating = false;
|
||||||
|
|
||||||
|
private scrollLocked = true;
|
||||||
|
|
||||||
|
private dataEdges: [BigInteger, BigInteger] = [bigInt.zero, bigInt.zero];
|
||||||
|
|
||||||
|
private loaded = {
|
||||||
|
top: false,
|
||||||
|
bottom: false
|
||||||
|
};
|
||||||
|
|
||||||
|
private dirtied : 'top' | 'bottom' | null = null;
|
||||||
|
|
||||||
|
constructor(props: VirtualScrollerProps<T>) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
startgap: props.origin === 'top' ? 0 : undefined,
|
startgap: props.origin === 'top' ? 0 : undefined,
|
||||||
@ -56,137 +92,92 @@ export default class VirtualScroller extends Component<VirtualScrollerProps, Vir
|
|||||||
endgap: props.origin === 'bottom' ? 0 : undefined,
|
endgap: props.origin === 'bottom' ? 0 : undefined,
|
||||||
totalHeight: 0,
|
totalHeight: 0,
|
||||||
averageHeight: 130,
|
averageHeight: 130,
|
||||||
scrollTop: props.origin === 'top' ? 0 : undefined
|
|
||||||
};
|
};
|
||||||
|
|
||||||
this.scrollContainer = React.createRef();
|
this.updateVisible = this.updateVisible.bind(this);
|
||||||
this.window = null;
|
|
||||||
this.cache = new BigIntOrderedMap();
|
|
||||||
|
|
||||||
this.recalculateTotalHeight = _.throttle(this.recalculateTotalHeight.bind(this), 200);
|
|
||||||
this.calculateVisibleItems = _.throttle(this.calculateVisibleItems.bind(this), 200);
|
|
||||||
this.estimateIndexFromScrollTop = this.estimateIndexFromScrollTop.bind(this);
|
|
||||||
this.invertedKeyHandler = this.invertedKeyHandler.bind(this);
|
this.invertedKeyHandler = this.invertedKeyHandler.bind(this);
|
||||||
this.heightOf = this.heightOf.bind(this);
|
this.onScroll = this.onScroll.bind(this)
|
||||||
this.setScrollTop = this.setScrollTop.bind(this);
|
|
||||||
this.scrollToData = this.scrollToData.bind(this);
|
|
||||||
this.scrollKeyMap = this.scrollKeyMap.bind(this);
|
this.scrollKeyMap = this.scrollKeyMap.bind(this);
|
||||||
this.loadRows = _.debounce(this.loadRows, 300, { leading: true }).bind(this);
|
window.restore = () => this.restore();
|
||||||
|
window.save = () => this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this.calculateVisibleItems();
|
this.updateDataEdges();
|
||||||
|
this.updateVisible(0);
|
||||||
this.recalculateTotalHeight();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps: VirtualScrollerProps, prevState: VirtualScrollerState) {
|
componentDidUpdate(prevProps: VirtualScrollerProps<T>, _prevState: VirtualScrollerState<T>) {
|
||||||
const {
|
const { id, size, data } = this.props;
|
||||||
scrollContainer, window,
|
const { visibleItems } = this.state;
|
||||||
props: { origin },
|
if(id !== prevProps.id) {
|
||||||
state: { totalHeight, scrollTop }
|
this.resetScroll();
|
||||||
} = this;
|
this.updateVisible(0);
|
||||||
}
|
} else if(size !== prevProps.size) {
|
||||||
|
const index = visibleItems.peekSmallest()?.[0]!;
|
||||||
|
const newOffset = [...data].findIndex(([i]) => i.eq(index));
|
||||||
|
|
||||||
scrollToData(targetIndex: BigInteger): Promise<void> {
|
if(this.scrollLocked && this.dataEdges[1].neq(data.peekLargest()?.[0]!)) {
|
||||||
if (!this.window) {
|
console.log('locking');
|
||||||
return new Promise((resolve, reject) => {
|
this.updateVisible(0);
|
||||||
reject();
|
return;
|
||||||
});
|
} else if(this.scrollLocked || newOffset === -1) {
|
||||||
}
|
console.log('resetting');
|
||||||
const { offsetHeight } = this.window;
|
this.resetScroll();
|
||||||
let scrollTop = 0;
|
|
||||||
let itemHeight = 0;
|
|
||||||
new BigIntOrderedMap([...this.props.data].reverse()).forEach((datum, index) => {
|
|
||||||
const height = this.heightOf(index);
|
|
||||||
if (index.geq(targetIndex)) {
|
|
||||||
scrollTop += height;
|
|
||||||
if (index.eq(targetIndex)) {
|
|
||||||
itemHeight = height;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
this.updateDataEdges();
|
||||||
return this.setScrollTop(scrollTop - (offsetHeight / 2) + itemHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
recalculateTotalHeight() {
|
|
||||||
let { averageHeight } = this.state;
|
|
||||||
let totalHeight = 0;
|
|
||||||
this.props.data.forEach((datum, index) => {
|
|
||||||
totalHeight += Math.max(this.heightOf(index), 0);
|
|
||||||
});
|
|
||||||
averageHeight = Number((totalHeight / this.props.data.size).toFixed());
|
|
||||||
totalHeight += (this.props.size - this.props.data.size) * averageHeight;
|
|
||||||
this.setState({ totalHeight, averageHeight });
|
|
||||||
}
|
|
||||||
|
|
||||||
estimateIndexFromScrollTop(targetScrollTop: number): BigInteger | undefined {
|
|
||||||
if (!this.window)
|
|
||||||
return undefined;
|
|
||||||
const index = bigInt(this.props.size);
|
|
||||||
const { averageHeight } = this.state;
|
|
||||||
let height = 0;
|
|
||||||
while (height < targetScrollTop) {
|
|
||||||
const itemHeight = this.cache.has(index) ? this.cache.get(index).height : averageHeight;
|
|
||||||
height += itemHeight;
|
|
||||||
index.subtract(bigInt.one);
|
|
||||||
}
|
}
|
||||||
return index;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
heightOf(index: BigInteger): number {
|
componentWillUnmount() {
|
||||||
return this.cache.has(index) ? this.cache.get(index).height : this.state.averageHeight;
|
window.removeEventListener('keydown', this.invertedKeyHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
calculateVisibleItems() {
|
updateDataEdges() {
|
||||||
if (!this.window)
|
const { data } = this.props;
|
||||||
return;
|
const small = data.peekSmallest()?.[0]!;
|
||||||
let startgap = 0, heightShown = 0, endgap = 0;
|
const large = data.peekLargest()?.[0]!;
|
||||||
let startGapFilled = false;
|
|
||||||
const visibleItems = new BigIntOrderedMap<any>();
|
|
||||||
const { scrollTop, offsetHeight: windowHeight } = this.window;
|
|
||||||
const { averageHeight, totalHeight } = this.state;
|
|
||||||
const { data, size: totalSize, onCalculateVisibleItems } = this.props;
|
|
||||||
|
|
||||||
[...data].forEach(([index, datum]) => {
|
this.dataEdges = [small, large];
|
||||||
const height = this.heightOf(index);
|
}
|
||||||
if (startgap < (scrollTop - this.overscan) && !startGapFilled) {
|
|
||||||
startgap += height;
|
|
||||||
} else if (heightShown < (windowHeight + this.overscan)) {
|
|
||||||
startGapFilled = true;
|
|
||||||
visibleItems.set(index, datum);
|
|
||||||
heightShown += height;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
endgap = totalHeight - heightShown - startgap;
|
startOffset() {
|
||||||
|
const startIndex = this.state.visibleItems.peekLargest()?.[0]!;
|
||||||
const firstVisibleKey = visibleItems.peekSmallest()?.[0] ?? this.estimateIndexFromScrollTop(scrollTop)!;
|
const offset = [...this.props.data].findIndex(([i]) => i.eq(startIndex))
|
||||||
const smallest = data.peekSmallest();
|
if(offset === -1) {
|
||||||
if (smallest && smallest[0].eq(firstVisibleKey)) {
|
throw new Error("a");
|
||||||
this.loadRows(false);
|
|
||||||
}
|
}
|
||||||
const lastVisibleKey =
|
return offset;
|
||||||
visibleItems.peekLargest()?.[0]
|
}
|
||||||
?? bigInt(this.estimateIndexFromScrollTop(scrollTop + windowHeight)!);
|
|
||||||
|
|
||||||
const largest = data.peekLargest();
|
/**
|
||||||
|
* Updates the `startOffset` and adjusts visible items accordingly.
|
||||||
if (largest && largest[0].eq(lastVisibleKey)) {
|
* Saves the scroll positions before repainting and restores it afterwards
|
||||||
this.loadRows(true);
|
*/
|
||||||
|
updateVisible(newOffset: number) {
|
||||||
|
if (!this.window || this.isUpdating) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
this.isUpdating = true;
|
||||||
|
|
||||||
|
const { data, onCalculateVisibleItems } = this.props;
|
||||||
|
const visibleItems = new BigIntOrderedMap<any>(
|
||||||
|
[...data].slice(newOffset, newOffset + PAGE_SIZE)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.save();
|
||||||
|
|
||||||
onCalculateVisibleItems ? onCalculateVisibleItems(visibleItems) : null;
|
onCalculateVisibleItems ? onCalculateVisibleItems(visibleItems) : null;
|
||||||
this.setState({
|
this.setState({
|
||||||
startgap: Number(startgap.toFixed()),
|
|
||||||
visibleItems,
|
visibleItems,
|
||||||
endgap: Number(endgap.toFixed())
|
}, () => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.restore();
|
||||||
|
this.isUpdating = false;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
loadRows(newer: boolean) {
|
|
||||||
this.props.loadRows(newer);
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollKeyMap(): Map<string, number> {
|
scrollKeyMap(): Map<string, number> {
|
||||||
return new Map([
|
return new Map([
|
||||||
['ArrowUp', this.state.averageHeight],
|
['ArrowUp', this.state.averageHeight],
|
||||||
@ -213,13 +204,9 @@ return;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
window.removeEventListener('keydown', this.invertedKeyHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
setWindow(element) {
|
setWindow(element) {
|
||||||
if (!element)
|
if (!element)
|
||||||
return;
|
return;
|
||||||
if (this.window) {
|
if (this.window) {
|
||||||
if (this.window.isSameNode(element)) {
|
if (this.window.isSameNode(element)) {
|
||||||
return;
|
return;
|
||||||
@ -228,8 +215,6 @@ return;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.overscan = Math.max(element.offsetHeight * 3, 500);
|
|
||||||
|
|
||||||
this.window = element;
|
this.window = element;
|
||||||
if (this.props.origin === 'bottom') {
|
if (this.props.origin === 'bottom') {
|
||||||
element.addEventListener('wheel', (event) => {
|
element.addEventListener('wheel', (event) => {
|
||||||
@ -244,48 +229,145 @@ return;
|
|||||||
this.resetScroll();
|
this.resetScroll();
|
||||||
}
|
}
|
||||||
|
|
||||||
resetScroll(): Promise<void> {
|
resetScroll() {
|
||||||
if (!this.window)
|
if (!this.window) {
|
||||||
return new Promise((resolve, reject) => {
|
return;
|
||||||
reject();
|
}
|
||||||
});
|
this.window.scrollTop = 0;
|
||||||
return this.setScrollTop(0);
|
this.savedIndex = null;
|
||||||
|
this.savedDistance = 0;
|
||||||
|
this.saveDepth = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
setScrollTop(distance: number, delay = 100): Promise<void> {
|
async loadRows(newer: boolean) {
|
||||||
return new Promise((resolve, reject) => {
|
const dir = newer ? 'bottom' : 'top';
|
||||||
setTimeout(() => {
|
if(this.loaded[dir]) {
|
||||||
if (!this.window) {
|
return;
|
||||||
reject();
|
}
|
||||||
return;
|
const done = await this.props.loadRows(newer);
|
||||||
}
|
if(done) {
|
||||||
this.window.scrollTop = distance;
|
this.dirtied = dir;
|
||||||
resolve();
|
this.loaded[dir] = true;
|
||||||
}, delay);
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onScroll(event) {
|
onScroll(event: UIEvent) {
|
||||||
if (!this.window)
|
if(!this.window || this.savedIndex) {
|
||||||
return;
|
// bail if we're going to adjust scroll anyway
|
||||||
const { onStartReached, onEndReached, onScroll } = this.props;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { onStartReached, onEndReached } = this.props;
|
||||||
const windowHeight = this.window.offsetHeight;
|
const windowHeight = this.window.offsetHeight;
|
||||||
const { scrollTop, scrollHeight } = this.window;
|
const { scrollTop, scrollHeight } = this.window;
|
||||||
if (scrollTop !== scrollHeight) {
|
|
||||||
this.setState({ scrollTop });
|
|
||||||
}
|
|
||||||
|
|
||||||
this.calculateVisibleItems();
|
const startOffset = this.startOffset();
|
||||||
onScroll ? onScroll({ scrollTop, scrollHeight, windowHeight }) : null;
|
if (scrollTop < 20) {
|
||||||
if (scrollTop === 0) {
|
if (onStartReached) {
|
||||||
if (onStartReached)
|
onStartReached();
|
||||||
onStartReached();
|
}
|
||||||
} else if (scrollTop + windowHeight >= scrollHeight) {
|
const newOffset = Math.max(0, startOffset - PAGE_DELTA);
|
||||||
if (onEndReached)
|
if(newOffset < 10) {
|
||||||
onEndReached();
|
setTimeout(() => this.loadRows(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
if(newOffset === 0) {
|
||||||
|
this.scrollLocked = true;
|
||||||
|
}
|
||||||
|
if(newOffset !== startOffset) {
|
||||||
|
this.updateVisible(newOffset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (scrollTop + windowHeight >= scrollHeight - 100) {
|
||||||
|
if (onEndReached) {
|
||||||
|
onEndReached();
|
||||||
|
}
|
||||||
|
|
||||||
|
const newOffset = Math.min(startOffset + PAGE_DELTA, this.props.data.size - PAGE_SIZE);
|
||||||
|
if((newOffset + 10 < this.props.data.size - PAGE_SIZE)) {
|
||||||
|
setTimeout(() => this.loadRows(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
if(newOffset !== startOffset) {
|
||||||
|
this.updateVisible(newOffset);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.scrollLocked = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
restore() {
|
||||||
|
if(!this.window || !this.savedIndex) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.saveDepth--;
|
||||||
|
if(this.saveDepth !== 0) {
|
||||||
|
//console.log('multiple restores');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { offsetTop } = this.childRefs.get(this.savedIndex)!;
|
||||||
|
const newScrollTop = this.window.scrollHeight - offsetTop - this.savedDistance;
|
||||||
|
|
||||||
|
this.window.scrollTop = newScrollTop;
|
||||||
|
this.savedIndex = null;
|
||||||
|
this.savedDistance = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
save() {
|
||||||
|
if(!this.window || this.savedIndex) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.saveDepth++;
|
||||||
|
if(this.saveDepth !== 1) {
|
||||||
|
//console.log(new Error().stack);
|
||||||
|
//console.log('multiple saves');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bottomIndex: BigInteger | null = null;
|
||||||
|
const { scrollTop, scrollHeight } = this.window;
|
||||||
|
const topSpacing = scrollHeight - scrollTop;
|
||||||
|
[...Array.from(this.state.visibleItems)].reverse().forEach(([index, datum]) => {
|
||||||
|
const idxTop = this.childRefs.get(index)!.offsetTop;
|
||||||
|
if(idxTop < topSpacing) {
|
||||||
|
bottomIndex = index;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if(!bottomIndex) {
|
||||||
|
console.log('weird case');
|
||||||
|
// weird, shouldn't really happen
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.savedIndex = bottomIndex;
|
||||||
|
const { offsetTop } = this.childRefs.get(bottomIndex)!;
|
||||||
|
this.savedDistance = topSpacing - offsetTop
|
||||||
|
}
|
||||||
|
|
||||||
|
shiftLayout = { save: this.save.bind(this), restore: this.restore.bind(this) };
|
||||||
|
|
||||||
|
setRef = (index: BigInteger) => (element: HTMLElement | null) => {
|
||||||
|
if(element) {
|
||||||
|
this.childRefs.set(index, element);
|
||||||
|
} else {
|
||||||
|
this.childRefs.delete(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderItem = (index: BigInteger) => {
|
||||||
|
const ref = this.setRef(index);
|
||||||
|
return this.props.renderer({
|
||||||
|
index,
|
||||||
|
ref,
|
||||||
|
shiftLayout: this.shiftLayout,
|
||||||
|
scrollWindow: this.window,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {
|
const {
|
||||||
startgap,
|
startgap,
|
||||||
@ -295,34 +377,20 @@ onEndReached();
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
origin = 'top',
|
origin = 'top',
|
||||||
loadRows,
|
|
||||||
renderer,
|
renderer,
|
||||||
style,
|
style,
|
||||||
data
|
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
const indexesToRender = origin === 'top' ? visibleItems.keys() : visibleItems.keys().reverse();
|
const indexesToRender = origin === 'top' ? visibleItems.keys() : visibleItems.keys().reverse();
|
||||||
|
|
||||||
const transform = origin === 'top' ? 'scale3d(1, 1, 1)' : 'scale3d(1, -1, 1)';
|
const transform = origin === 'top' ? 'scale3d(1, 1, 1)' : 'scale3d(1, -1, 1)';
|
||||||
|
|
||||||
const render = (index: BigInteger) => {
|
|
||||||
const measure = (element: any) => {
|
|
||||||
if (element) {
|
|
||||||
this.cache.set(index, {
|
|
||||||
height: element.offsetHeight,
|
|
||||||
element
|
|
||||||
});
|
|
||||||
this.recalculateTotalHeight();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return renderer({ index, measure, scrollWindow: this.window });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box overflowY='scroll' ref={this.setWindow.bind(this)} onScroll={this.onScroll.bind(this)} style={{ ...style, ...{ transform } }}>
|
<Box overflowY='scroll' ref={this.setWindow.bind(this)} onScroll={this.onScroll} style={{ ...style, ...{ transform } }}>
|
||||||
<Box ref={this.scrollContainer} style={{ transform, width: '100%' }}>
|
<Box style={{ transform, width: '100%' }}>
|
||||||
<Box style={{ height: `${origin === 'top' ? startgap : endgap}px` }}></Box>
|
<Box style={{ height: `${origin === 'top' ? startgap : endgap}px` }}></Box>
|
||||||
{indexesToRender.map(render)}
|
{indexesToRender.map(this.renderItem)}
|
||||||
<Box style={{ height: `${origin === 'top' ? endgap : startgap}px` }}></Box>
|
<Box style={{ height: `${origin === 'top' ? endgap : startgap}px` }}></Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
Loading…
Reference in New Issue
Block a user