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

Refactored state and effects #16

Merged
merged 4 commits into from
Dec 24, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@krjakbrjak/virtualtable",
"version": "1.1.6",
"version": "1.1.7",
"description": "",
"repository": {
"type": "git",
Expand Down
280 changes: 160 additions & 120 deletions src/VirtualTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,34 @@ import React, {
} from 'react';
import PropTypes from 'prop-types';

import { slideItems } from './helpers/collections';
import { slideItems, Page } from './helpers/collections';

import './base.css';

import { LazyPaginatedCollection } from './helpers/LazyPaginatedCollection';
import { Style, DataSource } from './helpers/types';
import { Container, Row, Col } from 'react-bootstrap';

/**
* Represent the rectangular.
*/
interface Rect {
x: number;
y: number;
height: number;
width: number;
}

interface State<Type> {
scrollTop: number,
itemHeight: number,
itemCount: number,
items: Array<Type>,
offset: number,
ready: boolean;
scrollTop: number;
itemHeight: number;
itemCount: number;
page: Page<Type>;
offset: number;
selected: number;
hovered: number;
rect: Rect;
}

interface Action<Type> {
Expand All @@ -39,6 +51,31 @@ interface Args<Type> {
style?: Style;
}

function get_initial_state<T>(): State<T> {
return {
ready: false,
scrollTop: 0,
itemHeight: 0,
itemCount: 0,
page: {
items: [],
offset: 0,
},
offset: 0,
selected: -1,
hovered: -1,
rect: {
x: 0,
y: 0,
height: 0,
width: 0,
}
}
}
function calculatePageCount(pageHeight: number, itemHeight: number) {
return 2 * Math.floor(pageHeight / itemHeight);
}

/**
* Reducer function for managing state changes.
*
Expand All @@ -50,13 +87,9 @@ interface Args<Type> {
function reducer<Type>(state: State<Type>, action: Action<Type>): State<Type> {
switch (action.type) {
case 'scroll':
return { ...state, ...action.data };
case 'render':
return { ...state, ...action.data };
case 'loaded':
return { ...state, ...action.data };
case 'click':
return { ...state, ...action.data };
case 'hover':
return { ...state, ...action.data };
default:
Expand All @@ -79,16 +112,6 @@ function reducer<Type>(state: State<Type>, action: Action<Type>): State<Type> {
* @property {DataSource} fetcher A datasource to fetch the data.
*/

/**
* Represent the rectangular.
*/
interface Rect {
x: number;
y: number;
height: number;
width: number;
}

/**
* @description VirtualTable component.
*
Expand All @@ -99,89 +122,9 @@ interface Rect {
*/
export default function VirtualTable<Type>({ height, renderer, fetcher, style }: Args<Type>): JSX.Element {
const ref = useRef(null);
const invisible = useRef(null);
const [collection, setCollection] = useState<LazyPaginatedCollection<Type>>(() => new LazyPaginatedCollection<Type>(1, fetcher));
const [rect, setRect] = useState<Rect>({
x: 0,
y: 0,
height: 0,
width: 0,
});

useEffect(() => {
setCollection(new LazyPaginatedCollection<Type>(collection.pageSize() ? collection.pageSize() : 1, fetcher));
}, [fetcher]);

const [state, dispatch] = useReducer(reducer<Type>, {
scrollTop: 0,
itemHeight: 0,
itemCount: 0,
items: [],
offset: 0,
selected: -1,
hovered: -1,
});

const [currentOffset, setCurrentOffset] = useState(0);

const calculatePageCount = () => 2 * Math.floor(height / state.itemHeight);

useEffect(() => {
const handler = () => {
if (ref && ref.current) {
setRect(ref.current.getBoundingClientRect());
}
};
window.addEventListener('resize', handler);
return function cleanup() {
window.removeEventListener('resize', handler, true);
}
}, []);

useEffect(() => {
if (collection) {
collection.slice(0, collection.pageSize()).then((result) => {
dispatch({
type: 'loaded',
data: {
scrollTop: 0,
itemHeight: 0,
items: [],
offset: 0,
selected: -1,
hovered: -1,
...result,
itemCount: collection.count(),
},
});
});
}
}, [collection]);

useEffect(() => {
if (state.itemHeight) {
const offset = Math.floor(state.scrollTop / state.itemHeight);
const c = calculatePageCount();
if (c !== collection.pageSize()) {
setCurrentOffset(0);
setCollection(new LazyPaginatedCollection<Type>(c, fetcher));
} else {
setCurrentOffset(offset);
collection.slice(offset, collection.pageSize()).then((result) => {
if (currentOffset !== result.offset) {
dispatch({
type: 'loaded',
data: {
...result,
itemCount: collection.count(),
},
});
}
});
}
}
}, [
state,
]);
const [state, dispatch] = useReducer(reducer<Type>, get_initial_state<Type>());

const generate = (offset: number, d: Array<Type>) => {
const ret = [];
Expand All @@ -201,26 +144,118 @@ export default function VirtualTable<Type>({ height, renderer, fetcher, style }:
return ret;
};


// A callback to update the table view in case of resize event.
const handler = () => {
let itemHeight = state.itemHeight;
let rect = state.rect;
if (invisible && invisible.current) {
itemHeight = invisible.current.clientHeight;
}
if (ref && ref.current) {
rect = ref.current.getBoundingClientRect();
}

// Update the size of the widget and the size of the items
dispatch({
type: 'render',
data: {
rect,
itemHeight,
scrollTop: 0,
selected: -1,
hovered: -1,
page: {
items: [],
offset: 0,
}
},
});

// If the item's height is already known, then update the lazy collection
// and re-fetch the items.
if (itemHeight) {
const new_collection = new LazyPaginatedCollection<Type>(calculatePageCount(rect.height, itemHeight), fetcher);
new_collection.slice(0, new_collection.pageSize()).then((result) => {
dispatch({
type: 'loaded',
data: {
page: result,
itemCount: new_collection.count(),
},
});
setCollection(new_collection);
});
}
};

// Effect that updates the lazy collection in case fetcher gets updated
useEffect(() => {
if (ref.current) {
ref.current.scrollTop = state.scrollTop % state.itemHeight;
if (ref.current.children && ref.current.children.length) {
if (ref.current.children[0].clientHeight !== state.itemHeight) {
setRect(ref.current.getBoundingClientRect());
setCollection(new LazyPaginatedCollection<Type>(collection.pageSize() ? collection.pageSize() : 1, fetcher));
}, [fetcher]);

// Effect to fetch the first item (to draw a fake item to get the true size if the item)
// and the total number of items.
useEffect(() => {
collection.slice(0, collection.pageSize()).then((result) => {
dispatch({
type: 'loaded',
data: {
ready: true,
page: result,
itemCount: collection.count(),
},
});
});

window.addEventListener('resize', handler);
return function cleanup() {
window.removeEventListener('resize', handler, true);
}
}, []);

// Effect to run on all state updates.
useEffect(() => {
if (state.ready) {
if (state.itemHeight) {
const offset = Math.floor(state.scrollTop / state.itemHeight);
const c = calculatePageCount(height, state.itemHeight);
if (c === collection.pageSize() && state.offset !== offset) {
// Update the offset first and then start fetching the necessary items.
// This ensures a non-interruptive user experience, where all the
// required data is already available.
dispatch({
type: 'render',
type: 'loaded',
data: {
itemHeight: ref.current.children[0].clientHeight,
offset,
},
});
collection.slice(offset, collection.pageSize()).then((result) => {
if (state.offset !== result.offset) {
dispatch({
type: 'loaded',
data: {
page: result,
itemCount: collection.count(),
},
});
}
});
}
} else {
handler();
}
}
}, [state]);

// Effect to run on each render to make sure that the scrolltop of
// the item container is up-to-date.
useEffect(() => {
if (ref.current) {
ref.current.scrollTop = state.scrollTop % state.itemHeight;
}
});

if (state.items.length === 0) {
return <div />;
}

return (
<Container>
Expand All @@ -233,18 +268,23 @@ export default function VirtualTable<Type>({ height, renderer, fetcher, style }:
height,
}}
>
{generate(currentOffset, slideItems(currentOffset, {
items: state.items,
offset: state.offset,
}))}
{state.ready && state.itemHeight === 0 &&
<div ref={invisible} style={{
'visibility': 'hidden',
position: 'absolute',
pointerEvents: 'none'
}}>
{renderer(state.page.items[0], '')}
</div>}
{state.itemHeight !== 0 && generate(state.offset, slideItems(state.offset, state.page))}
</div>
<div
className='overflow-scroll position-absolute'
style={{
top: rect.y,
left: rect.x,
width: rect.width,
height: rect.height,
top: state.rect.y,
left: state.rect.x,
width: state.rect.width,
height: state.rect.height,
}}
onMouseMove={(e) => {
const index = Math.floor((e.clientY + state.scrollTop - ref.current.getBoundingClientRect().top) / state.itemHeight);
Expand Down
2 changes: 1 addition & 1 deletion src/helpers/collections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Represent a page.
* @typedef {Object} Slice
*/
interface Page<Type> {
export interface Page<Type> {
/**
* Page items
*/
Expand Down