Merge pull request #5021 from urbit/lf/virtual-scroller-unification

VirtualScroller: unification
This commit is contained in:
matildepark 2021-06-23 20:53:53 -05:00 committed by GitHub
commit c2a9e4ac04
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 186 additions and 698 deletions

View File

@ -5,6 +5,7 @@ import {
} from '@urbit/api';
import bigInt, { BigInteger } from 'big-integer';
import React, { Component } from 'react';
import { GraphScroller } from '~/views/components/GraphScroller';
import VirtualScroller from '~/views/components/VirtualScroller';
import ChatMessage from './ChatMessage';
import UnreadNotice from './UnreadNotice';
@ -45,7 +46,7 @@ class ChatWindow extends Component<
ChatWindowProps,
ChatWindowState
> {
private virtualList: VirtualScroller<GraphNode> | null;
private virtualList: VirtualScroller<bigInt.BigInteger, GraphNode> | null;
private prevSize = 0;
private unreadSet = false;
@ -257,7 +258,7 @@ class ChatWindow extends Component<
dismissUnread={this.props.dismissUnread}
onClick={this.scrollToUnread}
/>)}
<VirtualScroller<GraphNode>
<GraphScroller
ref={(list) => {
this.virtualList = list;
}}

View File

@ -5,7 +5,7 @@ import React, {
Component, ReactNode
} from 'react';
import { isWriter } from '~/logic/lib/group';
import VirtualScroller from '~/views/components/VirtualScroller';
import { GraphScroller } from '~/views/components/GraphScroller';
import { LinkItem } from './components/LinkItem';
import LinkSubmit from './components/LinkSubmit';
@ -124,7 +124,7 @@ class LinkWindow extends Component<LinkWindowProps, {}> {
return (
<Col width="100%" height="calc(100% - 48px)" position="relative">
{/* @ts-ignore calling @liam-fitzgerald on virtualscroller */}
<VirtualScroller
<GraphScroller
origin="top"
offset={0}
style={style}

View File

@ -8,7 +8,7 @@ import { LinkBlockInput } from './LinkBlockInput';
import useLocalState from '~/logic/state/local';
import BigIntOrderedMap from '@urbit/api/lib/BigIntOrderedMap';
import bigInt from 'big-integer';
import VirtualScroller from '~/views/components/VirtualScroller';
import { BlockScroller } from '~/views/components/BlockScroller';
export interface LinkBlocksProps {
graph: Graph;
@ -102,7 +102,7 @@ export function LinkBlocks(props: LinkBlocksProps) {
return (
<Col overflowX="hidden" overflowY="auto" height="calc(100% - 48px)" {...bind}>
<VirtualScroller
<BlockScroller
origin="top"
offset={0}
style={style}

View File

@ -1,639 +0,0 @@
/* eslint-disable valid-jsdoc */
import { Box, Center, LoadingSpinner } from '@tlon/indigo-react';
import BigIntArrayOrderedMap, {
arrToString,
stringToArr
} from '@urbit/api/lib/BigIntArrayOrderedMap';
import bigInt, { BigInteger } from 'big-integer';
import _ from 'lodash';
import normalizeWheel from 'normalize-wheel';
import React, { Component, SyntheticEvent, useCallback } from 'react';
import styled from 'styled-components';
import { IS_IOS } from '~/logic/lib/platform';
import { VirtualContext } from '~/logic/lib/virtualContext';
import { clamp } from '~/logic/lib/util';
const ScrollbarLessBox = styled(Box)`
scrollbar-width: none !important;
::-webkit-scrollbar {
display: none;
}
`;
interface RendererProps {
index: BigInteger[];
scrollWindow: any;
ref: (el: HTMLElement | null) => void;
}
export { arrToString, stringToArr };
export function indexEqual(a: BigInteger[], b: BigInteger[]) {
const aLen = a.length;
const bLen = b.length;
if (aLen === bLen) {
let i = 0;
while (i < aLen && i < bLen) {
if (a[i].eq(b[i])) {
if (i === aLen - 1) {
return true;
}
i++;
} else {
return false;
}
}
}
return false;
}
interface VirtualScrollerProps<T> {
/**
* Start scroll from
*/
origin: 'top' | 'bottom';
/**
* Load more of the graph
*
* @returns boolean whether or not the graph is now fully loaded
*/
loadRows(newer: boolean): Promise<boolean>;
/**
* The data to iterate over
*/
data: BigIntArrayOrderedMap<T>;
/**
* The component to render the items
*
* @remarks
*
* This component must be referentially stable, so either use `useCallback` or
* a instance method. It must also forward the DOM ref from its root DOM node
*/
renderer: (props: RendererProps) => JSX.Element | null;
onStartReached?(): void;
onEndReached?(): void;
size: number;
pendingSize: number;
/**
* Average height of a single rendered item
*
* @remarks
* This is used primarily to calculate how many items should be onscreen. If
* size is variable, err on the lower side.
*/
averageHeight: number;
/**
* The offset to begin rendering at, on load.
*
* @remarks
* This is only looked up once, on component creation. Subsequent changes to
* this prop will have no effect
*/
offset: number;
style?: any;
/**
* Callback to execute when finished loading from start
*/
onBottomLoaded?: () => void;
}
interface VirtualScrollerState {
visibleItems: BigInteger[][];
scrollbar: number;
loaded: {
top: boolean;
bottom: boolean;
}
}
type LogLevel = 'scroll' | 'network' | 'bail' | 'reflow';
const logLevel = ['network', 'bail', 'scroll', 'reflow'] as LogLevel[];
const log = (level: LogLevel, message: string) => {
if(logLevel.includes(level)) {
console.log(`[${level}]: ${message}`);
}
};
const ZONE_SIZE = IS_IOS ? 20 : 80;
// nb: in this file, an index refers to a BigInteger[] and an offset refers to a
// number used to index a listified BigIntArrayOrderedMap
/**
* A virtualscroller for a `BigIntArrayOrderedMap`.
*
* VirtualScroller does not clean up or reset itself, so please use `key`
* to ensure a new instance is created for each BigIntArrayOrderedMap
*/
export default class ArrayVirtualScroller<T> extends Component<VirtualScrollerProps<T>, VirtualScrollerState> {
/**
* A reference to our scroll container
*/
window: HTMLDivElement | null = null;
/**
* A map of child refs, used to calculate scroll position
*/
private childRefs = new Map<string, HTMLElement>();
/**
* A set of child refs which have been unmounted
*/
private orphans = new Set<string>();
/**
* 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;
/**
* 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;
scrollLocked = true;
private pageSize = 50;
private pageDelta = 15;
private scrollRef: HTMLElement | null = null;
private cleanupRefInterval: NodeJS.Timeout | null = null;
constructor(props: VirtualScrollerProps<T>) {
super(props);
this.state = {
visibleItems: [],
scrollbar: 0,
loaded: {
top: false,
bottom: false
}
};
this.updateVisible = this.updateVisible.bind(this);
this.invertedKeyHandler = this.invertedKeyHandler.bind(this);
this.onScroll = IS_IOS ? _.debounce(this.onScroll.bind(this), 200) : this.onScroll.bind(this);
this.scrollKeyMap = this.scrollKeyMap.bind(this);
this.setWindow = this.setWindow.bind(this);
this.restore = this.restore.bind(this);
this.startOffset = this.startOffset.bind(this);
}
componentDidMount() {
this.updateVisible(0);
this.loadTop();
this.loadBottom();
this.cleanupRefInterval = setInterval(this.cleanupRefs, 5000);
}
cleanupRefs = () => {
if(this.saveDepth > 0) {
return;
}
[...this.orphans].forEach((o) => {
this.childRefs.delete(o);
});
this.orphans.clear();
};
// manipulate scrollbar manually, to dodge change detection
updateScroll = IS_IOS ? () => {} : _.throttle(() => {
if(!this.window || !this.scrollRef) {
return;
}
const { scrollTop, scrollHeight } = this.window;
const unloaded = (this.startOffset() / this.pageSize);
const totalpages = this.props.size / this.pageSize;
const loaded = (scrollTop / scrollHeight);
const result = ((unloaded + loaded) / totalpages) * this.window.offsetHeight;
this.scrollRef.style[this.props.origin] = `${result}px`;
}, 50);
componentDidUpdate(prevProps: VirtualScrollerProps<T>, _prevState: VirtualScrollerState) {
const { size, pendingSize } = this.props;
if(size !== prevProps.size || pendingSize !== prevProps.pendingSize) {
if((this.window?.scrollTop ?? 0) < ZONE_SIZE) {
this.scrollLocked = true;
this.updateVisible(0);
this.resetScroll();
}
}
}
componentWillUnmount() {
window.removeEventListener('keydown', this.invertedKeyHandler);
if(this.cleanupRefInterval) {
clearInterval(this.cleanupRefInterval);
}
this.cleanupRefs();
this.childRefs.clear();
}
startOffset() {
const { data } = this.props;
const startIndex = this.state.visibleItems?.[0];
if(!startIndex) {
return 0;
}
const dataList = Array.from(data);
const offset = dataList.findIndex(([i]) => indexEqual(i, startIndex));
if(offset === -1) {
// TODO: revisit when we remove nodes for any other reason than
// pending indices being removed
return 0;
}
return offset;
}
/**
* Updates the `startOffset` and adjusts visible items accordingly.
* Saves the scroll positions before repainting and restores it afterwards
*
* @param newOffset new startOffset
*/
updateVisible(newOffset: number) {
if (!this.window) {
return;
}
log('reflow', `from: ${this.startOffset()} to: ${newOffset}`);
const { data } = this.props;
const visibleItems = data.keys().slice(newOffset, newOffset + this.pageSize);
this.save();
this.setState({
visibleItems
});
requestAnimationFrame(() => {
this.restore();
});
}
scrollKeyMap(): Map<string, number> {
return new Map([
['ArrowUp', this.props.averageHeight],
['ArrowDown', this.props.averageHeight * -1],
['PageUp', this.window!.offsetHeight],
['PageDown', this.window!.offsetHeight * -1],
['Home', this.window!.scrollHeight],
['End', this.window!.scrollHeight * -1],
['Space', this.window!.offsetHeight * -1]
]);
}
invertedKeyHandler(event): void | false {
const map = this.scrollKeyMap();
if (map.has(event.code) && document.body.isSameNode(document.activeElement)) {
event.preventDefault();
event.stopImmediatePropagation();
let distance = map.get(event.code)!;
if (event.code === 'Space' && event.shiftKey) {
distance = distance * -1;
}
this.window!.scrollBy(0, distance);
return false;
}
}
setWindow(element) {
if (!element)
return;
this.save();
if (this.window) {
if (this.window.isSameNode(element)) {
return;
} else {
window.removeEventListener('keydown', this.invertedKeyHandler);
}
}
const { averageHeight } = this.props;
this.window = element;
this.pageSize = Math.floor(element.offsetHeight / Math.floor(averageHeight / 2));
this.pageDelta = Math.floor(this.pageSize / 4);
if (this.props.origin === 'bottom') {
element.addEventListener('wheel', (event) => {
event.preventDefault();
const normalized = normalizeWheel(event);
element.scrollBy(0, normalized.pixelY * -1);
return false;
}, { passive: false });
window.addEventListener('keydown', this.invertedKeyHandler, { passive: false });
}
this.restore();
}
resetScroll() {
if (!this.window) {
return;
}
this.window.scrollTop = 0;
this.savedIndex = null;
this.savedDistance = 0;
this.saveDepth = 0;
}
loadTop = _.throttle(() => this.loadRows(false), 100);
loadBottom = _.throttle(() => this.loadRows(true), 100);
loadRows = async (newer: boolean) => {
const dir = newer ? 'bottom' : 'top';
if(this.state.loaded[dir]) {
return;
}
log('network', `loading more at ${dir}`);
const done = await this.props.loadRows(newer);
if(done) {
this.setState({
loaded: {
...this.state.loaded,
[dir]: done
}
});
if(newer && this.props.onBottomLoaded) {
this.props.onBottomLoaded();
}
}
};
onScroll(event: SyntheticEvent<HTMLElement>) {
this.updateScroll();
if(!this.window) {
// bail if we're going to adjust scroll anyway
return;
}
if(this.saveDepth > 0) {
log('bail', 'deep scroll queue');
return;
}
const { onStartReached, onEndReached } = this.props;
const windowHeight = this.window.offsetHeight;
const { scrollTop, scrollHeight } = this.window;
const startOffset = this.startOffset();
if (scrollTop < ZONE_SIZE) {
log('scroll', `Entered start zone ${scrollTop}`);
if (startOffset === 0) {
onStartReached && onStartReached();
this.scrollLocked = true;
}
const newOffset =
clamp(startOffset - this.pageDelta, 0, this.props.data.size - this.pageSize);
if(newOffset < 10) {
this.loadBottom();
}
if(newOffset !== startOffset) {
this.updateVisible(newOffset);
}
} else if (scrollTop + windowHeight >= scrollHeight - ZONE_SIZE) {
this.scrollLocked = false;
log('scroll', `Entered end zone ${scrollTop}`);
const newOffset =
clamp(startOffset + this.pageDelta, 0, this.props.data.size - this.pageSize);
if (onEndReached && startOffset === 0) {
onEndReached();
}
if((newOffset + (3 * this.pageSize) > this.props.data.size)) {
this.loadTop();
}
if(newOffset !== startOffset) {
this.updateVisible(newOffset);
}
} else {
this.scrollLocked = false;
}
}
restore() {
if(!this.window || !this.savedIndex) {
return;
}
if(this.saveDepth !== 1) {
log('bail', 'Deep restore');
return;
}
if(this.scrollLocked) {
this.resetScroll();
requestAnimationFrame(() => {
this.savedIndex = null;
this.savedDistance = 0;
this.saveDepth--;
});
return;
}
const ref = this.childRefs.get(arrToString(this.savedIndex));
if(!ref) {
return;
}
const newScrollTop = this.props.origin === 'top'
? this.savedDistance + ref.offsetTop
: this.window.scrollHeight - ref.offsetTop - this.savedDistance;
this.window.scrollTo(0, newScrollTop);
requestAnimationFrame(() => {
this.savedIndex = null;
this.savedDistance = 0;
this.saveDepth--;
});
}
scrollToIndex = (index: BigInteger[]) => {
let ref = this.childRefs.get(arrToString(index));
if(!ref) {
const offset = [...this.props.data].findIndex(([idx]) => indexEqual(idx, index));
if(offset === -1) {
return;
}
this.scrollLocked = false;
this.updateVisible(Math.max(offset - this.pageDelta, 0));
requestAnimationFrame(() => {
ref = this.childRefs.get(arrToString(index));
requestAnimationFrame(() => {
this.savedIndex = null;
this.savedDistance = 0;
this.saveDepth = 0;
});
ref?.scrollIntoView({ block: 'center' });
});
} else {
ref?.scrollIntoView({ block: 'center' });
requestAnimationFrame(() => {
this.savedIndex = null;
this.savedDistance = 0;
this.saveDepth = 0;
});
}
};
save() {
if(!this.window || this.savedIndex) {
return;
}
if(this.saveDepth !== 0) {
return;
}
log('scroll', 'saving...');
this.saveDepth++;
const { visibleItems } = this.state;
let bottomIndex = visibleItems[visibleItems.length - 1];
const { scrollTop, scrollHeight } = this.window;
const topSpacing = this.props.origin === 'top' ? scrollTop : scrollHeight - scrollTop;
const items = this.props.origin === 'top' ? visibleItems : [...visibleItems].reverse();
items.forEach((index) => {
const el = this.childRefs.get(arrToString(index));
if(!el) {
return;
}
const { offsetTop } = el;
if(offsetTop < topSpacing) {
bottomIndex = index;
}
});
if(!bottomIndex) {
// weird, shouldn't really happen
this.saveDepth--;
log('bail', 'no index found');
return;
}
this.savedIndex = bottomIndex;
const ref = this.childRefs.get(arrToString(bottomIndex))!;
if(!ref) {
this.saveDepth--;
log('bail', 'missing ref');
return;
}
const { offsetTop } = ref;
this.savedDistance = topSpacing - offsetTop;
}
// disabled until we work out race conditions with loading new nodes
shiftLayout = { save: () => {}, restore: () => {} };
setRef = (element: HTMLElement | null, index: BigInteger[]) => {
if(element) {
this.childRefs.set(arrToString(index), element);
this.orphans.delete(arrToString(index));
} else {
this.orphans.add(arrToString(index));
}
}
render() {
const {
visibleItems
} = this.state;
const {
origin = 'top',
renderer,
style
} = this.props;
const isTop = origin === 'top';
const transform = isTop ? 'scale3d(1, 1, 1)' : 'scale3d(1, -1, 1)';
const children = isTop ? visibleItems : [...visibleItems].reverse();
const atStart =
indexEqual(
(this.props.data.peekLargest()?.[0] ?? [bigInt.zero]),
(visibleItems?.[0] || [bigInt.zero])
);
const atEnd =
indexEqual(
(this.props.data.peekSmallest()?.[0] ?? [bigInt.zero]),
(visibleItems?.[visibleItems.length - 1] || [bigInt.zero])
);
return (
<>
{!IS_IOS && (<Box borderRadius={3} top ={isTop ? '0' : undefined}
bottom={!isTop ? '0' : undefined} ref={(el) => {
this.scrollRef = el;
}}
right={0} height="50px"
position="absolute" width="4px"
backgroundColor="lightGray"
/>)}
<ScrollbarLessBox overflowY='scroll' ref={this.setWindow} onScroll={this.onScroll} style={{ ...style, ...{ transform }, 'WebkitOverflowScrolling': 'auto' }}>
<Box style={{ transform, width: 'calc(100% - 4px)' }}>
{(isTop ? !atStart : !atEnd) && (
<Center height={5}>
<LoadingSpinner />
</Center>
)}
<VirtualContext.Provider value={this.shiftLayout}>
{children.map(index => (
<VirtualChild
key={arrToString(index)}
setRef={this.setRef}
index={index}
scrollWindow={this.window}
renderer={renderer}
/>
))}
</VirtualContext.Provider>
{(!isTop ? !atStart : !atEnd) &&
(<Center height={5}>
<LoadingSpinner />
</Center>)}
</Box>
</ScrollbarLessBox>
</>
);
}
}
interface VirtualChildProps {
index: BigInteger[];
scrollWindow: any;
setRef: (el: HTMLElement | null, index: BigInteger[]) => void;
renderer: (p: RendererProps) => JSX.Element | null;
}
function VirtualChild(props: VirtualChildProps) {
const { setRef, renderer: Renderer, ...rest } = props;
const ref = useCallback((el: HTMLElement | null) => {
setRef(el, props.index);
// VirtualChild should always be keyed on the index, so the index should be
// valid for the entire lifecycle of the component, hence no dependencies
}, []);
return <Renderer ref={ref} {...rest} />;
}

View File

@ -21,7 +21,7 @@ export function AudioPlayer(props: { url: string; title?: string }) {
} else {
ref.current.play();
}
setPlaying((p) => !p);
setPlaying(p => !p);
},
[ref, playing]
);

View File

@ -0,0 +1,27 @@
import { GraphNode } from '@urbit/api';
import bigInt, { BigInteger } from 'big-integer';
import React from 'react';
import VirtualScroller, { VirtualScrollerProps } from './VirtualScroller';
type BlockScrollerProps = Omit<
VirtualScrollerProps<BigInteger, [BigInteger, GraphNode][]>,
'keyEq' | 'keyToString' | 'keyBunt'
>;
const keyEq = (a: BigInteger, b: BigInteger) => a.eq(b);
const keyToString = (a: BigInteger) => a.toString();
export const BlockScroller = React.forwardRef<
VirtualScroller<BigInteger, [BigInteger, GraphNode][]>,
BlockScrollerProps
>((props, ref) => {
return (
<VirtualScroller<BigInteger, [BigInteger, GraphNode][]>
ref={ref}
{...props}
keyEq={keyEq}
keyToString={keyToString}
keyBunt={bigInt.zero}
/>
);
});

View File

@ -0,0 +1,27 @@
import { GraphNode } from '@urbit/api';
import bigInt, { BigInteger } from 'big-integer';
import React from 'react';
import VirtualScroller, { VirtualScrollerProps } from './VirtualScroller';
type GraphScrollerProps = Omit<
VirtualScrollerProps<BigInteger, GraphNode>,
'keyEq' | 'keyToString' | 'keyBunt'
>;
const keyEq = (a: BigInteger, b: BigInteger) => a.eq(b);
const keyToString = (a: BigInteger) => a.toString();
export const GraphScroller = React.forwardRef<
VirtualScroller<BigInteger, GraphNode>,
GraphScrollerProps
>((props, ref) => {
return (
<VirtualScroller
ref={ref}
{...props}
keyEq={keyEq}
keyToString={keyToString}
keyBunt={bigInt.zero}
/>
);
});

View File

@ -0,0 +1,48 @@
import { BigInteger } from 'big-integer';
import React from 'react';
import VirtualScroller, { VirtualScrollerProps } from './VirtualScroller';
import { arrToString } from '@urbit/api/lib/BigIntArrayOrderedMap';
import { FlatGraphNode } from '@urbit/api';
type ThreadScrollerProps = Omit<
VirtualScrollerProps<BigInteger[], FlatGraphNode>,
'keyEq' | 'keyToString' | 'keyBunt'
>;
export function keyEq(a: BigInteger[], b: BigInteger[]) {
const aLen = a.length;
const bLen = b.length;
if (aLen === bLen) {
let i = 0;
while (i < aLen && i < bLen) {
if (a[i].eq(b[i])) {
if (i === aLen - 1) {
return true;
}
i++;
} else {
return false;
}
}
}
return false;
}
const keyBunt = [];
export const ThreadScroller = React.forwardRef<
VirtualScroller<BigInteger[], FlatGraphNode>,
ThreadScrollerProps
>((props: ThreadScrollerProps, ref) => {
return (
<VirtualScroller<BigInteger[], FlatGraphNode>
ref={ref}
{...props}
keyEq={keyEq}
keyToString={arrToString}
keyBunt={keyBunt}
/>
);
});

View File

@ -1,6 +1,4 @@
import { Box, Center, LoadingSpinner } from '@tlon/indigo-react';
import BigIntOrderedMap from '@urbit/api/lib/BigIntOrderedMap';
import bigInt, { BigInteger } from 'big-integer';
import _ from 'lodash';
import normalizeWheel from 'normalize-wheel';
import React, { Component, SyntheticEvent, useCallback } from 'react';
@ -17,13 +15,20 @@ const ScrollbarLessBox = styled(Box)`
}
`;
interface RendererProps {
index: BigInteger;
interface RendererProps<K> {
index: K;
scrollWindow: any;
ref: (el: HTMLElement | null) => void;
}
interface VirtualScrollerProps<T> {
interface OrderedMap<K,V> extends Iterable<[K,V]> {
peekLargest: () => [K,V] | undefined;
peekSmallest: () => [K,V] | undefined;
size: number;
keys: () => K[];
}
export interface VirtualScrollerProps<K,V> {
/**
* Start scroll from
*/
@ -37,7 +42,7 @@ interface VirtualScrollerProps<T> {
/**
* The data to iterate over
*/
data: BigIntOrderedMap<T>;
data: OrderedMap<K,V>;
/*
* The component to render the items
*
@ -46,12 +51,11 @@ interface VirtualScrollerProps<T> {
* This component must be referentially stable, so either use `useCallback` or
* a instance method. It must also forward the DOM ref from its root DOM node
*/
renderer: (props: RendererProps) => JSX.Element | null;
renderer: (props: RendererProps<K>) => JSX.Element | null;
onStartReached?(): void;
onEndReached?(): void;
size: number;
pendingSize: number;
totalSize?: number;
/*
* Average height of a single rendered item
*
@ -73,10 +77,22 @@ interface VirtualScrollerProps<T> {
* Callback to execute when finished loading from start
*/
onBottomLoaded?: () => void;
/*
* equality function for the key type
*/
keyEq: (a: K, b: K) => boolean;
/*
* string conversion for key type
*/
keyToString: (k: K) => string;
/*
* default value for key type
*/
keyBunt: K;
}
interface VirtualScrollerState {
visibleItems: BigInteger[];
interface VirtualScrollerState<K> {
visibleItems: K[];
scrollbar: number;
loaded: {
top: boolean;
@ -85,7 +101,9 @@ interface VirtualScrollerState {
}
type LogLevel = 'scroll' | 'network' | 'bail' | 'reflow';
const logLevel = ['network', 'bail', 'scroll', 'reflow'] as LogLevel[];
const logLevel = process.env.NODE_ENV === 'production'
? []
: ['network', 'bail', 'scroll', 'reflow'] as LogLevel[];
const log = (level: LogLevel, message: string) => {
if(logLevel.includes(level)) {
@ -104,7 +122,7 @@ const ZONE_SIZE = IS_IOS ? 20 : 80;
* VirtualScroller does not clean up or reset itself, so please use `key`
* to ensure a new instance is created for each BigIntOrderedMap
*/
export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T>, VirtualScrollerState> {
export default class VirtualScroller<K,V> extends Component<VirtualScrollerProps<K,V>, VirtualScrollerState<K>> {
/*
* A reference to our scroll container
*/
@ -120,7 +138,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
/*
* If saving, the bottommost visible element that we pin our scroll to
*/
private savedIndex: BigInteger | null = null;
private savedIndex: K | null = null;
/*
* If saving, the distance between the top of `this.savedEl` and the bottom
* of the screen
@ -144,7 +162,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
private cleanupRefInterval: NodeJS.Timeout | null = null;
constructor(props: VirtualScrollerProps<T>) {
constructor(props: VirtualScrollerProps<K,V>) {
super(props);
this.state = {
visibleItems: [],
@ -177,8 +195,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
return;
}
[...this.orphans].forEach((o) => {
const index = bigInt(o);
this.childRefs.delete(index.toString());
this.childRefs.delete(o);
});
this.orphans.clear();
};
@ -198,7 +215,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
this.scrollRef.style[this.props.origin] = `${result}px`;
}, 50);
componentDidUpdate(prevProps: VirtualScrollerProps<T>, _prevState: VirtualScrollerState) {
componentDidUpdate(prevProps: VirtualScrollerProps<K,V>, _prevState: VirtualScrollerState<K>) {
const { size, pendingSize } = this.props;
if(size !== prevProps.size || pendingSize !== prevProps.pendingSize) {
@ -220,13 +237,13 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
}
startOffset() {
const { data } = this.props;
const { data, keyEq } = this.props;
const startIndex = this.state.visibleItems?.[0];
if(!startIndex) {
return 0;
}
const dataList = Array.from(data);
const offset = dataList.findIndex(([i]) => i.eq(startIndex));
const offset = dataList.findIndex(([i]) => keyEq(i, startIndex));
if(offset === -1) {
// TODO: revisit when we remove nodes for any other reason than
// pending indices being removed
@ -401,6 +418,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
}
restore() {
const { keyToString } = this.props;
if(!this.window || !this.savedIndex) {
return;
}
@ -418,7 +436,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
return;
}
const ref = this.childRefs.get(this.savedIndex.toString());
const ref = this.childRefs.get(keyToString(this.savedIndex));
if(!ref) {
return;
}
@ -435,17 +453,18 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
});
}
scrollToIndex = (index: BigInteger) => {
let ref = this.childRefs.get(index.toString());
scrollToIndex = (index: K) => {
const { keyToString, keyEq } = this.props;
let ref = this.childRefs.get(keyToString(index));
if(!ref) {
const offset = [...this.props.data].findIndex(([idx]) => idx.eq(index));
const offset = [...this.props.data].findIndex(([idx]) => keyEq(idx, index));
if(offset === -1) {
return;
}
this.scrollLocked = false;
this.updateVisible(Math.max(offset - this.pageDelta, 0));
requestAnimationFrame(() => {
ref = this.childRefs.get(index.toString());
ref = this.childRefs.get(keyToString(index));
requestAnimationFrame(() => {
this.savedIndex = null;
this.savedDistance = 0;
@ -468,6 +487,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
if(!this.window || this.savedIndex) {
return;
}
log('reflow', `saving @ ${this.saveDepth}`);
if(this.saveDepth !== 0) {
return;
}
@ -476,13 +496,14 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
this.saveDepth++;
const { visibleItems } = this.state;
const { keyToString } = this.props;
let bottomIndex = visibleItems[visibleItems.length - 1];
const { scrollTop, scrollHeight } = this.window;
const topSpacing = this.props.origin === 'top' ? scrollTop : scrollHeight - scrollTop;
const items = this.props.origin === 'top' ? visibleItems : [...visibleItems].reverse();
items.forEach((index) => {
const el = this.childRefs.get(index.toString());
const el = this.childRefs.get(keyToString(index));
if(!el) {
return;
}
@ -500,7 +521,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
}
this.savedIndex = bottomIndex;
const ref = this.childRefs.get(bottomIndex.toString())!;
const ref = this.childRefs.get(keyToString(bottomIndex))!;
if(!ref) {
this.saveDepth--;
log('bail', 'missing ref');
@ -513,12 +534,13 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
// disabled until we work out race conditions with loading new nodes
shiftLayout = { save: () => {}, restore: () => {} };
setRef = (element: HTMLElement | null, index: BigInteger) => {
setRef = (element: HTMLElement | null, index: K) => {
const { keyToString } = this.props;
if(element) {
this.childRefs.set(index.toString(), element);
this.orphans.delete(index.toString());
this.childRefs.set(keyToString(index), element);
this.orphans.delete(keyToString(index));
} else {
this.orphans.add(index.toString());
this.orphans.add(keyToString(index));
}
}
@ -530,7 +552,10 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
const {
origin = 'top',
renderer,
style
style,
keyEq,
keyBunt,
keyToString
} = this.props;
const isTop = origin === 'top';
@ -538,8 +563,8 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
const transform = isTop ? 'scale3d(1, 1, 1)' : 'scale3d(1, -1, 1)';
const children = isTop ? visibleItems : [...visibleItems].reverse();
const atStart = (this.props.data.peekLargest()?.[0] ?? bigInt.zero).eq(visibleItems?.[0] || bigInt.zero);
const atEnd = (this.props.data.peekSmallest()?.[0] ?? bigInt.zero).eq(visibleItems?.[visibleItems.length -1 ] || bigInt.zero);
const atStart = keyEq(this.props.data.peekLargest()?.[0] ?? keyBunt, visibleItems?.[0] || keyBunt);
const atEnd = keyEq(this.props.data.peekSmallest()?.[0] ?? keyBunt, visibleItems?.[visibleItems.length -1 ] || keyBunt);
return (
<>
@ -559,8 +584,8 @@ backgroundColor="lightGray"
</Center>)}
<VirtualContext.Provider value={this.shiftLayout}>
{children.map(index => (
<VirtualChild
key={index.toString()}
<VirtualChild<K>
key={keyToString(index)}
setRef={this.setRef}
index={index}
scrollWindow={this.window}
@ -579,14 +604,14 @@ backgroundColor="lightGray"
}
}
interface VirtualChildProps {
index: BigInteger;
interface VirtualChildProps<K> {
index: K;
scrollWindow: any;
setRef: (el: HTMLElement | null, index: BigInteger) => void;
renderer: (p: RendererProps) => JSX.Element | null;
setRef: (el: HTMLElement | null, index: K) => void;
renderer: (p: RendererProps<K>) => JSX.Element | null;
}
function VirtualChild(props: VirtualChildProps) {
function VirtualChild<K>(props: VirtualChildProps<K>) {
const { setRef, renderer: Renderer, ...rest } = props;
const ref = useCallback((el: HTMLElement | null) => {

View File

@ -5,9 +5,9 @@ import bigInt from 'big-integer';
import React from 'react';
import { useHistory } from 'react-router';
import { resourceFromPath } from '~/logic/lib/group';
import VirtualScroller from '~/views/components/VirtualScroller';
import PostItem from './PostItem/PostItem';
import PostInput from './PostInput';
import { GraphScroller } from '~/views/components/GraphScroller';
import useGraphState, { GraphState } from '~/logic/state/graph';
import shallow from 'zustand/shallow';
@ -210,14 +210,13 @@ class PostFeed extends React.Component<PostFeedProps, any> {
return (
<Col width="100%" height="100%" position="relative">
<VirtualScroller
<GraphScroller
key={history.location.pathname}
origin="top"
offset={0}
data={graph}
averageHeight={106}
averageHeight={80}
size={graph.size}
totalSize={graph.size}
style={virtualScrollerStyle}
pendingSize={pendingSize}
renderer={this.renderItem}

View File

@ -4,10 +4,10 @@ import bigInt from 'big-integer';
import React from 'react';
import { RouteComponentProps, useHistory } from 'react-router';
import { resourceFromPath } from '~/logic/lib/group';
import ArrayVirtualScroller, {
indexEqual,
import {
arrToString
} from '~/views/components/ArrayVirtualScroller';
} from '@urbit/api/lib/BigIntArrayOrderedMap';
import { keyEq, ThreadScroller } from '~/views/components/ThreadScroller';
import PostItem from './PostItem/PostItem';
import PostInput from './PostInput';
import useGraphState, { GraphState } from '~/logic/state/graph';
@ -66,9 +66,9 @@ class PostFlatFeed extends React.Component<PostFeedProps, {}> {
const first = flatGraph.peekLargest()?.[0];
const last = flatGraph.peekSmallest()?.[0];
const isLast = last ? indexEqual(index, last) : false;
const isLast = last ? keyEq(index, last) : false;
if (indexEqual(index, (first ?? [bigInt.zero]))) {
if (keyEq(index, (first ?? [bigInt.zero]))) {
if (isThread) {
return (
<Col
@ -195,12 +195,12 @@ class PostFlatFeed extends React.Component<PostFeedProps, {}> {
return (
<Col width="100%" height="100%" position="relative">
<ArrayVirtualScroller
<ThreadScroller
key={history.location.pathname}
origin="top"
offset={0}
data={flatGraph}
averageHeight={122}
averageHeight={80}
size={flatGraph.size}
style={virtualScrollerStyle}
pendingSize={pendingSize}

View File

@ -6,7 +6,7 @@ import React, {
} from 'react';
import { resourceFromPath } from '~/logic/lib/group';
import { Loading } from '~/views/components/Loading';
import { arrToString } from '~/views/components/ArrayVirtualScroller';
import { arrToString } from '@urbit/api/lib/BigIntArrayOrderedMap';
import useGraphState from '~/logic/state/graph';
import PostFlatFeed from './PostFlatFeed';
import PostInput from './PostInput';