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
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 @@ -40,6 +40,7 @@ import Breadcrumbs from './Breadcrumbs'
import EmptySectionChildrenList from './EmptySectionChildrenList'
import { PageMetadata } from './PageMetadata'
import { useScrollToHeadline } from './useScrollToHeadline'
import { useScrollToSearchQuery } from './useScrollToSearchQuery'
import { useSetPageTitle } from './useSetPageTitle'
import { useToolbarActions } from './useToolbarActions'
import { useViewerStore } from './viewer'
Expand Down Expand Up @@ -114,6 +115,7 @@ export default function PageViewer() {

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

Expand Down
23 changes: 23 additions & 0 deletions ui/leafwiki-ui/src/features/viewer/useScrollToSearchQuery.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { scrollToSearchQuery } from '@/lib/scrollToSearchQuery'
import { useEffect } from 'react'
import { useLocation, useSearchParams } from 'react-router'

type UseScrollToSearchQueryOptions = {
content?: string
isLoading?: boolean
}

export function useScrollToSearchQuery({
content,
isLoading,
}: UseScrollToSearchQueryOptions) {
const { hash } = useLocation()
const [searchParams] = useSearchParams()
const query = (searchParams.get('q') ?? '').trim()

useEffect(() => {
// Headline hash navigation wins when both are present.
if (isLoading || !content || hash || query.length < 3) return
return scrollToSearchQuery(query)
}, [content, isLoading, hash, query])
}
4 changes: 4 additions & 0 deletions ui/leafwiki-ui/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -1535,6 +1535,10 @@
@apply text-brand-dark font-semibold;
}

.page-viewer__content mark.search-query-highlight {
@apply bg-brand/25 text-inherit rounded-sm px-0.5;
}

.page-refactor-dialog__results-view {
@apply max-h-60 rounded-none border-0;
}
Expand Down
85 changes: 85 additions & 0 deletions ui/leafwiki-ui/src/lib/scrollToSearchQuery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
clearSearchQueryHighlights,
scrollToSearchQuery,
} from './scrollToSearchQuery'

beforeEach(() => {
const sc = document.createElement('div')
sc.id = 'scroll-container'
document.body.appendChild(sc)

const content = document.createElement('article')
content.className = 'page-viewer__content'
content.innerHTML =
'<p>Hello world</p><p>Find the keyword here please</p><p>keyword again</p>'
document.body.appendChild(content)
})

afterEach(() => {
document.body.innerHTML = ''
vi.restoreAllMocks()
})

describe('scrollToSearchQuery', () => {
it('highlights and scrolls to the first case-insensitive match', () => {
const scrollSpy = vi
.spyOn(HTMLElement.prototype, 'scrollIntoView')
.mockImplementation(() => {})

scrollToSearchQuery('Keyword', { waitForStableLayout: false })

const mark = document.querySelector(
'mark.search-query-highlight',
) as HTMLElement
expect(mark).not.toBeNull()
expect(mark.textContent).toBe('keyword')
expect(scrollSpy).toHaveBeenCalled()

const content = document.querySelector(
'.page-viewer__content',
) as HTMLElement
const firstMatchParagraph = content.querySelectorAll('p')[1]
expect(firstMatchParagraph.contains(mark)).toBe(true)
})

it('does nothing for queries shorter than 3 characters', () => {
scrollToSearchQuery('ky', { waitForStableLayout: false })
expect(document.querySelector('mark.search-query-highlight')).toBeNull()
})

it('does nothing when no match exists', () => {
scrollToSearchQuery('missing', { waitForStableLayout: false })
expect(document.querySelector('mark.search-query-highlight')).toBeNull()
})

it('clears previous highlights before applying a new match', () => {
scrollToSearchQuery('keyword', { waitForStableLayout: false })
expect(
document.querySelectorAll('mark.search-query-highlight'),
).toHaveLength(1)

scrollToSearchQuery('Hello', { waitForStableLayout: false })
const marks = document.querySelectorAll('mark.search-query-highlight')
expect(marks).toHaveLength(1)
expect(marks[0].textContent).toBe('Hello')
})

it('cleanup removes highlights', () => {
const cancel = scrollToSearchQuery('keyword', {
waitForStableLayout: false,
})
expect(document.querySelector('mark.search-query-highlight')).not.toBeNull()
cancel()
expect(document.querySelector('mark.search-query-highlight')).toBeNull()
})
})

