refactor of reverse virtual list

This commit is contained in:
Roland Osborne 2022-08-30 00:45:57 -07:00
parent 4cceaab423
commit 296bed18d5
7 changed files with 426 additions and 325 deletions

View File

@ -36,8 +36,8 @@ export function Conversation({ closeConversation, openDetails, cardId, channelId
)}
</div>
<div class="thread">
<VirtualList id={channelId + cardId}
items={state.topics} itemRenderer={topicRenderer} onMore={actions.more} />
<VirtualList id={`${cardId}:${channelId}`}
items={state.topics} itemRenderer={topicRenderer} loadMore={actions.more} />
</div>
<div class="divider">
<div class="line" />

View File

@ -21,17 +21,17 @@ export function TopicItem({ host, topic }) {
nameClass = "unknown"
}
const renderAsset = (asset) => {
const renderAsset = (asset, idx, topicId) => {
if (asset.image) {
return <ImageAsset thumbUrl={actions.getAssetUrl(asset.image.thumb)}
fullUrl={actions.getAssetUrl(asset.image.full)} />
return <ImageAsset thumbUrl={actions.getAssetUrl(asset.image.thumb, topicId)}
fullUrl={actions.getAssetUrl(asset.image.full, topicId)} />
}
if (asset.video) {
return <VideoAsset thumbUrl={actions.getAssetUrl(asset.video.thumb)}
lqUrl={actions.getAssetUrl(asset.video.lq)} hdUrl={actions.getAssetUrl(asset.video.hd)} />
return <VideoAsset thumbUrl={actions.getAssetUrl(asset.video.thumb, topicId)}
lqUrl={actions.getAssetUrl(asset.video.lq, topicId)} hdUrl={actions.getAssetUrl(asset.video.hd, topicId)} />
}
if (asset.audio) {
return <AudioAsset label={asset.audio.label} audioUrl={actions.getAssetUrl(asset.audio.full)} />
return <AudioAsset label={asset.audio.label} audioUrl={actions.getAssetUrl(asset.audio.full, topicId)} />
}
return <></>
}
@ -95,6 +95,8 @@ export function TopicItem({ host, topic }) {
return (
<TopicItemWrapper>
{ state.init && (
<>
<div class="topic-header">
<div class="avatar">
<Logo width={32} height={32} radius={4} url={state.imageUrl} />
@ -136,6 +138,8 @@ export function TopicItem({ host, topic }) {
</div>
</div>
)}
</>
)}
</TopicItemWrapper>
)
}

View File

@ -23,6 +23,11 @@ export function ImageAsset({ thumbUrl, fullUrl }) {
}
}
const clearPopout = (e) => {
e.stopPropagation();
actions.clearPopout();
}
return (
<ImageAssetWrapper>
<ReactResizeDetector handleWidth={true} handleHeight={true}>
@ -46,7 +51,7 @@ export function ImageAsset({ thumbUrl, fullUrl }) {
<div class="viewer" style={{ width: dimension.width, height: dimension.height }} onClick={popout} />
)}
{ state.display === 'small' && state.popout && (
<div class="fullscreen" onClick={actions.clearPopout}>
<div class="fullscreen" onClick={clearPopout} onTouchEnd={clearPopout}>
<img class="image" src={fullUrl} />
</div>
)}

View File

