From 64f9c78814cff002d4e6ed605be0f50539c53ca6 Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Fri, 29 May 2026 15:23:09 +0800 Subject: [PATCH 1/4] fix: always show hover tooltip for descriptions, add tooltip to trends page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove truncation detection threshold in RepositoryCard — the scrollHeight > clientHeight check was unreliable across different fonts/browsers, causing tooltips to not show when text appeared visually truncated. Now tooltips always show on hover. Add hover tooltip to SubscriptionRepoCard (trends page) for both description and AI summary sections, which previously had no way to view truncated text. Closes #165 Co-Authored-By: Claude Sonnet 4.6 --- src/components/RepositoryCard.tsx | 26 +++-------------- src/components/SubscriptionRepoCard.tsx | 38 +++++++++++++++++++++---- 2 files changed, 37 insertions(+), 27 deletions(-) diff --git a/src/components/RepositoryCard.tsx b/src/components/RepositoryCard.tsx index b07de240..7da83527 100644 --- a/src/components/RepositoryCard.tsx +++ b/src/components/RepositoryCard.tsx @@ -135,13 +135,10 @@ const RepositoryCardComponent: React.FC = ({ const [editModalOpen, setEditModalOpen] = useState(false); const [readmeModalOpen, setReadmeModalOpen] = useState(false); const [showTooltip, setShowTooltip] = useState(false); - const [isTextTruncated, setIsTextTruncated] = useState(false); const [unstarring, setUnstarring] = useState(false); const [showDragHint, setShowDragHint] = useState(false); const dragHintTimeoutRef = useRef | null>(null); - const descriptionRef = useRef(null); - // 高亮搜索关键词的工具函数 - 使用缓存优化 const highlightSearchTerm = useCallback((text: string, searchTerm: string): React.ReactNode => { if (!searchTerm.trim() || !text) return text; @@ -176,28 +173,14 @@ const RepositoryCardComponent: React.FC = ({ return result; }, []); - // Check if text is actually truncated by comparing scroll height with client height + // Cleanup drag hint timeout on unmount useEffect(() => { - const checkTruncation = () => { - if (descriptionRef.current) { - const element = descriptionRef.current; - const isTruncated = element.scrollHeight > element.clientHeight; - setIsTextTruncated(isTruncated); - } - }; - - // Check truncation after component mounts and when content changes - checkTruncation(); - - // Also check on window resize - window.addEventListener('resize', checkTruncation); return () => { - window.removeEventListener('resize', checkTruncation); if (dragHintTimeoutRef.current) { clearTimeout(dragHintTimeoutRef.current); } }; - }, [repository, showAISummary]); + }, []); const formatNumber = (num: number) => { if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`; @@ -882,18 +865,17 @@ const RepositoryCardComponent: React.FC = ({
isTextTruncated && setShowTooltip(true)} + onMouseEnter={() => setShowTooltip(true)} onMouseLeave={() => setShowTooltip(false)} >

{highlightSearchTerm(displayContent.content, searchQuery)}

