Skip to content

Commit 8da7e34

Browse files
waleedlatif1claude
andauthored
refactor(resource-header): share floating-tooltip engine, prune dead overlay tooltips (#4844)
Clean up the breadcrumb truncation feature for reuse and correctness: - Extract useFloatingTooltip / useIsOverflowing / FloatingTooltip into a shared floating-tooltip module. BreadcrumbSegment and FloatingOverflowText now consume one implementation instead of duplicating ~150 lines of positioning, velocity, overflow-detection, and portal logic. - Replace the hardcoded terminal-label regex in ResourceHeader with a typed `terminal` flag on BreadcrumbItem (set by the document chunk/loading crumbs), decoupling the generic header from knowledge-base copy. - Clear the path-popover close timeout on unmount and reuse the shared POPOVER_ANIMATION_CLASSES constant. - Drop the redundant manual overflow-state writes (fixes a sticky fade mask). - Revert FloatingOverflowText inside Combobox `overlayContent` back to plain truncating spans across files/logs/tables/scheduled-tasks/document: the combobox overlay is pointer-events-none, so the tooltip handlers never fired there. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8c165bb commit 8da7e34

9 files changed

Lines changed: 321 additions & 481 deletions

File tree

Lines changed: 19 additions & 189 deletions
Original file line numberDiff line numberDiff line change
@@ -1,137 +1,42 @@
11
'use client'
22

33
import type React from 'react'
4-
import { memo, useEffect, useRef, useState } from 'react'
5-
import { createPortal } from 'react-dom'
4+
import { memo, useRef } from 'react'
65
import { cn } from '@/lib/core/utils/cn'
7-
8-
const FLOATING_TOOLTIP_OFFSET = 16
9-
const FLOATING_TOOLTIP_EDGE_GUTTER = 16
10-
const FLOATING_TOOLTIP_EDGE_THRESHOLD = 360
6+
import {
7+
FloatingTooltip,
8+
isTextClipped,
9+
useFloatingTooltip,
10+
useIsOverflowing,
11+
} from '@/app/workspace/[workspaceId]/components/resource/components/floating-tooltip'
1112

1213
interface FloatingOverflowTextProps {
14+
/** Full text shown in the tooltip and used as the default visible content. */
1315
label: string
16+
/** Optional custom visible content (e.g. highlighted text); defaults to `label`. */
1417
children?: React.ReactNode
1518
className?: string
19+
/** Forces the tooltip even when the text is not visually clipped (e.g. content truncated upstream). */
1620
showWhen?: boolean
1721
}
1822

19-
interface FloatingTooltipState {
20-
visible: boolean
21-
x: number
22-
y: number
23-
skew: number
24-
scaleX: number
25-
scaleY: number
26-
alignX: 'left' | 'right'
27-
alignY: 'above' | 'below'
28-
}
29-
30-
interface PointerSnapshot {
31-
x: number
32-
y: number
33-
time: number
34-
}
35-
23+
/**
24+
* Truncating text that fades its clipped edge and reveals the full value in a
25+
* pointer-reactive floating tooltip on hover or focus.
26+
*/
3627
export const FloatingOverflowText = memo(function FloatingOverflowText({
3728
label,
3829
children,
3930
className,
4031
showWhen,
4132
}: FloatingOverflowTextProps) {
4233
const textRef = useRef<HTMLSpanElement>(null)
43-
const lastPointerRef = useRef<PointerSnapshot | null>(null)
44-
const [isOverflowing, setIsOverflowing] = useState(false)
45-
const [tooltipState, setTooltipState] = useState<FloatingTooltipState>({
46-
visible: false,
47-
x: 0,
48-
y: 0,
49-
skew: 0,
50-
scaleX: 1,
51-
scaleY: 1,
52-
alignX: 'left',
53-
alignY: 'below',
54-
})
55-
56-
useEffect(() => {
34+
const isOverflowing = useIsOverflowing(textRef)
35+
const { state, handlers } = useFloatingTooltip(() => {
5736
const element = textRef.current
58-
if (!element) return
59-
60-
const updateOverflowState = () => {
61-
setIsOverflowing(isTextClipped(element))
62-
}
63-
64-
updateOverflowState()
65-
66-
const resizeObserver = new ResizeObserver(updateOverflowState)
67-
resizeObserver.observe(element)
68-
window.addEventListener('resize', updateOverflowState)
69-
70-
return () => {
71-
resizeObserver.disconnect()
72-
window.removeEventListener('resize', updateOverflowState)
73-
}
74-
}, [])
75-
76-
const canShowTooltip = (element: HTMLSpanElement | null) => {
7737
if (!element || label.length === 0) return false
7838
return Boolean(showWhen) || isTextClipped(element)
79-
}
80-
81-
const handleTooltipMove = (event: React.PointerEvent<HTMLSpanElement>) => {
82-
if (!canShowTooltip(textRef.current)) return
83-
84-
const now = performance.now()
85-
const previous = lastPointerRef.current
86-
const elapsed = previous ? Math.max(now - previous.time, 16) : 16
87-
const velocityX = previous ? ((event.clientX - previous.x) / elapsed) * 16 : 0
88-
const velocityY = previous ? ((event.clientY - previous.y) / elapsed) * 16 : 0
89-
const velocity = Math.hypot(velocityX, velocityY)
90-
const position = getFloatingTooltipPosition(event.clientX, event.clientY)
91-
92-
lastPointerRef.current = { x: event.clientX, y: event.clientY, time: now }
93-
setTooltipState({
94-
visible: true,
95-
...position,
96-
skew: clamp(velocityX * 0.11, -6, 6),
97-
scaleX: 1 + Math.min(0.035, velocity / 1100),
98-
scaleY: 1 - Math.min(0.02, velocity / 1500),
99-
})
100-
}
101-
102-
const showTooltip = (event: React.PointerEvent<HTMLSpanElement>) => {
103-
if (!canShowTooltip(textRef.current)) return
104-
const position = getFloatingTooltipPosition(event.clientX, event.clientY)
105-
lastPointerRef.current = { x: event.clientX, y: event.clientY, time: performance.now() }
106-
setIsOverflowing(true)
107-
setTooltipState({
108-
visible: true,
109-
...position,
110-
skew: 0,
111-
scaleX: 1,
112-
scaleY: 1,
113-
})
114-
}
115-
116-
const showTooltipFromFocus = (event: React.FocusEvent<HTMLSpanElement>) => {
117-
if (!canShowTooltip(textRef.current)) return
118-
const rect = event.currentTarget.getBoundingClientRect()
119-
const position = getFloatingTooltipPosition(rect.left + rect.width / 2, rect.bottom)
120-
lastPointerRef.current = null
121-
setIsOverflowing(true)
122-
setTooltipState({
123-
visible: true,
124-
...position,
125-
skew: 0,
126-
scaleX: 1,
127-
scaleY: 1,
128-
})
129-
}
130-
131-
const hideTooltip = () => {
132-
lastPointerRef.current = null
133-
setTooltipState((current) => ({ ...current, visible: false, skew: 0, scaleX: 1, scaleY: 1 }))
134-
}
39+
})
13540

13641
return (
13742
<>
@@ -143,86 +48,11 @@ export const FloatingOverflowText = memo(function FloatingOverflowText({
14348
'[mask-image:linear-gradient(to_right,black_calc(100%-18px),transparent)] hover:[mask-image:none] focus-visible:[mask-image:none]',
14449
className
14550
)}
146-
onPointerEnter={showTooltip}
147-
onPointerMove={handleTooltipMove}
148-
onPointerLeave={hideTooltip}
149-
onPointerDown={hideTooltip}
150-
onFocus={showTooltipFromFocus}
151-
onBlur={hideTooltip}
51+
{...handlers}
15252
>
15353
{children ?? label}
15454
</span>
155-
<FloatingTooltip label={label} state={tooltipState} />
55+
<FloatingTooltip label={label} state={state} />
15656
</>
15757
)
15858
})
159-
160-
function FloatingTooltip({ label, state }: { label: string; state: FloatingTooltipState }) {
161-
if (typeof document === 'undefined' || !state.visible) return null
162-
163-
return createPortal(
164-
<div
165-
aria-hidden='true'
166-
className={cn(
167-
'pointer-events-none fixed top-0 left-0 z-[var(--z-tooltip)] w-fit max-w-[min(16rem,calc(100vw-2rem))] rounded-lg border border-[var(--border)] bg-[var(--bg)] px-2 py-1.5 text-[var(--text-body)] text-xs opacity-100 shadow-sm transition-[opacity,filter,transform] duration-150 ease-out',
168-
'motion-reduce:transition-none'
169-
)}
170-
style={{
171-
transform: `${getFloatingTooltipTranslate(state)} skew(${state.skew}deg) scale(${state.scaleX}, ${state.scaleY})`,
172-
transformOrigin: state.alignX === 'left' ? '12px 12px' : 'calc(100% - 12px) 12px',
173-
}}
174-
>
175-
<span className='block whitespace-normal break-words text-left leading-[18px]'>{label}</span>
176-
</div>,
177-
document.body
178-
)
179-
}
180-
181-
function isTextClipped(element: HTMLElement): boolean {
182-
return element.scrollWidth > element.clientWidth + 1
183-
}
184-
185-
function getFloatingTooltipPosition(
186-
clientX: number,
187-
clientY: number
188-
): Pick<FloatingTooltipState, 'x' | 'y' | 'alignX' | 'alignY'> {
189-
if (typeof window === 'undefined') {
190-
return { x: clientX, y: clientY, alignX: 'left', alignY: 'below' }
191-
}
192-
193-
const alignX = window.innerWidth - clientX < FLOATING_TOOLTIP_EDGE_THRESHOLD ? 'right' : 'left'
194-
const alignY =
195-
window.innerHeight - clientY < FLOATING_TOOLTIP_EDGE_THRESHOLD / 2 ? 'above' : 'below'
196-
197-
return {
198-
x: clamp(
199-
clientX,
200-
FLOATING_TOOLTIP_EDGE_GUTTER,
201-
window.innerWidth - FLOATING_TOOLTIP_EDGE_GUTTER
202-
),
203-
y: clamp(
204-
clientY,
205-
FLOATING_TOOLTIP_EDGE_GUTTER,
206-
window.innerHeight - FLOATING_TOOLTIP_EDGE_GUTTER
207-
),
208-
alignX,
209-
alignY,
210-
}
211-
}
212-
213-
function getFloatingTooltipTranslate(state: FloatingTooltipState): string {
214-
const xOffset =
215-
state.alignX === 'left'
216-
? `${FLOATING_TOOLTIP_OFFSET}px`
217-
: `calc(-100% - ${FLOATING_TOOLTIP_OFFSET}px)`
218-
const yOffset =
219-
state.alignY === 'below'
220-
? `${FLOATING_TOOLTIP_OFFSET}px`
221-
: `calc(-100% - ${FLOATING_TOOLTIP_OFFSET}px)`
222-
223-
return `translate3d(${state.x}px, ${state.y}px, 0) translate(${xOffset}, ${yOffset})`
224-
}
225-
226-
function clamp(value: number, min: number, max: number): number {
227-
return Math.max(min, Math.min(max, value))
228-
}

0 commit comments

Comments
 (0)