1import { findThreadSearchOccurrences } from "@t3tools/shared/threadSearch";
2import { useEffect, useLayoutEffect, useRef } from "react";
3import { THREAD_FIND_BLOCK_TAGS } from "@t3tools/shared/threadFindText";
4
5const THREAD_FIND_HIGHLIGHT_NAME = "t3-thread-find";
6const THREAD_FIND_ACTIVE_HIGHLIGHT_NAME = "t3-thread-find-active";
7
8const THREAD_FIND_TEXT_SELECTOR = "[data-thread-find-text]";
9const THREAD_FIND_IGNORE_SELECTOR = "[data-thread-find-ignore]";
10
11interface ThreadFindRange {
12 readonly rowId: string;
13 readonly occurrence: number;
14 readonly range: Range;
15}
16
17/** Collects visible occurrences without modifying rendered markdown. */
18export function collectThreadFindRanges(container: HTMLElement, query: string): ThreadFindRange[] {
19 if (query.length === 0) return [];
20
21 const ranges: ThreadFindRange[] = [];
22 const occurrenceByRowId = new Map<string, number>();
23
24 for (const scope of container.querySelectorAll(THREAD_FIND_TEXT_SELECTOR)) {
25 if (scope.parentElement?.closest(THREAD_FIND_TEXT_SELECTOR)) continue;
26 const rowId = scope.closest("[data-timeline-row-id]")?.getAttribute("data-timeline-row-id");
27 if (!rowId) continue;
28
29 let text = "";
30 let nodes: { node: Node; start: number; end: number }[] = [];
31 const flush = () => {
32 // Offsets ascend, so both node cursors only move forward: linear in text nodes.
33 let startIndex = 0;
34 let endIndex = 0;
35 for (const offset of findThreadSearchOccurrences(text, query)) {
36 while (startIndex < nodes.length && nodes[startIndex]!.end <= offset) startIndex++;
37 if (endIndex < startIndex) endIndex = startIndex;
38 while (endIndex < nodes.length && nodes[endIndex]!.end < offset + query.length) endIndex++;
39 const start = nodes[startIndex];
40 const end = nodes[endIndex];
41 if (!start || !end) continue;
42 const range = container.ownerDocument.createRange();
43 range.setStart(start.node, offset - start.start);
44 range.setEnd(end.node, offset + query.length - end.start);
45 const occurrence = occurrenceByRowId.get(rowId) ?? 0;
46 ranges.push({ rowId, occurrence, range });
47 occurrenceByRowId.set(rowId, occurrence + 1);
48 }
49 text = "";
50 nodes = [];
51 };
52 const visit = (node: Node, inPre = false) => {
53 const element = node.nodeType === 1 ? (node as Element) : null;
54 if (element?.matches("svg")) return;
55 if (element?.matches(`${THREAD_FIND_IGNORE_SELECTOR}, [role="toolbar"]`)) {
56 flush();
57 return;
58 }
59 const tag = element?.tagName.toLowerCase() ?? "";
60 const block = THREAD_FIND_BLOCK_TAGS.has(tag);
61 if (block) flush();
62 if (node.nodeType === 3) {
63 const value = node.nodeValue ?? "";
64 const start = text.length;
65 text += inPre ? value : value.replace(/\r?\n/g, " ");
66 nodes.push({ node, start, end: text.length });
67 }
68 for (const child of node.childNodes) visit(child, inPre || tag === "pre");
69 if (block) flush();
70 };
71 visit(scope);
72 flush();
73 }
74 return ranges;
75}
76
77/** Ancestors that hide (`hidden`) or clip (`data-thread-find-fold`) text until find opens them. */
78const FOLD_SELECTOR = "[hidden], [data-thread-find-fold]";
79
80/** Folds around the range that currently keep it out of sight, innermost first. */
81function foldsHiding(range: Range, container: HTMLElement): Element[] {
82 const folds: Element[] = [];
83 for (
84 let fold = range.startContainer.parentElement?.closest(FOLD_SELECTOR);
85 fold && container.contains(fold);
86 fold = fold.parentElement?.closest(FOLD_SELECTOR)
87 ) {
88 // A clipped body still shows its first lines; only text past the cutoff is folded.
89 if (
90 fold.hasAttribute("hidden") ||
91 range.getBoundingClientRect().bottom > fold.getBoundingClientRect().bottom
92 )
93 folds.push(fold);
94 }
95 return folds;
96}
97
98/** Dispatches `beforematch` on each fold hiding the range; returns whether any existed. */
99function revealFolded(range: Range, container: HTMLElement): boolean {
100 const folds = foldsHiding(range, container);
101 for (const fold of folds) fold.dispatchEvent(new Event("beforematch"));
102 return folds.length > 0;
103}
104
105function affectsSearchableText(record: MutationRecord): boolean {
106 if (record.type === "attributes") return true;
107 const target =
108 record.target.nodeType === 1 ? (record.target as Element) : record.target.parentElement;
109 if (target?.closest(THREAD_FIND_TEXT_SELECTOR)) return true;
110 // Rows mounting or unmounting add or remove whole searchable scopes.
111 const touchesScope = (node: Node) =>
112 node.nodeType === 1 &&
113 ((node as Element).matches(THREAD_FIND_TEXT_SELECTOR) ||
114 (node as Element).querySelector(THREAD_FIND_TEXT_SELECTOR) !== null);
115 return [...record.addedNodes, ...record.removedNodes].some(touchesScope);
116}
117
118export function useThreadFindHighlights(input: {
119 readonly container: HTMLElement | null;
120 readonly query: string;
121 readonly activeRowId: string | null;
122 readonly activeOccurrence: number;
123 readonly onActiveRange: (range: Range | null) => void;
124}): void {
125 const { container, query, activeRowId, activeOccurrence, onActiveRange } = input;
126
127 const rangesRef = useRef<readonly ThreadFindRange[]>([]);
128 const paintRef = useRef<ReturnType<typeof makeThreadFindHighlightPainter> | null>(null);
129 const selectionRef = useRef({ activeRowId, activeOccurrence, onActiveRange });
130 useLayoutEffect(() => {
131 selectionRef.current = { activeRowId, activeOccurrence, onActiveRange };
132 }, [activeRowId, activeOccurrence, onActiveRange]);
133
134 useEffect(() => {
135 if (typeof CSS === "undefined" || !CSS.highlights || typeof Highlight === "undefined") {
136 selectionRef.current.onActiveRange(null);
137 return;
138 }
139 const clearHighlights = () => {
140 CSS.highlights.delete(THREAD_FIND_HIGHLIGHT_NAME);
141 CSS.highlights.delete(THREAD_FIND_ACTIVE_HIGHLIGHT_NAME);
142 };
143 rangesRef.current = [];
144 if (!container || query.length === 0) {
145 selectionRef.current.onActiveRange(null);
146 clearHighlights();
147 return;
148 }
149
150 // Selection changes reuse ranges. Only changed or newly mounted rows
151 // need their text walked again; removed rows release their DOM references.
152 const cache = new Map<Element, readonly ThreadFindRange[]>();
153 const paint = makeThreadFindHighlightPainter(container);
154 paintRef.current = paint;
155 const repaint = () => {
156 const rows = new Set(container.querySelectorAll("[data-timeline-row-id]"));
157 const ranges: ThreadFindRange[] = [];
158 for (const row of rows) {
159 let matches = cache.get(row);
160 if (
161 !matches ||
162 matches.some(
163 ({ range }) => !row.contains(range.startContainer) || !row.contains(range.endContainer),
164 )
165 ) {
166 matches = collectThreadFindRanges(row as HTMLElement, query);
167 cache.set(row, matches);
168 }
169 ranges.push(...matches);
170 }
171 for (const row of cache.keys()) if (!rows.has(row)) cache.delete(row);
172 rangesRef.current = ranges;
173 paint(ranges, selectionRef.current);
174 };
175 let frame: number | null = null;
176 const observer = new MutationObserver((mutations) => {
177 // Timers and status chrome tick every second; only searchable text and folds matter.
178 const relevant = mutations.filter(affectsSearchableText);
179 if (relevant.length === 0) return;
180 for (const mutation of relevant) {
181 const target =
182 mutation.target.nodeType === 1
183 ? (mutation.target as Element)
184 : mutation.target.parentElement;
185 const row = target?.closest("[data-timeline-row-id]");
186 if (row) cache.delete(row);
187 }
188 if (frame !== null) return;
189 frame = requestAnimationFrame(() => {
190 frame = null;
191 repaint();
192 });
193 });
194 observer.observe(container, {
195 subtree: true,
196 childList: true,
197 characterData: true,
198 attributes: true,
199 attributeFilter: [
200 "data-wrap",
201 "hidden",
202 "data-thread-find-fold",
203 "data-timeline-row-id",
204 "data-thread-find-text",
205 "data-thread-find-ignore",
206 ],
207 });
208 // Revealing the first match can synchronously mount or recycle list rows.
209 repaint();
210 return () => {
211 observer.disconnect();
212 if (frame !== null) cancelAnimationFrame(frame);
213 cache.clear();
214 paintRef.current = null;
215 rangesRef.current = [];
216 clearHighlights();
217 };
218 }, [container, query]);
219
220 useEffect(() => {
221 paintRef.current?.(rangesRef.current, { activeRowId, activeOccurrence, onActiveRange });
222 }, [activeOccurrence, activeRowId, onActiveRange]);
223}
224
225/** Selection changes touch only the old and new match; layout checks run once per DOM scan. */
226function makeThreadFindHighlightPainter(container: HTMLElement) {
227 let previousRanges: readonly ThreadFindRange[] | null = null;
228 const rangesByRow = new Map<string, Range[]>();
229 const inactive = new Highlight();
230 const active = new Highlight();
231 let previousActive: Range | null = null;
232 CSS.highlights.set(THREAD_FIND_HIGHLIGHT_NAME, inactive);
233 CSS.highlights.set(THREAD_FIND_ACTIVE_HIGHLIGHT_NAME, active);
234 return (
235 ranges: readonly ThreadFindRange[],
236 selection: Pick<
237 Parameters<typeof useThreadFindHighlights>[0],
238 "activeRowId" | "activeOccurrence" | "onActiveRange"
239 >,
240 ) => {
241 if (previousRanges !== ranges) {
242 previousRanges = ranges;
243 previousActive = null;
244 rangesByRow.clear();
245 inactive.clear();
246 for (const match of ranges) {
247 const row = rangesByRow.get(match.rowId) ?? [];
248 row[match.occurrence] = match.range;
249 rangesByRow.set(match.rowId, row);
250 if (foldsHiding(match.range, container).length === 0) inactive.add(match.range);
251 }
252 }
253 if (previousActive && foldsHiding(previousActive, container).length === 0)
254 inactive.add(previousActive);
255 const selected =
256 selection.activeRowId === null
257 ? null
258 : (rangesByRow.get(selection.activeRowId)?.[selection.activeOccurrence] ?? null);
259 active.clear();
260 if (selected) inactive.delete(selected);
261 previousActive = selected;
262 if (selected && revealFolded(selected, container)) {
263 selection.onActiveRange(null);
264 } else {
265 if (selected) active.add(selected);
266 selection.onActiveRange(selected);
267 }
268 };
269}