Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion ui/leafwiki-ui/src/features/search/SearchResultCard.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -31,12 +32,13 @@ const SearchResultCard = forwardRef<HTMLAnchorElement, SearchResultCardProps>(
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 (
<Link
ref={ref}
to={resultUrl}
state={createNavigationVisitState()}
state={createNavigationVisitState({ [SEARCH_QUERY_STATE_KEY]: searchQuery })}
data-testid={`search-result-card-${item.page_id}`}
aria-current={isRouteActive ? 'page' : undefined}
onMouseEnter={onMouseEnter}
Expand Down
2 changes: 2 additions & 0 deletions ui/leafwiki-ui/src/features/viewer/PageViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import Breadcrumbs from './Breadcrumbs'
import EmptySectionChildrenList from './EmptySectionChildrenList'
import { PageMetadata } from './PageMetadata'
import { useScrollToHeadline } from './useScrollToHeadline'
import { useScrollToSearchTerm } from './useScrollToSearchTerm'
import { useSetPageTitle } from './useSetPageTitle'
import { useToolbarActions } from './useToolbarActions'
import { useViewerStore } from './viewer'
Expand Down Expand Up @@ -81,6 +82,7 @@ export default function PageViewer() {

useScrollRestoration(getNavigationVisitKey(location), loading)
useScrollToHeadline({ content: page?.content || '', isLoading: loading })
useScrollToSearchTerm({ content: page?.content || '', isLoading: loading })
useToolbarActions(actions)
useSetPageTitle({ page })

Expand Down
82 changes: 82 additions & 0 deletions ui/leafwiki-ui/src/features/viewer/useScrollToSearchTerm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { getNavigationVisitKey } from '@/lib/navigationVisit'
import { waitUntilHeightStabilizes } from '@/lib/scrollToHeadline'
import { getNavigationSearchQuery } from '@/lib/searchNavigationState'
import { useEffect, useRef } from 'react'
import { useLocation } from 'react-router-dom'

function cleanupHighlights(root: Element) {
root.querySelectorAll('mark.search-highlight').forEach((el) => {
const parent = el.parentNode
Comment on lines +7 to +9
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)

Comment on lines +34 to +37
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<string>())

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])
}
4 changes: 4 additions & 0 deletions ui/leafwiki-ui/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -1389,6 +1389,10 @@
@apply text-muted text-xs;
}

.search-highlight {
@apply rounded-xs bg-yellow-300/70 dark:bg-yellow-400/30;
}
Comment on lines +1392 to +1394

.tags-result-card__link {
@apply block text-inherit no-underline;
}
Expand Down
76 changes: 42 additions & 34 deletions ui/leafwiki-ui/src/lib/scrollToHeadline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof setTimeout>

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,
{
Expand All @@ -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
Expand Down
10 changes: 10 additions & 0 deletions ui/leafwiki-ui/src/lib/searchNavigationState.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
const q = s[SEARCH_QUERY_STATE_KEY]
if (typeof q === 'string' && q.length > 0) return q
}
return undefined
}
Loading