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 /** 展开这几块、属性行和子行 */ 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): { 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('[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 => 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 } }