| 1 | import { useEffect, useRef, useState } from "react"; |
| 2 | |
| 3 | import { cn } from "~/lib/utils"; |
| 4 | |
| 5 | const HIDE_AFTER_MS = 1500; |
| 6 | const ZOOM_EPSILON = 0.001; |
| 7 | |
| 8 | interface Props { |
| 9 | /** Current zoom factor (1.0 = 100%); changes drive the transient indicator. */ |
| 10 | zoomFactor: number; |
| 11 | } |
| 12 | |
| 13 | /** |
| 14 | * Floating "X%" pill that surfaces in the top-right of the webview area |
| 15 | * whenever the zoom factor changes, then fades out after a short pause. |
| 16 | * |
| 17 | * Suppressed for the first render's value so we don't flash 100% on mount. |
| 18 | */ |
| 19 | export function ZoomIndicator({ zoomFactor }: Props) { |
| 20 | const [visible, setVisible] = useState(false); |
| 21 | const lastFactorRef = useRef(zoomFactor); |
| 22 | const timerRef = useRef<number | null>(null); |
| 23 | |
| 24 | useEffect(() => { |
| 25 | if (Math.abs(lastFactorRef.current - zoomFactor) < ZOOM_EPSILON) return; |
| 26 | lastFactorRef.current = zoomFactor; |
| 27 | setVisible(true); |
| 28 | if (timerRef.current !== null) window.clearTimeout(timerRef.current); |
| 29 | timerRef.current = window.setTimeout(() => { |
| 30 | setVisible(false); |
| 31 | timerRef.current = null; |
| 32 | }, HIDE_AFTER_MS); |
| 33 | return () => { |
| 34 | if (timerRef.current !== null) { |
| 35 | window.clearTimeout(timerRef.current); |
| 36 | timerRef.current = null; |
| 37 | } |
| 38 | }; |
| 39 | }, [zoomFactor]); |
| 40 | |
| 41 | const percent = `${Math.round(zoomFactor * 100)}%`; |
| 42 | |
| 43 | return ( |
| 44 | <div |
| 45 | aria-hidden={!visible} |
| 46 | className={cn( |
| 47 | "pointer-events-none absolute top-3 right-3 z-20 select-none rounded-full border border-border/70 bg-popover/95 px-2.5 py-1 text-xs font-medium text-foreground shadow-md/10 backdrop-blur transition-all duration-200 ease-out", |
| 48 | visible ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-1", |
| 49 | )} |
| 50 | > |
| 51 | {percent} |
| 52 | </div> |
| 53 | ); |
| 54 | } |