import type { Inline, Occurrence, Segment, WebviewToExtension } from './types' /** 高亮画成哪种底色,和 VS Code 编辑器一样:写入(定义、赋值)用强高亮色,读取和按文字认出的用普通高亮色。 */ export type OccurrenceStyle = 'strong' | 'normal' export function occurrenceStyle(kind: Occurrence['kind']): OccurrenceStyle { return kind === 'write' ? 'strong' : 'normal' } /** * 一段文字画不画高亮、画哪种:它讲的名字(sourceOf)和哪处出现重叠就画, * 同时碰上写入和读取的按写入画。没有下标的(译词、提示词、编辑状态的源码)不画。 */ export function segmentOccurrence(segment: Segment, occurrences: readonly Occurrence[]): OccurrenceStyle | undefined { let style: OccurrenceStyle | undefined for (const occurrence of occurrences) { if (overlaps(segment, occurrence)) { style = occurrenceStyle(occurrence.kind) if (style === 'strong') return style } } return style } /** * 段讲的是源码里哪个名字,[start, end):讲别处名字的段(refers,写在字符串里的参数名)是那个名字,照抄自源码的段是它自己占的 * [offset, offset + 字数);没有下标的段(译词、提示词)没有。跳到定义、单击高亮、各处出现画在哪都按它。 */ export function sourceOf(segment: Segment): { start: number, end: number } | undefined { if (segment.refers) return { start: segment.refers.startIndex, end: segment.refers.endIndex } return segment.offset === undefined ? undefined : { start: segment.offset, end: segment.offset + segment.text.length } } /** 段讲的名字(sourceOf)和这处出现重叠;没有下标的段不算。 */ export function overlaps(segment: Segment, occurrence: Occurrence): boolean { const source = sourceOf(segment) return source !== undefined && occurrence.start < source.end && source.start < occurrence.end } /** 文字里(连框里)有段和这处出现重叠。 */ export function showsOccurrence(pieces: readonly Inline[], occurrence: Occurrence): boolean { return pieces.some(piece => 'role' in piece ? overlaps(piece, occurrence) : showsOccurrence(piece.segments, occurrence)) } /** 一次点击的情况,useOccurrences 从事件和页面上读出来。 */ export interface Click { /** 按着 ⌘ / Ctrl / Alt / Shift:不是单纯的单击(⌘ / Ctrl + 点击归跳到定义) */ modified: boolean /** 连击的第几下(MouseEvent.detail),双击的第二下是 2 */ count: number /** 点完页面上有选中的文字:这是拖着选字,不是点 */ selecting: boolean /** 点中的带下标的名字:源码下标,页面上显示的字(起了中文名的是中文名) */ name?: { offset: number, text: string } /** 点在自己管点击的东西上:按钮、链接、输入框、下拉列表、筛选栏、属性名 */ control: boolean } /** find:问 offset 处名字的各处出现;clear:清掉高亮;ignore:不动 */ export type ClickAction = | { kind: 'find', offset: number, name: string } | { kind: 'clear' } | { kind: 'ignore' } /** * 单击名字就高亮,名字在折叠箭头这类按钮里也算(按钮照旧折叠);点空白处清掉; * 修饰键 + 点击、双击、拖着选字、点在按钮 / 链接 / 筛选栏这些自己管点击的东西上,高亮不动。 */ export function clickAction(click: Click): ClickAction { if (click.modified || click.count > 1 || click.selecting) return { kind: 'ignore' } if (click.name) return { kind: 'find', offset: click.name.offset, name: click.name.text } return click.control ? { kind: 'ignore' } : { kind: 'clear' } } /** 眼下高亮着的名字:点中时它的文字(筛选栏上显示)和它在文档里的各处出现。 */ export interface Highlight { name: string /** 照源码先后排 */ occurrences: readonly Occurrence[] /** 当前那一处(点中的、用键盘跳到的)在 occurrences 里的序号;点中的名字不在回答里时没有 */ current?: number } /** 段是不是当前那一处出现(点中的、用键盘跳到的)。 */ export function isCurrent(segment: Segment, highlight: Highlight): boolean { const current = highlight.current === undefined ? undefined : highlight.occurrences[highlight.current] return current !== undefined && overlaps(segment, current) } /** 筛选栏上名字后面写的:有当前那一处时「第几处/共几处」(2/5),没有时「N 处」;看不见的也算在总数里。 */ export function occurrenceCount(highlight: Highlight): string { const total = highlight.occurrences.length return highlight.current === undefined ? `${total} 处` : `${highlight.current + 1}/${total}` } /** * 往后(step 1)/ 往前(step -1)数,下一处能跳到的出现的序号,到头了从另一头接着数;一处都跳不到时没有。 * 从当前那一处(current)数起;没有当前那一处时从源码下标 from(点中的名字)数起。 */ export function stepIndex( occurrences: readonly Occurrence[], current: number | undefined, from: number, step: 1 | -1, reachable: (occurrence: Occurrence) => boolean, ): number | undefined { const count = occurrences.length // 没有当前那一处时,起点是 from 前面最后一处(往后数)/ 后面第一处(往前数),数的时候跳过它本身所在的位置 const start = current ?? (step === 1 ? occurrences.filter(occurrence => occurrence.start <= from).length - 1 : count - occurrences.filter(occurrence => occurrence.start >= from).length) for (let k = 1; k <= count; k++) { const index = (((start + step * k) % count) + count) % count if (reachable(occurrences[index])) return index } return undefined } /** 按键之后多久内发来的跳转算这个键触发的:命令从 VS Code 绕一圈回到页面通常只要几十毫秒 */ export const KEY_TO_COMMAND_MS = 1000 /** 浏览器对一个键自己的滚动(⌘↓ 滚到底)按下后多久内会开始:它可能在命令发来之后才开始,这段时间里跳到的位置要守住 */ export const KEY_SCROLL_MS = 500 export interface ScrollPosition { x: number, y: number } /** * 默认的「下一处 / 上一处」快捷键:mac 是 ⌘↓ / ⌘↑,别的系统是 ctrl。 * 只用来取消浏览器滚到底 / 滚到顶,不拿它判断命令到了没有(命令是 VS Code 发来的)。 */ export function occurrenceJumpKey( event: { key: string, metaKey: boolean, ctrlKey: boolean, altKey: boolean, shiftKey: boolean }, mac: boolean, ): boolean { if (event.altKey || event.shiftKey || (event.key !== 'ArrowDown' && event.key !== 'ArrowUp')) return false return mac ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey } /** * 跳到下一处 / 上一处的命令由 VS Code 的快捷键系统触发。默认的 ⌘↓ / ⌘↑(Windows 是 ctrl)在能跳的时候会取消浏览器滚到底 / 滚到顶, * 但用户改过快捷键时别的键仍会自己滚,而且可能早于或晚于命令。所以任何键: * 按键那一刻记下页面滚在哪(press);命令发来时先回到那里(take)再滚到目标;跳完守住跳到的位置(hold), * 按键后 KEY_SCROLL_MS 内浏览器自己滚走了就拉回来(scrolled)。 * 我们自己滚到的位置用 own 记下(拉回时差的那一像素、滚进视口),对应的滚动事件不算浏览器滚走;事件可能晚于滚动调用才到。 * 按键后点了鼠标、滚了滚轮、又按了键,或跳转隔了 KEY_TO_COMMAND_MS 以上才来(从命令面板运行),都不算这个键触发的。筛选栏上的按钮另走,不经过这里。 */ export class KeyScroll { private pressed: { position: ScrollPosition, at: number } | undefined private held: { position: ScrollPosition, until: number } | undefined /** 我们自己滚到的位置。守着的这段时间里,落到这里不算浏览器把页面滚走。 */ private owned: ScrollPosition[] = [] /** 按下任何键(照常交给浏览器和 VS Code 处理)时记下页面滚在哪。 */ press(position: ScrollPosition, at: number): void { this.pressed = { position, at } this.held = undefined this.owned = [] } forget(): void { this.pressed = undefined this.held = undefined this.owned = [] } /** 记下我们马上要滚到的位置。滚动事件晚到也认。 */ own(position: ScrollPosition): void { this.owned.push(position) } /** 跳转发来时:刚按过键就交出按键那一刻的位置(只交一次),从这时起守住它,直到跳完换成跳到的位置。 */ take(now: number): ScrollPosition | undefined { const pressed = this.pressed this.pressed = undefined this.owned = [] this.held = pressed && now - pressed.at <= KEY_TO_COMMAND_MS ? { position: pressed.position, until: pressed.at + KEY_SCROLL_MS } : undefined return this.held?.position } /** 跳完了(滚到了目标,或没处可跳留在原地):按键触发的跳转改守这个位置。 */ hold(position: ScrollPosition): void { if (this.held) this.held = { ...this.held, position } } /** 页面滚动了:还在守着、又被浏览器滚走了,给出要拉回的位置。我们自己滚到的位置不算。 */ scrolled(position: ScrollPosition, now: number): ScrollPosition | undefined { const held = this.held if (!held || now > held.until) { this.held = undefined this.owned = [] return undefined } if (this.owned.some(item => item.x === position.x && item.y === position.y)) return undefined return held.position.x === position.x && held.position.y === position.y ? undefined : held.position } } /** * 单击名字高亮它在文档里的各处出现,和编辑器里一样每回点都现问扩展端,回答不留到下次用; * 只认最近一次询问的回答。回答到之前旧高亮留着(点同一个名字的另一处不闪),回答是空的就不高亮。 * 高亮着时能用键盘在各处出现之间跳(move),跳到的那处是当前那一处。 */ export class OccurrenceHighlight { private version: number | undefined private translatorId: string | undefined /** 最近一次询问的 id;别的回答(点了别处、清掉之后才到的)都丢掉 */ private request = 0 /** 最近一次询问点中的名字:源码下标和页面上显示的字;答过或清掉后没有 */ private asked: { offset: number, name: string } | undefined /** 高亮着的名字没有当前那一处时,从这个源码下标(点中的名字)数起跳到哪一处 */ private from = 0 private current: Highlight | undefined constructor( private readonly post: (msg: WebviewToExtension) => void, private readonly changed: (highlight: Highlight | undefined) => void, ) {} get highlight(): Highlight | undefined { return this.current } /** 换了文档版本或翻译:下标、页面上的名字都换了,高亮清掉 */ setDocument(version: number | undefined, translatorId: string | undefined): void { if (version === this.version && translatorId === this.translatorId) return this.version = version this.translatorId = translatorId this.clear() } click(action: ClickAction): void { if (action.kind === 'clear') this.clear() else if (action.kind === 'find') this.find(action.offset, action.name) } /** 回答照源码先后排好;点中的那处就是当前那一处。 */ answer(id: number, occurrences: Occurrence[]): void { const asked = this.asked if (id !== this.request || asked === undefined) return this.asked = undefined const sorted = [...occurrences].sort((a, b) => a.start - b.start || a.end - b.end) const current = sorted.findIndex(occurrence => occurrence.start <= asked.offset && asked.offset < occurrence.end) this.from = asked.offset this.show(sorted.length > 0 ? { name: asked.name, occurrences: sorted, ...(current >= 0 && { current }) } : undefined) } /** * 当前那一处换成往后(step 1)/ 往前(step -1)的下一处能跳到的出现(见 stepIndex),返回它; * 没有高亮、一处都跳不到时不动,返回 undefined。 */ move(step: 1 | -1, reachable: (occurrence: Occurrence) => boolean): Occurrence | undefined { const highlight = this.current const index = highlight && stepIndex(highlight.occurrences, highlight.current, this.from, step, reachable) if (!highlight || index === undefined) return undefined this.show({ ...highlight, current: index }) return highlight.occurrences[index] } clear(): void { this.request++ this.asked = undefined this.show(undefined) } private find(offset: number, name: string): void { if (this.version === undefined) return this.asked = { offset, name } this.post({ type: 'findOccurrences', id: ++this.request, version: this.version, offset }) } private show(highlight: Highlight | undefined): void { if (highlight === this.current) return this.current = highlight this.changed(highlight) } } /** 能不能用键盘跳要看的几件事。 */ export interface NavigationFacts { /** 有名字高亮着 */ highlighted: boolean /** 页面有焦点:按的键才会到这个页面 */ focused: boolean /** 焦点在输入框里:按的键归输入框(打字、移光标),不拿来跳 */ typing: boolean } /** * 眼下能不能用键盘在高亮着的名字的各处出现之间跳:有高亮、页面有焦点、焦点不在输入框里。变了才告诉扩展端, * 扩展端跟着设快捷键 when 子句用的上下文键;页面也按它决定要不要拦下这些键在浏览器里的默认动作(滚到顶、滚到底)。 */ export class OccurrenceNavigation { private facts: NavigationFacts private reported: boolean | undefined constructor(private readonly post: (msg: WebviewToExtension) => void, initial: Omit) { this.facts = { highlighted: false, ...initial } } get available(): boolean { const { highlighted, focused, typing } = this.facts return highlighted && focused && !typing } /** 改了哪几件事;第一次调用时不管变没变都告诉扩展端(页面重新加载后扩展端记的可能是旧页面的) */ update(change: Partial): void { this.facts = { ...this.facts, ...change } this.report() } private report(): void { const { available } = this if (available === this.reported) return this.reported = available this.post({ type: 'occurrenceNavigation', available }) } }