221 lines
8.4 KiB
TypeScript
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)
|
|
}
|
|
}
|