✨ feat(vscode): 代码阅读鼠标停在名字和注释链接上弹出定义所在那块的卡片,别的文件的也行
This commit is contained in:
1 parent
bc7380c716
commit
a48b016d73
20 files changed
+1145
-89
No files matched your search
@@ -0,0 +1,221 @@
|
||||
import type { DefinitionPeek, WebviewToExtension } from './types'
|
||||
import { parseCommentLink } from './repoLink'
|
||||
|
||||
/** 定义卡片浮窗的根上的属性:浮窗里的名字、链接是别的文件的,页面上管名字的那几处(跳到定义、高亮各处出现、再弹卡片)都不认。 */
|
||||
export const PEEK_ATTRIBUTE = 'data-peek'
|
||||
|
||||
/** 能弹定义卡片的东西:带源码下标的名字,或注释里指到符号的链接(带 #符号 的仓库链接、不带路径的引用)。 */
|
||||
export type PeekSource = { offset: number } | { path?: string, 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
|
||||
}
|
||||
|
||||
/** 注释里一个链接地址能不能弹定义卡片:指到符号的才能(带 #符号 的仓库链接、不带路径的引用),只指到文件、目录的不能。 */
|
||||
export function linkPeekSource(href: string): PeekSource | undefined {
|
||||
const link = parseCommentLink(href)
|
||||
if (link?.symbol === undefined)
|
||||
return undefined
|
||||
return link.path === undefined ? { symbol: link.symbol } : { path: link.path, 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
|
||||
}
|
||||
const link = source.path === undefined ? { symbol: source.symbol } : { path: source.path, symbol: source.symbol }
|
||||
this.asked = { id, anchor, jump: { type: 'openRepoLink', ...link } }
|
||||
this.post({ type: 'peekLink', id, ...link })
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user