195 lines
10 KiB
TypeScript
195 lines
10 KiB
TypeScript
import type { Click, Highlight, ScrollPosition } from './occurrences'
|
|
import type { BlockView, Occurrence } from './types'
|
|
import { createContext, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
import { documentNameAt, PEEK_ATTRIBUTE } from './definitionPeek'
|
|
import { clickAction, KeyScroll, OccurrenceHighlight, occurrenceJumpKey, OccurrenceNavigation } from './occurrences'
|
|
import { occurrencePlan } from './reveal'
|
|
import { showInViewport } from './useReveal'
|
|
import { onMessage, postMessage } from './vscode'
|
|
|
|
/** 眼下高亮着的名字,SegmentText 按它给各处出现上底色、给当前那一处描边。 */
|
|
export const HighlightContext = createContext<Highlight | undefined>(undefined)
|
|
|
|
/** 自己管点击的东西:点在上面(名字以外的地方)高亮不动。定义卡片浮窗里的名字是别的文件的,点了也不动 */
|
|
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[]
|
|
/** 这块里的出现跳不到:被筛掉的、正在编辑的、被正在编辑的祖先盖住的 */
|
|
skip: (id: string) => boolean
|
|
/** 眼下折叠着的块、属性行和子行 */
|
|
collapsed: ReadonlySet<string>
|
|
/** 展开这几块、属性行和子行 */
|
|
expand: (ids: string[]) => void
|
|
}
|
|
|
|
function clickOf(event: MouseEvent): Click {
|
|
const target = event.target instanceof Element ? event.target : null
|
|
const name = documentNameAt(target)
|
|
const selection = window.getSelection()
|
|
return {
|
|
modified: event.metaKey || event.ctrlKey || event.altKey || event.shiftKey,
|
|
count: event.detail,
|
|
selecting: selection !== null && !selection.isCollapsed,
|
|
// 起了中文名的名字写的是中文名(定义处后面还跟着原名),筛选栏上写中文名
|
|
name: name?.dataset.offset === undefined ? undefined : { offset: Number(name.dataset.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)
|
|
}
|
|
|
|
/**
|
|
* 立刻滚到这里,顺带打断浏览器正在播(或刚排上、还没开始播)的滚动动画。滚到原地浏览器当没滚、不打断动画,
|
|
* 所以先挪一个像素再回来:同一段脚本里连着滚两下,中间不画,画面上看不出。
|
|
* 这两处位置先交给 own:滚动事件可能晚于这次调用才到,不能被当成浏览器把页面滚走。
|
|
*/
|
|
function scrollBack({ x, y }: ScrollPosition, own: (position: ScrollPosition) => void): void {
|
|
const nudged = { x, y: y > 0 ? y - 1 : y + 1 }
|
|
own(nudged)
|
|
own({ x, y })
|
|
window.scrollTo({ left: x, top: nudged.y, behavior: 'instant' })
|
|
window.scrollTo({ left: x, top: y, behavior: 'instant' })
|
|
}
|
|
|
|
/**
|
|
* 单击名字高亮它在文档里的各处出现,点空白处、Esc、换文档版本或翻译清掉;返回眼下高亮着的名字,和筛选栏上「上一处 / 下一处」用的跳转。
|
|
* 高亮着时扩展端的「跳到下一处 / 上一处高亮」命令发来 moveOccurrence,筛选栏上的按钮走同一条跳转:当前那一处换成下一处页面上找得到的出现,
|
|
* 展开把它折叠住的块、属性行和子行,再把它滚进视口。按钮不是按键,不按 ⌘↓ 那样拉回按键时的位置。
|
|
* 默认的 ⌘↓ / ⌘↑(Windows 是 ctrl)在能跳的时候取消浏览器滚到底 / 滚到顶,但不停事件继续传(VS Code 在后面才把按键收成命令)。
|
|
* 别的键浏览器仍可能自己滚,跳的时候盖掉它,见 KeyScroll。
|
|
*/
|
|
export function useOccurrences(version: number | undefined, translatorId: string | undefined, page: OccurrencePage | undefined): { highlight: Highlight | undefined, moveOccurrence: (step: 1 | -1) => void } {
|
|
const [highlight, setHighlight] = useState<Highlight | undefined>()
|
|
const [occurrences] = useState(() => new OccurrenceHighlight(postMessage, setHighlight))
|
|
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 el = document.querySelector<HTMLElement>('[data-current]')
|
|
if (el)
|
|
showInViewport(el, position => keyScroll.own(position))
|
|
keyScroll.hold(scrollPosition())
|
|
}, [moves, keyScroll])
|
|
|
|
useEffect(() => {
|
|
const move = (step: 1 | -1, fromKey: boolean): void => {
|
|
if (fromKey) {
|
|
// 触发命令的那个键浏览器可能已经照自己的意思滚了(⌘↓ 滚到底),先回到按键那一刻
|
|
const before = keyScroll.take(performance.now())
|
|
if (before)
|
|
scrollBack(before, position => keyScroll.own(position))
|
|
}
|
|
else {
|
|
// 筛选栏按钮不是按键,别把这次滚动当成 ⌘↓ 拉回按键时的位置
|
|
keyScroll.forget()
|
|
}
|
|
const current = latestPage.current
|
|
const planOf = (occurrence: Occurrence): ReturnType<typeof occurrencePlan> => current && occurrencePlan(current.blocks, 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)))
|
|
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 = (event: Event): void => {
|
|
const back = keyScroll.scrolled(scrollPosition(), event.timeStamp)
|
|
if (back)
|
|
scrollBack(back, position => keyScroll.own(position))
|
|
}
|
|
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 })
|
|
window.addEventListener('scroll', onScroll, { passive: true })
|
|
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 })
|
|
window.removeEventListener('scroll', onScroll)
|
|
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 }
|
|
}
|