Skip to content

Commit c5c691a

Browse files
committed
cleanup
1 parent 3570067 commit c5c691a

File tree

6 files changed

+195
-200
lines changed

6 files changed

+195
-200
lines changed

special-pages/pages/history/app/components/App.jsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Fragment, h } from 'preact';
1+
import { h } from 'preact';
22
import styles from './App.module.css';
33
import { useEnv } from '../../../../shared/components/EnvironmentProvider.js';
44
import { Header } from './Header.js';
@@ -59,7 +59,7 @@ export function App() {
5959
<Sidebar ranges={initial.ranges.ranges} />
6060
</aside>
6161
<main class={styles.main} ref={containerRef} data-main-scroller data-term={term}>
62-
<Results results={results} term={term} containerRef={containerRef} />
62+
<Results results={results} />
6363
</main>
6464
</div>
6565
);
Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,37 @@
11
import { h } from 'preact';
2-
import { VirtualizedHistoryList } from './VirtualizedHistoryList.js';
32
import { OVERSCAN_AMOUNT } from '../constants.js';
3+
import { Item } from './Item.js';
4+
import styles from './VirtualizedHistoryList.module.css';
5+
import { VisibleItems } from './VirtualizedList.js';
46

57
/**
68
* @param {object} props
79
* @param {import("@preact/signals").Signal<import("./App.jsx").Results>} props.results
8-
* @param {import("@preact/signals").Signal<string|null>} props.term
9-
* @param {any} props.containerRef
1010
*/
11-
export function Results({ results, term, containerRef }) {
11+
export function Results({ results }) {
12+
const totalHeight = results.value.heights.reduce((acc, item) => acc + item, 0);
1213
return (
13-
<div>
14-
<VirtualizedHistoryList
14+
<ul class={styles.container} style={{ height: totalHeight + 'px' }}>
15+
<VisibleItems
16+
scrollingElement={'main'}
1517
items={results.value.items}
1618
heights={results.value.heights}
17-
containerRef={containerRef}
1819
overscan={OVERSCAN_AMOUNT}
20+
renderItem={({ item, cssClassName, style, index }) => {
21+
return (
22+
<li key={item.id} data-id={item.id} class={cssClassName} style={style}>
23+
<Item
24+
id={item.id}
25+
kind={results.value.heights[index]}
26+
url={item.url}
27+
title={item.title}
28+
dateRelativeDay={item.dateRelativeDay}
29+
dateTimeOfDay={item.dateTimeOfDay}
30+
/>
31+
</li>
32+
);
33+
}}
1934
/>
20-
</div>
35+
</ul>
2136
);
2237
}

special-pages/pages/history/app/components/VirtualizedHistoryList.js

Lines changed: 0 additions & 65 deletions
This file was deleted.

special-pages/pages/history/app/components/VirtualizedHistoryList.module.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
.container {
22
position: relative;
33
}
4+
45
.listItem {
56
display: block;
67
width: 100%;
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { Fragment, h } from 'preact';
2+
import { memo } from 'preact/compat';
3+
import styles from './VirtualizedHistoryList.module.css';
4+
import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
5+
6+
/**
7+
* @template T
8+
* @typedef RenderProps
9+
* @property {T} item
10+
* @property {number} index
11+
* @property {string} cssClassName
12+
* @property {number} itemTopOffset
13+
* @property {Record<string, any>} style - inline styles to apply to your element
14+
*
15+
*/
16+
17+
/**
18+
* @template T
19+
* @param {object} props
20+
* @param {T[]} props.items
21+
* @param {number[]} props.heights
22+
* @param {number} props.overscan
23+
* @param {string} props.scrollingElement
24+
* @param {(arg: RenderProps<T>) => import("preact").ComponentChild} props.renderItem - A function to render individual items.
25+
*/
26+
export function VirtualizedList({ items, heights, overscan, scrollingElement, renderItem }) {
27+
const { start, end } = useVisibleRows(items, heights, scrollingElement, overscan);
28+
const subset = items.slice(start, end + 1);
29+
return (
30+
<Fragment>
31+
{subset.map((item, rowIndex) => {
32+
const originalIndex = start + rowIndex;
33+
const itemTopOffset = heights.slice(0, originalIndex).reduce((acc, item) => acc + item, 0);
34+
return renderItem({
35+
item,
36+
index: originalIndex,
37+
cssClassName: styles.listItem,
38+
itemTopOffset,
39+
style: {
40+
transform: `translateY(${itemTopOffset}px)`,
41+
},
42+
});
43+
})}
44+
</Fragment>
45+
);
46+
}
47+
48+
export const VisibleItems = memo(VirtualizedList);
49+
50+
/**
51+
* @param {Array} rows - The array of rows to be virtually rendered. Each row represents an item in the list.
52+
* @param {number[]} heights - index lookup for known element heights
53+
* @param {string} scrollerSelector - A CSS selector for tracking the scrollable area
54+
* @param {number} overscan - how many items to fetch outside the window
55+
* @return {Object} An object containing the calculated `start` and `end` indices of the visible rows.
56+
*/
57+
export function useVisibleRows(rows, heights, scrollerSelector, overscan = 5) {
58+
// set the start/end indexes of the elements
59+
const [{ start, end }, setVisibleRange] = useState({ start: 0, end: 1 });
60+
61+
// hold a mutable value that we update on resize
62+
const mainScrollerRef = useRef(/** @type {Element|null} */ (null));
63+
const scrollingSize = useRef(/** @type {number|null} */ (null));
64+
65+
/**
66+
* When called, make the expensive calls to `getBoundingClientRect` to measure things
67+
*/
68+
function updateGlobals() {
69+
if (!mainScrollerRef.current) return;
70+
const rec = mainScrollerRef.current.getBoundingClientRect();
71+
scrollingSize.current = rec.height;
72+
}
73+
74+
/**
75+
* decide which the start/end indexes should be, based on scroll position.
76+
* NOTE: this is called on scroll, so must not incur expensive checks/measurements - math only!
77+
*/
78+
function setVisibleRowsForOffset() {
79+
if (!mainScrollerRef.current) return console.warn('cannot access mainScroller ref');
80+
if (scrollingSize.current === null) return console.warn('need height');
81+
const scrollY = mainScrollerRef.current?.scrollTop ?? 0;
82+
const next = calcVisibleRows(heights || [], scrollingSize.current, scrollY);
83+
84+
const withOverScan = {
85+
start: Math.max(next.startIndex - overscan, 0),
86+
end: next.endIndex + overscan,
87+
};
88+
89+
// don't set state if the offset didn't change
90+
setVisibleRange((prev) => {
91+
if (withOverScan.start !== prev.start || withOverScan.end !== prev.end) {
92+
// todo: find a better place to emit this!
93+
window.dispatchEvent(new CustomEvent('range-change', { detail: { start: withOverScan.start, end: withOverScan.end } }));
94+
return { start: withOverScan.start, end: withOverScan.end };
95+
}
96+
return prev;
97+
});
98+
}
99+
100+
useLayoutEffect(() => {
101+
mainScrollerRef.current = document.querySelector(scrollerSelector) || document.documentElement;
102+
if (!mainScrollerRef.current) console.warn('missing elements');
103+
104+
// always update globals first
105+
updateGlobals();
106+
107+
// and set visible rows once the size is known
108+
setVisibleRowsForOffset();
109+
110+
const controller = new AbortController();
111+
112+
// when the main area is scrolled, update the visible offset for the rows.
113+
mainScrollerRef.current?.addEventListener('scroll', setVisibleRowsForOffset, { signal: controller.signal });
114+
115+
return () => {
116+
controller.abort();
117+
};
118+
}, [rows, heights, scrollerSelector]);
119+
120+
useEffect(() => {
121+
let lastWindowHeight = window.innerHeight;
122+
function handler() {
123+
if (lastWindowHeight === window.innerHeight) return;
124+
lastWindowHeight = window.innerHeight;
125+
updateGlobals();
126+
setVisibleRowsForOffset();
127+
}
128+
window.addEventListener('resize', handler);
129+
return () => {
130+
return window.removeEventListener('resize', handler);
131+
};
132+
}, [heights, rows]);
133+
134+
return { start, end };
135+
}
136+
137+
/**
138+
* @param {number[]} heights - an array of integers that represents a 1:1 mapping to `rows` - each value is pixels
139+
* @param {number} space - the height in pixels that we have to fill
140+
* @param {number} scrollOffset - the y offset in pixels representing scrolling
141+
* @return {{startIndex: number, endIndex: number}}
142+
*/
143+
export function calcVisibleRows(heights, space, scrollOffset) {
144+
let startIndex = 0;
145+
let endIndex = 0;
146+
let currentHeight = 0;
147+
148+
// Adjust startIndex for the scrollOffset
149+
for (let i = 0; i < heights.length; i++) {
150+
if (currentHeight + heights[i] > scrollOffset) {
151+
startIndex = i;
152+
break;
153+
}
154+
currentHeight += heights[i];
155+
}
156+
157+
// Start calculating endIndex from the adjusted startIndex
158+
currentHeight = 0;
159+
for (let i = startIndex; i < heights.length; i++) {
160+
if (currentHeight + heights[i] > space) {
161+
endIndex = i;
162+
break;
163+
}
164+
currentHeight += heights[i];
165+
endIndex = i;
166+
}
167+
168+
return { startIndex, endIndex };
169+
}

0 commit comments

Comments
 (0)