|
| 1 | +// Copyright 2025, Command Line Inc. |
| 2 | +// SPDX-License-Identifier: Apache-2.0 |
| 3 | + |
| 4 | +import { PLATFORM, PlatformMacOS } from "@/util/platformutil"; |
| 5 | +import { FloatingPortal, VirtualElement, flip, offset, shift, useFloating } from "@floating-ui/react"; |
| 6 | +import * as React from "react"; |
| 7 | +import type { TermWrap } from "./termwrap"; |
| 8 | + |
| 9 | +// ── low-level primitive ────────────────────────────────────────────────────── |
| 10 | + |
| 11 | +interface TermTooltipProps { |
| 12 | + /** Screen-space mouse position (clientX/clientY). null means hidden. */ |
| 13 | + mousePos: { x: number; y: number } | null; |
| 14 | + content: React.ReactNode; |
| 15 | +} |
| 16 | + |
| 17 | +/** |
| 18 | + * A floating tooltip anchored to the current mouse position. |
| 19 | + * Uses a floating-ui virtual element (via refs.setPositionReference) so no |
| 20 | + * real DOM reference is required. Renders into a FloatingPortal. |
| 21 | + */ |
| 22 | +export const TermTooltip = React.memo(function TermTooltip({ mousePos, content }: TermTooltipProps) { |
| 23 | + const isOpen = mousePos != null; |
| 24 | + |
| 25 | + // Keep latest mousePos in a ref so the virtual element always reflects it. |
| 26 | + const mousePosRef = React.useRef(mousePos); |
| 27 | + mousePosRef.current = mousePos; |
| 28 | + |
| 29 | + const { refs, floatingStyles } = useFloating({ |
| 30 | + open: isOpen, |
| 31 | + placement: "top-start", |
| 32 | + middleware: [offset({ mainAxis: 12, crossAxis: -20 }), flip(), shift({ padding: 0 })], |
| 33 | + }); |
| 34 | + |
| 35 | + // Update the position reference whenever mousePos changes. |
| 36 | + React.useLayoutEffect(() => { |
| 37 | + if (!isOpen) { |
| 38 | + return; |
| 39 | + } |
| 40 | + const virtualEl: VirtualElement = { |
| 41 | + getBoundingClientRect() { |
| 42 | + const pos = mousePosRef.current ?? { x: 0, y: 0 }; |
| 43 | + return new DOMRect(pos.x, pos.y, 0, 0); |
| 44 | + }, |
| 45 | + }; |
| 46 | + refs.setPositionReference(virtualEl); |
| 47 | + }, [isOpen, mousePos?.x, mousePos?.y]); |
| 48 | + |
| 49 | + if (!isOpen) { |
| 50 | + return null; |
| 51 | + } |
| 52 | + |
| 53 | + return ( |
| 54 | + <FloatingPortal> |
| 55 | + <div |
| 56 | + ref={refs.setFloating} |
| 57 | + style={floatingStyles} |
| 58 | + className="bg-zinc-800/70 rounded-md px-2 py-1 text-xs text-secondary shadow-xl z-50 pointer-events-none select-none" |
| 59 | + > |
| 60 | + {content} |
| 61 | + </div> |
| 62 | + </FloatingPortal> |
| 63 | + ); |
| 64 | +}); |
| 65 | + |
| 66 | +// ── wired-up sub-component ─────────────────────────────────────────────────── |
| 67 | + |
| 68 | +function clearTimeoutRef(ref: React.RefObject<number | null>) { |
| 69 | + if (ref.current == null) { |
| 70 | + return; |
| 71 | + } |
| 72 | + window.clearTimeout(ref.current); |
| 73 | + ref.current = null; |
| 74 | +} |
| 75 | + |
| 76 | +const HoverDelayMs = 600; |
| 77 | +const MaxHoverTimeMs = 2200; |
| 78 | +const modKey = PLATFORM === PlatformMacOS ? "Cmd" : "Ctrl"; |
| 79 | + |
| 80 | +interface TermLinkTooltipProps { |
| 81 | + /** |
| 82 | + * The live TermWrap instance. Pass the instance directly (not a ref) so |
| 83 | + * React re-runs the effect when it changes (e.g. on terminal recreate). |
| 84 | + */ |
| 85 | + termWrap: TermWrap | null; |
| 86 | +} |
| 87 | + |
| 88 | +/** |
| 89 | + * Self-contained sub-component that subscribes to the termWrap link-hover |
| 90 | + * callback and renders a tooltip after a short delay. Keeping state here |
| 91 | + * prevents unnecessary re-renders of the parent TerminalView. |
| 92 | + */ |
| 93 | +export const TermLinkTooltip = React.memo(function TermLinkTooltip({ termWrap }: TermLinkTooltipProps) { |
| 94 | + const [mousePos, setMousePos] = React.useState<{ x: number; y: number } | null>(null); |
| 95 | + const timeoutRef = React.useRef<number | null>(null); |
| 96 | + const maxTimeoutRef = React.useRef<number | null>(null); |
| 97 | + |
| 98 | + React.useEffect(() => { |
| 99 | + if (termWrap == null) { |
| 100 | + return; |
| 101 | + } |
| 102 | + |
| 103 | + termWrap.onLinkHover = (uri: string | null, mouseX: number, mouseY: number) => { |
| 104 | + clearTimeoutRef(timeoutRef); |
| 105 | + |
| 106 | + if (uri == null) { |
| 107 | + clearTimeoutRef(maxTimeoutRef); |
| 108 | + setMousePos(null); |
| 109 | + return; |
| 110 | + } |
| 111 | + |
| 112 | + // Show after a short delay so fast mouse movements don't flicker. |
| 113 | + timeoutRef.current = window.setTimeout(() => { |
| 114 | + timeoutRef.current = null; |
| 115 | + setMousePos({ x: mouseX, y: mouseY }); |
| 116 | + // Auto-dismiss after MaxHoverTimeMs so the tooltip doesn't linger forever. |
| 117 | + clearTimeoutRef(maxTimeoutRef); |
| 118 | + maxTimeoutRef.current = window.setTimeout(() => { |
| 119 | + maxTimeoutRef.current = null; |
| 120 | + setMousePos(null); |
| 121 | + }, MaxHoverTimeMs); |
| 122 | + }, HoverDelayMs); |
| 123 | + }; |
| 124 | + |
| 125 | + return () => { |
| 126 | + termWrap.onLinkHover = null; |
| 127 | + clearTimeoutRef(timeoutRef); |
| 128 | + clearTimeoutRef(maxTimeoutRef); |
| 129 | + setMousePos(null); |
| 130 | + }; |
| 131 | + }, [termWrap]); |
| 132 | + |
| 133 | + return <TermTooltip mousePos={mousePos} content={<span>{modKey}-click to open link</span>} />; |
| 134 | +}); |
0 commit comments