diff --git a/ui/leafwiki-ui/src/features/viewer/PageViewer.tsx b/ui/leafwiki-ui/src/features/viewer/PageViewer.tsx index fc31b7890..bbe451fcc 100644 --- a/ui/leafwiki-ui/src/features/viewer/PageViewer.tsx +++ b/ui/leafwiki-ui/src/features/viewer/PageViewer.tsx @@ -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' @@ -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 }) diff --git a/ui/leafwiki-ui/src/features/viewer/useScrollToSearchQuery.tsx b/ui/leafwiki-ui/src/features/viewer/useScrollToSearchQuery.tsx new file mode 100644 index 000000000..a4815b011 --- /dev/null +++ b/ui/leafwiki-ui/src/features/viewer/useScrollToSearchQuery.tsx @@ -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]) +} diff --git a/ui/leafwiki-ui/src/index.css b/ui/leafwiki-ui/src/index.css index 40b3f759b..fe7e59b9b 100644 --- a/ui/leafwiki-ui/src/index.css +++ b/ui/leafwiki-ui/src/index.css @@ -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; } diff --git a/ui/leafwiki-ui/src/lib/scrollToSearchQuery.test.ts b/ui/leafwiki-ui/src/lib/scrollToSearchQuery.test.ts new file mode 100644 index 000000000..be7c87e15 --- /dev/null +++ b/ui/leafwiki-ui/src/lib/scrollToSearchQuery.test.ts @@ -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 = + '

Hello world

Find the keyword here please

keyword again

' + 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') + }) +}) diff --git a/ui/leafwiki-ui/src/lib/scrollToSearchQuery.ts b/ui/leafwiki-ui/src/lib/scrollToSearchQuery.ts new file mode 100644 index 000000000..5cdde72e4 --- /dev/null +++ b/ui/leafwiki-ui/src/lib/scrollToSearchQuery.ts @@ -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) + } +} diff --git a/ui/leafwiki-ui/src/lib/useScrollRestoration.ts b/ui/leafwiki-ui/src/lib/useScrollRestoration.ts index fb91bbd6b..1f2795be2 100644 --- a/ui/leafwiki-ui/src/lib/useScrollRestoration.ts +++ b/ui/leafwiki-ui/src/lib/useScrollRestoration.ts @@ -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) @@ -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) {