| 1 | import { findThreadSearchOccurrences } from "@t3tools/shared/threadSearch"; |
| 2 | import { useEffect, useLayoutEffect, useRef } from "react"; |
| 3 | import { THREAD_FIND_BLOCK_TAGS } from "@t3tools/shared/threadFindText"; |
| 4 | |
| 5 | const THREAD_FIND_HIGHLIGHT_NAME = "t3-thread-find"; |
| 6 | const THREAD_FIND_ACTIVE_HIGHLIGHT_NAME = "t3-thread-find-active"; |
| 7 | |
| 8 | const THREAD_FIND_TEXT_SELECTOR = "[data-thread-find-text]"; |
| 9 | const THREAD_FIND_IGNORE_SELECTOR = "[data-thread-find-ignore]"; |
| 10 | |
| 11 | interface ThreadFindRange { |
| 12 | readonly rowId: string; |
| 13 | readonly occurrence: number; |
| 14 | readonly range: Range; |
| 15 | } |
| 16 | |
| 17 | /** Collects visible occurrences without modifying rendered markdown. */ |
| 18 | export 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. */ |
| 78 | const FOLD_SELECTOR = "[hidden], [data-thread-find-fold]"; |
| 79 | |
| 80 | /** Folds around the range that currently keep it out of sight, innermost first. */ |
| 81 | function 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. */ |
| 99 | function 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 | |
| 105 | function 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 | |
| 118 | export 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. */ |
| 226 | function 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 | } |