Skip to content

Commit c190d2c

Browse files
committed
Merge branch 'improvement/platform' of github.com:simstudioai/sim into improvement/platform
2 parents 92da21e + 74705a7 commit c190d2c

14 files changed

Lines changed: 851 additions & 117 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
'use client'
2+
3+
import type React from 'react'
4+
import { memo } from 'react'
5+
import { cn } from '@/lib/core/utils/cn'
6+
import {
7+
FloatingTooltip,
8+
isTextClipped,
9+
useFloatingTooltip,
10+
useIsOverflowing,
11+
} from '@/app/workspace/[workspaceId]/components/resource/components/floating-tooltip'
12+
13+
interface FloatingOverflowTextProps {
14+
/** Full text shown in the tooltip and used as the default visible content. */
15+
label: string
16+
/** Optional custom visible content (e.g. highlighted text); defaults to `label`. */
17+
children?: React.ReactNode
18+
className?: string
19+
/** Forces the tooltip even when the text is not visually clipped (e.g. content truncated upstream). */
20+
showWhen?: boolean
21+
}
22+
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+
*/
27+
export const FloatingOverflowText = memo(function FloatingOverflowText({
28+
label,
29+
children,
30+
className,
31+
showWhen,
32+
}: FloatingOverflowTextProps) {
33+
const { ref: textRef, node, isOverflowing } = useIsOverflowing<HTMLSpanElement>()
34+
const { state, handlers } = useFloatingTooltip(() => {
35+
const element = node.current
36+
if (!element || label.length === 0) return false
37+
return Boolean(showWhen) || isTextClipped(element)
38+
})
39+
40+
return (
41+
<>
42+
<span
43+
ref={textRef}
44+
className={cn(
45+
'min-w-0',
46+
isOverflowing &&
47+
'[mask-image:linear-gradient(to_right,black_calc(100%-18px),transparent)] hover:[mask-image:none] focus-visible:[mask-image:none]',
48+
className
49+
)}
50+
{...handlers}
51+
>
52+
{children ?? label}
53+
</span>
54+
<FloatingTooltip label={label} state={state} />
55+
</>
56+
)
57+
})
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
'use client'
2+
3+
import { memo, type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'
4+
import { createPortal } from 'react-dom'
5+
import { cn } from '@/lib/core/utils/cn'
6+
7+
const TOOLTIP_OFFSET = 16
8+
const EDGE_GUTTER = 16
9+
const EDGE_THRESHOLD = 360
10+
const MIN_FRAME_MS = 16
11+
12+
/**
13+
* Resolved position and motion of a floating tooltip. `x`/`y` are viewport
14+
* coordinates the tooltip anchors to; `alignX`/`alignY` flip the tooltip away
15+
* from the nearest viewport edge; `skew`/`scale*` add the velocity-reactive
16+
* flourish while the pointer is moving.
17+
*/
18+
export interface FloatingTooltipState {
19+
visible: boolean
20+
x: number
21+
y: number
22+
skew: number
23+
scaleX: number
24+
scaleY: number
25+
alignX: 'left' | 'right'
26+
alignY: 'above' | 'below'
27+
}
28+
29+
interface PointerSnapshot {
30+
x: number
31+
y: number
32+
time: number
33+
}
34+
35+
/**
36+
* Pointer/focus event handlers that drive a {@link useFloatingTooltip}. Spread
37+
* onto the element that should reveal the tooltip on hover or focus.
38+
*/
39+
export interface FloatingTooltipHandlers {
40+
onPointerEnter: (event: React.PointerEvent<HTMLElement>) => void
41+
onPointerMove: (event: React.PointerEvent<HTMLElement>) => void
42+
onPointerLeave: () => void
43+
onPointerDown: () => void
44+
onFocus: (event: React.FocusEvent<HTMLElement>) => void
45+
onBlur: () => void
46+
}
47+
48+
const HIDDEN_STATE: FloatingTooltipState = {
49+
visible: false,
50+
x: 0,
51+
y: 0,
52+
skew: 0,
53+
scaleX: 1,
54+
scaleY: 1,
55+
alignX: 'left',
56+
alignY: 'below',
57+
}
58+
59+
/**
60+
* Drives a pointer-reactive floating tooltip. `canShow` is queried on every
61+
* gesture with the event target, letting the caller gate the tooltip on its own
62+
* overflow measurement. Returns the current {@link FloatingTooltipState} to feed
63+
* a {@link FloatingTooltip} and a stable set of {@link FloatingTooltipHandlers}
64+
* to spread onto the trigger element.
65+
*/
66+
export function useFloatingTooltip(canShow: (target: HTMLElement) => boolean): {
67+
state: FloatingTooltipState
68+
handlers: FloatingTooltipHandlers
69+
} {
70+
const canShowRef = useRef(canShow)
71+
canShowRef.current = canShow
72+
73+
const lastPointerRef = useRef<PointerSnapshot | null>(null)
74+
const [state, setState] = useState<FloatingTooltipState>(HIDDEN_STATE)
75+
76+
const handlers = useMemo<FloatingTooltipHandlers>(() => {
77+
const hide = () => {
78+
lastPointerRef.current = null
79+
setState((current) => (current.visible ? HIDDEN_STATE : current))
80+
}
81+
82+
const showStatic = (clientX: number, clientY: number) => {
83+
lastPointerRef.current = { x: clientX, y: clientY, time: performance.now() }
84+
setState({
85+
visible: true,
86+
...getTooltipPosition(clientX, clientY),
87+
skew: 0,
88+
scaleX: 1,
89+
scaleY: 1,
90+
})
91+
}
92+
93+
return {
94+
onPointerEnter: (event) => {
95+
if (!canShowRef.current(event.currentTarget)) return
96+
showStatic(event.clientX, event.clientY)
97+
},
98+
onPointerMove: (event) => {
99+
if (!canShowRef.current(event.currentTarget)) return
100+
const now = performance.now()
101+
const previous = lastPointerRef.current
102+
const elapsed = previous ? Math.max(now - previous.time, MIN_FRAME_MS) : MIN_FRAME_MS
103+
const velocityX = previous ? ((event.clientX - previous.x) / elapsed) * MIN_FRAME_MS : 0
104+
const velocityY = previous ? ((event.clientY - previous.y) / elapsed) * MIN_FRAME_MS : 0
105+
const velocity = Math.hypot(velocityX, velocityY)
106+
107+
lastPointerRef.current = { x: event.clientX, y: event.clientY, time: now }
108+
setState({
109+
visible: true,
110+
...getTooltipPosition(event.clientX, event.clientY),
111+
skew: clamp(velocityX * 0.11, -6, 6),
112+
scaleX: 1 + Math.min(0.035, velocity / 1100),
113+
scaleY: 1 - Math.min(0.02, velocity / 1500),
114+
})
115+
},
116+
onPointerLeave: hide,
117+
onPointerDown: hide,
118+
onFocus: (event) => {
119+
const target = event.currentTarget
120+
if (!canShowRef.current(target)) return
121+
const rect = target.getBoundingClientRect()
122+
lastPointerRef.current = null
123+
setState({
124+
visible: true,
125+
...getTooltipPosition(rect.left + rect.width / 2, rect.bottom),
126+
skew: 0,
127+
scaleX: 1,
128+
scaleY: 1,
129+
})
130+
},
131+
onBlur: hide,
132+
}
133+
}, [])
134+
135+
return { state, handlers }
136+
}
137+
138+
/**
139+
* Tracks whether an element's text is horizontally clipped, re-measuring via a
140+
* `ResizeObserver` and window resizes.
141+
*
142+
* Returns a callback `ref` to attach to the element — the observer follows the
143+
* element across mount, unmount, and reassignment, so it is safe to use on
144+
* conditionally rendered children. `node` is a stable ref for reading the
145+
* current element (e.g. for live measurements in event handlers).
146+
*/
147+
export function useIsOverflowing<T extends HTMLElement = HTMLElement>(): {
148+
ref: (node: T | null) => void
149+
node: RefObject<T | null>
150+
isOverflowing: boolean
151+
} {
152+
const [isOverflowing, setIsOverflowing] = useState(false)
153+
const nodeRef = useRef<T | null>(null)
154+
const observerRef = useRef<ResizeObserver | null>(null)
155+
156+
const measure = useCallback(() => {
157+
const element = nodeRef.current
158+
if (element) setIsOverflowing(isTextClipped(element))
159+
}, [])
160+
161+
const ref = useCallback(
162+
(node: T | null) => {
163+
observerRef.current?.disconnect()
164+
observerRef.current = null
165+
nodeRef.current = node
166+
if (!node) return
167+
168+
measure()
169+
const observer = new ResizeObserver(measure)
170+
observer.observe(node)
171+
observerRef.current = observer
172+
},
173+
[measure]
174+
)
175+
176+
useEffect(() => {
177+
window.addEventListener('resize', measure)
178+
return () => {
179+
window.removeEventListener('resize', measure)
180+
observerRef.current?.disconnect()
181+
}
182+
}, [measure])
183+
184+
return { ref, node: nodeRef, isOverflowing }
185+
}
186+
187+
/** Whether an element's content is wider than its visible box. */
188+
export function isTextClipped(element: HTMLElement): boolean {
189+
return element.scrollWidth > element.clientWidth + 1
190+
}
191+
192+
/** Clamps `value` to the inclusive `[min, max]` range. */
193+
export function clamp(value: number, min: number, max: number): number {
194+
return Math.max(min, Math.min(max, value))
195+
}
196+
197+
/**
198+
* Portaled tooltip body positioned from a {@link FloatingTooltipState}. Renders
199+
* nothing while hidden or during SSR.
200+
*/
201+
export const FloatingTooltip = memo(function FloatingTooltip({
202+
label,
203+
state,
204+
}: {
205+
label: string
206+
state: FloatingTooltipState
207+
}) {
208+
if (typeof document === 'undefined' || !state.visible) return null
209+
210+
return createPortal(
211+
<div
212+
aria-hidden='true'
213+
className={cn(
214+
'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',
215+
'motion-reduce:transition-none'
216+
)}
217+
style={{
218+
transform: `${getTooltipTranslate(state)} skew(${state.skew}deg) scale(${state.scaleX}, ${state.scaleY})`,
219+
transformOrigin: state.alignX === 'left' ? '12px 12px' : 'calc(100% - 12px) 12px',
220+
}}
221+
>
222+
<span className='block whitespace-normal break-words text-left leading-[18px]'>{label}</span>
223+
</div>,
224+
document.body
225+
)
226+
})
227+
228+
function getTooltipPosition(
229+
clientX: number,
230+
clientY: number
231+
): Pick<FloatingTooltipState, 'x' | 'y' | 'alignX' | 'alignY'> {
232+
if (typeof window === 'undefined') {
233+
return { x: clientX, y: clientY, alignX: 'left', alignY: 'below' }
234+
}
235+
236+
const alignX = window.innerWidth - clientX < EDGE_THRESHOLD ? 'right' : 'left'
237+
const alignY = window.innerHeight - clientY < EDGE_THRESHOLD / 2 ? 'above' : 'below'
238+
239+
return {
240+
x: clamp(clientX, EDGE_GUTTER, window.innerWidth - EDGE_GUTTER),
241+
y: clamp(clientY, EDGE_GUTTER, window.innerHeight - EDGE_GUTTER),
242+
alignX,
243+
alignY,
244+
}
245+
}
246+
247+
function getTooltipTranslate(state: FloatingTooltipState): string {
248+
const xOffset =
249+
state.alignX === 'left' ? `${TOOLTIP_OFFSET}px` : `calc(-100% - ${TOOLTIP_OFFSET}px)`
250+
const yOffset =
251+
state.alignY === 'below' ? `${TOOLTIP_OFFSET}px` : `calc(-100% - ${TOOLTIP_OFFSET}px)`
252+
253+
return `translate3d(${state.x}px, ${state.y}px, 0) translate(${xOffset}, ${yOffset})`
254+
}

0 commit comments

Comments
 (0)