1import type { LegendListRef } from "@legendapp/list/react";
2import { useCallback, useEffect, useLayoutEffect, useRef, type RefObject } from "react";
3import type { ThreadFindMatch } from "./threadFind";
4import { useThreadFindHighlights } from "./threadFindHighlights";
5
6const FIND_MATCH_VIEW_MARGIN = 96;
7
8/** Materializes missing results and otherwise scrolls only enough to reveal the active text. */
9export function useThreadFindNavigation({
10 container,
11 query,
12 match,
13 navigationId,
14 rowIndex,
15 entries,
16 listRef,
17 contentInsetEndAdjustment,
18 listReady,
19 historyControls,
20}: {
21 container: HTMLElement | null;
22 query: string;
23 match: ThreadFindMatch | null;
24 navigationId: number;
25 rowIndex: number;
26 entries: readonly { readonly id: string }[];
27 listRef: RefObject<LegendListRef | null>;
28 contentInsetEndAdjustment: number;
29 listReady: boolean;
30 historyControls?:
31 | {
32 readonly hasMoreHistory: boolean;
33 readonly loading: boolean;
34 readonly error: string | null;
35 readonly onLoadEarlier: (throughEntryId?: string) => void;
36 }
37 | undefined;
38}) {
39 const matchKey = match ? `${navigationId}:${query}:${match.entryId}:${match.occurrence}` : null;
40 const positioningRef = useRef<{
41 key: string;
42 rowIndex: number;
43 entries: typeof entries;
44 } | null>(null);
45 const settledMatchRef = useRef<string | null>(null);
46 const currentMatchKeyRef = useRef(matchKey);
47 const activeRangeRef = useRef<Range | null>(null);
48 const pagingPositionRef = useRef<{ key: string; entries: typeof entries; top: number } | null>(
49 null,
50 );
51 const currentEntriesRef = useRef(entries);
52 useLayoutEffect(() => {
53 currentEntriesRef.current = entries;
54 }, [entries]);
55 useEffect(() => {
56 if (!container) return;
57 const onScroll = () => {
58 const position = pagingPositionRef.current;
59 const range = activeRangeRef.current;
60 if (
61 !position ||
62 position.entries !== currentEntriesRef.current ||
63 !range?.startContainer.isConnected
64 )
65 return;
66 const rect = range.getBoundingClientRect();
67 const viewport = container.getBoundingClientRect();
68 if (rect.bottom < viewport.top || rect.top > viewport.bottom)
69 pagingPositionRef.current = null;
70 else position.top = rect.top;
71 };
72 container.addEventListener("scroll", onScroll, { passive: true });
73 return () => container.removeEventListener("scroll", onScroll);
74 }, [container]);
75 const revealRef = useRef<(range: Range | null) => void>(() => {});
76 useLayoutEffect(() => {
77 currentMatchKeyRef.current = matchKey;
78 return () => {
79 currentMatchKeyRef.current = null;
80 };
81 }, [matchKey]);
82 const revealedMatchRef = useRef<string | null>(null);
83 const positionedEntriesRef = useRef<typeof entries | null>(null);
84 const requestedPagesRef = useRef<{ key: string | null; pages: Set<string> }>({
85 key: null,
86 pages: new Set(),
87 });
88 useEffect(() => {
89 if (requestedPagesRef.current.key !== matchKey)
90 requestedPagesRef.current = { key: matchKey, pages: new Set() };
91 if (
92 !match ||
93 !listReady ||
94 rowIndex >= 0 ||
95 entries.some((entry) => entry.id === match.entryId)
96 )
97 return;
98 if (!historyControls?.hasMoreHistory || historyControls.loading || historyControls.error)
99 return;
100 const cursor = entries[0]?.id ?? "first";
101 if (requestedPagesRef.current.pages.has(cursor)) return;
102 requestedPagesRef.current.pages.add(cursor);
103
104 historyControls.onLoadEarlier(match.entryId);
105 }, [entries, historyControls, listReady, match, matchKey, rowIndex]);
106 const reveal = useCallback(
107 (range: Range | null) => {
108 activeRangeRef.current = range;
109 if (!matchKey) {
110 positioningRef.current = null;
111 settledMatchRef.current = null;
112 revealedMatchRef.current = null;
113 pagingPositionRef.current = null;
114 return;
115 }
116 const pagingPosition = pagingPositionRef.current;
117 if (pagingPosition && pagingPosition.key !== matchKey) pagingPositionRef.current = null;
118 if (
119 range?.startContainer.isConnected &&
120 pagingPosition?.key === matchKey &&
121 pagingPosition.entries !== entries
122 ) {
123 const restore = () => {
124 const pinned = pagingPositionRef.current;
125 const currentRange = activeRangeRef.current;
126 if (
127 currentMatchKeyRef.current !== matchKey ||
128 !pinned ||
129 !currentRange?.startContainer.isConnected
130 )
131 return;
132 const delta = currentRange.getBoundingClientRect().top - pinned.top;
133 const scroll = listRef.current?.getState?.().scroll;
134 if (Math.abs(delta) >= 1 && typeof scroll === "number")
135 listRef.current?.scrollToOffset({ offset: scroll + delta, animated: false });
136 };
137 pagingPosition.entries = entries;
138 restore();
139 // The list measures newly inserted activity after the first layout pass.
140 requestAnimationFrame(() =>
141 requestAnimationFrame(() => {
142 restore();
143 if (!historyControls?.loading && pagingPositionRef.current === pagingPosition)
144 pagingPositionRef.current = null;
145 }),
146 );
147 }
148 if (historyControls?.error) pagingPositionRef.current = null;
149 if (revealedMatchRef.current === matchKey) return;
150 if (!listReady || rowIndex < 0) return;
151 const materialize = () => {
152 const list = listRef.current;
153 const previous = positioningRef.current;
154 if (
155 !list ||
156 (previous?.key === matchKey &&
157 previous.rowIndex === rowIndex &&
158 previous.entries === entries)
159 )
160 return;
161 // Activity can insert rows while LegendList waits for layout. Retarget the
162 // pending jump instead of treating the first requested index as final.
163 const request = { key: matchKey, rowIndex, entries };
164 positioningRef.current = request;
165
166 void list
167 .scrollToIndex({
168 index: rowIndex,
169 animated: false,
170 viewOffset: FIND_MATCH_VIEW_MARGIN,
171 })
172 .then(() => {
173 if (positioningRef.current !== request || currentMatchKeyRef.current !== matchKey)
174 return;
175
176 positionedEntriesRef.current = entries;
177 settledMatchRef.current = matchKey;
178 revealRef.current(activeRangeRef.current);
179 });
180 };
181 if (!range) {
182 if (container) materialize();
183 return;
184 }
185
186 const codeScroller = range.startContainer.parentElement?.closest("pre");
187 if (codeScroller) {
188 const rect = range.getBoundingClientRect();
189 const viewport = codeScroller.getBoundingClientRect();
190 if (rect.left < viewport.left) codeScroller.scrollLeft += rect.left - viewport.left - 16;
191 else if (rect.right > viewport.right)
192 codeScroller.scrollLeft += rect.right - viewport.right + 16;
193 }
194 const rect = range.getBoundingClientRect();
195 const viewport = container?.getBoundingClientRect();
196 if (!viewport || rect.height === 0) return;
197 // Shrink the margin when the band above the composer is short, so the match stays inside it.
198 const margin = Math.min(
199 FIND_MATCH_VIEW_MARGIN,
200 Math.max(0, (viewport.height - contentInsetEndAdjustment - rect.height) / 2),
201 );
202 const top = viewport.top + margin;
203 const bottom = viewport.bottom - margin - contentInsetEndAdjustment;
204 const delta =
205 rect.top < top ? rect.top - top : rect.bottom > bottom ? rect.bottom - bottom : 0;
206 // A new list can expose DOM text before its virtual row sizes settle.
207 // Await row positioning before measuring an offscreen occurrence.
208 if (
209 Math.abs(delta) >= 1 &&
210 positionedEntriesRef.current !== entries &&
211 settledMatchRef.current !== matchKey
212 ) {
213 materialize();
214 return;
215 }
216 const scroll = listRef.current?.getState?.().scroll;
217 positionedEntriesRef.current = entries;
218
219 revealedMatchRef.current = matchKey;
220 if (historyControls?.loading)
221 pagingPositionRef.current = { key: matchKey, entries, top: rect.top - delta };
222 if (Math.abs(delta) >= 1 && typeof scroll === "number") {
223 listRef.current?.scrollToOffset({ offset: scroll + delta, animated: false });
224 // Scrolling can mount rows whose measured heights move the selected text again.
225 requestAnimationFrame(() => {
226 if (currentMatchKeyRef.current !== matchKey) return;
227 if (
228 listRef.current?.getState?.().scroll === scroll &&
229 range.getBoundingClientRect().top === rect.top
230 )
231 return;
232 revealedMatchRef.current = null;
233 revealRef.current(activeRangeRef.current);
234 });
235 }
236 },
237 [
238 container,
239 contentInsetEndAdjustment,
240 entries,
241 historyControls?.loading,
242 historyControls?.error,
243 listRef,
244 listReady,
245 matchKey,
246 rowIndex,
247 ],
248 );
249
250 useLayoutEffect(() => {
251 revealRef.current = reveal;
252 }, [reveal]);
253
254 useThreadFindHighlights({
255 container,
256 query,
257 activeRowId: match?.entryId ?? null,
258 activeOccurrence: match?.occurrence ?? 0,
259 onActiveRange: reveal,
260 });
261}