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 { anchor: A source: PeekSource } /** 弹着的定义卡片:挂在哪个元素旁边、卡片,点标题时发什么(同 ⌘ / Ctrl + 点击这个名字、点这个链接)。 */ export interface ShownPeek { 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('[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, size: Pick, 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 { private version: number | undefined private delays: { delay: number, hidingDelay: number } | undefined /** 指针下能弹卡片的那一处(浮窗外);没指着的、指针在浮窗里时是 null */ private hovered: A | null = null private inPopup = false private askTimer: ReturnType | undefined private hideTimer: ReturnType | undefined private request = 0 /** 最近一次发出去、还没答的询问 */ private asked: { id: number, anchor: A, jump: WebviewToExtension } | undefined private shown: ShownPeek | undefined constructor( private readonly post: (msg: WebviewToExtension) => void, private readonly onChange: (shown: ShownPeek | 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 | 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): void { if (!this.delays) return this.askTimer = setTimeout(() => { this.askTimer = undefined this.ask(target) }, this.delays.delay) } private ask({ anchor, source }: PeekTarget): 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 | undefined): void { if (shown === this.shown) return this.shown = shown this.onChange(shown) } }