diff --git a/ui/leafwiki-ui/src/features/search/SearchResultCard.tsx b/ui/leafwiki-ui/src/features/search/SearchResultCard.tsx index 0e5bbe688..648babfae 100644 --- a/ui/leafwiki-ui/src/features/search/SearchResultCard.tsx +++ b/ui/leafwiki-ui/src/features/search/SearchResultCard.tsx @@ -1,6 +1,7 @@ import { SearchResultItem } from '@/lib/api/search' import { createNavigationVisitState } from '@/lib/navigationVisit' import { buildViewUrl } from '@/lib/routePath' +import { SEARCH_QUERY_STATE_KEY } from '@/lib/searchNavigationState' import { normalizeWikiRoutePath } from '@/lib/wikiPath' import { forwardRef } from 'react' import { Link, useLocation } from 'react-router-dom' @@ -31,12 +32,13 @@ const SearchResultCard = forwardRef( const isEditorActive = currentEditorPageId === item.page_id const isActive = isRouteActive || isEditorActive || isSelected const kindLabel = item.kind === 'section' ? 'Section' : 'Page' + const searchQuery = new URLSearchParams(location.search).get('q') || undefined return ( { + const parent = el.parentNode + if (!parent) return + parent.replaceChild(document.createTextNode(el.textContent ?? ''), el) + parent.normalize() + }) +} + +function highlightAndScroll(searchTerm: string) { + const contentEl = document.querySelector('.page-viewer__content') + if (!contentEl) return + + cleanupHighlights(contentEl) + + const walker = document.createTreeWalker(contentEl, NodeFilter.SHOW_TEXT) + const term = searchTerm.toLowerCase() + + let node: Node | null + while ((node = walker.nextNode())) { + const text = node.textContent ?? '' + const idx = text.toLowerCase().indexOf(term) + if (idx === -1) continue + + const parent = node.parentNode + if (!parent) continue + + const mark = document.createElement('mark') + mark.className = 'search-highlight' + mark.textContent = text.slice(idx, idx + searchTerm.length) + + const fragment = document.createDocumentFragment() + const before = text.slice(0, idx) + const after = text.slice(idx + searchTerm.length) + if (before) fragment.appendChild(document.createTextNode(before)) + fragment.appendChild(mark) + if (after) fragment.appendChild(document.createTextNode(after)) + + parent.replaceChild(fragment, node) + mark.scrollIntoView({ behavior: 'smooth', block: 'center' }) + return + } +} + +type Options = { + content?: string + isLoading?: boolean +} + +export function useScrollToSearchTerm({ content, isLoading }: Options) { + const location = useLocation() + const searchQuery = getNavigationSearchQuery(location.state) + const visitKey = getNavigationVisitKey(location) + const highlightedRef = useRef(new Set()) + + useEffect(() => { + if (isLoading || !content || !searchQuery || location.hash) return + + const highlightKey = `${visitKey}:${searchQuery}` + if (highlightedRef.current.has(highlightKey)) return + + const scrollContainer = document.getElementById('scroll-container') + if (!scrollContainer) return + + const cancel = waitUntilHeightStabilizes(scrollContainer, () => { + highlightAndScroll(searchQuery) + highlightedRef.current.add(highlightKey) + }) + + return () => { + cancel() + const contentEl = document.querySelector('.page-viewer__content') + if (contentEl) cleanupHighlights(contentEl) + } + }, [content, isLoading, searchQuery, location.hash, visitKey]) +} diff --git a/ui/leafwiki-ui/src/index.css b/ui/leafwiki-ui/src/index.css index 07927ce5e..0f2eedd6f 100644 --- a/ui/leafwiki-ui/src/index.css +++ b/ui/leafwiki-ui/src/index.css @@ -1389,6 +1389,10 @@ @apply text-muted text-xs; } + .search-highlight { + @apply rounded-xs bg-yellow-300/70 dark:bg-yellow-400/30; + } + .tags-result-card__link { @apply block text-inherit no-underline; } diff --git a/ui/leafwiki-ui/src/lib/scrollToHeadline.ts b/ui/leafwiki-ui/src/lib/scrollToHeadline.ts index 23ae21e4a..29fa93a00 100644 --- a/ui/leafwiki-ui/src/lib/scrollToHeadline.ts +++ b/ui/leafwiki-ui/src/lib/scrollToHeadline.ts @@ -3,6 +3,48 @@ type ScrollToHeadlineOptions = { waitForStableLayout?: boolean } +export function waitUntilHeightStabilizes( + element: HTMLElement, + callback: () => void, + interval = 250, + maxTotalTime = 3000, + stableTime = 500, +): () => void { + let lastHeight = element.scrollHeight + let stableFor = 0 + let elapsedTime = 0 + let cancelled = false + let currentTimer: ReturnType + + const checkHeight = () => { + if (cancelled) return + const currentHeight = element.scrollHeight + if (currentHeight === lastHeight) { + stableFor += interval + if (stableFor >= stableTime) { + callback() + return + } + } else { + lastHeight = currentHeight + stableFor = 0 + } + elapsedTime += interval + if (elapsedTime < maxTotalTime) { + currentTimer = setTimeout(checkHeight, interval) + } else { + callback() + } + } + + currentTimer = setTimeout(checkHeight, interval) + + return () => { + cancelled = true + clearTimeout(currentTimer) + } +} + export function scrollToHeadlineHash( hash: string, { @@ -15,40 +57,6 @@ export function scrollToHeadlineHash( ) as HTMLElement | null if (!contentContainer) return - function waitUntilHeightStabilizes( - element: HTMLElement, - callback: () => void, - interval = 250, - maxTotalTime = 3000, - stableTime = 500, - ) { - let lastHeight = element.scrollHeight - let stableFor = 0 - let elapsedTime = 0 - - const checkHeight = () => { - const currentHeight = element.scrollHeight - if (currentHeight === lastHeight) { - stableFor += interval - if (stableFor >= stableTime) { - callback() - return - } - } else { - lastHeight = currentHeight - stableFor = 0 - } - elapsedTime += interval - if (elapsedTime < maxTotalTime) { - setTimeout(checkHeight, interval) - } else { - callback() - } - } - - setTimeout(checkHeight, interval) - } - const scrollToTarget = () => { const rawHeadlineId = hash.substring(1) let headlineId = rawHeadlineId diff --git a/ui/leafwiki-ui/src/lib/searchNavigationState.ts b/ui/leafwiki-ui/src/lib/searchNavigationState.ts new file mode 100644 index 000000000..0d761e5d2 --- /dev/null +++ b/ui/leafwiki-ui/src/lib/searchNavigationState.ts @@ -0,0 +1,10 @@ +export const SEARCH_QUERY_STATE_KEY = 'leafwikiSearchQuery' + +export function getNavigationSearchQuery(state: unknown): string | undefined { + if (typeof state === 'object' && state !== null) { + const s = state as Record + const q = s[SEARCH_QUERY_STATE_KEY] + if (typeof q === 'string' && q.length > 0) return q + } + return undefined +}