Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[KED-1923] Add LazyList component using IntersectionObserver on sidebar #307

Merged
merged 24 commits into from
Dec 14, 2020
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e95df26
[KED-1923] Refactor and memoise NodeListRow
bru5 Nov 13, 2020
44f9a0f
[KED-1923] Add LazyList component
bru5 Nov 13, 2020
d1be957
[KED-1923] Refactor NodeListGroup, NodeListGroups using LazyList
bru5 Nov 13, 2020
e191e94
[KED-1923] Add fade effect to placeholders on lazy lists
bru5 Nov 13, 2020
8cdba33
[KED-1923] Add comments and docs to LazyList
bru5 Nov 17, 2020
ade568c
[KED-1923] Fix placeholder fade on node list
bru5 Nov 17, 2020
724349e
[KED-1923] Add viewport clipping to LazyList
bru5 Nov 18, 2020
eaa61a6
[KED-1923] Refactor into NodeRowList
bru5 Nov 18, 2020
092cd69
[KED-1923] Add notes on row height
bru5 Nov 18, 2020
45e9ec2
[KED-1923] Add flag for lazy sidebar features
bru5 Nov 18, 2020
29f8071
Merge branch 'main' into feature/lazy-sidebar
richardwestenra Nov 26, 2020
e2b3b1b
Use CSS custom properties for gradient theming
richardwestenra Nov 26, 2020
4b4d34b
[KED-1923] Fix tests for node-list after refactor
bru5 Dec 3, 2020
1696308
[KED-1923] Add tests for LazyList
bru5 Dec 3, 2020
a77e701
[KED-1923] Simplify LazyList clipping
bru5 Dec 7, 2020
307e4e9
[KED-1923] Improve tests for LazyList
bru5 Dec 7, 2020
ecf3f55
Merge origin/feature/lazy-sidebar
bru5 Dec 7, 2020
e6c0643
[KED-1923] Update version for lazy flag in readme
bru5 Dec 8, 2020
9a38263
[KED-1923] Fix LazyList tests for CI
bru5 Dec 11, 2020
17ac26d
[KED-1923] Fix app tests
bru5 Dec 11, 2020
4b55ce1
Merge branch 'main' into feature/lazy-sidebar
bru5 Dec 11, 2020
b6d4b5b
[KED-1923] Add comments to LazyList about behaviour when not supported
bru5 Dec 14, 2020
7ae380a
[KED-1923] Move changed utility to shared module
bru5 Dec 14, 2020
074aea9
Merge branch 'main' into feature/lazy-sidebar
bru5 Dec 14, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 180 additions & 0 deletions src/components/lazy-list/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { useState, useLayoutEffect, useRef, useMemo, useCallback } from 'react';

const supported = typeof IntersectionObserver !== 'undefined';

export default ({
children,
height,
total,
onChange,
lazy = true,
dispose = false,
buffer = 0.5
}) => {
const active = lazy && supported;

const [range, setRange] = useState([0, 0]);
const nextRange = useRef([0, 0]);

const listRef = useRef();
const lowerRef = useRef();
const upperRef = useRef();

const itemHeight = useMemo(() => height(0, 1), [height]);
const totalHeight = useMemo(() => height(0, total), [height, total]);
const upperHeight = useMemo(() => height(0, range[0]), [height, range]);
const lowerHeight = useMemo(() => height(range[1], total), [
height,
range,
total
]);

if (active) {
const requestUpdate = useRequestFrameOnce(
useCallback(() => {
const visibleRange = visibleRangeOf(
listRef.current,
listRef.current?.offsetParent,
buffer,
total,
itemHeight
);

const effectiveRange = dispose
? visibleRange
: rangeUnion(nextRange.current, visibleRange);

// Avoid duplicate renders if ranges are equal
if (!rangeEqual(nextRange.current, effectiveRange)) {
nextRange.current = effectiveRange;
setRange(effectiveRange);
}
}, [buffer, total, itemHeight, dispose])
);

const observerOptions = useMemo(() => ({ threshold: thresholds(total) }), [
total
]);

useIntersection(listRef, observerOptions, requestUpdate);
useIntersection(upperRef, observerOptions, requestUpdate);
useIntersection(lowerRef, observerOptions, requestUpdate);

useLayoutEffect(() => requestUpdate(), [total, totalHeight, requestUpdate]);
}

const childProps = useMemo(
() => ({
start: active ? range[0] : 0,
end: active ? range[1] : total,
total,
listRef,
upperRef,
lowerRef,
listStyle: {
position: 'relative',
height: active ? totalHeight : undefined,
paddingTop: active ? upperHeight : undefined
},
upperStyle: {
position: 'absolute',
display: !active ? 'none' : undefined,
height: upperHeight,
width: '100%',
top: '0'
},
lowerStyle: {
position: 'absolute',
display: !active ? 'none' : undefined,
height: lowerHeight,
width: '100%',
bottom: '0'
}
}),
[
active,
range,
total,
listRef,
upperRef,
lowerRef,
totalHeight,
upperHeight,
lowerHeight
]
);

onChange && onChange(childProps);

return children(childProps);
};

