import React, { useState, useRef, useCallback, useLayoutEffect, useEffect, } from "react"; import { GripVertical, Monitor, ChevronLeft, ChevronRight, ChevronUp, ChevronDown, ChevronsLeftRight, } from "lucide-react"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/tooltip.tsx"; import type { GuacamoleDisplayHandle } from "@/features/guacamole/GuacamoleDisplay.tsx"; import { useTranslation } from "react-i18next"; import { cn } from "@/lib/utils"; interface GuacamoleToolbarProps { displayRef: React.RefObject; protocol: "rdp" | "vnc" | "telnet"; } const MODIFIER_KEYSYMS = { ctrl: 0xffe3, alt: 0xffe9, shift: 0xffe1, win: 0xff67, } as const; const FKEY_KEYSYMS = Array.from({ length: 12 }, (_, i) => 0xffbe + i); const BTN_BASE = "flex items-center justify-center gap-1 h-7 px-2 text-[10px] font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors rounded-sm whitespace-nowrap select-none"; const BTN_ICON = "flex items-center justify-center size-7 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors rounded-sm select-none"; const SEP = "w-px h-5 bg-border mx-0.5 shrink-0"; function TipBtn({ tooltip, onClick, className, children, }: { tooltip: string; onClick: () => void; className?: string; children: React.ReactNode; }) { return ( {tooltip} ); } function TipIconBtn({ tooltip, onClick, className, children, }: { tooltip: string; onClick: () => void; className?: string; children: React.ReactNode; }) { return ( {tooltip} ); } export const GuacamoleToolbar: React.FC = ({ displayRef, protocol, }) => { const { t } = useTranslation(); const [position, setPosition] = useState({ x: 0, y: 12 }); const [collapsed, setCollapsed] = useState(false); const [showFKeys, setShowFKeys] = useState(false); const [stickyKeys, setStickyKeys] = useState>({ [MODIFIER_KEYSYMS.ctrl]: false, [MODIFIER_KEYSYMS.alt]: false, [MODIFIER_KEYSYMS.shift]: false, [MODIFIER_KEYSYMS.win]: false, }); const toolbarRef = useRef(null); const isDraggingRef = useRef(false); const dragOriginRef = useRef({ mouseX: 0, mouseY: 0, posX: 0, posY: 0 }); const [isDragging, setIsDragging] = useState(false); useLayoutEffect(() => { const el = toolbarRef.current; if (!el) return; const parent = el.offsetParent as HTMLElement | null; if (!parent) return; const parentW = parent.clientWidth; const toolbarW = el.offsetWidth; setPosition((p) => ({ ...p, x: Math.max(0, (parentW - toolbarW) / 2) })); }, [collapsed]); useEffect(() => { if (!isDragging) return; const onMove = (e: MouseEvent) => { if (!isDraggingRef.current) return; const parent = toolbarRef.current?.offsetParent as HTMLElement | null; const parentW = parent?.clientWidth ?? Infinity; const parentH = parent?.clientHeight ?? Infinity; const toolbarW = toolbarRef.current?.offsetWidth ?? 0; const toolbarH = toolbarRef.current?.offsetHeight ?? 0; const dx = e.clientX - dragOriginRef.current.mouseX; const dy = e.clientY - dragOriginRef.current.mouseY; setPosition({ x: Math.max( 0, Math.min(dragOriginRef.current.posX + dx, parentW - toolbarW), ), y: Math.max( 0, Math.min(dragOriginRef.current.posY + dy, parentH - toolbarH), ), }); }; const onUp = () => { isDraggingRef.current = false; setIsDragging(false); document.body.style.userSelect = ""; }; document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); return () => { document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }; }, [isDragging]); const startDrag = useCallback( (e: React.MouseEvent) => { e.preventDefault(); isDraggingRef.current = true; dragOriginRef.current = { mouseX: e.clientX, mouseY: e.clientY, posX: position.x, posY: position.y, }; document.body.style.userSelect = "none"; setIsDragging(true); }, [position], ); const releaseStickyKeys = useCallback(() => { const display = displayRef.current; if (!display) return; setStickyKeys((prev) => { const next = { ...prev }; for (const [ksStr, active] of Object.entries(prev)) { if (active) { display.sendKey(Number(ksStr), false); next[Number(ksStr)] = false; } } return next; }); }, [displayRef]); const sendCombo = useCallback( (...keysyms: number[]) => { const display = displayRef.current; if (!display) return; for (const k of keysyms) display.sendKey(k, true); for (const k of [...keysyms].reverse()) display.sendKey(k, false); releaseStickyKeys(); }, [displayRef, releaseStickyKeys], ); const toggleStickyKey = useCallback( (keysym: number) => { const display = displayRef.current; if (!display) return; setStickyKeys((prev) => { const isActive = prev[keysym]; display.sendKey(keysym, !isActive); return { ...prev, [keysym]: !isActive }; }); }, [displayRef], ); const isRdpVnc = protocol === "rdp" || protocol === "vnc"; const containerStyle: React.CSSProperties = { position: "absolute", left: position.x, top: position.y, zIndex: 20, }; return (
e.preventDefault()} > {collapsed ? (
{t("guacamole.toolbar.expand")} ) : (
{/* Drag handle */} {t("guacamole.toolbar.dragHandle")} {/* System combos — RDP/VNC only */} {isRdpVnc && ( <>
sendCombo(0xffe3, 0xffe9, 0xffff)} > CAD sendCombo(0xff67, 0x006c)} > Win+L sendCombo(0xff67)} > Win )} {/* Sticky modifiers — RDP/VNC only */} {isRdpVnc && ( <>
{( [ [ "ctrl", MODIFIER_KEYSYMS.ctrl, t("guacamole.toolbar.ctrl"), ], ["alt", MODIFIER_KEYSYMS.alt, t("guacamole.toolbar.alt")], [ "shift", MODIFIER_KEYSYMS.shift, t("guacamole.toolbar.shift"), ], ["win", MODIFIER_KEYSYMS.win, t("guacamole.toolbar.win")], ] as [string, number, string][] ).map(([key, ks, label]) => ( {stickyKeys[ks] ? t("guacamole.toolbar.stickyActive", { key: label }) : t("guacamole.toolbar.stickyInactive", { key: label })} ))} )} {/* Function key toggle */}
setShowFKeys((v) => !v)} className={cn( showFKeys && "bg-primary/15 text-primary border border-primary/30", )} > Fn {/* F1-F12 row */} {showFKeys && FKEY_KEYSYMS.map((ks, i) => ( sendCombo(ks)} > F{i + 1} ))} {/* Navigation */}
sendCombo(0xff1b)} > Esc sendCombo(0xff09)} > Tab sendCombo(0xff50)} > Home sendCombo(0xff57)} > End sendCombo(0xff55)} > PgUp sendCombo(0xff56)} > PgDn {/* Arrow cluster */}
sendCombo(0xff52)} >
sendCombo(0xff51)} > sendCombo(0xff54)} > sendCombo(0xff53)} >
{/* Collapse */}
{t("guacamole.toolbar.collapse")}
)}
); };