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

190 lines
10 KiB
TypeScript

import type { Click, Highlight, ScrollPosition } from './occurrences'
import type { Placement } from './placement'
import type { TextMarks } from './SegmentText'
import type { Store } from './store'
import type { BlockView, Occurrence } from './types'
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { documentNameAt, PEEK_ATTRIBUTE } from './definitionPeek'
import { clickAction, KeyScroll, OccurrenceHighlight, occurrenceJumpKey, OccurrenceNavigation } from './occurrences'
import { nameOffset, NO_PLACEMENT } from './placement'
import { occurrencePlan } from './reveal'
import { scrollOwner } from './scrollOwner'
import { useStore } from './store'
import { showInViewport } from './useReveal'
import { onMessage, postMessage } from './vscode'
/** 自己管点击的东西:点在上面(名字以外的地方)高亮不动。定义卡片浮窗里的名字是别的文件的,点了也不动 */
const CONTROLS = `a, button, input, select, textarea, [role="listbox"], [role="dialog"], .cr-filter-bar, .cr-prop-label, [${PEEK_ATTRIBUTE}]`
/** 不收文字的输入框:焦点在它们上面时方向键不归它们移光标 */
const NON_TEXT_INPUTS = new Set(['checkbox', 'radio', 'button', 'submit', 'reset'])
/** 用键盘跳到一处出现、查找栏跳到一处命中时要用的页面状态。 */
export interface OccurrencePage {
blocks: BlockView[]
/** 各块在文件里的位置:点中的名字、各处出现按它和文件里的下标对上 */
placement: Placement
/** 这块里的出现跳不到:被筛掉的、正在编辑的、被正在编辑的祖先盖住的 */
skip: (id: string) => boolean
/** 眼下折叠着的块、属性行和子行 */
collapsed: ReadonlySet<string>
/** 展开这几块、属性行和子行 */
expand: (ids: string[]) => void
}
/** 一次点击的情况;点中的名字在文件里的下标按 placement 换算 */
function clickOf(event: MouseEvent, placement: Placement): Click {
const target = event.target instanceof Element ? event.target : null
const name = documentNameAt(target)
const offset = name ? nameOffset(name, placement) : undefined
const selection = window.getSelection()
return {
modified: event.metaKey || event.ctrlKey || event.altKey || event.shiftKey,
count: event.detail,
selecting: selection !== null && !selection.isCollapsed,
// 起了中文名的名字写的是中文名(定义处后面还跟着原名),筛选栏上写中文名
name: !name || offset === undefined ? undefined : { offset, text: name.dataset.name ?? name.textContent ?? '' },
control: target?.closest(CONTROLS) != null,
}
}
/** 焦点在能打字的地方:文字输入框、多行输入框、下拉列表、可编辑的元素(块的「编辑」) */
function isTyping(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement))
return false
return target.isContentEditable || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement
|| (target instanceof HTMLInputElement && !NON_TEXT_INPUTS.has(target.type))
}
function scrollPosition(): ScrollPosition {
return { x: window.scrollX, y: window.scrollY }
}
/** mac 用 ⌘,别的系统用 ctrl。页面跑在 VS Code 的网页里,平台从浏览器读。 */
function isMac(): boolean {
return /Mac|iPhone|iPad/.test(navigator.platform)
}
/**
* 单击名字高亮它在文档里的各处出现,点空白处、Esc、换文档版本或翻译清掉;高亮着的名字记进 marks(页面文字的标记),返回它和筛选栏上「上一处 / 下一处」用的跳转。
* 高亮着时扩展端的「跳到下一处 / 上一处高亮」命令发来 moveOccurrence,筛选栏上的按钮走同一条跳转:当前那一处换成下一处页面上找得到的出现,
* 展开把它折叠住的块、属性行和子行,再把它滚进视口。按钮不是按键,不按 ⌘↓ 那样拉回按键时的位置。
* 默认的 ⌘↓ / ⌘↑(Windows 是 ctrl)在能跳的时候取消浏览器滚到底 / 滚到顶,但不停事件继续传(VS Code 在后面才把按键收成命令)。
* 别的键浏览器仍可能自己滚,跳的时候盖掉它,见 KeyScroll。
*/
export function useOccurrences(version: number | undefined, translatorId: string | undefined, page: OccurrencePage | undefined, marks: Store<TextMarks>): { highlight: Highlight | undefined, moveOccurrence: (step: 1 | -1) => void } {
// 高亮记在页面文字的标记里(TextMarksContext),各段文字按自己那几段订阅,换个名字高亮只重画画法变了的那几段
const highlight = useStore(marks, current => current.highlight)
const [occurrences] = useState(() => new OccurrenceHighlight(postMessage, next => marks.set(current => ({ ...current, highlight: next }))))
const [navigation] = useState(() => new OccurrenceNavigation(postMessage, { focused: document.hasFocus(), typing: isTyping(document.activeElement) }))
const [keyScroll] = useState(() => new KeyScroll())
/** 用键盘或筛选栏按钮跳过几次:每跳一次,画完后把当前那一处滚进视口 */
const [moves, setMoves] = useState(0)
const latestPage = useRef(page)
/** 筛选栏按钮调用的跳转。真正的函数在下面的效果里,挂上监听时才有。 */
const moveFromButton = useRef<(step: 1 | -1) => void>(() => {})
useLayoutEffect(() => {
latestPage.current = page
})
// 赶在绘制前清掉:新文档的下标换了意思,旧高亮不能落在新页面的别的名字上
useLayoutEffect(() => occurrences.setDocument(version, translatorId), [occurrences, version, translatorId])
useEffect(() => navigation.update({ highlighted: highlight !== undefined }), [navigation, highlight])
// 要展开的已经展开、当前那一处已经画出来(data-current),页面上按先后第一个就是它
useLayoutEffect(() => {
if (moves === 0)
return
const current = (): HTMLElement | null => document.querySelector<HTMLElement>('[data-current]')
if (current())
showInViewport(current)
keyScroll.hold(scrollPosition())
}, [moves, keyScroll])
useEffect(() => {
const move = (step: 1 | -1, fromKey: boolean): void => {
if (fromKey) {
// 触发命令的那个键浏览器可能已经照自己的意思滚了(⌘↓ 滚到底),先回到按键那一刻
const before = keyScroll.take(performance.now())
if (before)
scrollOwner.pullBack(before)
}
else {
// 筛选栏按钮不是按键,别把这次滚动当成 ⌘↓ 拉回按键时的位置
keyScroll.forget()
}
const current = latestPage.current
const planOf = (occurrence: Occurrence): ReturnType<typeof occurrencePlan> => current && occurrencePlan(current.blocks, current.placement, occurrence, current.skip)
const target = current && occurrences.move(step, occurrence => planOf(occurrence) !== undefined)
const plan = target && planOf(target)
if (!current || !plan) {
keyScroll.hold(scrollPosition())
return
}
const folded = plan.expand.filter(id => current.collapsed.has(id))
if (folded.length > 0)
current.expand(folded)
setMoves(count => count + 1)
}
// 捕获阶段:赶在块里的按钮处理点击、折叠箭头换图标之前读出点在哪
const onClick = (event: MouseEvent): void => occurrences.click(clickAction(clickOf(event, latestPage.current?.placement ?? NO_PLACEMENT)))
moveFromButton.current = step => move(step, false)
// 任何键都照常交给 VS Code。能跳时,默认的 ⌘↓ / ⌘↑(Windows 是 ctrl)取消浏览器滚到底 / 滚到顶,但不停冒泡,命令还是收得到
const onKeyDown = (event: KeyboardEvent): void => {
if (navigation.available && occurrenceJumpKey(event, isMac()))
event.preventDefault()
keyScroll.press(scrollPosition(), event.timeStamp)
}
const onPointer = (): void => keyScroll.forget()
const onScroll = (ours: boolean, event: Event): void => {
const back = keyScroll.scrolled(event.timeStamp, scrollPosition, ours)
if (back)
scrollOwner.pullBack(back)
}
const onKey = (event: KeyboardEvent): void => {
// 输入框、下拉列表自己用掉的 Esc 不算
if (event.key === 'Escape' && !event.defaultPrevented)
occurrences.clear()
}
const onFocus = (): void => navigation.update({ focused: true })
const onBlur = (): void => navigation.update({ focused: false })
const onFocusIn = (event: FocusEvent): void => navigation.update({ typing: isTyping(event.target) })
const onFocusOut = (): void => navigation.update({ typing: false })
const off = onMessage((msg) => {
if (msg.type === 'occurrencesFound')
occurrences.answer(msg.id, msg.occurrences)
else if (msg.type === 'moveOccurrence')
move(msg.step, true)
})
navigation.update({})
document.addEventListener('click', onClick, true)
window.addEventListener('keydown', onKeyDown, true)
window.addEventListener('pointerdown', onPointer, true)
window.addEventListener('wheel', onPointer, { capture: true, passive: true })
const offScroll = scrollOwner.onScroll(onScroll)
window.addEventListener('keydown', onKey)
window.addEventListener('focus', onFocus)
window.addEventListener('blur', onBlur)
document.addEventListener('focusin', onFocusIn)
document.addEventListener('focusout', onFocusOut)
return () => {
off()
document.removeEventListener('click', onClick, true)
window.removeEventListener('keydown', onKeyDown, true)
window.removeEventListener('pointerdown', onPointer, true)
window.removeEventListener('wheel', onPointer, { capture: true })
offScroll()
window.removeEventListener('keydown', onKey)
window.removeEventListener('focus', onFocus)
window.removeEventListener('blur', onBlur)
document.removeEventListener('focusin', onFocusIn)
document.removeEventListener('focusout', onFocusOut)
}
}, [occurrences, navigation, keyScroll])
const moveOccurrence = useCallback((step: 1 | -1) => moveFromButton.current(step), [])
return { highlight, moveOccurrence }
}