🐛 fix(vscode): 悬停提示不用原生 title,webview 统一用自绘提示框,代码阅读的种类图标停上去能看到种类名

VS Code 1.139 带的 Electron 43 在 macOS 上只在鼠标压着带 title 的元素自身边缘时才弹原生提示,
停在它里面的图标、文字上不弹(electron/electron#49843,Electron 38 起)。种类图标 20px 的框
几乎被 16px 的图形占满,所以基本看不到种类名;块右上角按钮同样受影响。页面上核对过:图标中心点
命中的是图标里的线条,没有盖在上面的东西。

- components/ui/tooltip.tsx 改成整页只挂一次的 TooltipHost:元素只写 data-tooltip,事件统一交给它;
  样子照 VS Code 悬停框(editorHoverWidget 那组变量、widget-shadow 阴影),停 500ms 才弹、弹着时换元素
  立刻换,移开 / 按下 / 按键 / 滚动收起;键盘聚焦立刻弹,提示不是元素自己的名字时挂 aria-describedby
- 代码阅读里所有 title(种类图标、块按钮、筛选栏、问答区、中文名的「原名」)改成 data-tooltip;
  SelectMenu 的 title 属性改名 tooltip;看板工单详情原来包一层的 Tooltip 也改成 data-tooltip
- 何时弹 / 收的规则抽成纯函数加测试;更新代码阅读 spec
This commit is contained in:
cruldra committed 2026-09-27 21:21:13 +08:00
1 parent 15790ce7c8
commit ec34ace7cf
17 files changed
+399 -187

No files matched your search

@@ -9,7 +9,6 @@ import { isSessionMissing } from '../lib/missingSession'
import { toReadOnlySchema } from '../lib/readOnlySchema'
import { COLUMN_LABELS, COLUMN_ORDER } from '../types'
import { PropertyGrid } from './property-grid'
import { Tooltip } from './ui/tooltip'
/** 会话 id 在共享状态里、本机却没有会话文件时,各会话行的统一说明。 */
const MISSING_SESSION_DESC = '本机没有该会话文件(会话在别的机器上创建),无法 resume'
@@ -839,86 +838,81 @@ export function IssueDetailPanel({
</span>
)
: null}
<Tooltip label="在浏览器里打开">
<button
type="button"
onClick={() => onOpenInBrowser(issue.htmlUrl)}
className="inline-flex shrink-0 items-center gap-1 rounded border border-[var(--vscode-panel-border)] px-2 py-1 text-xs hover:bg-black/10 dark:hover:bg-white/10"
>
<ExternalLink className="size-3.5" />
{issue.source === 'youtrack' ? '在 YouTrack 打开' : '在 Gitea 打开'}
</button>
</Tooltip>
<button
type="button"
data-tooltip="在浏览器里打开"
onClick={() => onOpenInBrowser(issue.htmlUrl)}
className="inline-flex shrink-0 items-center gap-1 rounded border border-[var(--vscode-panel-border)] px-2 py-1 text-xs hover:bg-black/10 dark:hover:bg-white/10"
>
<ExternalLink className="size-3.5" />
{issue.source === 'youtrack' ? '在 YouTrack 打开' : '在 Gitea 打开'}
</button>
{/* Close/delete write to gitea by number — hide for youtrack cards to
avoid acting on a gitea issue of the same number. */}
{issue.source !== 'youtrack' && !readOnly && (
<>
{issue.handoffAttachmentId && me !== undefined && (issue.assignees ?? []).includes(me) && (
<Tooltip label={`接管 ${issue.handoffFrom ?? '?'} 的移交`}>
<button
type="button"
disabled={isHandoffRunning(issue.number)}
onClick={() => onAcceptHandoff(issue.number)}
aria-label="接管"
className="inline-flex shrink-0 items-center gap-1 rounded border border-green-500/60 bg-green-500/10 px-2 py-1 text-xs text-green-500 hover:bg-green-500/20 disabled:opacity-50"
>
{isHandoffRunning(issue.number) ? <Loader2 className="size-3.5 animate-spin" /> : <Download className="size-3.5" />}
接管
</button>
</Tooltip>
<button
type="button"
data-tooltip={`接管 ${issue.handoffFrom ?? '?'} 的移交`}
disabled={isHandoffRunning(issue.number)}
onClick={() => onAcceptHandoff(issue.number)}
aria-label="接管"
className="inline-flex shrink-0 items-center gap-1 rounded border border-green-500/60 bg-green-500/10 px-2 py-1 text-xs text-green-500 hover:bg-green-500/20 disabled:opacity-50"
>
{isHandoffRunning(issue.number) ? <Loader2 className="size-3.5 animate-spin" /> : <Download className="size-3.5" />}
接管
</button>
)}
{!issue.handoffAttachmentId && issue.column !== 'done' && ((issue.assignees ?? []).length === 0 || (me !== undefined && (issue.assignees ?? []).includes(me))) && (
<Tooltip label="移交给同事">
<button
type="button"
disabled={isHandoffRunning(issue.number)}
onClick={() => onStartHandoff(issue.number)}
aria-label="移交"
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-blue-500/60 hover:bg-blue-500/10 hover:text-blue-500 disabled:opacity-50"
>
{isHandoffRunning(issue.number) ? <Loader2 className="size-3.5 animate-spin" /> : <ArrowRightLeft className="size-3.5" />}
</button>
</Tooltip>
<button
type="button"
data-tooltip="移交给同事"
disabled={isHandoffRunning(issue.number)}
onClick={() => onStartHandoff(issue.number)}
aria-label="移交"
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-blue-500/60 hover:bg-blue-500/10 hover:text-blue-500 disabled:opacity-50"
>
{isHandoffRunning(issue.number) ? <Loader2 className="size-3.5 animate-spin" /> : <ArrowRightLeft className="size-3.5" />}
</button>
)}
{issue.handoffAttachmentId && !(me !== undefined && (issue.assignees ?? []).includes(me)) && (
<Tooltip label={`${issue.handoffFrom ?? '?'} 已移交,等 ${(issue.assignees ?? []).join(', ') || '?'} 接管`}>
<span className="inline-flex shrink-0 items-center rounded border border-[var(--vscode-panel-border)] px-1.5 py-0.5 text-[10px] opacity-70">
待接管
</span>
</Tooltip>
<span
className="inline-flex shrink-0 items-center rounded border border-[var(--vscode-panel-border)] px-1.5 py-0.5 text-[10px] opacity-70"
data-tooltip={`${issue.handoffFrom ?? '?'} 已移交,等 ${(issue.assignees ?? []).join(', ') || '?'} 接管`}
>
待接管
</span>
)}
{(issue.column === 'in-progress' || issue.column === 'review') && (
<Tooltip label="重置为待办(停会话、删 worktree 和未合并 PR)">
<button
type="button"
onClick={() => onResetToTodo(issue.number)}
aria-label="重置为待办"
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-yellow-500/60 hover:bg-yellow-500/10 hover:text-yellow-500"
>
<RotateCcw className="size-3.5" />
</button>
</Tooltip>
)}
<Tooltip label="关闭工单(Gitea 里可重开)">
<button
type="button"
onClick={() => onCloseIssue(issue.number)}
aria-label="关闭工单"
data-tooltip="重置为待办(停会话、删 worktree 和未合并 PR)"
onClick={() => onResetToTodo(issue.number)}
aria-label="重置为待办"
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-yellow-500/60 hover:bg-yellow-500/10 hover:text-yellow-500"
>
<CircleSlash className="size-3.5" />
<RotateCcw className="size-3.5" />
</button>
</Tooltip>
<Tooltip label="删除工单(不可撤销)">
<button
type="button"
onClick={() => issue && onDeleteIssue?.(issue.number)}
aria-label="删除工单"
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-red-500/60 hover:bg-red-500/10 hover:text-red-500"
>
<Trash2 className="size-3.5" />
</button>
</Tooltip>
)}
<button
type="button"
data-tooltip="关闭工单(Gitea 里可重开)"
onClick={() => onCloseIssue(issue.number)}
aria-label="关闭工单"
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-yellow-500/60 hover:bg-yellow-500/10 hover:text-yellow-500"
>
<CircleSlash className="size-3.5" />
</button>
<button
type="button"
data-tooltip="删除工单(不可撤销)"
onClick={() => issue && onDeleteIssue?.(issue.number)}
aria-label="删除工单"
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-red-500/60 hover:bg-red-500/10 hover:text-red-500"
>
<Trash2 className="size-3.5" />
</button>
</>
)}
</div>
@@ -424,7 +424,7 @@ function SessionRow({ session, profiles, onRename, onResume, onDelete, onCloseTa
<SelectMenu
value={pickedProfile}
onChange={setUserPicked}
title="换 profile 后点行打开;已打开的终端下次才生效"
tooltip="换 profile 后点行打开;已打开的终端下次才生效"
variant="ghost"
options={[
...(storedMissing
@@ -15,7 +15,7 @@ export interface SelectMenuProps {
options: SelectMenuOption[]
onChange: (value: string) => void
placeholder?: string
title?: string
tooltip?: string
disabled?: boolean
variant?: 'input' | 'ghost'
className?: string
@@ -60,7 +60,7 @@ export function SelectMenu({
options,
onChange,
placeholder = '请选择',
title,
tooltip,
disabled = false,
variant = 'input',
className,
@@ -265,7 +265,7 @@ export function SelectMenu({
ref={triggerRef}
type="button"
disabled={disabled}
title={title}
data-tooltip={tooltip}
aria-haspopup="listbox"
aria-expanded={open}
onClick={onTriggerClick}
@@ -334,8 +334,8 @@ export function SelectMenu({
<Check className="size-3.5" />
</span>
<span className="flex min-w-0 flex-col">
<span className="truncate" title={opt.label}>{opt.label}</span>
{opt.hint && <span className="truncate text-[10px] opacity-70" title={opt.hint}>{opt.hint}</span>}
<span className="truncate" data-tooltip={opt.label}>{opt.label}</span>
{opt.hint && <span className="truncate text-[10px] opacity-70" data-tooltip={opt.hint}>{opt.hint}</span>}
</span>
</button>
))}
@@ -0,0 +1,123 @@
import type { Tip, TooltipController } from './tooltip'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createTooltipController, placeTooltip, SHOW_DELAY_MS } from './tooltip'
interface Anchor { text: string }
const icon: Anchor = { text: '函数' }
const route: Anchor = { text: '接口' }
const blank: Anchor = { text: '' }
function setup(): { controller: TooltipController<Anchor>, changes: Array<Tip<Anchor> | null> } {
const changes: Array<Tip<Anchor> | null> = []
const controller = createTooltipController<Anchor>(anchor => anchor.text, tip => changes.push(tip))
return { controller, changes }
}
describe('悬停提示什么时候出、什么时候收', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('鼠标停在带提示的元素上,过一小会儿才弹', () => {
const { controller, changes } = setup()
controller.hover(icon)
vi.advanceTimersByTime(SHOW_DELAY_MS - 1)
expect(changes).toEqual([])
vi.advanceTimersByTime(1)
expect(changes).toEqual([{ anchor: icon, text: '函数' }])
})
it('在同一个元素里移动(划过图标的线条和空白)不重新计时', () => {
const { controller, changes } = setup()
controller.hover(icon)
vi.advanceTimersByTime(SHOW_DELAY_MS - 100)
controller.hover(icon)
vi.advanceTimersByTime(100)
expect(changes).toEqual([{ anchor: icon, text: '函数' }])
})
it('没等到就移开,不弹', () => {
const { controller, changes } = setup()
controller.hover(icon)
vi.advanceTimersByTime(SHOW_DELAY_MS / 2)
controller.hover(null)
vi.advanceTimersByTime(SHOW_DELAY_MS)
expect(changes).toEqual([])
})
it('弹着时移到另一个带提示的元素上立刻换,移到没有提示的地方就收', () => {
const { controller, changes } = setup()
controller.hover(icon)
vi.advanceTimersByTime(SHOW_DELAY_MS)
controller.hover(route)
expect(changes.at(-1)).toEqual({ anchor: route, text: '接口' })
controller.hover(null)
expect(changes.at(-1)).toBeNull()
controller.hover(icon)
expect(changes.at(-1)).toBeNull()
vi.advanceTimersByTime(SHOW_DELAY_MS)
expect(changes.at(-1)).toEqual({ anchor: icon, text: '函数' })
})
it('按下鼠标、按键、滚动就收;鼠标不离开这个元素不再弹,移出去再回来照常弹', () => {
const { controller, changes } = setup()
controller.hover(icon)
vi.advanceTimersByTime(SHOW_DELAY_MS)
controller.dismiss()
expect(changes.at(-1)).toBeNull()
controller.hover(icon)
vi.advanceTimersByTime(SHOW_DELAY_MS)
expect(changes.at(-1)).toBeNull()
controller.hover(null)
controller.hover(icon)
vi.advanceTimersByTime(SHOW_DELAY_MS)
expect(changes.at(-1)).toEqual({ anchor: icon, text: '函数' })
})
it('等着弹的时候按下鼠标,这次不弹了', () => {
const { controller, changes } = setup()
controller.hover(icon)
controller.dismiss()
vi.advanceTimersByTime(SHOW_DELAY_MS)
expect(changes).toEqual([])
})
it('键盘聚焦立刻弹,焦点离开就收;别的元素失焦不影响', () => {
const { controller, changes } = setup()
controller.focus(route)
expect(changes).toEqual([{ anchor: route, text: '接口' }])
controller.blur(icon)
expect(changes).toHaveLength(1)
controller.blur(route)
expect(changes.at(-1)).toBeNull()
})
it('提示文字是空的不弹', () => {
const { controller, changes } = setup()
controller.hover(blank)
vi.advanceTimersByTime(SHOW_DELAY_MS)
controller.focus(blank)
expect(changes).toEqual([])
})
})
describe('提示框放在哪', () => {
const size = { width: 40, height: 18 }
it('上方放得下就在上方,水平对着元素中间', () => {
expect(placeTooltip({ top: 100, bottom: 120, left: 50, width: 20 }, size, 900)).toEqual({ top: 76, left: 40 })
})
it('上方放不下翻到下方', () => {
expect(placeTooltip({ top: 10, bottom: 30, left: 50, width: 20 }, size, 900)).toEqual({ top: 36, left: 40 })
})
it('左右钳在视口里,贴边留 8px', () => {
expect(placeTooltip({ top: 100, bottom: 120, left: 0, width: 20 }, size, 900).left).toBe(8)
expect(placeTooltip({ top: 100, bottom: 120, left: 890, width: 10 }, size, 900).left).toBe(852)
})
})
+167 -80
View File
@@ -1,95 +1,182 @@
import type { ReactElement, ReactNode } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { ReactElement } from 'react'
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { cn } from '@/lib/utils'
/** 悬停多久才弹,避免扫过一排按钮时闪一串气泡。 */
const SHOW_DELAY_MS = 300
/** 气泡与触发元素之间的间距。 */
/** 鼠标停多久才弹,扫过一排按钮、一行代码时不闪一串提示。 */
export const SHOW_DELAY_MS = 500
/** 提示框与元素之间的间距。 */
const GAP_PX = 6
/** 贴边留白,气泡不顶到 webview 边缘。 */
/** 贴边留白,提示框不顶到 webview 边缘。 */
const GUTTER_PX = 8
interface TooltipProps {
/** 气泡文案;为空则不弹。 */
label: ReactNode
children: ReactNode
className?: string
/** 正在显示的提示:挂在哪个元素上、写什么。 */
export interface Tip<A> {
anchor: A
text: string
}
export interface TooltipController<A> {
/** 鼠标移到这个带提示的元素上;null 是移到了没有提示的地方或移出页面 */
hover: (anchor: A | null) => void
/** 键盘把焦点移到这个带提示的元素上 */
focus: (anchor: A) => void
/** 焦点离开这个元素 */
blur: (anchor: A) => void
/** 按下鼠标、按键、滚动:收起,鼠标不离开眼下这个元素就不再弹 */
dismiss: () => void
dispose: () => void
}
/**
* 悬停提示气泡。
*
* 原生 `title` 在 webview 里延迟长、样式是系统的,暗色主题下很突兀,
* 这里自己渲染:portal 到 body,避开 overflow 容器裁剪;位置优先在上方,
* 上方放不下就翻到下方,左右钳在视口内。
* 悬停提示什么时候出、换成谁、什么时候收(不碰 DOM,TooltipHost 把页面事件喂进来):
* 鼠标停在带提示的元素上 SHOW_DELAY_MS 后出,已经出着时移到另一个上立刻换;键盘聚焦立刻出;移开、失焦、dismiss 就收。
*/
export function Tooltip({ label, children, className }: TooltipProps): ReactElement {
const [visible, setVisible] = useState(false)
const wrapRef = useRef<HTMLSpanElement | null>(null)
export function createTooltipController<A>(textOf: (anchor: A) => string, onChange: (tip: Tip<A> | null) => void): TooltipController<A> {
let hovered: A | null = null
let shown: Tip<A> | null = null
let timer: ReturnType<typeof setTimeout> | undefined
const set = (tip: Tip<A> | null): void => {
clearTimeout(timer)
timer = undefined
if (tip?.anchor === shown?.anchor && tip?.text === shown?.text)
return
shown = tip
onChange(tip)
}
const show = (anchor: A): void => {
const text = textOf(anchor)
set(text ? { anchor, text } : null)
}
const schedule = (anchor: A): void => {
clearTimeout(timer)
timer = setTimeout(show, SHOW_DELAY_MS, anchor)
}
return {
hover(anchor) {
if (anchor === hovered)
return
hovered = anchor
if (anchor === null)
set(null)
else if (shown)
show(anchor)
else
schedule(anchor)
},
focus: show,
blur(anchor) {
if (shown?.anchor === anchor)
set(null)
},
dismiss: () => set(null),
dispose: () => clearTimeout(timer),
}
}
/** 提示框放在哪:优先在元素上方,上方放不下翻到下方;水平对着元素中间,钳在视口内。 */
export function placeTooltip(
anchor: Pick<DOMRect, 'top' | 'bottom' | 'left' | 'width'>,
size: Pick<DOMRect, 'width' | 'height'>,
viewportWidth: number,
): { top: number, left: number } {
const above = anchor.top - size.height - GAP_PX
const centered = anchor.left + anchor.width / 2 - size.width / 2
return {
top: above >= GUTTER_PX ? above : anchor.bottom + GAP_PX,
left: Math.max(GUTTER_PX, Math.min(centered, viewportWidth - GUTTER_PX - size.width)),
}
}
/** 页面上带提示的元素:提示文字写在 data-tooltip 上。 */
function anchorOf(target: EventTarget | null): Element | null {
return target instanceof Element ? target.closest('[data-tooltip]:not([data-tooltip=""])') : null
}
/** 提示是不是已经是元素的名字(aria-label 或它的字):是就不再挂成描述,读屏不念两遍。 */
function isOwnName(anchor: Element, text: string): boolean {
return anchor.getAttribute('aria-label') === text || anchor.textContent?.trim() === text
}
/**
* 整个 webview 唯一的悬停提示:页面根上挂一次,元素只写 data-tooltip="提示"(名字照旧写在 aria-label 里)。
* 不用原生 title:macOS 上 VS Code 的 Electron 只在鼠标压着元素自身边缘时才弹原生提示,停在里面的图标、文字上不弹
* (electron/electron#49843),样式也是系统的。这里画成 VS Code 悬停框的样子(--vscode-editorHoverWidget-*),
* portal 到 body 不被 overflow 容器裁掉;键盘聚焦(:focus-visible)立刻弹,并用 aria-describedby 挂到元素上。
*/
export function TooltipHost(): ReactElement | null {
const id = useId()
const [tip, setTip] = useState<Tip<Element> | null>(null)
const bubbleRef = useRef<HTMLDivElement | null>(null)
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const hide = useCallback((): void => {
clearTimeout(timerRef.current)
setVisible(false)
}, [])
const show = useCallback((): void => {
if (!label)
return
clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => {
if (wrapRef.current)
setVisible(true)
}, SHOW_DELAY_MS)
}, [label])
useEffect(() => () => clearTimeout(timerRef.current), [])
// 气泡先在左上角量一次尺寸再落位。必须量在 left:0:fixed 元素的可用宽度是
// 「视口宽 - left」,贴右边量会被挤窄、行数变多,按那个高度定位就会浮高一截。
useEffect(() => {
if (!visible)
return
const bubble = bubbleRef.current
const rect = wrapRef.current?.getBoundingClientRect()
if (!bubble || !rect)
return
const { width, height } = bubble.getBoundingClientRect()
const above = rect.top - height - GAP_PX
const top = above >= GUTTER_PX ? above : rect.bottom + GAP_PX
let left = rect.left + rect.width / 2 - width / 2
left = Math.max(GUTTER_PX, Math.min(left, window.innerWidth - GUTTER_PX - width))
bubble.style.top = `${top}px`
bubble.style.left = `${left}px`
bubble.style.visibility = 'visible'
}, [visible])
let described: Element | null = null
const controller = createTooltipController<Element>(
anchor => anchor.getAttribute('data-tooltip') ?? '',
(next) => {
described?.removeAttribute('aria-describedby')
described = next && !isOwnName(next.anchor, next.text) ? next.anchor : null
described?.setAttribute('aria-describedby', id)
setTip(next)
},
)
const onPointerOver = (event: PointerEvent): void => controller.hover(anchorOf(event.target))
const onPointerOut = (event: PointerEvent): void => {
if (event.relatedTarget === null)
controller.hover(null)
}
const onFocusIn = (event: FocusEvent): void => {
const anchor = anchorOf(event.target)
if (anchor && event.target instanceof Element && event.target.matches(':focus-visible'))
controller.focus(anchor)
}
const onFocusOut = (event: FocusEvent): void => {
const anchor = anchorOf(event.target)
if (anchor)
controller.blur(anchor)
}
const dismiss = (): void => controller.dismiss()
document.addEventListener('pointerover', onPointerOver)
document.addEventListener('pointerout', onPointerOut)
document.addEventListener('focusin', onFocusIn)
document.addEventListener('focusout', onFocusOut)
document.addEventListener('pointerdown', dismiss, true)
document.addEventListener('keydown', dismiss, true)
window.addEventListener('scroll', dismiss, true)
return () => {
document.removeEventListener('pointerover', onPointerOver)
document.removeEventListener('pointerout', onPointerOut)
document.removeEventListener('focusin', onFocusIn)
document.removeEventListener('focusout', onFocusOut)
document.removeEventListener('pointerdown', dismiss, true)
document.removeEventListener('keydown', dismiss, true)
window.removeEventListener('scroll', dismiss, true)
controller.dispose()
described?.removeAttribute('aria-describedby')
}
}, [id])
return (
<>
<span
ref={wrapRef}
className={cn('inline-flex shrink-0', className)}
onMouseEnter={show}
onMouseLeave={hide}
onMouseDown={hide}
onFocus={show}
onBlur={hide}
>
{children}
</span>
{visible && label && createPortal(
<div
ref={bubbleRef}
role="tooltip"
style={{ top: 0, left: 0, visibility: 'hidden' }}
className="pointer-events-none fixed z-50 max-w-[22rem] rounded border border-[var(--vscode-editorHoverWidget-border,var(--vscode-panel-border))] bg-[var(--vscode-editorHoverWidget-background,var(--vscode-editorWidget-background))] px-2 py-1 text-[11px] leading-tight break-words text-[var(--vscode-editorHoverWidget-foreground,var(--vscode-foreground))] shadow-md"
>
{label}
</div>,
document.body,
)}
</>
// 先在左上角量尺寸再落位:fixed 元素的可用宽度是「视口宽 - left」,贴右边量会被挤窄、行数变多,按那个高度定位就浮高一截
useLayoutEffect(() => {
const bubble = bubbleRef.current
if (!tip || !bubble)
return
Object.assign(bubble.style, { top: '0px', left: '0px' })
const { top, left } = placeTooltip(tip.anchor.getBoundingClientRect(), bubble.getBoundingClientRect(), window.innerWidth)
Object.assign(bubble.style, { top: `${top}px`, left: `${left}px`, visibility: 'visible' })
}, [tip])
return tip && createPortal(
<div
ref={bubbleRef}
id={id}
role="tooltip"
style={{ top: 0, left: 0, visibility: 'hidden' }}
className="pointer-events-none fixed z-50 max-w-[22rem] rounded border border-[var(--vscode-editorHoverWidget-border)] bg-[var(--vscode-editorHoverWidget-background)] px-2 py-1 text-[11px] leading-tight break-words text-[var(--vscode-editorHoverWidget-foreground)] shadow-[0_2px_8px_var(--vscode-widget-shadow)]"
>
{tip.text}
</div>,
document.body,
)
}