Skip to content

Commit 0874312

Browse files
zombieJclaude
andcommitted
refactor: simplify useListScroll implementation
- Consolidate touch refs into single touchStartRef object - Inline helper functions for cleaner code - Add JSDoc comments for better documentation - Simplify syncScrollOffset by removing redundant equality check - Clean up key list change detection logic Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent afdee75 commit 0874312

File tree

1 file changed

+58
-79
lines changed

1 file changed

+58
-79
lines changed

src/hooks/useListScroll.ts

Lines changed: 58 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,76 +1,68 @@
11
import * as React from 'react';
22
import type { NodePosition } from './useListPosition';
33

4-
function clampScrollOffset(offset: number, maxScroll: number) {
5-
return Math.min(0, Math.max(-maxScroll, offset));
6-
}
7-
8-
function getViewportInnerHeight(node: HTMLDivElement | null) {
9-
if (!node) {
4+
/**
5+
* Measures how much the list content can scroll inside the viewport.
6+
*/
7+
function getMaxScroll(viewportNode: HTMLDivElement | null, contentNode: HTMLDivElement | null) {
8+
if (!viewportNode) {
109
return 0;
1110
}
1211

13-
const { paddingBottom, paddingTop } = window.getComputedStyle(node);
14-
const topPadding = parseFloat(paddingTop) || 0;
15-
const bottomPadding = parseFloat(paddingBottom) || 0;
16-
17-
return node.clientHeight - topPadding - bottomPadding;
18-
}
19-
20-
function getMaxScroll(viewportNode: HTMLDivElement | null, contentNode: HTMLDivElement | null) {
21-
const viewportHeight = getViewportInnerHeight(viewportNode);
22-
const measuredContentHeight = contentNode?.scrollHeight ?? 0;
12+
const { paddingBottom, paddingTop } = window.getComputedStyle(viewportNode);
13+
const viewportHeight =
14+
viewportNode.clientHeight - (parseFloat(paddingTop) || 0) - (parseFloat(paddingBottom) || 0);
2315

24-
return Math.max(measuredContentHeight - viewportHeight, 0);
16+
return Math.max((contentNode?.scrollHeight ?? 0) - viewportHeight, 0);
2517
}
2618

19+
/**
20+
* Manages wheel and touch scrolling for the notification list.
21+
*/
2722
export default function useListScroll(
2823
keyList: string[],
2924
notificationPosition: Map<string, NodePosition>,
3025
expanded = false,
3126
) {
3227
const viewportRef = React.useRef<HTMLDivElement>(null);
3328
const contentRef = React.useRef<HTMLDivElement>(null);
34-
const touchStartYRef = React.useRef<number | null>(null);
35-
const touchStartOffsetRef = React.useRef(0);
36-
const prevKeyListRef = React.useRef<string[]>(keyList);
37-
const prevNotificationPositionRef = React.useRef<Map<string, NodePosition>>(new Map());
29+
const touchStartRef = React.useRef<{ y: number; offset: number } | null>(null);
30+
const prevRef = React.useRef({ keyList, notificationPosition });
3831
const scrollOffsetRef = React.useRef(0);
3932
const [scrollOffset, setScrollOffset] = React.useState(0);
4033

34+
/**
35+
* Applies the next offset and keeps it within the current scroll bounds.
36+
*/
4137
const syncScrollOffset = React.useCallback((nextOffset: number) => {
4238
const maxScroll = getMaxScroll(viewportRef.current, contentRef.current);
43-
const mergedOffset = clampScrollOffset(nextOffset, maxScroll);
39+
// Clamp the offset so the content never scrolls past its visible range.
40+
const mergedOffset = Math.max(-maxScroll, Math.min(0, nextOffset));
4441

4542
scrollOffsetRef.current = mergedOffset;
46-
setScrollOffset((prev) => (prev === mergedOffset ? prev : mergedOffset));
43+
setScrollOffset(mergedOffset);
4744
}, []);
4845

4946
React.useLayoutEffect(() => {
50-
const prevKeyList = prevKeyListRef.current;
51-
const prevNotificationPosition = prevNotificationPositionRef.current;
47+
const { keyList: prevKeyList, notificationPosition: prevNotificationPosition } =
48+
prevRef.current;
49+
let nextOffset = scrollOffsetRef.current;
5250

53-
if (scrollOffsetRef.current < 0) {
54-
const prependCount = prevKeyList.length
55-
? keyList.findIndex((key) => key === prevKeyList[0])
56-
: -1;
57-
const removedCount = keyList.length ? prevKeyList.findIndex((key) => key === keyList[0]) : -1;
51+
if (nextOffset < 0) {
52+
const prevFirstKey = prevKeyList[0];
53+
const firstKey = keyList[0];
54+
const prependCount = prevFirstKey === undefined ? -1 : keyList.indexOf(prevFirstKey);
55+
const removedCount = firstKey === undefined ? -1 : prevKeyList.indexOf(firstKey);
5856

5957
if (prependCount > 0) {
60-
const prependHeight = notificationPosition.get(prevKeyList[0])?.y ?? 0;
61-
syncScrollOffset(scrollOffsetRef.current - prependHeight);
58+
nextOffset -= notificationPosition.get(prevFirstKey)?.y ?? 0;
6259
} else if (removedCount > 0) {
63-
const removedHeight = keyList[0] ? (prevNotificationPosition.get(keyList[0])?.y ?? 0) : 0;
64-
syncScrollOffset(scrollOffsetRef.current + removedHeight);
65-
} else {
66-
syncScrollOffset(scrollOffsetRef.current);
60+
nextOffset += prevNotificationPosition.get(firstKey)?.y ?? 0;
6761
}
68-
} else {
69-
syncScrollOffset(scrollOffsetRef.current);
7062
}
7163

72-
prevKeyListRef.current = keyList;
73-
prevNotificationPositionRef.current = new Map(notificationPosition);
64+
syncScrollOffset(nextOffset);
65+
prevRef.current = { keyList, notificationPosition };
7466
}, [keyList, notificationPosition, syncScrollOffset]);
7567

7668
React.useLayoutEffect(() => {
@@ -81,82 +73,69 @@ export default function useListScroll(
8173
return;
8274
}
8375

84-
const resizeObserver = new ResizeObserver(() => {
85-
syncScrollOffset(scrollOffsetRef.current);
86-
});
76+
const resizeObserver = new ResizeObserver(() => syncScrollOffset(scrollOffsetRef.current));
8777

8878
resizeObserver.observe(viewportNode);
8979
resizeObserver.observe(contentNode);
9080

91-
return () => {
92-
resizeObserver.disconnect();
93-
};
81+
return () => resizeObserver.disconnect();
9482
}, [syncScrollOffset]);
9583

9684
React.useEffect(() => {
97-
if (!expanded) {
98-
touchStartYRef.current = null;
99-
touchStartOffsetRef.current = 0;
100-
syncScrollOffset(0);
85+
if (expanded) {
86+
return;
10187
}
88+
89+
touchStartRef.current = null;
90+
syncScrollOffset(0);
10291
}, [expanded, syncScrollOffset]);
10392

93+
/**
94+
* Updates the list offset from mouse wheel input.
95+
*/
10496
const onWheel = React.useCallback(
10597
(event: React.WheelEvent<HTMLDivElement>) => {
106-
const maxScroll = getMaxScroll(viewportRef.current, contentRef.current);
107-
108-
if (!maxScroll) {
98+
if (!getMaxScroll(viewportRef.current, contentRef.current)) {
10999
return;
110100
}
111101

112102
event.preventDefault();
113-
114-
const nextOffset = clampScrollOffset(scrollOffsetRef.current - event.deltaY, maxScroll);
115-
116-
if (nextOffset !== scrollOffsetRef.current) {
117-
syncScrollOffset(nextOffset);
118-
}
103+
syncScrollOffset(scrollOffsetRef.current - event.deltaY);
119104
},
120105
[syncScrollOffset],
121106
);
122107

108+
/**
109+
* Stores the touch start position and current offset.
110+
*/
123111
const onTouchStart = React.useCallback((event: React.TouchEvent<HTMLDivElement>) => {
124112
const touch = event.touches[0];
125-
126-
if (!touch) {
127-
return;
128-
}
129-
130-
touchStartYRef.current = touch.clientY;
131-
touchStartOffsetRef.current = scrollOffsetRef.current;
113+
touchStartRef.current = touch ? { y: touch.clientY, offset: scrollOffsetRef.current } : null;
132114
}, []);
133115

116+
/**
117+
* Updates the list offset while the user is dragging.
118+
*/
134119
const onTouchMove = React.useCallback(
135120
(event: React.TouchEvent<HTMLDivElement>) => {
136121
const touch = event.touches[0];
137-
const touchStartY = touchStartYRef.current;
138-
const maxScroll = getMaxScroll(viewportRef.current, contentRef.current);
122+
const touchStart = touchStartRef.current;
139123

140-
if (!touch || touchStartY === null || !maxScroll) {
124+
if (!touch || !touchStart || !getMaxScroll(viewportRef.current, contentRef.current)) {
141125
return;
142126
}
143127

144128
event.preventDefault();
145-
146-
const nextOffset = clampScrollOffset(
147-
touchStartOffsetRef.current + touch.clientY - touchStartY,
148-
maxScroll,
149-
);
150-
151-
if (nextOffset !== scrollOffsetRef.current) {
152-
syncScrollOffset(nextOffset);
153-
}
129+
syncScrollOffset(touchStart.offset + touch.clientY - touchStart.y);
154130
},
155131
[syncScrollOffset],
156132
);
157133

134+
/**
135+
* Clears the active touch scroll state.
136+
*/
158137
const onTouchEnd = React.useCallback(() => {
159-
touchStartYRef.current = null;
138+
touchStartRef.current = null;
160139
}, []);
161140

162141
return {

0 commit comments

Comments
 (0)