diff --git a/app/layout.tsx b/app/layout.tsx index b086b5d..ad6c381 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -29,6 +29,11 @@ export default function RootLayout({ }>) { return ( + + {/* Highlight stylesheet cannot be loaded via CSS preprocessor (not supported yet) */} + {/* eslint-disable-next-line @next/next/no-css-tags */} + + diff --git a/lib/useHighlight.tsx b/lib/useHighlight.tsx new file mode 100644 index 0000000..8ec5929 --- /dev/null +++ b/lib/useHighlight.tsx @@ -0,0 +1,48 @@ +"use client"; +import { useEffect, useRef } from "react"; + +export function useHighlight(query: string, dependencies: unknown[] = []) { + const ref = useRef(null); + + useEffect(() => { + if (!ref.current || !query) { + CSS.highlights.delete("search"); + return; + } + + const treeWalker = document.createTreeWalker( + ref.current, + NodeFilter.SHOW_TEXT, + ); + const ranges: Range[] = []; + + while (treeWalker.nextNode()) { + const node = treeWalker.currentNode; + const text = node.textContent?.toLowerCase() ?? ""; + let startIdx = 0; + + while ((startIdx = text.indexOf(query.toLowerCase(), startIdx)) !== -1) { + const range = new Range(); + range.setStart(node, startIdx); + range.setEnd(node, startIdx + query.length); + ranges.push(range); + startIdx += query.length; + } + } + + if (ranges.length > 0) { + console.log(`Highlighting ${ranges.length} occurrences of "${query}"`); + CSS.highlights.set("search", new Highlight(...ranges)); + } else { + console.log(`No occurrences of "${query}" found`); + CSS.highlights.delete("search"); + } + + return () => { + CSS.highlights.delete("search"); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [query, ...dependencies]); + + return ref; +} diff --git a/public/highlights.css b/public/highlights.css new file mode 100644 index 0000000..9410937 --- /dev/null +++ b/public/highlights.css @@ -0,0 +1,4 @@ +::highlight(search) { + background-color: #fbbf24; + color: black; +}