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

221 lines
8.4 KiB
TypeScript

import type { CommentLink } from './repoLink'
import type { DefinitionPeek, WebviewToExtension } from './types'
/**
* 定义卡片浮窗的根上的属性,值是卡片那份文件([DefinitionPeek] 的 `uri`):浮窗里的名字、链接是那份文件的,页面上管名字的那几处
* (跳到定义、高亮各处出现、再弹卡片)都不认;注释里不带路径的引用带上它,扩展端按那份文件找(见 commentLinkOf)。
*/
export const PEEK_ATTRIBUTE = 'data-peek'
/** 能弹定义卡片的东西:带源码下标的名字,或注释里指到符号的链接(带 #符号 的仓库链接、不带路径的引用,后者带着写在哪块、哪份文件)。 */
export type PeekSource = { offset: number } | (CommentLink & { symbol: string })
/** 指针下能弹定义卡片的一处:页面上的哪个元素(浮窗挂在它旁边),它是什么。 */
export interface PeekTarget<A> {
anchor: A
source: PeekSource
}
/** 弹着的定义卡片:挂在哪个元素旁边、卡片,点标题时发什么(同 ⌘ / Ctrl + 点击这个名字、点这个链接)。 */
export interface ShownPeek<A> {
anchor: A
peek: DefinitionPeek
jump: WebviewToExtension
}
/** 元素在不在定义卡片浮窗里。 */
export function inPeek(target: Element): boolean {
return target.closest(`[${PEEK_ATTRIBUTE}]`) !== null
}
/** 事件落在的那个页面上这份文档的名字(SegmentText 渲染的 data-offset);浮窗里的名字下标是别的文件的,不算。 */
export function documentNameAt(target: EventTarget | null): HTMLElement | null {
const name = target instanceof Element ? target.closest<HTMLElement>('[data-offset]') : null
return name && !inPeek(name) ? name : null
}
/** 注释里一个链接(见 commentLinkOf)能不能弹定义卡片:指到符号的才能(带 #符号 的仓库链接、不带路径的引用),只指到文件、目录的不能。 */
export function linkPeekSource(link: CommentLink | undefined): PeekSource | undefined {
return link?.symbol === undefined ? undefined : { ...link, symbol: link.symbol }
}
/** 浮窗与名字之间的间距。 */
const GAP_PX = 4
/** 贴边留白,浮窗不顶到 webview 边缘。 */
const GUTTER_PX = 8
/**
* 浮窗落在哪:top 是在名字下方、从这儿往下长,bottom 是在名字上方、底边离视口底这么远、往上长(卡片展开时不盖住名字);
* maxWidth、maxHeight 是那边还剩的空间,卡片再大就在浮窗里滚。
*/
export type PeekPlacement = { left: number, maxWidth: number, maxHeight: number } & ({ top: number } | { bottom: number })
/**
* 浮窗放在哪(同 VS Code 编辑器里的悬停):优先在名字下方,下方放不下、上方空间更大就放上方。
* 左边对齐名字,钳在视口内。size 是浮窗不受限时的大小。
*/
export function placePeek(
anchor: Pick<DOMRect, 'top' | 'bottom' | 'left'>,
size: Pick<DOMRect, 'width' | 'height'>,
viewport: { width: number, height: number },
): PeekPlacement {
const below = viewport.height - GUTTER_PX - (anchor.bottom + GAP_PX)
const above = anchor.top - GAP_PX - GUTTER_PX
const left = Math.max(GUTTER_PX, Math.min(anchor.left, viewport.width - GUTTER_PX - size.width))
const maxWidth = viewport.width - GUTTER_PX - left
if (size.height <= below || below >= above)
return { top: anchor.bottom + GAP_PX, left, maxWidth, maxHeight: below }
return { bottom: viewport.height - (anchor.top - GAP_PX), left, maxWidth, maxHeight: above }
}
/**
* 定义卡片的浮窗什么时候弹、换成谁、什么时候收(同 VS Code 编辑器里的悬停;不碰 DOM,页面把指针、按键、滚动喂进来,anchor 是页面上的元素):
* 鼠标停在能弹的名字、链接上 delay 毫秒后才问扩展端要卡片,没到时间就移开的作罢;回答带卡片才弹,同一时刻只有一个浮窗。
* 移开名字、浮窗两者 hidingDelay 毫秒后收,期间移进浮窗或移回名字就不收;指针在浮窗里时不弹别的。
* Esc、滚动、按下鼠标、换了文档版本立刻收。只认最近一次询问的回答,迟到的丢掉。两个延迟都取 VS Code 的设置(hoverDelay),没收到之前不弹。
*/
export class DefinitionPeeks<A> {
private version: number | undefined
private delays: { delay: number, hidingDelay: number } | undefined
/** 指针下能弹卡片的那一处(浮窗外);没指着的、指针在浮窗里时是 null */
private hovered: A | null = null
private inPopup = false
private askTimer: ReturnType<typeof setTimeout> | undefined
private hideTimer: ReturnType<typeof setTimeout> | undefined
private request = 0
/** 最近一次发出去、还没答的询问 */
private asked: { id: number, anchor: A, jump: WebviewToExtension } | undefined
private shown: ShownPeek<A> | undefined
constructor(
private readonly post: (msg: WebviewToExtension) => void,
private readonly onChange: (shown: ShownPeek<A> | undefined) => void,
) {}
/** VS Code 的 editor.hover.delay / editor.hover.hidingDelay */
setDelays(delay: number, hidingDelay: number): void {
this.delays = { delay, hidingDelay }
}
/** 换了一份文档:名字的下标换了意思,收起浮窗、作罢没问完的;指针下的名字等它再动时重新认 */
setVersion(version: number | undefined): void {
if (version === this.version)
return
this.version = version
this.hovered = null
this.dismiss()
}
/** 指针在浮窗外:指着能弹卡片的一处,或没指着(null) */
point(target: PeekTarget<A> | null): void {
const anchor = target?.anchor ?? null
if (!this.inPopup && anchor === this.hovered)
return
this.inPopup = false
this.hovered = anchor
this.cancelAsk()
if (this.shown) {
if (anchor === this.shown.anchor)
this.cancelHide()
else
this.scheduleHide()
}
if (target && anchor !== this.shown?.anchor)
this.scheduleAsk(target)
}
/** 指针移进浮窗:不收,也不弹别的 */
enterPopup(): void {
if (this.inPopup)
return
this.inPopup = true
this.hovered = null
this.cancelAsk()
this.cancelHide()
}
/** id 那次询问的回答;不是最近一次的、指针已经移开的丢掉 */
answer(id: number, peek: DefinitionPeek | undefined): void {
if (this.asked?.id !== id)
return
const { anchor, jump } = this.asked
this.asked = undefined
if (!peek)
return
this.cancelHide()
this.setShown({ anchor, peek, jump })
}
/** 点了浮窗的标题:跳到定义(同 ⌘ / Ctrl + 点击名字、点链接),收起浮窗 */
jump(): void {
const jump = this.shown?.jump
this.dismiss()
if (jump)
this.post(jump)
}
/** 立刻收,作罢没问完的;指针不离开眼下这一处就不再弹 */
dismiss(): void {
this.inPopup = false
this.cancelAsk()
this.cancelHide()
this.setShown(undefined)
}
dispose(): void {
clearTimeout(this.askTimer)
clearTimeout(this.hideTimer)
}
private scheduleAsk(target: PeekTarget<A>): void {
if (!this.delays)
return
this.askTimer = setTimeout(() => {
this.askTimer = undefined
this.ask(target)
}, this.delays.delay)
}
private ask({ anchor, source }: PeekTarget<A>): void {
const id = ++this.request
if ('offset' in source) {
if (this.version === undefined)
return
const { version } = this
this.asked = { id, anchor, jump: { type: 'goToDefinition', version, offset: source.offset } }
this.post({ type: 'peekDefinition', id, version, offset: source.offset })
return
}
this.asked = { id, anchor, jump: { type: 'openRepoLink', ...source } }
this.post({ type: 'peekLink', id, ...source })
}
private cancelAsk(): void {
clearTimeout(this.askTimer)
this.askTimer = undefined
this.asked = undefined
}
/** 已经在等着收的不重新计时:从名字移向浮窗时扫过别的名字,不把收的时间往后推 */
private scheduleHide(): void {
if (this.hideTimer !== undefined || !this.delays)
return
this.hideTimer = setTimeout(() => {
this.hideTimer = undefined
this.setShown(undefined)
}, this.delays.hidingDelay)
}
private cancelHide(): void {
clearTimeout(this.hideTimer)
this.hideTimer = undefined
}
private setShown(shown: ShownPeek<A> | undefined): void {
if (shown === this.shown)
return
this.shown = shown
this.onChange(shown)
}
}