describe('clearSearchQueryHighlights', () => {
it('unwraps highlight marks without losing text', () => {
scrollToSearchQuery('keyword', { waitForStableLayout: false })
clearSearchQueryHighlights()
expect(document.querySelector('mark.search-query-highlight')).toBeNull()
expect(document.body.textContent).toContain('Find the keyword here please')
})
})
163 changes: 163 additions & 0 deletions ui/leafwiki-ui/src/lib/scrollToSearchQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
const HIGHLIGHT_CLASS = 'search-query-highlight'
const CONTENT_SELECTOR = '.page-viewer__content'
const SCROLL_CONTAINER_ID = 'scroll-container'

export type ScrollToSearchQueryOptions = {
behavior?: ScrollBehavior
waitForStableLayout?: boolean
rootSelector?: string
}

export function clearSearchQueryHighlights(
root: ParentNode = document,
): void {
root.querySelectorAll(`mark.${HIGHLIGHT_CLASS}`).forEach((mark) => {
const parent = mark.parentNode
if (!parent) return
while (mark.firstChild) {
parent.insertBefore(mark.firstChild, mark)
}
parent.removeChild(mark)
parent.normalize()
})
}

function findFirstTextMatch(
root: HTMLElement,
query: string,
): { node: Text; index: number } | null {
const normalizedQuery = query.toLowerCase()
if (!normalizedQuery) return null

const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
const parent = node.parentElement
if (!parent) return NodeFilter.FILTER_REJECT
if (parent.closest('script, style, noscript')) {
return NodeFilter.FILTER_REJECT
}
if (!node.textContent?.trim()) return NodeFilter.FILTER_REJECT
return NodeFilter.FILTER_ACCEPT
},
})

let current = walker.nextNode()
while (current) {
const text = current.textContent ?? ''
const index = text.toLowerCase().indexOf(normalizedQuery)
if (index >= 0) {
return { node: current as Text, index }
}
current = walker.nextNode()
}

return null
}

function wrapMatch(node: Text, index: number, length: number): HTMLElement {
const fullText = node.textContent ?? ''
const before = fullText.slice(0, index)
const matchText = fullText.slice(index, index + length)
const after = fullText.slice(index + length)

const mark = document.createElement('mark')
mark.className = HIGHLIGHT_CLASS
mark.textContent = matchText
mark.setAttribute('data-testid', 'search-query-highlight')

const parent = node.parentNode
if (!parent) return mark

const fragment = document.createDocumentFragment()
if (before) fragment.appendChild(document.createTextNode(before))
fragment.appendChild(mark)
if (after) fragment.appendChild(document.createTextNode(after))
parent.replaceChild(fragment, node)
return mark
}

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)
}

/**
* Finds the first case-insensitive occurrence of `query` in the page content,
* wraps it in a highlight mark, and scrolls it into view.
*/
export function scrollToSearchQuery(
query: string,
{
behavior = 'smooth',
waitForStableLayout = true,
rootSelector = CONTENT_SELECTOR,
}: ScrollToSearchQueryOptions = {},
): () => void {
const trimmed = query.trim()
if (trimmed.length < 3) return () => {}

const scrollContainer = document.getElementById(
SCROLL_CONTAINER_ID,
) as HTMLElement | null
if (!scrollContainer) return () => {}

let cancelled = false

const run = () => {
if (cancelled) return

const contentRoot = document.querySelector(
rootSelector,
) as HTMLElement | null
if (!contentRoot) return

clearSearchQueryHighlights(contentRoot)

const match = findFirstTextMatch(contentRoot, trimmed)
if (!match) return

const mark = wrapMatch(match.node, match.index, trimmed.length)
mark.scrollIntoView({ behavior, block: 'center', inline: 'nearest' })
}

if (waitForStableLayout) {
waitUntilHeightStabilizes(scrollContainer, run)
} else {
run()
}

return () => {
cancelled = true
const contentRoot = document.querySelector(rootSelector)
if (contentRoot) clearSearchQueryHighlights(contentRoot)
}
}
11 changes: 11 additions & 0 deletions ui/leafwiki-ui/src/lib/useScrollRestoration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ export function useScrollRestoration(
const hash = window.location.hash
if (hash) return

// Search result navigation (?q=) scrolls to the first match instead.
const searchQuery = (
new URLSearchParams(window.location.search).get('q') ?? ''
).trim()
if (searchQuery.length >= 3) return

if (isLoading) return

const el = document.getElementById(containerId)
Expand All @@ -35,6 +41,11 @@ export function useScrollRestoration(
const hash = window.location.hash
if (hash) return

const searchQuery = (
new URLSearchParams(window.location.search).get('q') ?? ''
).trim()
if (searchQuery.length >= 3) return

return () => {
const el = document.getElementById(containerId)
if (el) {
Expand Down