const range = (start, end, min, max) => [
Math.max(Math.min(start, max), min),
Math.max(Math.min(end, max), min)
];

const rangeUnion = (rangeA, rangeB) => [
Math.min(rangeA[0], rangeB[0]),
Math.max(rangeA[1], rangeB[1])
];

const rangeEqual = (rangeA, rangeB) =>
rangeA[0] === rangeB[0] && rangeA[1] === rangeB[1];

const visibleRangeOf = (element, container, buffer, total, itemHeight) => {
if (!element || !container) {
return [0, 0];
}

const rect = element.getBoundingClientRect();
const clip = container.getBoundingClientRect();

const bufferCount = Math.ceil((buffer * clip.height) / itemHeight);

if (rect.bottom < clip.top) {
return range(total - bufferCount, total, 0, total);
}

if (rect.top > clip.bottom) {
return range(0, bufferCount, 0, total);
}

const top = Math.min(Math.max(rect.top, clip.top), clip.bottom);
const bottom = Math.max(Math.min(rect.bottom, clip.bottom), clip.top);

const start = Math.floor((top - rect.top) / itemHeight);
const end = Math.ceil((bottom - rect.top) / itemHeight);

return range(start - bufferCount, end + bufferCount, 0, total);
};

const useRequestFrameOnce = callback => {
const request = useRef();

// Keep the callback cached to avoid constant re-creation
return useCallback(() => {
cancelAnimationFrame(request.current);
request.current = requestAnimationFrame(callback);
}, [request, callback]);
};

const thresholds = total =>
Array.from({ length: total }, (_, i) => i / (total - 1));

const useIntersection = (element, options, callback) => {
const observer = useRef();

// Must be an effect to avoid constant re-creation
return useLayoutEffect(() => {
if (!element.current) {
return;
}
if (observer.current) {
observer.current.disconnect();
}
observer.current = new IntersectionObserver(callback, options);
observer.current.observe(element.current);
callback();
}, [callback, element, options]);
};
97 changes: 82 additions & 15 deletions src/components/node-list/node-list-group.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import React from 'react';
import classnames from 'classnames';
import NodeListRow from './node-list-row';
import modifiers from '../../utils/modifiers';
import NodeListRow, { nodeListRowHeight } from './node-list-row';
import LazyList from '../lazy-list';

