Files
superwork/vscode/webview-ui/src/codeReader/useFind.ts
T

211 lines
9.2 KiB
TypeScript

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<HTMLElement>('[data-find]')
const node = holder && root?.contains(holder) ? textOf(holder).nodes.find(item => item.node === startContainer) : undefined
const block = holder?.closest<HTMLElement>('.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<TextMarks>, painter: FindPainter): FindView {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [search, setSearch] = useState<Search>({ 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<HTMLElement>(`.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,
}
}