@ -8,6 +8,7 @@ export function useTopicItem(topic) {
const [guid, setGuid] = useState(null);
const [state, setState] = useState({
init: false,
name: null,
handle: null,
imageUrl: null,
@ -18,6 +19,7 @@ export function useTopicItem(topic) {
error: false,
owner: false,
assets: [],
topicId: null,
editing: false,
busy: false,
textColor: '#444444',
@ -98,18 +100,18 @@ export function useTopicItem(topic) {
if (profile.state.profile.guid == guid) {
const { name, handle, imageUrl } = profile.actions.getProfile();
updateState({ name, handle, imageUrl, status, message, transform, assets, confirmed, error, ready, created: createdStr, owner, textColor, textSize });
updateState({ name, handle, imageUrl, status, message, transform, assets, confirmed, error, ready, created: createdStr, owner, textColor, textSize, topicId: topic.id, init: true });
}
else {
const { name, handle, imageUrl } = card.actions.getCardProfileByGuid(guid);
updateState({ name, handle, imageUrl, status, message, transform, assets, confirmed, error, ready, created: createdStr, owner, textColor, textSize });
updateState({ name, handle, imageUrl, status, message, transform, assets, confirmed, error, ready, created: createdStr, owner, textColor, textSize, topicId: topic.id, init: true });
}
}
}, [profile, card, conversation, topic]);
const actions = {
getAssetUrl: (assetId) => {
return conversation.actions.getAssetUrl(topic?.id, assetId);
getAssetUrl: (assetId, topicId) => {
return conversation.actions.getAssetUrl(state.topicId, assetId);
},
removeTopic: async () => {
return await conversation.actions.removeTopic(topic.id);

View File

@ -1,338 +1,371 @@
import React, { useRef, useState, useEffect } from 'react';
import { VirtualListWrapper, VirtualItem } from './VirtualList.styled';
import ReactResizeDetector from 'react-resize-detector';
import { useVirtualList } from './useVirtualList.hook';
export function VirtualList({ id, items, itemRenderer, onMore }) {
export function VirtualList({ id, items, itemRenderer, loadMore }) {
const REDZONE = 1024; // recenter on canvas if in canvas edge redzone
const HOLDZONE = 2048; // drop slots outside of holdzone of view
const OVERSCAN = 1024; // add slots in overscan of view
const DEFAULT_ITEM_HEIGHT = 256;
const DEFAULT_LIST_HEIGHT = 4096;
const GUTTER = 1;
const redZone = 1024;
const holdZone = 2048;
const fillZone = 1024;
const [ msg, setMsg ] = useState("YO");
const pushDelay = 250;
const moreDelay = 2000;
const latchDelay = 500;
const [ canvasHeight, setCanvasHeight ] = useState(16384);
const [ slots, setSlots ] = useState(new Map());
const [ scroll, setScroll ] = useState('hidden');
const defaultHeight = 32;
let update = useRef(0);
let viewHeight = useRef(DEFAULT_LIST_HEIGHT);
let latch = useRef(true);
let scrollTop = useRef(0);
let containers = useRef([]);
let listRef = useRef();
let key = useRef(null);
let itemView = useRef([]);
let nomore = useRef(false);
const rollHeight = 16384;
const addSlot = (id, slot) => {
setSlots((m) => { m.set(id, slot); return new Map(m); })
}
const { state, actions } = useVirtualList();
const containers = useRef([]);
const itemView = useRef([]);
const debounce = useRef([]);
const nomore = useRef(false);
const nolatch = useRef(false);
const list = useRef(null);
const scrollTop = useRef(0);
const updateSlot = (id, slot) => {
setSlots((m) => { m.set(id, slot); return new Map(m); })
}
const removeSlot = (id) => {
setSlots((m) => { m.delete(id); return new Map(m); });
}
const clearSlots = () => {
setSlots((m) => { return new Map() })
}
const [scrollPos, setScrollPos] = useState(0);
useEffect(() => {
updateCanvas();
}, [canvasHeight]);
scrollTop.current = 8192;
list.current.scrollTo({ top: 8192, left: 0 });
}, []);
useEffect(() => {
if (key.current != id) {
key.current = id;
latch.current = true;
containers.current = [];
clearSlots();
}
// reference copy
itemView.current = items;
setItems();
}, [items, id]);
const onScrollWheel = (e) => {
latch.current = false;
// genearte set of active ids
let ids = new Map();
for (let i = 0; i < itemView.current.length; i++) {
ids.set(getItemKey(itemView.current[i]), i);
}
const onScrollView = (e) => {
scrollTop.current = e.target.scrollTop;
loadNextSlot();
dropSlots();
centerCanvas();
limitScroll();
}
const updateCanvas = () => {
alignSlots();
loadNextSlot();
dropSlots();
centerCanvas();
limitScroll();
latchScroll();
}
const limitScroll = () => {
let view = getPlacement();
if (view && containers.current[containers.current.length - 1].index == itemView.current.length - 1) {
if (view?.overscan?.bottom <= 0) {
if (view.position.height < viewHeight.current) {
if (scrollTop.current != view.position.top) {
scrollTop.current = view.position.top;
listRef.current.scrollTo({ top: scrollTop.current, left: 0 });
}
// remove any deleted items
let slots = [];
containers.current.forEach((container) => {
if (!ids.has(container.key)) {
actions.removeSlot(container.key);
}
else {
if (scrollTop.current != view.position.bottom - viewHeight.current) {
scrollTop.current = view.position.bottom - viewHeight.current;
listRef.current.scrollTo({ top: scrollTop.current, left: 0 });
container.index = ids.get(container.key);
container.item = itemView.current[container.index];
slots.push(container);
}
});
containers.current = slots;
// sort by index
containers.current.sort((a, b) => {
if (a.index < b.index) {
return -1;
}
latch.current = true;
}
}
if (view && containers.current[0].index == 0) {
if (view?.overscan?.top <= 0) {
scrollTop.current = containers.current[0].top;
listRef.current.scrollTo({ top: scrollTop.current, left: 0 });
if (!nomore.current) {
nomore.current = true;
onMore();
return 1;
});
// rerender list
layoutItems();
}, [items]);
useEffect(() => {
layoutItems();
}, [scrollPos, state.listHeight, state.view]);
const layoutItems = () => {
alignSlots();
loadSlots();
releaseSlots();
centerSlots();
latchSlots();
pushSlots();
};
const latchSlots = () => {
if (containers.current.length > 0 && state.latched) {
if (!nolatch.current) {
const last = containers.current[containers.current.length - 1];
const bottom = last.top + last.height;
if (last.heightSet && scrollTop.current < bottom - state.listHeight) {
list.current.scrollTo({ top: bottom - state.listHeight, left: 0, behavior: 'smooth' });
nolatch.current = true;
setTimeout(() => {
nomore.current = false;
}, 2500);
nolatch.current = false;
latchSlots();
}, latchDelay);
}
}
}
}
const loadNextSlot = () => {
let view = getPlacement();
if (view) {
if (view.overscan.top < OVERSCAN) {
if (containers.current[0].index > 0 && containers.current[0].index < itemView.current.length) {
let below = containers.current[0];
let container = {
top: below.top - (DEFAULT_ITEM_HEIGHT + 2 * GUTTER),
height: DEFAULT_ITEM_HEIGHT,
index: containers.current[0].index - 1,
id: itemView.current[containers.current[0].index - 1].id,
revision: itemView.current[containers.current[0].index - 1].revision,
}
containers.current.unshift(container);
addSlot(container.id, getSlot(container))
loadNextSlot();
const pushSlots = () => {
if (debounce.current != null) {
clearTimeout(debounce.current);
};
debounce.current = setTimeout(() => {
if (containers.current.length > 0) {
const range = getContainerRange();
if (range.bottom - range.top < state.listHeight) {
if (scrollTop.current + state.listHeight != range.bottom) {
list.current.scrollTo({ top: range.bottom - state.listHeight, left: 0, behavior: 'smooth' });
actions.latch();
}
}
if (view.overscan.bottom < OVERSCAN) {
if (containers.current[containers.current.length - 1].index + 1 < itemView.current.length) {
let above = containers.current[containers.current.length - 1];
let container = {
top: above.top + above.height + 2 * GUTTER,
height: DEFAULT_ITEM_HEIGHT,
index: containers.current[containers.current.length - 1].index + 1,
id: itemView.current[containers.current[containers.current.length - 1].index + 1].id,
revision: itemView.current[containers.current[containers.current.length - 1].index + 1].revision,
else if (scrollTop.current + state.listHeight > range.bottom) {
list.current.scrollTo({ top: range.bottom - state.listHeight, left: 0, behavior: 'smooth' });
actions.latch();
}
containers.current.push(container);
addSlot(container.id, getSlot(container))
loadNextSlot();
}
}
}
}
const dropSlots = () => {
while (containers.current.length > 0 &&
containers.current[0].top + containers.current[0].height + 2 * GUTTER + HOLDZONE < scrollTop.current) {
removeSlot(containers.current[0].id);
containers.current.shift();
}
while (containers.current.length > 0 &&
containers.current[containers.current.length - 1].top > scrollTop.current + viewHeight.current + HOLDZONE) {
removeSlot(containers.current[containers.current.length - 1].id);
containers.current.pop();
else if (scrollTop.current < range.top) {
list.current.scrollTo({ top: range.top, left: 0, behavior: 'smooth' });
}
}
}, pushDelay);
};
const alignSlots = () => {
if (containers.current.length > 1) {
let mid = Math.floor(containers.current.length / 2);
let alignTop = containers.current[mid].top + containers.current[mid].height + 2 * GUTTER;
for (let i = mid + 1; i < containers.current.length; i++) {
if (containers.current[i].top != alignTop) {
containers.current[i].top = alignTop;
updateSlot(containers.current[i].id, getSlot(containers.current[i]));
if (state.latched) {
const index = containers.current.length - 1;
const last = containers.current[index];
let bottom = last.top + last.height;
for (let i = index; i >= 0; i--) {
let container = containers.current[i];
if (container.top + container.height != bottom) {
container.top = bottom - container.height;
actions.updateSlot(container.key, getSlot(container));
}
bottom -= container.height;
}
}
else {
const index = Math.floor(containers.current.length / 2);
const mid = containers.current[index];
let top = mid.top;
for (let i = index; i < containers.current.length; i++) {
let container = containers.current[i];
if (container.top != top) {
container.top = top;
actions.updateSlot(container.key, getSlot(container));
}
top += container.height;
}
let bottom = mid.top + mid.height;
for (let i = index; i >= 0; i--) {
let container = containers.current[i];
if (container.top + container.height != bottom) {
container.top = bottom - container.height;
actions.updateSlot(container.key, getSlot(container));
}
bottom -= container.height;
}
}
}
alignTop += containers.current[i].height + 2 * GUTTER;
}
let alignBottom = containers.current[mid].top;
for (let i = mid - 1; i >= 0; i--) {
alignBottom -= (containers.current[i].height + 2 * GUTTER);
if (containers.current[i].top != alignBottom) {
containers.current[i].top = alignBottom;
updateSlot(containers.current[i].id, getSlot(containers.current[i]));
const loadSlots = () => {
if (containers.current.length == 0) {
// add the first slot
if (itemView.current.length > 0) {
let item = itemView.current[itemView.current.length - 1];
let slot = {
top: rollHeight / 2 + state.listHeight,
height: defaultHeight,
heightSet: false,
key: getItemKey(item),
index: itemView.current.length - 1,
item: item,
}
containers.current.push(slot);
actions.addSlot(slot.key, getSlot(slot));
list.current.scrollTo({ top: rollHeight / 2, left: 0 });
}
}
else {
// fill in any missing slots
let index = containers.current[0].index;
for (let i = 1; i < containers.current.length; i++) {
let container = containers.current[i];
if (container.index != index + i) {
const item = itemView.current[index + 1];
let slot = {
top: container.top - defaultHeight,
height: defaultHeight,
heightSet: false,
index: index + i,
item: item,
key: getItemKey(item),
}
containers.current.splice(i, 0, slot);
actions.addSlot(slot.key, getSlot(slot));
}
}
}
loadSlotAbove();
loadSlotBelow();
};
const loadSlotAbove = () => {
if (containers.current.length > 0) {
const range = getContainerRange();
if (scrollTop.current - fillZone < range.top) {
const container = containers.current[0];
if (container.index > 0) {
const index = container.index - 1;
const item = itemView.current[index];
let slot = {
top: container.top - defaultHeight,
height: defaultHeight,
heightSet: false,
index: index,
item: item,
key: getItemKey(item),
}
containers.current.unshift(slot);
actions.addSlot(slot.key, getSlot(slot));
loadSlotAbove();
}
}
}
}
const loadSlotBelow = () => {
if (containers.current.length > 0) {
const container = containers.current[containers.current.length - 1];
if (container.index + 1 < itemView.current.length) {
const range = getContainerRange();
if (scrollTop.current + state.listHeight + fillZone > range.bottom) {
const index = container.index + 1;
const item = itemView.current[index];
let slot = {
top: container.top + container.height,
height: defaultHeight,
heightSet: false,
index: index,
item: item,
key: getItemKey(item),
}
containers.current.push(slot);
actions.addSlot(slot.key, getSlot(slot));
loadSlotBelow();
}
}
}
}
const releaseSlots = () => {
releaseSlotAbove();
releaseSlotBelow();
};
const releaseSlotAbove = () => {
if (containers.current.length > 1) {
const container = containers.current[0];
if (container.top + container.height < scrollTop.current - holdZone) {
actions.removeSlot(container.key);
containers.current.shift();
releaseSlotAbove();
}
}
}
const releaseSlotBelow = () => {
if (containers.current.length > 1) {
const container = containers.current[containers.current.length - 1];
if (container.top > scrollTop.current + state.listHeight + holdZone) {
actions.removeSlot(container.key);
containers.current.pop();
releaseSlotBelow();
}
}
}
const centerSlots = () => {
if (containers.current.length > 0) {
const top = containers.current[0];
if (top.top < redZone) {
containers.current.forEach(container => {
container.top += rollHeight / 2;
actions.updateSlot(container.key, getSlot(container));
});
list.current.scrollTo({ top: scrollTop.current + rollHeight / 2, left: 0 });
}
const bottom = containers.current[containers.current.length - 1];
if (bottom.top + bottom.height > rollHeight - redZone) {
containers.current.forEach(container => {
container.top -= rollHeight / 2;
actions.updateSlot(container.key, getSlot(container));
});
list.current.scrollTo({ top: scrollTop.current - rollHeight / 2, left: 0 });
}
}
};
const centerCanvas = () => {
let view = getPlacement();
if (view) {
let height = canvasHeight;
if (view.position.top < REDZONE) {
let shift = height / 2;
for (let i = 0; i < containers.current.length; i++) {
containers.current[i].top += shift;
updateSlot(containers.current[i].id, getSlot(containers.current[i]));
}
scrollTop.current += shift;
listRef.current.scrollTo({ top: scrollTop.current, left: 0 });
}
else if (view.position.bottom + REDZONE > height) {
let shift = height / 2;
for (let i = 0; i < containers.current.length; i++) {
containers.current[i].top -= shift;
updateSlot(containers.current[i].id, getSlot(containers.current[i]));
}
scrollTop.current -= shift;
listRef.current.scrollTo({ top: scrollTop.current, left: 0 });
}
}
const getItemKey = (item) => {
return `${id}.${item.id}.${item.revision}`
}
const latchScroll = () => {
if (latch.current) {
let view = getPlacement();
if (view) {
if (view.position.height < viewHeight.current) {
if (scrollTop.current != view.position.top) {
scrollTop.current = view.position.top;
listRef.current.scrollTo({ top: scrollTop.current, left: 0, behavior: 'smooth' });
}
}
else {
if (scrollTop.current != view.position.bottom - viewHeight.current) {
scrollTop.current = view.position.bottom - viewHeight.current;
listRef.current.scrollTo({ top: scrollTop.current, left: 0, behavior: 'smooth' });
}
}
}
}
}
const setItems = () => {
// align containers in case history was loaded
if (containers.current.length > 0) {
let container = containers.current[0];
for (let j = 0; j < itemView.current.length; j++) {
if (itemView.current[j].id == container.id) {
for(let i = 0; i < containers.current.length; i++) {
containers.current[i].index = i + j;
}
break;
}
}
}
// remove containers following any removed item
for (let i = 0; i < containers.current.length; i++) {
let container = containers.current[i];
if (itemView.current.length <= container.index || itemView.current[container.index].id != container.id) {
while (containers.current.length > i) {
let popped = containers.current.pop();
removeSlot(popped.id);
}
break;
}
else if (itemView.current[container.index].revision != container.revision) {
updateSlot(container.id, getSlot(containers.current[i]));
containers.revision = itemView.current[container.index].revision;
}
}
// place first slot
if (itemView.current.length > 0 && canvasHeight > 0) {
let view = getPlacement();
if (!view) {
let pos = canvasHeight / 2;
listRef.current.scrollTo({ top: pos, left: 0 });
scrollTop.current = pos;
let container = {
top: pos - DEFAULT_ITEM_HEIGHT,
height: DEFAULT_ITEM_HEIGHT,
index: itemView.current.length - 1,
id: itemView.current[itemView.current.length - 1].id,
revision: itemView.current[itemView.current.length - 1].revision,
}
containers.current.push(container);
addSlot(container.id, getSlot(container));
}
}
updateCanvas();
}
const onItemHeight = (container, height) => {
container.height = height;
updateCanvas();
}
const getSlot = (container) => {
const getSlot = (item) => {
const container = item;
return (
<VirtualItem style={{ top: container.top, paddingTop: GUTTER, paddingBottom: GUTTER }}>
<VirtualItem style={{ top: container.top, key: container.key }}>
<ReactResizeDetector handleHeight={true}>
{({ height }) => {
if (typeof height !== 'undefined') {
onItemHeight(container, height);
if (typeof height !== 'undefined' && container.height != height) {
container.height = height;
container.heightSet = true;
layoutItems();
}
return itemRenderer(itemView.current[container.index]);
return itemRenderer(container.item);
}}
</ReactResizeDetector>
</VirtualItem>
)
}
const getPlacement = () => {
if (containers.current.length == 0) {
return null;
const getContainerRange = () => {
let top = rollHeight;
let bottom = 0;
containers.current.forEach((c) => {
if (c.top < top) {
top = c.top;
}
let top = containers.current[0].top;
let bottom = containers.current[containers.current.length-1].top + containers.current[containers.current.length-1].height + 2 * GUTTER;
let overTop = scrollTop.current - top;
let overBottom = bottom - (scrollTop.current + viewHeight.current);
return {
position: { top, bottom, height: bottom - top },
overscan: { top: overTop, bottom: overBottom }
};
if (c.top + c.height > bottom) {
bottom = c.top + c.height;
}
});
return { top, bottom };
}
const scrollView = (e) => {
if (containers.current.length > 0 && containers.current[0].index == 0 && !nomore.current) {
loadMore();
nomore.current = true;
setTimeout(() => {
nomore.current = false;
}, moreDelay);
}
scrollTop.current = e.target.scrollTop;
setScrollPos(e.target.scrollTop);
}
return (
<div style={{ position: 'relative', height: '100%' }}>
<ReactResizeDetector handleHeight={true} handleWidth={true}>
<ReactResizeDetector handleHeight={true} handleWidth={false}>
{({ height }) => {
if (height) {
viewHeight.current = height;
updateCanvas();
if (height && state.listHeight != height) {
actions.setListHeight(height);
}
return (
<VirtualListWrapper onScroll={onScrollView} onWheel={onScrollWheel} onTouchStart={onScrollWheel} >
<div class="rollview" style={{ overflowY: 'auto' }} ref={listRef} onScroll={onScrollView}>
<div class="roll" style={{ height: canvasHeight }}>
{ slots.values() }
<VirtualListWrapper onScroll={scrollView}
onWheel={actions.unlatch} onTouchStart={actions.unlatch} >
<div class="rollview" ref={list} onScroll={scrollView}>
<div class="roll" style={{ height: rollHeight }}>
{ state.slots }
</div>
</div>
</VirtualListWrapper>

View File

@ -8,6 +8,7 @@ export const VirtualListWrapper = styled.div`
.rollview {
width: 100%;
height: 100%;
overflow-y: auto;
/* hide scrollbar for IE, Edge and Firefox */
-ms-overflow-style: none;
@ -28,4 +29,5 @@ export const VirtualItem = styled.div`
position: absolute;
width: 100%;
overflow: hidden;
max-height: 1024px;
`;

View File

@ -0,0 +1,55 @@
import { useContext, useState, useEffect, useRef } from 'react';
export function useVirtualList(id) {
const [state, setState] = useState({
view: null,
listHeight: 128,
slots: [],
latched: true,
});
const slots = useRef(new Map());
const updateState = (value) => {
setState((s) => ({ ...s, ...value }));
}
const actions = {
setView: (view) => {
updateState({ view });
},
latch: () => {
updateState({ latched: true });
},
unlatch: () => {
updateState({ latched: false });
},
setListHeight: (listHeight) => {
updateState({ listHeight });
},
addSlot: (id, slot) => {
slots.current.set(id, slot);
let items = Array.from(slots.current.values());
updateState({ slots: items });
},
updateSlot: (id, slot) => {
slots.current.set(id, slot);
let items = Array.from(slots.current.values());
updateState({ slots: items });
},
removeSlot: (id) => {
slots.current.set(id, (<></>));
let items = Array.from(slots.current.values());
updateState({ slots: items });
},
clearSlots: () => {
slots.current = new Map();
let items = Array.from(slots.current.values());
updateState({ slots: items });
},
};
return { state, actions };
}