export const NodeListGroup = ({
container: Container = 'div',
childrenContainer: ChildrenContainer = 'div',
childrenClassName,
children,
items,
group,
collapsed,
id,
name,
Expand All @@ -18,9 +18,13 @@ export const NodeListGroup = ({
visibleIcon,
invisibleIcon,
onToggleChecked,
onToggleCollapsed
onToggleCollapsed,
onItemClick,
onItemChange,
onItemMouseEnter,
onItemMouseLeave
}) => (
<Container
<li
className={classnames(
'pipeline-nodelist__group',
`pipeline-nodelist__group--type-${id}`,
Expand Down Expand Up @@ -51,14 +55,77 @@ export const NodeListGroup = ({
/>
</NodeListRow>
</h3>

<ChildrenContainer
className={classnames(childrenClassName, 'pipeline-nodelist__children', {
'pipeline-nodelist__children--closed': collapsed
})}>
{children}
</ChildrenContainer>
</Container>
<LazyList
name={name}
height={(start, end) => (end - start) * nodeListRowHeight}
total={items.length}
onChange={({ start, end, total }) =>
console.log(
`${group.name} ${start} to ${end} (${end - start} of ${total})`
)
}>
bru5 marked this conversation as resolved.
Show resolved Hide resolved
{({
start,
end,
total,
listRef,
upperRef,
lowerRef,
listStyle,
upperStyle,
lowerStyle
}) => (
<ul
ref={listRef}
style={listStyle}
className={modifiers(
'pipeline-nodelist__children',
{ closed: collapsed },
'pipeline-nodelist__list'
)}>
<li
className={modifiers('pipeline-nodelist__placeholder-upper', {
fade: start > 0
})}
ref={upperRef}
style={upperStyle}
/>
<li
className={modifiers('pipeline-nodelist__placeholder-lower', {
fade: end < total
})}
ref={lowerRef}
style={lowerStyle}
/>
{items.slice(start, end).map(item => (
<NodeListRow
container="li"
key={item.id}
id={item.id}
kind={group.kind}
label={item.highlightedLabel}
name={item.name}
type={item.type}
active={item.active}
checked={item.checked}
disabled={item.disabled}
faded={item.faded}
visible={item.visible}
selected={item.selected}
unset={item.unset}
visibleIcon={item.visibleIcon}
invisibleIcon={item.invisibleIcon}
onClick={() => onItemClick(item)}
// Disabled to avoid unrelated hover lag for now.
// onMouseEnter={() => onItemMouseEnter(item)}
// onMouseLeave={() => onItemMouseLeave(item)}
richardwestenra marked this conversation as resolved.
Show resolved Hide resolved
onChange={e => onItemChange(item, !e.target.checked)}
/>
))}
</ul>
)}
</LazyList>
</li>
);

export default NodeListGroup;
38 changes: 8 additions & 30 deletions src/components/node-list/node-list-groups.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import React, { useState } from 'react';
import { loadState, saveState } from '../../store/helpers';
import NodeListGroup from './node-list-group';
import NodeListRow from './node-list-row';

const storedState = loadState();

Expand Down Expand Up @@ -35,9 +34,8 @@ const NodeListGroups = ({
const group = groups[typeId];
return (
<NodeListGroup
container="li"
childrenContainer="ul"
childrenClassName="pipeline-nodelist__list pipeline-nodelist__list--nested"
group={group}
items={items[group.id] || []}
key={group.id}
id={group.id}
name={group.name}
Expand All @@ -49,32 +47,12 @@ const NodeListGroups = ({
invisibleIcon={group.invisibleIcon}
collapsed={Boolean(searchValue) ? false : collapsed[group.id]}
onToggleCollapsed={onToggleGroupCollapsed}
onToggleChecked={onToggleGroupChecked}>
{(items[group.id] || []).map(item => (
<NodeListRow
container="li"
key={item.id}
id={item.id}
kind={group.kind}
label={item.highlightedLabel}
name={item.name}
type={item.type}
active={item.active}
checked={item.checked}
disabled={item.disabled}
faded={item.faded}
visible={item.visible}
selected={item.selected}
unset={item.unset}
visibleIcon={item.visibleIcon}
invisibleIcon={item.invisibleIcon}
onClick={() => onItemClick(item)}
onMouseEnter={() => onItemMouseEnter(item)}
onMouseLeave={() => onItemMouseLeave(item)}
onChange={e => onItemChange(item, !e.target.checked)}
/>
))}
</NodeListGroup>
onToggleChecked={onToggleGroupChecked}
onItemClick={onItemClick}
onItemChange={onItemChange}
onItemMouseEnter={onItemMouseEnter}
onItemMouseLeave={onItemMouseLeave}
/>
);
})}
</ul>
Expand Down
Loading