11'use client'
22
33import type React from 'react'
4- import { memo , useEffect , useRef , useState } from 'react'
5- import { createPortal } from 'react-dom'
4+ import { memo , useRef } from 'react'
65import { 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
1213interface 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+ */
3627export 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