333 lines
15 KiB
TypeScript
333 lines
15 KiB
TypeScript
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<NavigationFacts, 'highlighted'>) {
|
|
this.facts = { highlighted: false, ...initial }
|
|
}
|
|
|
|
get available(): boolean {
|
|
const { highlighted, focused, typing } = this.facts
|
|
return highlighted && focused && !typing
|
|
}
|
|
|
|
/** 改了哪几件事;第一次调用时不管变没变都告诉扩展端(页面重新加载后扩展端记的可能是旧页面的) */
|
|
update(change: Partial<NavigationFacts>): 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 })
|
|
}
|
|
}
|