{/* Enhanced Tooltip - Optimized for Light Mode Readability */} - {isTextTruncated && showTooltip && ( + {showTooltip && (
{highlightSearchTerm(displayContent.content, searchQuery)} diff --git a/src/components/SubscriptionRepoCard.tsx b/src/components/SubscriptionRepoCard.tsx index 580d3ade..6ddc3467 100644 --- a/src/components/SubscriptionRepoCard.tsx +++ b/src/components/SubscriptionRepoCard.tsx @@ -39,6 +39,8 @@ export const SubscriptionRepoCard: React.FC = ({ repo // 取消Star确认对话框状态 const [unstarConfirmOpen, setUnstarConfirmOpen] = useState(false); const [pendingUnstarAction, setPendingUnstarAction] = useState<(() => void) | null>(null); + const [descTooltip, setDescTooltip] = useState(false); + const [aiTooltip, setAiTooltip] = useState(false); const abortControllerRef = useRef(null); @@ -403,18 +405,44 @@ export const SubscriptionRepoCard: React.FC = ({ repo {/* Description */} {repo.description && ( -

- {repo.description} -

+
setDescTooltip(true)} + onMouseLeave={() => setDescTooltip(false)} + > +

+ {repo.description} +

+ {descTooltip && ( +
+
+ {repo.description} +
+
+
+ )} +
)} {/* AI Summary */} {repo.ai_summary && ( -
+
setAiTooltip(true)} + onMouseLeave={() => setAiTooltip(false)} + > -

+

{repo.ai_summary}

+ {aiTooltip && ( +
+
+ {repo.ai_summary} +
+
+
+ )}
)} From a1bacb8ea85e981658ca70d38f5d49e9ea9d7ef5 Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Fri, 29 May 2026 15:29:55 +0800 Subject: [PATCH 2/4] fix: add keyboard/touch accessibility to description tooltips Add onFocus/onBlur/onTouchStart handlers and tabIndex={0} to all tooltip containers so keyboard and touch users can also access full descriptions. Addresses CodeRabbitAI review findings on PR #170. Co-Authored-By: Claude Sonnet 4.6 --- src/components/RepositoryCard.tsx | 4 ++++ src/components/SubscriptionRepoCard.tsx | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/src/components/RepositoryCard.tsx b/src/components/RepositoryCard.tsx index 7da83527..04ed0de4 100644 --- a/src/components/RepositoryCard.tsx +++ b/src/components/RepositoryCard.tsx @@ -867,6 +867,10 @@ const RepositoryCardComponent: React.FC = ({ className="relative group" onMouseEnter={() => setShowTooltip(true)} onMouseLeave={() => setShowTooltip(false)} + onFocus={() => setShowTooltip(true)} + onBlur={() => setShowTooltip(false)} + onTouchStart={() => setShowTooltip((v) => !v)} + tabIndex={0} >

= ({ repo className="relative mb-3" onMouseEnter={() => setDescTooltip(true)} onMouseLeave={() => setDescTooltip(false)} + onFocus={() => setDescTooltip(true)} + onBlur={() => setDescTooltip(false)} + onTouchStart={() => setDescTooltip((v) => !v)} + tabIndex={0} >

{repo.description} @@ -430,6 +434,10 @@ export const SubscriptionRepoCard: React.FC = ({ repo className="relative flex items-start gap-1.5 mb-3" onMouseEnter={() => setAiTooltip(true)} onMouseLeave={() => setAiTooltip(false)} + onFocus={() => setAiTooltip(true)} + onBlur={() => setAiTooltip(false)} + onTouchStart={() => setAiTooltip((v) => !v)} + tabIndex={0} >

From 24d5375b830aa78caf015ea89798ea2737de16a6 Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Fri, 29 May 2026 15:44:20 +0800 Subject: [PATCH 3/4] fix: use portal-based tooltip to avoid overflow clipping Replace absolute-positioned tooltips with FloatingTooltip component that uses createPortal to render outside overflow containers. The tooltip uses fixed positioning calculated from getBoundingClientRect(), so it's never clipped by parent overflow-y-auto scroll containers. This fixes the issue where trend page tooltips were being cut off by the DiscoveryView scroll container boundary. Co-Authored-By: Claude Sonnet 4.6 --- src/components/FloatingTooltip.tsx | 78 +++++++++++++++++++++++++ src/components/RepositoryCard.tsx | 22 ++++--- src/components/SubscriptionRepoCard.tsx | 33 ++++++----- 3 files changed, 105 insertions(+), 28 deletions(-) create mode 100644 src/components/FloatingTooltip.tsx diff --git a/src/components/FloatingTooltip.tsx b/src/components/FloatingTooltip.tsx new file mode 100644 index 00000000..a94a5254 --- /dev/null +++ b/src/components/FloatingTooltip.tsx @@ -0,0 +1,78 @@ +import React, { useEffect, useRef, useCallback } from 'react'; +import { createPortal } from 'react-dom'; + +interface FloatingTooltipProps { + content: React.ReactNode; + visible: boolean; + triggerRef: React.RefObject; + onMouseLeave: () => void; +} + +export const FloatingTooltip: React.FC = ({ + content, + visible, + triggerRef, + onMouseLeave, +}) => { + const tooltipRef = useRef(null); + + const updatePosition = useCallback(() => { + if (!triggerRef.current || !tooltipRef.current || !visible) return; + + const triggerRect = triggerRef.current.getBoundingClientRect(); + const tooltipEl = tooltipRef.current; + + // Reset height to get natural measurement + tooltipEl.style.maxHeight = '280px'; + const tooltipHeight = tooltipEl.offsetHeight; + const tooltipWidth = triggerRect.width; + + // Position above the trigger + const top = triggerRect.top - tooltipHeight - 8; + const left = triggerRect.left; + + // If tooltip would go above viewport, flip it below + if (top < 8) { + tooltipEl.style.top = `${triggerRect.bottom + 8}px`; + tooltipEl.style.bottom = 'auto'; + // Flip arrow direction + tooltipEl.dataset.flipped = 'true'; + } else { + tooltipEl.style.top = `${top}px`; + tooltipEl.style.bottom = 'auto'; + tooltipEl.dataset.flipped = 'false'; + } + + tooltipEl.style.left = `${left}px`; + tooltipEl.style.width = `${tooltipWidth}px`; + }, [triggerRef, visible]); + + useEffect(() => { + if (visible) { + updatePosition(); + const handleResize = () => updatePosition(); + window.addEventListener('resize', handleResize); + window.addEventListener('scroll', handleResize, true); + return () => { + window.removeEventListener('resize', handleResize); + window.removeEventListener('scroll', handleResize, true); + }; + } + }, [visible, updatePosition]); + + if (!visible) return null; + + return createPortal( +

+
+ {content} +
+
, + document.body + ); +}; \ No newline at end of file diff --git a/src/components/RepositoryCard.tsx b/src/components/RepositoryCard.tsx index 04ed0de4..e5414775 100644 --- a/src/components/RepositoryCard.tsx +++ b/src/components/RepositoryCard.tsx @@ -10,6 +10,7 @@ import { GitHubApiService } from '../services/githubApi'; import { formatDistanceToNow } from 'date-fns'; import { RepositoryEditModal } from './RepositoryEditModal'; import { ReadmeModal } from './ReadmeModal'; +import { FloatingTooltip } from './FloatingTooltip'; import { shallow } from 'zustand/shallow'; import { useDialog } from '../hooks/useDialog'; @@ -135,6 +136,7 @@ const RepositoryCardComponent: React.FC = ({ const [editModalOpen, setEditModalOpen] = useState(false); const [readmeModalOpen, setReadmeModalOpen] = useState(false); const [showTooltip, setShowTooltip] = useState(false); + const descTriggerRef = useRef(null); const [unstarring, setUnstarring] = useState(false); const [showDragHint, setShowDragHint] = useState(false); const dragHintTimeoutRef = useRef | null>(null); @@ -861,9 +863,10 @@ const RepositoryCardComponent: React.FC = ({
- {/* Description with Tooltip - Enhanced for Light Mode */} + {/* Description with Tooltip */}
setShowTooltip(true)} onMouseLeave={() => setShowTooltip(false)} @@ -877,17 +880,12 @@ const RepositoryCardComponent: React.FC = ({ > {highlightSearchTerm(displayContent.content, searchQuery)}

- - {/* Enhanced Tooltip - Optimized for Light Mode Readability */} - {showTooltip && ( -
-
- {highlightSearchTerm(displayContent.content, searchQuery)} -
- {/* Arrow with Light Mode Optimization */} -
-
- )} + setShowTooltip(false)} + />
{/* 方案一:同时显示多个状态标签 */} diff --git a/src/components/SubscriptionRepoCard.tsx b/src/components/SubscriptionRepoCard.tsx index 303cce56..a591c492 100644 --- a/src/components/SubscriptionRepoCard.tsx +++ b/src/components/SubscriptionRepoCard.tsx @@ -7,6 +7,7 @@ import { forceSyncToBackend } from '../services/autoSync'; import { GitHubApiService } from '../services/githubApi'; import { ReadmeModal } from './ReadmeModal'; import { Modal } from './Modal'; +import { FloatingTooltip } from './FloatingTooltip'; import { useDialog } from '../hooks/useDialog'; interface SubscriptionRepoCardProps { @@ -41,6 +42,8 @@ export const SubscriptionRepoCard: React.FC = ({ repo const [pendingUnstarAction, setPendingUnstarAction] = useState<(() => void) | null>(null); const [descTooltip, setDescTooltip] = useState(false); const [aiTooltip, setAiTooltip] = useState(false); + const descTriggerRef = useRef(null); + const aiTriggerRef = useRef(null); const abortControllerRef = useRef(null); @@ -406,6 +409,7 @@ export const SubscriptionRepoCard: React.FC = ({ repo {/* Description */} {repo.description && (
setDescTooltip(true)} onMouseLeave={() => setDescTooltip(false)} @@ -417,20 +421,19 @@ export const SubscriptionRepoCard: React.FC = ({ repo

{repo.description}

- {descTooltip && ( -
-
- {repo.description} -
-
-
- )} + setDescTooltip(false)} + />
)} {/* AI Summary */} {repo.ai_summary && (
setAiTooltip(true)} onMouseLeave={() => setAiTooltip(false)} @@ -443,14 +446,12 @@ export const SubscriptionRepoCard: React.FC = ({ repo

{repo.ai_summary}

- {aiTooltip && ( -
-
- {repo.ai_summary} -
-
-
- )} + setAiTooltip(false)} + />
)} From 6d9ad1f387971d49292b699e968c27b6a23a70ff Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Fri, 29 May 2026 15:53:44 +0800 Subject: [PATCH 4/4] fix: hover-intent tooltip, useLayoutEffect, rAF throttling Three fixes addressing CodeRabbitAI audit findings: 1. Hover-intent pattern: tooltip hide is delayed 150ms, cancelled on re-entry (both trigger and tooltip onMouseEnter). Users can now move cursor from trigger to tooltip and scroll long content. 2. useLayoutEffect instead of useEffect for positioning: prevents first-frame flash where tooltip briefly appears at default position. 3. requestAnimationFrame throttling for scroll/resize: coalesces rapid reposition events into one per animation frame. Co-Authored-By: Claude Sonnet 4.6 --- src/components/FloatingTooltip.tsx | 20 +++++++++------- src/components/RepositoryCard.tsx | 17 ++++++++----- src/components/SubscriptionRepoCard.tsx | 32 +++++++++++++++++-------- 3 files changed, 44 insertions(+), 25 deletions(-) diff --git a/src/components/FloatingTooltip.tsx b/src/components/FloatingTooltip.tsx index a94a5254..2619e445 100644 --- a/src/components/FloatingTooltip.tsx +++ b/src/components/FloatingTooltip.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useCallback } from 'react'; +import React, { useLayoutEffect, useRef, useCallback } from 'react'; import { createPortal } from 'react-dom'; interface FloatingTooltipProps { @@ -6,6 +6,7 @@ interface FloatingTooltipProps { visible: boolean; triggerRef: React.RefObject; onMouseLeave: () => void; + onMouseEnter?: () => void; } export const FloatingTooltip: React.FC = ({ @@ -13,8 +14,10 @@ export const FloatingTooltip: React.FC = ({ visible, triggerRef, onMouseLeave, + onMouseEnter, }) => { const tooltipRef = useRef(null); + const rafRef = useRef(0); const updatePosition = useCallback(() => { if (!triggerRef.current || !tooltipRef.current || !visible) return; @@ -22,38 +25,36 @@ export const FloatingTooltip: React.FC = ({ const triggerRect = triggerRef.current.getBoundingClientRect(); const tooltipEl = tooltipRef.current; - // Reset height to get natural measurement tooltipEl.style.maxHeight = '280px'; const tooltipHeight = tooltipEl.offsetHeight; const tooltipWidth = triggerRect.width; - // Position above the trigger const top = triggerRect.top - tooltipHeight - 8; const left = triggerRect.left; - // If tooltip would go above viewport, flip it below if (top < 8) { tooltipEl.style.top = `${triggerRect.bottom + 8}px`; tooltipEl.style.bottom = 'auto'; - // Flip arrow direction - tooltipEl.dataset.flipped = 'true'; } else { tooltipEl.style.top = `${top}px`; tooltipEl.style.bottom = 'auto'; - tooltipEl.dataset.flipped = 'false'; } tooltipEl.style.left = `${left}px`; tooltipEl.style.width = `${tooltipWidth}px`; }, [triggerRef, visible]); - useEffect(() => { + useLayoutEffect(() => { if (visible) { updatePosition(); - const handleResize = () => updatePosition(); + const handleResize = () => { + cancelAnimationFrame(rafRef.current); + rafRef.current = requestAnimationFrame(updatePosition); + }; window.addEventListener('resize', handleResize); window.addEventListener('scroll', handleResize, true); return () => { + cancelAnimationFrame(rafRef.current); window.removeEventListener('resize', handleResize); window.removeEventListener('scroll', handleResize, true); }; @@ -65,6 +66,7 @@ export const FloatingTooltip: React.FC = ({ return createPortal(
= ({ const [readmeModalOpen, setReadmeModalOpen] = useState(false); const [showTooltip, setShowTooltip] = useState(false); const descTriggerRef = useRef(null); + const tooltipHideTimerRef = useRef | null>(null); const [unstarring, setUnstarring] = useState(false); const [showDragHint, setShowDragHint] = useState(false); const dragHintTimeoutRef = useRef | null>(null); @@ -175,12 +176,15 @@ const RepositoryCardComponent: React.FC = ({ return result; }, []); - // Cleanup drag hint timeout on unmount + // Cleanup timeouts on unmount useEffect(() => { return () => { if (dragHintTimeoutRef.current) { clearTimeout(dragHintTimeoutRef.current); } + if (tooltipHideTimerRef.current) { + clearTimeout(tooltipHideTimerRef.current); + } }; }, []); @@ -868,10 +872,10 @@ const RepositoryCardComponent: React.FC = ({
setShowTooltip(true)} - onMouseLeave={() => setShowTooltip(false)} - onFocus={() => setShowTooltip(true)} - onBlur={() => setShowTooltip(false)} + onMouseEnter={() => { clearTimeout(tooltipHideTimerRef.current); setShowTooltip(true); }} + onMouseLeave={() => { tooltipHideTimerRef.current = setTimeout(() => setShowTooltip(false), 150); }} + onFocus={() => { clearTimeout(tooltipHideTimerRef.current); setShowTooltip(true); }} + onBlur={() => { tooltipHideTimerRef.current = setTimeout(() => setShowTooltip(false), 150); }} onTouchStart={() => setShowTooltip((v) => !v)} tabIndex={0} > @@ -884,7 +888,8 @@ const RepositoryCardComponent: React.FC = ({ content={highlightSearchTerm(displayContent.content, searchQuery)} visible={showTooltip} triggerRef={descTriggerRef} - onMouseLeave={() => setShowTooltip(false)} + onMouseEnter={() => clearTimeout(tooltipHideTimerRef.current)} + onMouseLeave={() => { tooltipHideTimerRef.current = setTimeout(() => setShowTooltip(false), 150); }} />
diff --git a/src/components/SubscriptionRepoCard.tsx b/src/components/SubscriptionRepoCard.tsx index a591c492..e49b7bf6 100644 --- a/src/components/SubscriptionRepoCard.tsx +++ b/src/components/SubscriptionRepoCard.tsx @@ -44,6 +44,16 @@ export const SubscriptionRepoCard: React.FC = ({ repo const [aiTooltip, setAiTooltip] = useState(false); const descTriggerRef = useRef(null); const aiTriggerRef = useRef(null); + const hideTimerRef = useRef | null>(null); + + const scheduleHide = useCallback((setter: (v: boolean) => void) => { + if (hideTimerRef.current) clearTimeout(hideTimerRef.current); + hideTimerRef.current = setTimeout(() => setter(false), 150); + }, []); + + const cancelHide = useCallback(() => { + if (hideTimerRef.current) clearTimeout(hideTimerRef.current); + }, []); const abortControllerRef = useRef(null); @@ -411,10 +421,10 @@ export const SubscriptionRepoCard: React.FC = ({ repo
setDescTooltip(true)} - onMouseLeave={() => setDescTooltip(false)} - onFocus={() => setDescTooltip(true)} - onBlur={() => setDescTooltip(false)} + onMouseEnter={() => { cancelHide(); setDescTooltip(true); }} + onMouseLeave={() => scheduleHide(setDescTooltip)} + onFocus={() => { cancelHide(); setDescTooltip(true); }} + onBlur={() => scheduleHide(setDescTooltip)} onTouchStart={() => setDescTooltip((v) => !v)} tabIndex={0} > @@ -425,7 +435,8 @@ export const SubscriptionRepoCard: React.FC = ({ repo content={repo.description} visible={descTooltip} triggerRef={descTriggerRef} - onMouseLeave={() => setDescTooltip(false)} + onMouseEnter={() => { cancelHide(); }} + onMouseLeave={() => scheduleHide(setDescTooltip)} />
)} @@ -435,10 +446,10 @@ export const SubscriptionRepoCard: React.FC = ({ repo
setAiTooltip(true)} - onMouseLeave={() => setAiTooltip(false)} - onFocus={() => setAiTooltip(true)} - onBlur={() => setAiTooltip(false)} + onMouseEnter={() => { cancelHide(); setAiTooltip(true); }} + onMouseLeave={() => scheduleHide(setAiTooltip)} + onFocus={() => { cancelHide(); setAiTooltip(true); }} + onBlur={() => scheduleHide(setAiTooltip)} onTouchStart={() => setAiTooltip((v) => !v)} tabIndex={0} > @@ -450,7 +461,8 @@ export const SubscriptionRepoCard: React.FC = ({ repo content={repo.ai_summary} visible={aiTooltip} triggerRef={aiTriggerRef} - onMouseLeave={() => setAiTooltip(false)} + onMouseEnter={() => { cancelHide(); }} + onMouseLeave={() => scheduleHide(setAiTooltip)} />
)}