diff --git a/vscode/package.json b/vscode/package.json index 6fe12e8..f0438c4 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -2,7 +2,7 @@ "publisher": "clurdra", "name": "superpowers-vscode-clurdra", "displayName": "Superpowers-clurdra", - "version": "0.2.129", + "version": "0.2.130", "packageManager": "pnpm@10.27.0", "description": "Superpowers specs and plans Kanban explorer", "author": "clurdra", diff --git a/vscode/webview-ui/src/components/IssueDetailPanel.tsx b/vscode/webview-ui/src/components/IssueDetailPanel.tsx index bf34eb3..5b66fa6 100644 --- a/vscode/webview-ui/src/components/IssueDetailPanel.tsx +++ b/vscode/webview-ui/src/components/IssueDetailPanel.tsx @@ -8,6 +8,7 @@ import { isIssueMine, issueOwnerLogin } from '../lib/issueOwnership' import { toReadOnlySchema } from '../lib/readOnlySchema' import { COLUMN_LABELS, COLUMN_ORDER } from '../types' import { PropertyGrid } from './property-grid' +import { Tooltip } from './ui/tooltip' /** 跨机绝对路径只比 basename(同名 .json)映射到本机 profiles 列表。 */ function matchProfileOption( @@ -616,77 +617,86 @@ export function IssueDetailPanel({ ) : null} - + + + {/* 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) && ( - + + + )} {!issue.handoffAttachmentId && issue.column !== 'done' && ((issue.assignees ?? []).length === 0 || (me !== undefined && (issue.assignees ?? []).includes(me))) && ( - + + + )} {issue.handoffAttachmentId && !(me !== undefined && (issue.assignees ?? []).includes(me)) && ( - - 待接管 - + + + 待接管 + + )} {(issue.column === 'in-progress' || issue.column === 'review') && ( + + + + )} + - )} - - + + + + )} diff --git a/vscode/webview-ui/src/components/ui/tooltip.tsx b/vscode/webview-ui/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..28e54d2 --- /dev/null +++ b/vscode/webview-ui/src/components/ui/tooltip.tsx @@ -0,0 +1,96 @@ +import type { ReactElement, ReactNode } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { cn } from '@/lib/utils' + +/** 悬停多久才弹,避免扫过一排按钮时闪一串气泡。 */ +const SHOW_DELAY_MS = 300 +/** 气泡与触发元素之间的间距。 */ +const GAP_PX = 6 +/** 贴边留白,气泡不顶到 webview 边缘。 */ +const GUTTER_PX = 8 + +interface TooltipProps { + /** 气泡文案;为空则不弹。 */ + label: ReactNode + children: ReactNode + className?: string +} + +/** + * 悬停提示气泡。 + * + * 原生 `title` 在 webview 里延迟长、样式是系统的,暗色主题下很突兀, + * 这里自己渲染:portal 到 body,避开 overflow 容器裁剪;位置优先在上方, + * 上方放不下就翻到下方,左右钳在视口内。 + */ +export function Tooltip({ label, children, className }: TooltipProps): ReactElement { + const [pos, setPos] = useState<{ top: number, left: number } | null>(null) + const wrapRef = useRef(null) + const bubbleRef = useRef(null) + const timerRef = useRef | undefined>(undefined) + + const hide = useCallback((): void => { + clearTimeout(timerRef.current) + setPos(null) + }, []) + + const show = useCallback((): void => { + if (!label) + return + clearTimeout(timerRef.current) + timerRef.current = setTimeout(() => { + const rect = wrapRef.current?.getBoundingClientRect() + if (rect) + setPos({ top: rect.top, left: rect.left + rect.width / 2 }) + }, SHOW_DELAY_MS) + }, [label]) + + useEffect(() => () => clearTimeout(timerRef.current), []) + + // 气泡渲染后才知道自身尺寸,这时才能定最终位置:默认在上方, + // 上方空间不够就翻到触发元素下方;水平居中后钳进视口。 + useEffect(() => { + if (!pos) + 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' + }, [pos]) + + return ( + <> + + {children} + + {pos && label && createPortal( +
+ {label} +
, + document.body, + )} + + ) +}