import type { NameMap } from './displayNames' import type { FindAnchor, FindMatch, FindResult, FindUnit } from './find' import type { FindPainter } from './findHighlights' import type { TextMarks } from './SegmentText' import type { ReadableStore } from './store' import type { OccurrencePage } from './useOccurrences' import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { findCount, findUnits, PageFind } from './find' import { markdownText, textOf } from './findHighlights' import { ShortcutContext } from './occurrences' import { pageLayout } from './pageLayout' import { useStore } from './store' import { showInViewport } from './useReveal' import { onMessage, postMessage } from './vscode' /** 打字停下多久才找:大文件里每敲一个字都整份找一遍、重画一遍高亮会卡 */ const TYPING_MS = 100 const NO_UNITS: readonly FindUnit[] = [] const NO_NAMES: NameMap = new Map() const NOTHING: FindResult = { query: '', matches: [] } /** 查找栏要画的和它的动作,见 [FindBar](vscode/webview-ui/src/codeReader/FindBar.tsx#FindBar)。 */ export interface FindView { open: boolean query: string count?: string canMove: boolean focus: number onQuery: (query: string) => void onMove: (step: 1 | -1) => void onClose: () => void } /** 一次找:查的字,从哪往后找(⌘F 拿选中的字填进去时是选中的那处)。 */ interface Search { query: string from?: FindAnchor } /** * 页面上选中的一行字,和查找从哪开始(它在页面内容 `root` 里哪段字的第几个字);没选中、选中了好几行、焦点在输入框里时没有。 * `line` 是那块在文件里的第一行(见 [FindAnchor])。 */ function selectedText(root: HTMLElement | null, page: OccurrencePage | undefined): Search | undefined { const selection = window.getSelection() const query = selection?.toString() ?? '' const field = document.activeElement if (!selection || selection.rangeCount === 0 || query.trim() === '' || /[\r\n]/.test(query) || field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement) return undefined const { startContainer, startOffset } = selection.getRangeAt(0) const holder = (startContainer instanceof Element ? startContainer : startContainer.parentElement)?.closest('[data-find]') const node = holder && root?.contains(holder) ? textOf(holder).nodes.find(item => item.node === startContainer) : undefined const block = holder?.closest('.cr-block')?.dataset.id if (!holder || !node || block === undefined) return { query } return { query, from: { key: holder.dataset.find, start: node.start + startOffset, line: page?.placement.of(block)?.line ?? 0 } } } /** 视口顶部那块(钉住的筛选栏下面)的行:查找栏刚写字、还没有当前那处时从它往后找。 */ function viewportAnchor(): FindAnchor { return { start: 0, line: pageLayout.anchorAt(window.scrollY + pageLayout.pinnedHeight())?.line ?? 0 } } /** * 查找栏:扩展端的「查找」命令(⌘F)发来 openFind 时打开、聚焦输入框,页面上选中了一行字时先填进去、从它那处开始找; * 写了字停下一会儿就在整份文件里找(见 [PageFind]),当前那处展开把它折叠住的块、属性行和子行,滚进视口; * 「查找下一处 / 上一处」命令(⌘G / ⇧⌘G)发来 moveFind、输入框里 Enter / Shift+Enter、栏上的按钮都在命中之间跳。 * 高亮由 `painter` 画在页面上,块树不重画;关掉时清掉高亮,查的字留着,下次打开照旧。 * 查找栏开着、页面有焦点时告诉扩展端 ⌘G 归这个页面(见 [ShortcutContext])。 * `page` 同单击高亮跳到一处出现用的页面状态,`marks` 是页面文字的标记(中文名在里面,找的时候才订阅,中文名到了不连累整页重画)。 */ export function useFind(page: OccurrencePage | undefined, marks: ReadableStore, painter: FindPainter): FindView { const [open, setOpen] = useState(false) const [query, setQuery] = useState('') const [search, setSearch] = useState({ query: '' }) /** 每次 ⌘F 加一:聚焦输入框、选中里面的字 */ const [focus, setFocus] = useState(0) const [result, setResult] = useState(NOTHING) const [find] = useState(() => new PageFind(setResult)) const [shortcuts] = useState(() => new ShortcutContext( { open: false, focused: document.hasFocus() }, facts => facts.open && facts.focused, available => postMessage({ type: 'findNavigation', available }), )) /** 跳过几次:每跳一次,画完后把当前那处滚进视口 */ const [reveals, setReveals] = useState(0) const latest = useRef({ page, open, query, search }) useLayoutEffect(() => { latest.current = { page, open, query, search } }) const reachable = useCallback((unit: FindUnit) => latest.current.page?.skip(unit.block) === false, []) /** 跳到 match:先展开把它折叠住的,画完后滚过去 */ const reveal = useCallback((match: FindMatch) => { const current = latest.current.page const folded = current ? find.unitOf(match).expand.filter(id => current.collapsed.has(id)) : [] if (current && folded.length > 0) current.expand(folded) setReveals(count => count + 1) }, [find]) // 开着、写了字才把整份文件的字备好;关着时不跟着新文档重算,原来那处留着,下次打开从它附近接着 const active = open && search.query !== '' const names = useStore(marks, current => active ? current.names : NO_NAMES) const blocks = page?.blocks const placement = page?.placement const units = useMemo(() => active && blocks && placement ? findUnits(blocks, placement, names, markdownText) : NO_UNITS, [active, blocks, placement, names]) // 先换上新的字(新一版文档、中文名到了、又打开了)再找:当前那处停在原来那处附近,不滚 useLayoutEffect(() => { if (active) find.setUnits(units, reachable) }, [find, active, units, reachable]) useLayoutEffect(() => { const from = search.from ?? (search.query !== '' && find.state.current === undefined ? viewportAnchor() : undefined) const match = find.search(search.query, reachable, from) if (match) reveal(match) }, [find, search, reachable, reveal]) useEffect(() => { const timer = window.setTimeout(() => setSearch(previous => previous.query === query ? previous : { query }), TYPING_MS) return () => window.clearTimeout(timer) }, [query]) // 结果变了、开关了:照着画;之后页面变了(展开、换了一版文档)由 painter 自己下一帧重画 useLayoutEffect(() => { painter.show(open && result.query !== '' ? find.paint() : undefined) }, [painter, find, open, result]) useLayoutEffect(() => { if (reveals === 0) return // 要展开的已经展开:按展开后的页面重画一遍,再滚到当前那处;它画不出来就滚到它那段字、那块 const painted = painter.show(latest.current.open ? find.paint() : undefined) const { current, matches } = find.state const block = current === undefined ? undefined : find.unitOf(matches[current]).block const target = painted.current ?? painted.text ?? (block === undefined ? null : document.querySelector(`.cr-block[data-id="${block}"]`)) if (target) showInViewport(target) painter.place() }, [reveals, painter, find]) const move = useCallback((step: 1 | -1) => { const { open, query, search } = latest.current if (!open) return // 刚写的字还没找:先找,跳到它的第一处 if (query !== search.query) { setSearch({ query }) return } const match = find.move(step, reachable) if (match) reveal(match) }, [find, reachable, reveal]) const close = useCallback(() => { const focused = document.activeElement if (focused instanceof HTMLElement && focused.closest('.cr-find-bar')) focused.blur() setOpen(false) }, []) useEffect(() => onMessage((msg) => { if (msg.type === 'openFind') { const selected = selectedText(painter.root, latest.current.page) if (selected) { setQuery(selected.query) setSearch(selected) } setOpen(true) setFocus(count => count + 1) } else if (msg.type === 'moveFind') { move(msg.step) } }), [painter, move]) useEffect(() => shortcuts.update({ open }), [shortcuts, open]) useEffect(() => { const onFocus = (): void => shortcuts.update({ focused: true }) const onBlur = (): void => shortcuts.update({ focused: false }) window.addEventListener('focus', onFocus) window.addEventListener('blur', onBlur) // 焦点落进页面里(⌘F 聚焦输入框)时页面一定有焦点 document.addEventListener('focusin', onFocus) return () => { window.removeEventListener('focus', onFocus) window.removeEventListener('blur', onBlur) document.removeEventListener('focusin', onFocus) } }, [shortcuts]) return { open, query, count: open ? findCount(result) : undefined, canMove: result.matches.length > 0, focus, onQuery: setQuery, onMove: move, onClose: close, } }